merge: vendor Lucide for the console's chrome
ISC-licensed SVGs as in-code constants, never a CDN — the console has the same no-build-step, no-external-request rule as the status page. Follows Eleven's icon system, including its rule that emoji are content and icons are chrome, so the factor-X robot in the footer stays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+278
−9
internal/console/icons.go+77 −0internal/console/icons_test.go+175 −0internal/console/server.go+4 −0internal/console/templates/layout.html+22 −9
diff --git a/internal/console/icons.go b/internal/console/icons.go| new file mode 100644 |
| index 000000000..2597ae982 |
| --- /dev/null |
| +++ b/internal/console/icons.go |
| @@ -0,0 +1,77 @@ |
| +// The console's icon set — every piece of interface chrome, one file. This |
| +// is the server-side twin of Eleven's web/static/icons.js: static, in-code |
| +// SVG strings in the Lucide idiom (24×24 viewBox, currentColor, stroke, round |
| +// caps and joins), inlined into the page at render time. |
| +// |
| +// Vendored, never a CDN. The console has the same hard constraint as the |
| +// fleet status page — no build step, no JavaScript framework, and no external |
| +// request, ever. A <script src> or <link href> at some icon host would be a |
| +// second origin the console cannot vouch for, cannot cache-control, and |
| +// cannot serve when it is offline. Inlining the paths is the only option, so |
| +// the paths live here. |
| +// |
| +// Icons are vendored from Lucide — https://lucide.dev — used under the ISC |
| +// licence: |
| +// |
| +// Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as |
| +// part of Feather (MIT). All other copyright (c) for Lucide are held by |
| +// Lucide Contributors 2022. |
| +// |
| +// 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. |
| +// |
| +// Emoji policy, inherited from Eleven's docs/icons.md: emoji are CONTENT — a |
| +// mark a person or a model chose, like the footer's factor-X disclosure — and |
| +// never chrome. Interface furniture uses these icons. Adding one = paste the |
| +// Lucide SVG's paths in below, keep the (Lucide: <slug>) comment, register it |
| +// in the icons map, use it as {{icon "<slug>"}}. |
| +// |
| +// Each icon carries aria-hidden="true" because every one of them sits beside |
| +// its own text label — the label is the accessible name, and a second one |
| +// from the icon would only be read out twice. An icon used as the ONLY label |
| +// must not use these constants bare: give its button or link an aria-label. |
| +package console |
| + |
| +import "html/template" |
| + |
| +// template.HTML is used HERE AND ONLY HERE in the console, and this is why it |
| +// is safe here and nowhere else: every string in this file is a compile-time |
| +// constant of our own markup. No user input, no bucket object, no registry |
| +// row, nothing derived from a request is ever concatenated into one. Anything |
| +// from those sources must stay a plain string so html/template escapes it — |
| +// a review in this repo has already flagged template.HTML on bucket-derived |
| +// text as a Critical finding, and that judgement stands. |
| +const ( |
| + // iconOpen is the shared opening tag: no width or height, so the icon |
| + // sizes from the CSS .icon rule (1em) and tracks the text beside it. |
| + iconOpen = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` + |
| + `stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` |
| + |
| + // The account switcher's disclosure caret (Lucide: chevron-down). |
| + iconChevronDown template.HTML = iconOpen + `<path d="m6 9 6 6 6-6"/></svg>` |
| + |
| + // The switcher menu's mark on the account you are currently in |
| + // (Lucide: check). |
| + iconCheck template.HTML = iconOpen + `<path d="M20 6 9 17l-5-5"/></svg>` |
| + |
| + // "New account", in the same column the check sits in (Lucide: plus). |
| + iconPlus template.HTML = iconOpen + `<path d="M5 12h14"/><path d="M12 5v14"/></svg>` |
| +) |
| + |
| +// icons maps each Lucide slug to its vendored markup. Slugs are the Lucide |
| +// names so the mapping back to lucide.dev stays obvious. |
| +var icons = map[string]template.HTML{ |
| + "chevron-down": iconChevronDown, |
| + "check": iconCheck, |
| + "plus": iconPlus, |
| +} |
| + |
| +// icon renders one chrome icon by its Lucide slug: {{icon "chevron-down"}}. |
| +// An unknown slug renders nothing rather than panicking a page mid-response — |
| +// a typo must cost a missing caret, not a 500. TestTemplateIconSlugsAllExist |
| +// is what actually catches the typo, at test time. |
| +func icon(slug string) template.HTML { return icons[slug] } |
diff --git a/internal/console/icons_test.go b/internal/console/icons_test.go| new file mode 100644 |
| index 000000000..9ccb1e8b8 |
| --- /dev/null |
| +++ b/internal/console/icons_test.go |
| @@ -0,0 +1,175 @@ |
| +package console |
| + |
| +import ( |
| + "io/fs" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The switcher's chrome is drawn, not typed: the disclosure caret and the |
| +// menu's marks are inline SVG from icons.go, and the text glyphs they replace |
| +// ("▾", "✓") are gone from the header. A glyph is whatever the reader's font |
| +// decides it is — a caret that renders as a black triangle on one machine and |
| +// a coloured emoji on another is not a control, and neither size nor colour |
| +// obeys the page. An icon does both. |
| +func TestSwitcherChromeIsInlineSVGNotTextGlyphs(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/")) |
| + h := header(t, body) |
| + |
| + summary, _, ok := strings.Cut(h, "</summary>") |
| + if !ok { |
| + t.Fatalf("no <summary> in the header:\n%s", h) |
| + } |
| + // The trigger's caret: a real <svg>, in the vendored idiom, hidden from |
| + // assistive tech because the account name beside it is the label. |
| + if !strings.Contains(summary, "<svg") { |
| + t.Errorf("the switcher trigger has no inline <svg> caret:\n%s", summary) |
| + } |
| + for _, want := range []string{`viewBox="0 0 24 24"`, `stroke="currentColor"`, `aria-hidden="true"`} { |
| + if !strings.Contains(summary, want) { |
| + t.Errorf("the caret svg is missing %s:\n%s", want, summary) |
| + } |
| + } |
| + // The menu marks the current account and offers a new one, both with |
| + // icons rather than a reused tick character. |
| + if n := strings.Count(h, "<svg"); n < 3 { |
| + t.Errorf("header has %d inline icons, want the caret plus a mark per menu row:\n%s", n, h) |
| + } |
| + // No chrome glyph survives anywhere in the header. Text arrows, ticks and |
| + // crosses are exactly what the icon set exists to replace. |
| + for _, glyph := range []string{"▾", "▼", "▽", "⌄", "✓", "✔", "✕", "✖", "×", "→", "←"} { |
| + if strings.Contains(h, glyph) { |
| + t.Errorf("header still uses the text glyph %q as chrome:\n%s", glyph, h) |
| + } |
| + } |
| +} |
| + |
| +// Emoji are content, never chrome (Eleven's docs/icons.md). The footer's |
| +// robot is a factor-X disclosure — a mark this platform puts on LLM-written |
| +// text everywhere, from README.md to site/platform — so it is content and it |
| +// stays. This test pins that distinction so a later sweep of "remove the |
| +// emoji" cannot quietly take the disclosure with it. |
| +func TestFactorXDisclosureKeepsItsEmoji(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/")) |
| + foot, _, ok := strings.Cut(body, "</footer>") |
| + if !ok { |
| + t.Fatalf("no <footer> in:\n%s", body) |
| + } |
| + if !strings.Contains(foot, "🤖") || !strings.Contains(foot, "written by an LLM") { |
| + t.Errorf("the footer lost its factor-X disclosure:\n%s", foot) |
| + } |
| +} |
| + |
| +// Same-origin, the console's version. internal/status asserts this on its |
| +// published page with a flat "no http(s):// anywhere" ban; the console cannot |
| +// use that ban verbatim because it legitimately LINKS out — the footer's |
| +// 11factor.org citation, an app's canary host. A link is navigation the |
| +// reader chooses. What must never appear is a SUBRESOURCE: anything the |
| +// browser fetches on its own while rendering the page — a script, a |
| +// stylesheet, a font, an image, an @import, a CSS url(). The console ships |
| +// one HTML document with its CSS inline and its icons inline, and that is the |
| +// whole of it. Vendoring the icons is what keeps this true. |
| +func TestConsoleTemplatesFetchNothingOffOrigin(t *testing.T) { |
| + banned := []string{ |
| + "<script src", // an external script, the usual way an icon CDN arrives |
| + "<link ", // stylesheets, fonts, preconnects, prefetches |
| + "src=", // no <img>, <iframe> or <source> of any origin |
| + "@import", // a stylesheet importing another |
| + "url(http", // a CSS-referenced asset... |
| + "url(//", // ...and the protocol-relative spelling of one |
| + `href="//`, // protocol-relative, dodging the "http" ban entirely |
| + "cdn.", // any CDN host, spelled anywhere at all |
| + "lucide.dev", // the icons are vendored; the source URL lives in a Go comment |
| + "unpkg", |
| + "jsdelivr", |
| + "googleapis", |
| + } |
| + err := fs.WalkDir(tmplFS, ".", func(path string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return err |
| + } |
| + b, err := tmplFS.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + for _, bad := range banned { |
| + if strings.Contains(string(b), bad) { |
| + t.Errorf("%s must fetch nothing off-origin; found %q", path, bad) |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +var iconCall = regexp.MustCompile(`{{\s*icon\s+"([^"]*)"\s*}}`) |
| + |
| +// icon returns "" for an unknown slug so a typo cannot 500 a page mid-render. |
| +// This is the test that turns that empty string back into a failure, at build |
| +// time, where a missing caret is cheap to fix. |
| +func TestTemplateIconSlugsAllExist(t *testing.T) { |
| + used := 0 |
| + err := fs.WalkDir(tmplFS, ".", func(path string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return err |
| + } |
| + b, err := tmplFS.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + for _, m := range iconCall.FindAllStringSubmatch(string(b), -1) { |
| + used++ |
| + if icon(m[1]) == "" { |
| + t.Errorf("%s calls {{icon %q}}, which is not in icons.go", path, m[1]) |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if used == 0 { |
| + t.Error("no template uses {{icon}} — the chrome went back to text glyphs") |
| + } |
| +} |
| + |
| +// Every vendored icon is one self-contained, colour-inheriting, screen-reader |
| +// -silent 24×24 SVG. Getting any of these wrong is invisible in review and |
| +// obvious on the page: a hardcoded fill ignores dark mode, a stray absolute |
| +// URL reintroduces the request vendoring exists to avoid. |
| +func TestVendoredIconsAreSelfContained(t *testing.T) { |
| + if len(icons) == 0 { |
| + t.Fatal("the icon set is empty") |
| + } |
| + for slug, svg := range icons { |
| + s := string(svg) |
| + for _, want := range []string{ |
| + "<svg", "</svg>", `viewBox="0 0 24 24"`, |
| + `stroke="currentColor"`, `aria-hidden="true"`, `class="icon"`, |
| + } { |
| + if !strings.Contains(s, want) { |
| + t.Errorf("icon %q is missing %s: %s", slug, want, s) |
| + } |
| + } |
| + for _, bad := range []string{"http://", "https://", "<image", "xlink:href", "url("} { |
| + if strings.Contains(s, bad) { |
| + t.Errorf("icon %q reaches outside the page (%q): %s", slug, bad, s) |
| + } |
| + } |
| + // No width/height of their own: they size from the .icon CSS rule so |
| + // one icon at a 0.9rem menu row and the same icon in body text are the |
| + // same shape at two sizes, not two hand-tuned copies. (The leading |
| + // space is what keeps stroke-width, a different thing entirely, out |
| + // of this check.) |
| + if strings.Contains(s, " width=") || strings.Contains(s, " height=") { |
| + t.Errorf("icon %q hardcodes a size instead of sizing from the text: %s", slug, s) |
| + } |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 536e0f2cd..4e49d6b1a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -70,6 +70,10 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| // public page and the design spec both fix; indexing by name keeps that |
| // order explicit in the template instead. |
| "mapGet": func(m map[string]*int, k string) *int { return m[k] }, |
| + // icon inlines one vendored Lucide SVG by slug — see icons.go for the |
| + // set, the licence, and why template.HTML is safe there and nowhere |
| + // else. Chrome is drawn; emoji stay content. |
| + "icon": icon, |
| }).ParseFS(tmplFS, "templates/*.html")) |
| const ( |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 7d2975305..eb87bd008 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -40,6 +40,11 @@ |
| border: 1px solid var(--rule); border-radius: 0.5rem; padding: 0.3rem 0.5rem; |
| } |
| td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; } |
| + /* Interface icons: inline SVG from internal/console/icons.go — Lucide, |
| + vendored, because this page fetches nothing from anywhere. They carry no |
| + width/height of their own, so they size to the text beside them and |
| + inherit its colour. */ |
| + .icon { width: 1em; height: 1em; flex: none; vertical-align: -0.125em; } |
| header { |
| max-width: 56rem; margin: 0 auto; padding: 1.4rem 0; |
| display: flex; align-items: baseline; gap: 1rem; |
| @@ -72,8 +77,10 @@ |
| font-weight: 700; min-width: 0; max-width: 14rem; |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| } |
| - header .switcher .caret { font-size: 0.7rem; color: var(--muted); } |
| - header .switcher[open] > summary .caret { transform: rotate(180deg); display: inline-block; } |
| + /* The caret is an icon, not a text character: centred against a |
| + baseline-aligned row, muted, and flipped while the menu is open. */ |
| + header .switcher > summary > .icon { color: var(--muted); align-self: center; width: 0.9em; } |
| + header .switcher[open] > summary > .icon { transform: rotate(180deg); } |
| header .switcher-menu { |
| position: absolute; z-index: 20; top: calc(100% + 0.6rem); left: -0.5rem; |
| min-width: 13rem; max-width: 20rem; |
| @@ -89,9 +96,15 @@ |
| } |
| header .switcher-menu a:hover, header .switcher-menu a:focus { background: var(--accent-soft); } |
| header .switcher-menu .current { font-weight: 600; background: var(--accent-soft); } |
| - /* The tick reserves its width on every row so the names stay in one column. */ |
| - header .switcher-menu .tick { color: var(--accent); width: 0.9rem; flex: none; } |
| - header .switcher-menu a .tick { visibility: hidden; } |
| + /* The mark column reserves its width on every row so the names stay in one |
| + column: a check on the account you are in, hidden on the ones you are |
| + not, a plus on "New account". */ |
| + header .switcher-menu .mark { |
| + color: var(--accent); width: 0.9rem; flex: none; |
| + display: flex; align-self: center; |
| + } |
| + header .switcher-menu a .mark { visibility: hidden; } |
| + header .switcher-menu .new a .mark { visibility: visible; } |
| header .switcher-menu hr { border: 0; border-top: 1px solid var(--rule); margin: 0.35rem 0.2rem; } |
| header .switcher-menu .new a { color: var(--accent); } |
| main { max-width: 56rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; } |
| @@ -165,19 +178,19 @@ |
| <summary> |
| <span class="small">Account</span> |
| <span class="current-name">{{.Account.Name}}</span> |
| - <span class="caret" aria-hidden="true">▾</span> |
| + {{icon "chevron-down"}} |
| </summary> |
| <div class="switcher-menu"> |
| <ul> |
| {{range .Accounts}}<li>{{if eq .Sqid $.Account.Sqid}} |
| - <span class="current" aria-current="true"><span class="tick" aria-hidden="true">✓</span>{{.Name}}</span> |
| + <span class="current" aria-current="true"><span class="mark">{{icon "check"}}</span>{{.Name}}</span> |
| {{else}} |
| - <a href="/a/{{.Sqid}}/"><span class="tick" aria-hidden="true">✓</span>{{.Name}}</a> |
| + <a href="/a/{{.Sqid}}/"><span class="mark">{{icon "check"}}</span>{{.Name}}</a> |
| {{end}}</li> |
| {{end}} |
| </ul> |
| <hr> |
| - <div class="new"><a href="/accounts/new"><span class="tick" aria-hidden="true">✓</span>New account</a></div> |
| + <div class="new"><a href="/accounts/new"><span class="mark">{{icon "plus"}}</span>New account</a></div> |
| </div> |
| </details> |
| {{else}} |