carlos / platform Public

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>
Paul Campbell 771c2f0a2994d3ff06f1456b8cb1f36363a9186d parent cecc01aparent da69c70
4 files changed, +278 −9
  • internal/console/icons.go +77 −0
  • internal/console/icons_test.go +175 −0
  • internal/console/server.go +4 −0
  • internal/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}}