oficina / docs Public

Add approved Oficina app icons

Wire the shared white Lucide artwork into product branding. Source review: GPT-6 Astra (independent agent).
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by paul@keymail.dev 169f9f033128d7568241c0d4683a2babbc4d5f4b parent a8f5c0e
11 files changed, +92 −100
  • internal/docs/brand/LICENSE.lucide +43 −0
  • internal/docs/brand/apple-touch-icon.png binary
  • internal/docs/brand/brand.css +19 −0
  • internal/docs/brand/icon-32.png binary
  • internal/docs/brand/icon.svg +5 −44
  • internal/docs/list_test.go +3 −2
  • internal/docs/render.go +10 −2
  • internal/docs/views/head.html +9 −49
  • internal/docs/views/list.html +1 −1
  • internal/docs/views/public.html +1 −1
  • internal/docs/views/show.html +1 −1

CI passed — run details

diff --git a/internal/docs/brand/LICENSE.lucide b/internal/docs/brand/LICENSE.lucide
new file mode 100644
index 0000000..718bb3f
--- /dev/null
+++ b/internal/docs/brand/LICENSE.lucide
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/internal/docs/brand/apple-touch-icon.png b/internal/docs/brand/apple-touch-icon.png
new file mode 100644
index 0000000..d75bdba
Binary files /dev/null and b/internal/docs/brand/apple-touch-icon.png differ
diff --git a/internal/docs/brand/brand.css b/internal/docs/brand/brand.css
new file mode 100644
index 0000000..2e194c6
--- /dev/null
+++ b/internal/docs/brand/brand.css
@@ -0,0 +1,19 @@
+/* Oficina brand artwork. Source: oficina/estilo brand/. */
+[estilo-brand] {
+ display: inline-flex;
+ align-items: center;
+ gap: .5rem;
+ min-block-size: 24px;
+}
+[estilo-brand-icon] {
+ display: inline-block;
+ flex-shrink: 0;
+ inline-size: 1.75rem;
+ block-size: 1.75rem;
+ border-radius: 21%;
+ vertical-align: middle;
+}
+[estilo-brand-icon="listing"] {
+ inline-size: 2.5rem;
+ block-size: 2.5rem;
+}
diff --git a/internal/docs/brand/icon-32.png b/internal/docs/brand/icon-32.png
new file mode 100644
index 0000000..b61cb5f
Binary files /dev/null and b/internal/docs/brand/icon-32.png differ
diff --git a/internal/docs/brand/icon.svg b/internal/docs/brand/icon.svg
index 52f4376..9b7f4c6 100644
--- a/internal/docs/brand/icon.svg
+++ b/internal/docs/brand/icon.svg
@@ -1,44 +1,5 @@
-<!-- CARLOS Docs' tab icon. Drawn here rather than taken from an icon
- set: rastrillo ships no page or document glyph (rastrillo/icons.go
- carries thirteen, none of them this), and a Unicode character
- standing in for a mark is not an icon.
-
- A sheet with a turned corner, on a tile in the day theme's accent
- (#2464e0, the light value of the rst-accent custom property in
- ui/themes/day.css). Taken as a literal because an SVG served as a
- static file cannot read a CSS custom property, and because this
- mark must not repaint with a reader's colour scheme the way the
- chrome around it does.
-
- Sized for 16px first: one filled tile, one white silhouette, one
- turned corner, and two rules for the text. Everything is at least
- 1.5 units across at a 32 unit viewBox, so nothing thins below a
- device pixel at tab size. The rules are what make it read as a
- document rather than a blank file, and they stop at two because a
- third closes the gap between them into grey mush.
-
- Why brand/ and not static/, which is where this file was first
- written: internal/docs/static/ is build output, not a place to
- keep things. The gate rebuilds the editor bundle into it and then
- fails on any dirt at all in that directory
- (.amadan/ci.d/10-gate's "the editor bundle must match a fresh
- build"), before restoring it with git checkout and git clean.
- A hand written file there is therefore not merely untidy: it
- turns the gate red, and then the gate's own cleanup deletes it.
- It did, once. Hand authored assets belong beside the build
- output, never inside it.
-
- Note for whoever edits this comment: an XML comment may not
- contain two consecutive hyphens, so a CSS custom property cannot
- be written here with its leading pair. The first version of this
- file did exactly that, and the result was a well formed HTTP 200
- serving a document no browser could parse: right status, right
- Content-Type, right byte count, broken image. assets_test.go's
- TestIconIsWellFormedXML exists because of it. -->
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
- <rect width="32" height="32" rx="7" fill="#2464e0"/>
- <path d="M9.5 8.5A2 2 0 0 1 11.5 6.5h6.6L23.5 11.9V23.5a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2Z" fill="#ffffff"/>
- <path d="M18.1 6.5 23.5 11.9h-5.4Z" fill="#a9c4f4"/>
- <rect x="12.9" y="15.6" width="7.6" height="1.7" rx="0.85" fill="#2464e0"/>
- <rect x="12.9" y="19.2" width="5.2" height="1.7" rx="0.85" fill="#2464e0"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Docs"><title>Docs</title><rect x="0" y="0" width="1024" height="1024" rx="0" fill="#2563EB"/><g transform="translate(153.6 153.6) scale(29.866666666666664)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
+ <path d="M14 2v5a1 1 0 0 0 1 1h5" />
+ <path d="M10 9H8" />
+ <path d="M16 13H8" />
+ <path d="M16 17H8" /></g></svg>
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index 77bd794..95b96fd 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -456,7 +456,7 @@ func TestDocumentPageLinksBackToTheList(t *testing.T) {
}
body := rec.Body.String()
- if !strings.Contains(body, `<a rst-shell-brand href="/">`) {
+ if !strings.Contains(body, `<a rst-shell-brand estilo-brand href="/">`) {
t.Error("no brand link to / on the document page — a document reached directly has no route to the list")
}
@@ -481,7 +481,8 @@ func TestDocumentPageLinksBackToTheList(t *testing.T) {
t.Fatalf("list status %d", listRec.Code)
}
listTitle := betweenTags(t, listRec.Body.String(), "<h1>", "</h1>")
- backLabel := betweenTags(t, body, `<a rst-shell-brand href="/">`, "</a>")
+ backLabel := betweenTags(t, body, `<a rst-shell-brand estilo-brand href="/">`, "</a>")
+ backLabel = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(backLabel, "")
if backLabel == "" || backLabel != listTitle {
t.Errorf("back link label = %q, want the list's own <h1> (%q)", backLabel, listTitle)
}
diff --git a/internal/docs/render.go b/internal/docs/render.go
index e7dea7a..2e2e741 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -597,7 +597,7 @@ func vendoredStaticFS() (fs.FS, error) {
if !ok {
return nil, fmt.Errorf("docs: rastrillo ui: theme %q not shipped", dayTheme)
}
- return fstest.MapFS{
+ files := fstest.MapFS{
"static/estilo-save-status.css": {Data: savestatus.CSS()},
"static/estilo-save-status.mjs": {Data: savestatus.JS()},
"static/tokens.css": {Data: ui.TokensCSS()},
@@ -605,5 +605,13 @@ func vendoredStaticFS() (fs.FS, error) {
"static/rastrillo.js": {Data: ui.ShimJS()},
"static/select.js": {Data: ui.SelectJS()},
"static/datetime.js": {Data: ui.DatetimeJS()},
- }, nil
+ }
+ for _, name := range []string{"icon-32.png", "apple-touch-icon.png", "brand.css", "LICENSE.lucide"} {
+ body, err := brandFS.ReadFile("brand/" + name)
+ if err != nil {
+ return nil, err
+ }
+ files["static/brand/"+name] = &fstest.MapFile{Data: body}
+ }
+ return files, nil
}
diff --git a/internal/docs/views/head.html b/internal/docs/views/head.html
index 125e95d..f01046e 100644
--- a/internal/docs/views/head.html
+++ b/internal/docs/views/head.html
@@ -1,49 +1,9 @@
-{{/* head — this app's slot in the shell's <head>.
-
- rastrillo's three shells all end their head with
- {{block "head" .}}{{end}}, described in ui/layouts/column.html as
- "the app's own slot in <head>: a favicon, a meta tag, one more
- stylesheet". This file redefines that block once, in the base
- template every page is cloned from (render.go's newRenderer), so
- all three pages inherit it and there is exactly one place the
- icon is declared. A page that ever needs head content of its own
- defines "head" in its own template and overrides this, which is
- ordinary block behaviour and needs nothing added here.
-
- Before this existed the app declared no icon at all, so every
- browser fell back to asking for /favicon.ico, got a 404, and
- showed the generic blank page mark — on a document editor, where
- the tab strip is the thing a person navigates between open
- documents with, and where two sibling apps (Sheets, and the
- suite home) sit in the same tab strip under the same suite.
-
- The href is a literal rather than {{asset}}: icon.svg is served
- by assets.go's own staticHandler at a fixed, unhashed iconPath,
- not out of the rastrillo.Assets tree {{asset}} fingerprints.
- assets_test.go asserts this markup and that constant agree, so
- the pair cannot drift into a silent 404. */}}
-{{/* The one app-wide style rule, and it is here for the same reason
- the icon is: it belongs to every page and must be declared once.
-
- [rst-shell-brand] is now a real target on the signed-in screens —
- it is the route back to the list, which used to be [docs-back] in
- show.html's own header. tokens.css gives the brand colour, weight
- and no padding at all, so it measures 21px tall against 14px body
- text at line-height 1.5. AGENTS.md commits to WCAG 2.2 AA with no
- exemptions, and SC 2.5.8's 24x24 floor is one of the three it
- names. 21px clears that only through the spacing exception, which
- is a different argument that stops holding the moment anything
- lands beside the brand.
-
- [docs-back] had already learned this and stated the floor outright
- rather than hoping padding added up to it — measured, its first
- version came to 22px because --rst-fs-sm is 12.5px and not the
- 14px it was assumed to be. Deleting [docs-back] deleted that
- finding with it; this is the same rule at the link's new address.
- inline-flex is what makes min-block-size apply to a link at all.
-
- Written against the framework's own element rather than proposed
- upstream yet: spec §10 says a component we keep editing belongs
- upstream, and if the sibling apps want the same floor this is the
- rule to send. It is inert on sign-in, which hides the bar. */}}
-{{define "head"}}<link rel="icon" type="image/svg+xml" href="/static/icon.svg"><style>[rst-shell-brand]{display:inline-flex;align-items:center;min-block-size:24px}</style>{{end}}
+{{/* App-wide brand assets. SVG keeps its existing public URL; the other
+ exports use the renderer's content-hashed asset tree. Source: Estilo brand/. */}}
+{{define "head"}}
+<link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}">
+<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
+<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}">
+<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
+<style>[rst-shell-brand]{display:inline-flex;align-items:center;min-block-size:24px}</style>
+{{end}}
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index cfe9fda..aeb4c84 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -52,7 +52,7 @@
do: the bar names the app, the heading names the screen, and on
the one screen where those are the same thing they read as the
same thing. */}}
-{{define "brand"}}<a rst-shell-brand href="/">{{.Title}}</a>{{end}}
+{{define "brand"}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="/static/icon.svg" alt="" width="28" height="28">{{.Title}}</a>{{end}}
{{/* account is the dropdown's MENU BODY, not the whole menu — see
show.html's own copy of this block for the reasoning; the two are
identical on purpose, and are two definitions rather than one
diff --git a/internal/docs/views/public.html b/internal/docs/views/public.html
index c85172b..db3bbc3 100644
--- a/internal/docs/views/public.html
+++ b/internal/docs/views/public.html
@@ -17,7 +17,7 @@
guard anyway, so the mount point would only produce a page that
fails at the network. Ruling 15's other half: every write has a
named actor, and whoever is reading this has no name. */}}
-{{define "brand"}}<a rst-shell-brand href="/">{{.Title}}</a>{{end}}
+{{define "brand"}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="/static/icon.svg" alt="" width="28" height="28">{{.Title}}</a>{{end}}
{{define "content"}}
<article rst-stack docs-public>
<p rst-notice docs-public-notice>{{.ReadOnly}}</p>
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index 6b3d88e..83ead77 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -57,7 +57,7 @@
The title is printed as-is, the same way list.html's row titles
are — it is the document's own data, not authored copy, so it
never goes through rastrillo.T. */}}
-{{define "brand"}}<a rst-shell-brand href="/">{{.BackLabel}}</a><span docs-shell-title>{{.Title}}</span>{{end}}
+{{define "brand"}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="/static/icon.svg" alt="" width="28" height="28">{{.BackLabel}}</a><span docs-shell-title>{{.Title}}</span>{{end}}
{{/* account is the dropdown's MENU BODY, not the whole menu: the
shell owns the <details>/<summary> and this block supplies only
what sits inside [rst-dropdown-menu]. A <form> rather than a