plan: rastrillo icon vendoring implementation plan
Scoped to §3 of the UI addendum only (Lucide icons) -- §2/§4/§5 depend on §9's component-vocabulary system, which doesn't exist in rastrillo's code yet. One task: icons.go + icons_test.go, mirroring carlosframework/platform's internal/console/icons.go exactly, reusing its already-tested SVG path data verbatim.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 file changed,
+227
−0
docs/superpowers/plans/2026-08-02-rastrillo-icon-vendoring.md+227 −0
diff --git a/docs/superpowers/plans/2026-08-02-rastrillo-icon-vendoring.md b/docs/superpowers/plans/2026-08-02-rastrillo-icon-vendoring.md| new file mode 100644 |
| index 000000000..f2ffcaa35 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-02-rastrillo-icon-vendoring.md |
| @@ -0,0 +1,227 @@ |
| +# Rastrillo Icon Vendoring Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Give rastrillo apps a vendored Lucide icon set, usable today from any hand-written `html/template.Template`, via a single `rastrillo.Icon(slug string) template.HTML` function. |
| + |
| +**Architecture:** One new file, `icons.go`, at the repo root of `github.com/carlosframework/rastrillo` (`package rastrillo`, alongside the existing `ctx.go`/`serve.go`). Mirrors `internal/console/icons.go` in `carlosframework/platform` exactly — same vendoring discipline (compile-time `template.HTML` constants, no CDN, no runtime fetch), same accessibility convention (`aria-hidden="true"`, icon always paired with a visible label or an explicit `aria-label` on the caller's side), same "unknown slug renders nothing rather than panicking" safety rule. The three icons vendored (`chevron-down`, `check`, `plus`) reuse the EXACT SVG path data already shipped and tested in `internal/console/icons.go` — not re-typed from memory, to avoid any risk of subtly wrong path syntax. |
| + |
| +**Tech Stack:** Go `html/template` (`template.HTML`), no new dependencies. |
| + |
| +## Global Constraints |
| + |
| +- Icons are vendored as Go source constants — never fetched from a CDN or npm package, matching the addendum spec's "no runtime or CDN dependency either way" (`docs/superpowers/specs/2026-08-02-rastrillo-ui-addendum-design.md` §3). |
| +- Every icon carries `aria-hidden="true"` — icons are always paired with a visible text label or the caller supplies an explicit `aria-label`; this package never renders an icon as a lone accessible name (same spec, §3). |
| +- ISC licence text for Lucide must be reproduced in the file, not just linked (matches `internal/console/icons.go`'s existing practice). |
| +- An unknown slug passed to `Icon` returns `""`, never panics (matches console's existing safety rule — a typo costs a missing icon, not a 500/crash). |
| +- No icon in the vendored set hardcodes its own `width`/`height` — icons size from the consuming app's own CSS (e.g. a `.icon { width: 1em; height: 1em }` rule the app defines), matching console's convention exactly. |
| + |
| +--- |
| + |
| +## Task 1: Vendor the icon set and its lookup function |
| + |
| +**Files:** |
| +- Create: `icons.go` (repo root, `package rastrillo`) |
| +- Test: `icons_test.go` (repo root, `package rastrillo`) |
| + |
| +**Interfaces:** |
| +- Produces: `rastrillo.Icon(slug string) template.HTML` — exported, the only symbol other packages need. `slug` is the Lucide icon name (e.g. `"chevron-down"`). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Create `icons_test.go`: |
| + |
| +```go |
| +package rastrillo |
| + |
| +import ( |
| + "html/template" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every vendored icon is one self-contained, colour-inheriting, |
| +// screen-reader-silent 24x24 SVG. Mirrors |
| +// carlosframework/platform's internal/console/icons_test.go |
| +// TestVendoredIconsAreSelfContained. |
| +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) |
| + } |
| + } |
| + if strings.Contains(s, " width=") || strings.Contains(s, " height=") { |
| + t.Errorf("icon %q hardcodes a size instead of sizing from the caller's CSS: %s", slug, s) |
| + } |
| + } |
| +} |
| + |
| +// An unknown slug must not panic a caller mid-render -- it renders nothing, |
| +// same rule as console's icon(). |
| +func TestIconUnknownSlugRendersNothing(t *testing.T) { |
| + if got := Icon("not-a-real-icon"); got != "" { |
| + t.Errorf("Icon(%q) = %q, want empty string", "not-a-real-icon", got) |
| + } |
| +} |
| + |
| +// The actual value proposition: Icon must work as a real html/template |
| +// FuncMap entry, end to end -- not just type-check as template.HTML. |
| +func TestIconWorksAsTemplateFunc(t *testing.T) { |
| + tmpl := template.Must( |
| + template.New("t").Funcs(template.FuncMap{"icon": Icon}).Parse(`{{icon "check"}}`), |
| + ) |
| + var buf strings.Builder |
| + if err := tmpl.Execute(&buf, nil); err != nil { |
| + t.Fatalf("Execute: %v", err) |
| + } |
| + got := buf.String() |
| + if !strings.Contains(got, "<svg") || !strings.Contains(got, `aria-hidden="true"`) { |
| + t.Errorf("rendered template output missing expected icon markup: %s", got) |
| + } |
| +} |
| + |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `cd /path/to/rastrillo && GOCACHE=/tmp/gocache GOPATH=/tmp/gopath go test ./... -run "TestIcon|TestVendored" -v` |
| +Expected: FAIL to compile — `undefined: icons`, `undefined: Icon`. This is the expected Go TDD red state; no scaffolding needed to reach it. |
| + |
| +- [ ] **Step 3: Write `icons.go`** |
| + |
| +```go |
| +// The rastrillo default icon set — vendored, never a CDN. Mirrors |
| +// carlosframework/platform's internal/console/icons.go exactly: static, |
| +// in-code SVG strings in the Lucide idiom (24x24 viewBox, currentColor, |
| +// stroke, round caps and joins), safe to inline into any app's own |
| +// html/template output via {{icon "slug"}} (register with |
| +// template.FuncMap{"icon": rastrillo.Icon}). |
| +// |
| +// Vendored, never a CDN: a rastrillo app that ships this promises the |
| +// same thing the platform's own console does -- no build step, no |
| +// second origin a browser fetches from and the app can't vouch for or |
| +// serve when 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 via the console: |
| +// emoji are CONTENT -- a mark a person or a model chose -- 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>"}}. |
| +// |
| +// Every icon carries aria-hidden="true" because it is meant to sit |
| +// beside its own visible text label -- the label is the accessible |
| +// name, and a second one from the icon would only be read out twice. |
| +// An app using an icon as a control's ONLY label must not use these |
| +// constants bare: give that button or link an explicit aria-label. |
| +package rastrillo |
| + |
| +import "html/template" |
| + |
| +// template.HTML is used HERE AND ONLY HERE in this package, for the same |
| +// reason it's safe in the console: every string in this file is a |
| +// compile-time constant of our own markup, never user input, a bucket |
| +// object, or anything derived from a request. |
| +const ( |
| + // iconOpen is the shared opening tag: no width or height, so the icon |
| + // sizes from the consuming app's own CSS (e.g. a .icon { } rule) 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">` |
| + |
| + // A disclosure caret -- dropdowns, expandable sections (Lucide: chevron-down). |
| + iconChevronDown template.HTML = iconOpen + `<path d="m6 9 6 6 6-6"/></svg>` |
| + |
| + // Confirmation, success states (Lucide: check). |
| + iconCheck template.HTML = iconOpen + `<path d="M20 6 9 17l-5-5"/></svg>` |
| + |
| + // "New" / "create" actions (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 vendored icon by its Lucide slug for use as an |
| +// html/template FuncMap entry: |
| +// |
| +// tmpl.Funcs(template.FuncMap{"icon": rastrillo.Icon}) |
| +// // then, in the template: {{icon "check"}} |
| +// |
| +// An unknown slug renders nothing rather than panicking a page |
| +// mid-response -- a typo must cost a missing icon, not a crash. |
| +func Icon(slug string) template.HTML { return icons[slug] } |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `cd /path/to/rastrillo && GOCACHE=/tmp/gocache GOPATH=/tmp/gopath go test ./... -run "TestIcon|TestVendored" -v` |
| +Expected: `PASS` for `TestVendoredIconsAreSelfContained`, `TestIconUnknownSlugRendersNothing`, `TestIconWorksAsTemplateFunc`. |
| + |
| +- [ ] **Step 5: Run the full suite, vet, and format check** |
| + |
| +Run: `cd /path/to/rastrillo && GOCACHE=/tmp/gocache GOPATH=/tmp/gopath go build ./... && go vet ./... && go test ./... && gofmt -l icons.go icons_test.go` |
| +Expected: build/vet/test all clean; `gofmt -l` prints nothing (no files need reformatting). |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add icons.go icons_test.go |
| +git commit -m "$(cat <<'EOF' |
| +feat: vendor a default Lucide icon set |
| + |
| +rastrillo.Icon(slug) template.HTML -- register as a template.FuncMap |
| +entry ({"icon": rastrillo.Icon}) for use as {{icon "slug"}} in any |
| +hand-written app template. Mirrors carlosframework/platform's |
| +internal/console/icons.go exactly: same vendoring discipline (no CDN, |
| +compile-time constants), same aria-hidden + label-pairing convention, |
| +same "unknown slug renders nothing, never panics" safety rule. The |
| +three vendored icons (chevron-down, check, plus) reuse the console's |
| +own already-tested SVG path data verbatim, not re-typed from memory. |
| + |
| +Per docs/superpowers/specs/2026-08-02-rastrillo-ui-addendum-design.md §3 |
| +in carlosframework/platform. |
| +EOF |
| +)" |
| +``` |
| + |
| +--- |
| + |
| +## Self-review notes |
| + |
| +- **Spec coverage:** This plan covers only §3 (icons) of the addendum spec's readiness-scoped slice. §2 (app shape/reactivity), §4 (WCAG vet enforcement), and §5 (dropdown/component set) are explicitly out of scope — they depend on §9's component-vocabulary system, which does not exist in rastrillo's code yet (confirmed: no `html/template` usage anywhere in the current codebase before this plan). This is a deliberate, user-confirmed scope cut, not an oversight. |
| +- **Placeholder scan:** none found — every step has real, complete code or a real, runnable command. |
| +- **Type consistency:** `Icon(slug string) template.HTML` is the only exported symbol; used identically in the doc comment's usage example, the `TestIconWorksAsTemplateFunc` test, and the Step 3 implementation. |
| +- **Not included, and why:** console's `TestConsoleTemplatesFetchNothingOffOrigin` and `TestTemplateIconSlugsAllExist` both walk a live template filesystem (`tmplFS`) checking real `.html` files — rastrillo has no template tree yet to walk (no component system built). Both tests are real, valuable, and directly portable in shape once §9 lands; they're not needed to make *this* package correct and complete on its own. |