carlos / platform Public

plan: rastrillo ui component library — 8 tasks, full markup and verified palette

The plan carries the complete partial markup, tokens.css (all 50 contrast
pairs computed and reviewer-verified), and test code. Spec §3/§7 amended
to record the approved scaffold static-serving ruling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b08df01081866861042e2724b7b38812759ec5a8 parent afbe532
2 files changed, +2433 −3
  • docs/superpowers/plans/2026-08-02-rastrillo-ui-component-library.md +2410 −0
  • docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md +23 −3
diff --git a/docs/superpowers/plans/2026-08-02-rastrillo-ui-component-library.md b/docs/superpowers/plans/2026-08-02-rastrillo-ui-component-library.md
new file mode 100644
index 000000000..b571a03b1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-rastrillo-ui-component-library.md
@@ -0,0 +1,2410 @@
+# Rastrillo `ui` Component Library 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:** Build `rastrillo/ui` — eight embedded List-screen `{{define}}` partials, a from-scratch WCAG 2.2 AA design-token + component stylesheet, and the two template helpers they need — so a rastrillo app gets a working, accessible, zero-JS List screen from an import and a `ParseFS` call. The north star is the slice that comes after this one: **a sample blog-posting app that looks half-decent with nothing but stock components.** This plan builds only the library and the §7 scaffold wiring that delivers its stylesheet and makes it reachable — no blog app, no manifest, no generator (spec §3, Non-goals).
+
+**Architecture:** A new root-level package `ui/` (sibling of `icons.go`/`serve.go`, same shape `icons.go` already established: vendored content, embedded, never a CDN). `ui.go` embeds `partials/*.html` and `tokens.css` and exposes `Templates() fs.FS` / `TokensCSS() []byte`. `funcs.go` exposes `Funcs() template.FuncMap` — `dict`, `list`, and `icon` (the root package's existing `rastrillo.Icon`). The app owns rendering: it builds its own `*html/template.Template`, calls `.Funcs(ui.Funcs())` and `.ParseFS(ui.Templates(), "*.html")` alongside its own templates. `rastrillo.Serve` is untouched — it never serves CSS. `tokens.css` reaches an app exactly once, at `rastrillo new` time, and is app-owned from then on.
+
+**Tech Stack:** Go 1.25 stdlib only — `embed`, `io/fs`, `html/template`, `testing`. No new module dependencies. Plain CSS (custom properties, flexbox, `@media (prefers-color-scheme: dark)`), no build step, no preprocessor, no external font or asset of any kind.
+
+## Global Constraints
+
+- **Repo:** all work happens in the rastrillo repo at `/tmp/claude-1001/rastrillo-build/rastrillo` (module `github.com/carlosframework/rastrillo`). Nothing in the platform repo changes.
+- **Branch:** `ui-component-library`, created **from `vendor-lucide-icons`** (PR #2, not yet merged). This work stacks on it: it imports `rastrillo.Icon` and adds a slug to `icons.go`.
+- **No new dependencies.** `go.mod` gains nothing. Tests are plain stdlib `testing`, in the style of the existing `icons_test.go` — no testify, no golden-file library.
+- **Zero-JS baseline.** Every partial is fully functional with JavaScript disabled: search submits via an ordinary form GET, pagination and row actions are ordinary links, the empty state's CTA is a link or a plain form POST. No partial's primary function depends on a script running. No `<script>` tag appears anywhere in this package.
+- **WCAG 2.2 AA, enforced by hand for this set.** Every text/background pair and every status-pill tone pair meets **4.5:1** (normal text) / **3:1** (large text and UI component boundaries). State is never conveyed by colour alone. Every interactive element renders with a real accessible name.
+- **From scratch, not a port.** No markup or CSS is copied from titogo. Titogo's real ticket-types list screen is the *acceptance reference* (spec §10), not source material. The palette is deliberately **not** titogo's cornflower.
+- **No runtime CSS serving.** `rastrillo.Serve` does not serve `tokens.css` (spec §3). The generated app serves its own `static/` directory, app-side, from code `rastrillo new` writes.
+- **No i18n catalog.** Every fixed string a partial renders is a Go default the caller overrides by passing its own value. No call into a localization catalog (spec §3).
+- **No illustration assets, no dropdown, no form/field partials, no manifest or codegen** (spec §3). `list-bar` reserves the layout position a future `dropdown` slice attaches to; it renders no filter or sort control.
+- **Error handling is standard `html/template` semantics** (spec §9). With `dict`-built map data, a key the caller forgot to set does **not** fail at `Execute`: `html/template` renders the zero value for a missing map key silently. `Execute` only errors for a malformed template call (wrong argument count or type to `dict`, an undefined partial name) or for a struct-typed data value whose named field genuinely does not exist. No validation layer is added to close that gap; a caller who wants missing-field detection passes a Go struct instead of a `dict`-built map. Partials still guard every optional field with `{{if}}`, so an unset field renders nothing at all rather than an empty attribute or a stray wrapper element.
+- **CSS naming:** every class and custom property this package ships is prefixed `rst-`, so dropping `tokens.css` into an app that already has CSS cannot collide.
+
+---
+
+## File Structure
+
+**Created in the rastrillo repo:**
+
+| Path | Responsibility |
+|---|---|
+| `ui/ui.go` | Package doc comment, the two `embed` directives, `Templates()`, `TokensCSS()`. |
+| `ui/funcs.go` | `Funcs()`, `dict`, `list`. |
+| `ui/tokens.css` | One file: design tokens at the top (colour, spacing, type, radius; light + dark), the component classes the eight partials use below. Embedded here, scaffolded into apps by `rastrillo new`. |
+| `ui/partials/page-header.html` | `{{define "page-header"}}` |
+| `ui/partials/list-bar.html` | `{{define "list-bar"}}` |
+| `ui/partials/list-bar-search.html` | `{{define "list-search-submit"}}` and `{{define "list-bar-search"}}` — the two live together because `list-bar-search` is the only caller of `list-search-submit` (spec §4: file layout mirrors usage, not the 8-item list 1:1). |
+| `ui/partials/list-row-action.html` | `{{define "list-row-action"}}` |
+| `ui/partials/status-pill.html` | `{{define "status-pill"}}` |
+| `ui/partials/empty-state.html` | `{{define "empty-state"}}` |
+| `ui/partials/pagination.html` | `{{define "pagination"}}` |
+| `ui/funcs_test.go` | Unit tests for `dict`, `list`, `Funcs`. |
+| `ui/ui_test.go` | Parse/execute per partial, self-containment (renders + `TokensCSS()`), the render-everything smoke test. |
+| `cmd/rastrillo/new_test.go` | Tests for the scaffold's new behaviour. |
+
+**Modified in the rastrillo repo:**
+
+| Path | Change |
+|---|---|
+| `icons.go` | One new vendored Lucide slug: `search`. |
+| `icons_test.go` | `TestExpectedIconSlugsRegistered`'s slug list gains `"search"`. |
+| `cmd/rastrillo/new.go` | Scaffold `<name>/static/tokens.css` from `ui.TokensCSS()`; `mainTemplate` serves the app's `static/` dir app-side. |
+
+**Why this split:** `ui.go` and `funcs.go` are separate because they have different reasons to change — `ui.go` changes when the embedded asset set changes, `funcs.go` when the template vocabulary does. One partial per file (except the pair above) keeps each file small enough to read whole, and matches how `ParseFS(Templates(), "*.html")` flattens them anyway. `tokens.css` is one file, tokens above components, because it is also a *delivery artifact*: `rastrillo new` copies exactly this file into an app, and an app that has to manage two stylesheets to get one look has been handed a worse deal.
+
+---
+
+### Task 1: Branch, and the `search` icon slug
+
+The whole package hinges on `{{icon "search"}}` resolving. An unregistered slug renders **empty** rather than erroring (`Icon`'s documented behaviour), so without this the search bar would ship with an invisible icon and every test in this plan would still pass. Do it first, in the root package, before any `ui/` file exists.
+
+**Files:**
+- Modify: `icons.go:52-75` (the `const` block and the `icons` map)
+- Test: `icons_test.go:46-54` (`TestExpectedIconSlugsRegistered`, and the comment line above it)
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `rastrillo.Icon("search") template.HTML` returns a non-empty vendored Lucide magnifying-glass SVG. Every later task's `{{icon "search"}}` depends on it.
+
+- [ ] **Step 1: Create the branch from `vendor-lucide-icons`**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git checkout vendor-lucide-icons
+git pull --ff-only || true
+git checkout -b ui-component-library
+git status --short # expect: clean
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Edit `icons_test.go`, in `TestExpectedIconSlugsRegistered`. Replace the `expected` line and the comment above the function:
+
+```go
+// All four expected icon slugs are registered and non-empty.
+func TestExpectedIconSlugsRegistered(t *testing.T) {
+ expected := []string{"chevron-down", "check", "plus", "search"}
+ for _, slug := range expected {
+ if got := Icon(slug); got == "" {
+ t.Errorf("Icon(%q) is empty or not registered", slug)
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test -run TestExpectedIconSlugsRegistered ./... -v`
+Expected: FAIL — `Icon("search") is empty or not registered`.
+
+- [ ] **Step 4: Add the vendored slug**
+
+In `icons.go`, add the constant to the existing `const` block, immediately after `iconPlus` (keep the `(Lucide: <slug>)` comment idiom exactly):
+
+```go
+ // Search fields and filter bars (Lucide: search).
+ iconSearch template.HTML = iconOpen + `<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`
+```
+
+And add the map entry to `icons`:
+
+```go
+var icons = map[string]template.HTML{
+ "chevron-down": iconChevronDown,
+ "check": iconCheck,
+ "plus": iconPlus,
+ "search": iconSearch,
+}
+```
+
+- [ ] **Step 5: Run the whole root-package suite**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./... -v`
+Expected: PASS. `TestVendoredIconsAreSelfContained` iterates the `icons` map, so it now covers `search` with no edit — confirm its output mentions no `search` failure. (The new SVG carries `<path`, `<svg`, `</svg>`, `viewBox="0 0 24 24"`, `stroke="currentColor"`, `aria-hidden="true"` and `class="icon"` via `iconOpen`, and hardcodes no width/height — every assertion that test makes.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add icons.go icons_test.go
+git commit -m "feat(icons): vendor Lucide's search glyph
+
+The ui package's list-bar search field calls {{icon \"search\"}}. An
+unregistered slug renders empty rather than erroring, so this has to
+land before the partial that uses it or the bug is invisible."
+```
+
+---
+
+### Task 2: `ui/funcs.go` — `dict`, `list`, `Funcs`
+
+Every partial takes exactly one data value. `dict` is how a caller builds that value inline in a template call without a Go view-model type per combination. `list` builds the slice `pagination` iterates. Both are the standard sprig-style helpers; `dict` fails loudly on a bad call rather than silently dropping a key.
+
+**Files:**
+- Create: `ui/funcs.go`
+- Test: `ui/funcs_test.go`
+
+**Interfaces:**
+- Consumes: `rastrillo.Icon(slug string) template.HTML` from Task 1's package.
+- Produces:
+ - `func dict(pairs ...any) (map[string]any, error)` — package-private. Errors on an odd argument count or a non-string key.
+ - `func list(items ...any) []any` — package-private. Never errors.
+ - `func Funcs() template.FuncMap` — exported. Returns exactly three entries: `"dict"`, `"list"`, `"icon"`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `ui/funcs_test.go`:
+
+```go
+package ui
+
+import (
+ "html/template"
+ "strings"
+ "testing"
+)
+
+func TestDictBuildsAMap(t *testing.T) {
+ m, err := dict("Title", "Posts", "Count", 3)
+ if err != nil {
+ t.Fatalf("dict: %v", err)
+ }
+ if m["Title"] != "Posts" {
+ t.Errorf(`m["Title"] = %v, want "Posts"`, m["Title"])
+ }
+ if m["Count"] != 3 {
+ t.Errorf(`m["Count"] = %v, want 3`, m["Count"])
+ }
+ if len(m) != 2 {
+ t.Errorf("len(m) = %d, want 2", len(m))
+ }
+}
+
+func TestDictWithNoPairsIsEmptyNotNil(t *testing.T) {
+ m, err := dict()
+ if err != nil {
+ t.Fatalf("dict: %v", err)
+ }
+ if m == nil {
+ t.Fatal("dict() returned a nil map; callers index into it")
+ }
+ if len(m) != 0 {
+ t.Errorf("len(m) = %d, want 0", len(m))
+ }
+}
+
+// An odd argument count must fail loudly at Execute rather than silently
+// dropping the last key (spec §4.1).
+func TestDictOddArgCountIsAnError(t *testing.T) {
+ if _, err := dict("Title", "Posts", "Count"); err == nil {
+ t.Fatal("dict with 3 args returned no error")
+ }
+}
+
+func TestDictNonStringKeyIsAnError(t *testing.T) {
+ if _, err := dict(7, "Posts"); err == nil {
+ t.Fatal("dict with a non-string key returned no error")
+ }
+}
+
+func TestListBuildsASlice(t *testing.T) {
+ got := list("a", 2, true)
+ if len(got) != 3 {
+ t.Fatalf("len = %d, want 3", len(got))
+ }
+ if got[0] != "a" || got[1] != 2 || got[2] != true {
+ t.Errorf("list(...) = %v, want [a 2 true]", got)
+ }
+}
+
+func TestListWithNoItemsIsEmptyNotNil(t *testing.T) {
+ if got := list(); got == nil {
+ t.Fatal("list() returned nil; templates range over this")
+ }
+}
+
+func TestFuncsRegistersDictListAndIcon(t *testing.T) {
+ f := Funcs()
+ for _, name := range []string{"dict", "list", "icon"} {
+ if _, ok := f[name]; !ok {
+ t.Errorf("Funcs() is missing %q", name)
+ }
+ }
+ if len(f) != 3 {
+ t.Errorf("Funcs() has %d entries, want exactly 3", len(f))
+ }
+}
+
+// The value proposition: these have to work as real FuncMap entries end
+// to end, the same standard icons_test.go's TestIconWorksAsTemplateFunc
+// holds Icon to.
+func TestFuncsWorkEndToEnd(t *testing.T) {
+ tmpl := template.Must(template.New("t").Funcs(Funcs()).Parse(
+ `{{$d := dict "Label" "Search"}}{{$d.Label}}|{{len (list 1 2 3)}}|{{icon "search"}}`,
+ ))
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, nil); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ got := buf.String()
+ if !strings.HasPrefix(got, "Search|3|") {
+ t.Errorf("dict/list did not render as expected: %s", got)
+ }
+ if !strings.Contains(got, "<svg") {
+ t.Errorf("icon did not resolve through Funcs(): %s", got)
+ }
+}
+
+// An odd dict call must surface as an Execute error, not a silent render.
+func TestDictErrorSurfacesAtExecute(t *testing.T) {
+ tmpl := template.Must(template.New("t").Funcs(Funcs()).Parse(`{{$d := dict "A"}}{{$d.A}}`))
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, nil); err == nil {
+ t.Fatal("Execute returned no error for an odd-argument dict call")
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: FAIL — the package does not build (`undefined: dict`, `undefined: list`, `undefined: Funcs`).
+
+- [ ] **Step 3: Write the implementation**
+
+Create `ui/funcs.go`:
+
+```go
+package ui
+
+import (
+ "fmt"
+ "html/template"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+// Funcs returns the helpers ui's partials call, for an app to register on
+// its own template tree:
+//
+// tmpl := template.Must(template.New("").Funcs(ui.Funcs()).
+// ParseFS(ui.Templates(), "*.html"))
+//
+// dict and list are the map/slice builders that let a caller compose a
+// partial's one data value inline, with no Go view-model type per
+// combination. icon is the root package's vendored-Lucide lookup
+// (rastrillo.Icon), so {{icon "search"}} resolves identically inside a
+// vendored partial and inside an app's own templates.
+//
+// An app is free to add its own entries on top; it must not drop these
+// three, or the shipped partials stop parsing.
+func Funcs() template.FuncMap {
+ return template.FuncMap{"dict": dict, "list": list, "icon": rastrillo.Icon}
+}
+
+// dict builds a map from alternating key/value arguments:
+//
+// {{template "status-pill" dict "Tone" "positive" "Label" "Published"}}
+//
+// An odd argument count or a non-string key is an error, which
+// html/template surfaces from Execute. Failing loudly beats silently
+// dropping the trailing key — a partial rendering one field short is a
+// bug that looks like a design decision.
+func dict(pairs ...any) (map[string]any, error) {
+ if len(pairs)%2 != 0 {
+ return nil, fmt.Errorf("ui: dict wants an even number of arguments (key, value, …), got %d", len(pairs))
+ }
+ m := make(map[string]any, len(pairs)/2)
+ for i := 0; i < len(pairs); i += 2 {
+ key, ok := pairs[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("ui: dict key %d is %T, want string", i/2, pairs[i])
+ }
+ m[key] = pairs[i+1]
+ }
+ return m, nil
+}
+
+// list builds a slice from its arguments — pagination's Items, mostly:
+//
+// {{template "pagination" dict "Items" (list
+// (dict "Label" "1" "Current" true)
+// (dict "Label" "2" "Href" "/posts?page=2"))}}
+//
+// It returns an empty, non-nil slice for no arguments so a template can
+// range over the result unconditionally.
+func list(items ...any) []any {
+ out := make([]any, 0, len(items))
+ return append(out, items...)
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: PASS, all nine tests.
+
+Also run `go vet ./ui/` — expected: no output.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add ui/funcs.go ui/funcs_test.go
+git commit -m "feat(ui): dict, list and Funcs for the component library
+
+Every partial takes one data value; dict is how a caller builds it inline
+without a Go view-model type per combination. An odd argument count is an
+error, so a dropped key fails at Execute instead of rendering one field
+short."
+```
+
+---
+
+### Task 3: `ui/ui.go`, `tokens.css`, and the first partial (`status-pill`)
+
+This task proves the whole delivery pipeline end to end — `embed` → `fs.Sub` → `ParseFS("*.html")` → `Execute` — on the smallest possible partial, and lands the complete stylesheet.
+
+**`tokens.css` is written once, here, complete.** It contains the component classes every later partial references. Later tasks add partials only; none of them touches `tokens.css` again.
+
+**Files:**
+- Create: `ui/ui.go`, `ui/tokens.css`, `ui/partials/status-pill.html`, `ui/ui_test.go`
+- Test: `ui/ui_test.go`
+
+**Interfaces:**
+- Consumes: `Funcs()` from Task 2.
+- Produces:
+ - `func Templates() fs.FS` — the embedded partials **rooted at `partials/`**, so every caller parses `"*.html"` regardless of this package's source-tree layout.
+ - `func TokensCSS() []byte` — `tokens.css`'s raw bytes, for Task 8's scaffold step.
+ - Template `"status-pill"`. Data keys: `Label` (required, string), `Tone` (optional, string — one of `neutral` (the default), `positive`, `warning`, `negative`).
+ - The CSS class vocabulary every later task's markup uses: `.rst-page`, `.rst-page-header`, `.rst-page-header__titles`, `.rst-page-header__sub`, `.rst-btn`, `.rst-btn--primary`, `.rst-list`, `.rst-lbar`, `.rst-search`, `.rst-sr-only`, `.rst-row`, `.rst-row__lead`, `.rst-row__main`, `.rst-row__sub`, `.rst-row__action`, `.rst-status`, `.rst-empty`, `.rst-empty__title`, `.rst-empty__body`, `.rst-empty__cta`, `.rst-pagination`, `.rst-pagination__gap`, and `.icon` (the class `rastrillo.Icon` already emits).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `ui/ui_test.go`:
+
+```go
+package ui
+
+import (
+ "html/template"
+ "io/fs"
+ "strings"
+ "testing"
+)
+
+// parseAll builds the template tree exactly the way an app is documented
+// to: ui.Funcs() registered, then ParseFS over Templates() with the flat
+// "*.html" glob.
+func parseAll(t *testing.T) *template.Template {
+ t.Helper()
+ tmpl, err := template.New("").Funcs(Funcs()).ParseFS(Templates(), "*.html")
+ if err != nil {
+ t.Fatalf("ParseFS: %v", err)
+ }
+ return tmpl
+}
+
+// render executes one named partial against data and returns its output.
+func render(t *testing.T, name string, data any) string {
+ t.Helper()
+ var buf strings.Builder
+ if err := parseAll(t).ExecuteTemplate(&buf, name, data); err != nil {
+ t.Fatalf("ExecuteTemplate(%q): %v", name, err)
+ }
+ return buf.String()
+}
+
+// Templates() must hand back a filesystem rooted at the partials, not at
+// the package directory — the documented ParseFS call uses "*.html".
+func TestTemplatesIsRootedAtPartials(t *testing.T) {
+ entries, err := fs.ReadDir(Templates(), ".")
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("Templates() is empty")
+ }
+ for _, e := range entries {
+ if !strings.HasSuffix(e.Name(), ".html") {
+ t.Errorf("unexpected entry %q at the root of Templates()", e.Name())
+ }
+ }
+}
+
+func TestTokensCSSIsEmbedded(t *testing.T) {
+ css := TokensCSS()
+ if len(css) == 0 {
+ t.Fatal("TokensCSS() is empty")
+ }
+ for _, want := range []string{
+ "--rst-bg", "--rst-surface", "--rst-text", "--rst-accent",
+ "--rst-tone-positive-fg", "--rst-sp-4",
+ "prefers-color-scheme: dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`,
+ ".rst-status", ".rst-sr-only",
+ } {
+ if !strings.Contains(string(css), want) {
+ t.Errorf("tokens.css is missing %q", want)
+ }
+ }
+}
+
+// Both themes are authored, never inverted: every themed token is
+// declared three times — light, dark-by-OS, and dark-by-toggle. A
+// half-authored dark theme is the classic way a token file rots.
+func TestBothThemesDeclareEveryColourToken(t *testing.T) {
+ css := string(TokensCSS())
+ themed := []string{
+ "--rst-bg", "--rst-surface", "--rst-surface-2", "--rst-line", "--rst-line-strong",
+ "--rst-text", "--rst-text-muted", "--rst-text-faint",
+ "--rst-accent", "--rst-accent-strong", "--rst-accent-soft", "--rst-on-accent",
+ "--rst-tone-neutral-fg", "--rst-tone-neutral-bg",
+ "--rst-tone-positive-fg", "--rst-tone-positive-bg",
+ "--rst-tone-warning-fg", "--rst-tone-warning-bg",
+ "--rst-tone-negative-fg", "--rst-tone-negative-bg",
+ }
+ for _, prop := range themed {
+ // Declarations are "<prop>: value"; uses are "var(<prop>)", so the
+ // trailing colon counts declarations only.
+ if got := strings.Count(css, prop+":"); got != 3 {
+ t.Errorf("%s is declared %d times, want 3 (light, prefers-color-scheme dark, [data-theme=dark])", prop, got)
+ }
+ }
+}
+
+func TestStatusPillRendersLabelAndTone(t *testing.T) {
+ got := render(t, "status-pill", map[string]any{"Tone": "positive", "Label": "Published"})
+ if !strings.Contains(got, `data-tone="positive"`) {
+ t.Errorf("missing tone attribute: %s", got)
+ }
+ if !strings.Contains(got, "Published") {
+ t.Errorf("missing visible label: %s", got)
+ }
+}
+
+// State is never colour alone (spec §5, addendum §4): the label is always
+// real text in the output, whatever the tone.
+func TestStatusPillAlwaysCarriesTextLabel(t *testing.T) {
+ for _, tone := range []string{"neutral", "positive", "warning", "negative"} {
+ got := render(t, "status-pill", map[string]any{"Tone": tone, "Label": "Draft"})
+ if !strings.Contains(got, ">Draft<") {
+ t.Errorf("tone %q rendered no text label: %s", tone, got)
+ }
+ }
+}
+
+// The minimal fixture: Label only. A missing Tone falls back to neutral
+// rather than rendering an empty attribute.
+func TestStatusPillMinimalFixture(t *testing.T) {
+ got := render(t, "status-pill", map[string]any{"Label": "Draft"})
+ if !strings.Contains(got, `data-tone="neutral"`) {
+ t.Errorf("missing Tone did not fall back to neutral: %s", got)
+ }
+ if strings.Contains(got, `data-tone=""`) {
+ t.Errorf("rendered an empty tone attribute: %s", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: FAIL — the package does not build (`undefined: Templates`, `undefined: TokensCSS`).
+
+- [ ] **Step 3: Write `ui/ui.go`**
+
+```go
+// Package ui is rastrillo's server-shape component library: a small
+// starter set of List-screen html/template partials, a design-token
+// stylesheet, and the template helpers they need — vendored the same way
+// icons.go vendors Lucide, so an app pulls in a working component with an
+// import and a ParseFS call, not a hand-copy.
+//
+// It is a component library, not a screen generator. Nothing here
+// generates a screen, decides a route, or owns rendering. An app builds
+// its own template tree and calls these partials by name:
+//
+// tmpl := template.Must(template.New("").Funcs(ui.Funcs()).
+// ParseFS(ui.Templates(), "*.html"))
+// tmpl = template.Must(tmpl.ParseFS(appTemplateFS, "templates/*.html"))
+//
+// The eight partials are page-header, list-bar, list-bar-search,
+// list-search-submit, list-row-action, status-pill, empty-state and
+// pagination. Each takes exactly one data value; build it inline with
+// dict (see Funcs). Each partial's own file carries its data contract in
+// a template comment above the {{define}}.
+//
+// Two container classes the partials assume but do not emit, because
+// they belong to the app's own page markup:
+//
+// <div class="rst-page"> — the centred content column every screen sits in
+// <div class="rst-list"> — the card wrapping a list-bar and a run of rows
+//
+// Styling comes from tokens.css, which rastrillo new writes once into a
+// new app's static/ directory. rastrillo.Serve never serves it: from the
+// moment it is scaffolded it is an ordinary app-owned static file the app
+// is free to edit in place.
+//
+// Errors follow ordinary html/template semantics (nothing is
+// special-cased). With dict-built map data a key the caller forgot to set
+// does not fail at Execute; the partials guard every optional field, so a
+// missing key renders nothing. A caller who wants missing-field detection
+// gets it by passing a Go struct instead of a dict-built map.
+package ui
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed partials/*.html
+var partialsFS embed.FS
+
+//go:embed tokens.css
+var tokensCSS []byte
+
+// Templates returns the embedded partials rooted at partials/, so every
+// caller parses "*.html" regardless of this package's own source-tree
+// layout:
+//
+// tmpl := template.Must(template.New("").Funcs(ui.Funcs()).
+// ParseFS(ui.Templates(), "*.html"))
+func Templates() fs.FS {
+ sub, err := fs.Sub(partialsFS, "partials")
+ if err != nil {
+ panic(err) // partials/ is embedded above; this cannot fail
+ }
+ return sub
+}
+
+// TokensCSS returns tokens.css's raw bytes, for rastrillo new's scaffold
+// step to write into a new app's static directory. The stylesheet is
+// delivered once, at scaffold time, and is app-owned from then on.
+func TokensCSS() []byte { return tokensCSS }
+```
+
+- [ ] **Step 4: Write `ui/tokens.css` — the complete stylesheet**
+
+Create `ui/tokens.css` with exactly this content:
+
+```css
+/* rastrillo/ui — design tokens and the component classes the eight
+ shipped partials use.
+
+ rastrillo new writes this file once into your app's static/ directory.
+ From then on it is yours: edit it, replace it, delete rules you do not
+ want. Nothing regenerates it, and rastrillo.Serve never serves it —
+ your app's own static handler does.
+
+ Everything is prefixed rst- so this file cannot collide with CSS you
+ already have. There are no imports, no remote assets and no webfont:
+ this stylesheet fetches nothing, from anywhere, ever. That is a test,
+ not a wish (ui_test.go, TestTokensCSSIsSelfContained) — which is also
+ why this comment cannot spell those constructs out literally.
+
+ The look: ink on paper. Neutrals carry a faint violet cast rather than
+ the usual cool blue-grey, and the one accent is an iron-gall violet —
+ the colour writing ink oxidises to. Semantic status colours are
+ functional and never the accent, so a pill can never be mistaken for a
+ link. Both themes are authored, never inverted: dark follows the OS,
+ and the [data-theme] hooks let an explicit toggle win in both
+ directions.
+
+ ── WCAG 2.2 AA, measured ─────────────────────────────────────────────
+ Normal text needs 4.5:1; large text and UI component boundaries need
+ 3:1. Every pair below was computed from these exact hex values. If you
+ change a token, recompute its row.
+
+ LIGHT ratio requirement
+ --rst-text on --rst-surface 17.23 4.5:1 text
+ --rst-text on --rst-bg 15.47 4.5:1 text
+ --rst-text on --rst-surface-2 16.43 4.5:1 text
+ --rst-text on --rst-accent-soft 15.11 4.5:1 text
+ --rst-text-muted on --rst-surface 6.98 4.5:1 text
+ --rst-text-muted on --rst-bg 6.26 4.5:1 text
+ --rst-text-muted on --rst-surface-2 6.65 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 6.12 4.5:1 text
+ --rst-text-faint on --rst-surface 5.21 4.5:1 text
+ --rst-text-faint on --rst-bg 4.67 4.5:1 text
+ --rst-text-faint on --rst-surface-2 4.96 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 4.56 4.5:1 text
+ --rst-accent on --rst-surface 7.72 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 6.93 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 7.36 4.5:1 text
+ --rst-accent on --rst-accent-soft 6.77 4.5:1 text
+ --rst-on-accent on --rst-accent 7.72 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 10.06 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 3.36 3:1 control border
+ --rst-line-strong on --rst-bg 3.02 3:1 control border
+ --rst-line-strong on --rst-surface-2 3.20 3:1 control border
+ pill neutral fg on its own tint 7.88 4.5:1 text
+ pill positive fg on its own tint 6.10 4.5:1 text
+ pill warning fg on its own tint 6.21 4.5:1 text
+ pill negative fg on its own tint 6.37 4.5:1 text
+
+ DARK ratio requirement
+ --rst-text on --rst-surface 14.92 4.5:1 text
+ --rst-text on --rst-bg 16.20 4.5:1 text
+ --rst-text on --rst-surface-2 15.60 4.5:1 text
+ --rst-text on --rst-accent-soft 13.35 4.5:1 text
+ --rst-text-muted on --rst-surface 7.11 4.5:1 text
+ --rst-text-muted on --rst-bg 7.72 4.5:1 text
+ --rst-text-muted on --rst-surface-2 7.43 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 6.36 4.5:1 text
+ --rst-text-faint on --rst-surface 5.69 4.5:1 text
+ --rst-text-faint on --rst-bg 6.17 4.5:1 text
+ --rst-text-faint on --rst-surface-2 5.95 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 5.09 4.5:1 text
+ --rst-accent on --rst-surface 8.08 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 8.77 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 8.45 4.5:1 text
+ --rst-accent on --rst-accent-soft 7.23 4.5:1 text
+ --rst-on-accent on --rst-accent 8.15 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 10.34 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 3.32 3:1 control border
+ --rst-line-strong on --rst-bg 3.60 3:1 control border
+ --rst-line-strong on --rst-surface-2 3.47 3:1 control border
+ pill neutral fg on its own tint 7.43 4.5:1 text
+ pill positive fg on its own tint 8.42 4.5:1 text
+ pill warning fg on its own tint 8.21 4.5:1 text
+ pill negative fg on its own tint 6.93 4.5:1 text
+
+ --rst-line is a hairline divider between rows, not a control border or
+ a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
+ borders use --rst-line-strong, which does clear 3:1.
+
+ Pagination chips are the one deliberate exception: they are bordered
+ with --rst-line rather than --rst-line-strong, because a page strip
+ ringed in hard outlines shouts louder than the list it belongs to.
+ 1.4.11 is satisfied without that border — each chip's meaning is
+ carried by its text label at 4.5:1 or better, and the current page
+ adds a filled background, a heavier weight and aria-current on top.
+ The border is decoration, so it is allowed to be quiet.
+
+ --rst-text-faint is the tightest pair in the set — 4.67:1 in light on
+ --rst-bg, against a 4.5:1 floor. Put it on a surface not listed above
+ and you are guessing; recompute first.
+
+ Padding values are load-bearing for SC 2.5.8 Target Size (AA, new in
+ WCAG 2.2): pagination chips measure about 30px, row action pills
+ about 27px and buttons about 34px on their smaller axis, all clear of
+ the 24px minimum. Do not shrink them without re-measuring.
+
+ Type sizes are rem, not px, so the whole scale tracks a reader who has
+ raised their browser's default font size. Spacing is rem for the same
+ reason. Anything you add here should follow suit.
+ ──────────────────────────────────────────────────────────────────── */
+
+/* ── Scale: type, spacing, radius. Theme-independent. ─────────────── */
+:root {
+ --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --rst-fs-base: 0.875rem; /* 14px at the browser default */
+ --rst-fs-sm: 0.78125rem; /* 12.5px */
+ --rst-fs-xs: 0.71875rem; /* 11.5px */
+
+ --rst-sp-1: 0.25rem;
+ --rst-sp-2: 0.5rem;
+ --rst-sp-3: 0.75rem;
+ --rst-sp-4: 1rem;
+ --rst-sp-5: 1.5rem;
+ --rst-sp-6: 2.5rem;
+
+ --rst-radius: 10px;
+ --rst-radius-sm: 7px;
+ --rst-radius-pill: 999px;
+}
+
+/* ── Colour: light. Also the explicit-toggle-to-light case. ───────── */
+:root,
+:root[data-theme="light"] {
+ color-scheme: light;
+
+ --rst-bg: #f3f2f7;
+ --rst-surface: #ffffff;
+ --rst-surface-2: #faf9fc;
+ --rst-line: #e4e2ec;
+ --rst-line-strong: #8d89a4;
+
+ --rst-text: #1b1a23;
+ --rst-text-muted: #5b5670;
+ --rst-text-faint: #6e6987;
+
+ --rst-accent: #5b3fa8;
+ --rst-accent-strong: #47308f;
+ --rst-accent-soft: #f2eefc;
+ --rst-on-accent: #ffffff;
+
+ --rst-tone-neutral-fg: #45415a;
+ --rst-tone-neutral-bg: #e8e6ef;
+ --rst-tone-positive-fg: #17603f;
+ --rst-tone-positive-bg: #d7ecdf;
+ --rst-tone-warning-fg: #74490e;
+ --rst-tone-warning-bg: #f4e4c9;
+ --rst-tone-negative-fg: #93262f;
+ --rst-tone-negative-bg: #f9dcdd;
+}
+
+/* ── Colour: dark, following the OS. ──────────────────────────────── */
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: dark;
+
+ --rst-bg: #0d0c12;
+ --rst-surface: #17161f;
+ --rst-surface-2: #121119;
+ --rst-line: #292733;
+ --rst-line-strong: #6b6782;
+
+ --rst-text: #ebe9f2;
+ --rst-text-muted: #a5a0b8;
+ --rst-text-faint: #928db0;
+
+ --rst-accent: #b6a0ff;
+ --rst-accent-strong: #cbbaff;
+ --rst-accent-soft: #241c3d;
+ --rst-on-accent: #1a1030;
+
+ --rst-tone-neutral-fg: #bcb7cf;
+ --rst-tone-neutral-bg: #2a2836;
+ --rst-tone-positive-fg: #74dba8;
+ --rst-tone-positive-bg: #16301f;
+ --rst-tone-warning-fg: #eab86c;
+ --rst-tone-warning-bg: #322512;
+ --rst-tone-negative-fg: #f58c95;
+ --rst-tone-negative-bg: #35191e;
+ }
+}
+
+/* ── Colour: dark, chosen explicitly. Higher specificity than the
+ media query above, so a toggle wins over the OS in both
+ directions. ───────────────────────────────────────────────────── */
+:root[data-theme="dark"] {
+ color-scheme: dark;
+
+ --rst-bg: #0d0c12;
+ --rst-surface: #17161f;
+ --rst-surface-2: #121119;
+ --rst-line: #292733;
+ --rst-line-strong: #6b6782;
+
+ --rst-text: #ebe9f2;
+ --rst-text-muted: #a5a0b8;
+ --rst-text-faint: #928db0;
+
+ --rst-accent: #b6a0ff;
+ --rst-accent-strong: #cbbaff;
+ --rst-accent-soft: #241c3d;
+ --rst-on-accent: #1a1030;
+
+ --rst-tone-neutral-fg: #bcb7cf;
+ --rst-tone-neutral-bg: #2a2836;
+ --rst-tone-positive-fg: #74dba8;
+ --rst-tone-positive-bg: #16301f;
+ --rst-tone-warning-fg: #eab86c;
+ --rst-tone-warning-bg: #322512;
+ --rst-tone-negative-fg: #f58c95;
+ --rst-tone-negative-bg: #35191e;
+}
+
+/* ── Base. Three rules, so a freshly scaffolded app looks finished
+ before it has any CSS of its own. Delete them if your app owns
+ its own base styles. ──────────────────────────────────────────── */
+body {
+ margin: 0;
+ background: var(--rst-bg);
+ color: var(--rst-text);
+ font-family: var(--rst-font);
+ font-size: var(--rst-fs-base);
+ line-height: 1.5;
+}
+
+/* The content column every screen sits in. Your page markup emits this;
+ no partial does. */
+.rst-page {
+ box-sizing: border-box;
+ margin: 0 auto;
+ max-width: 52rem;
+ padding: var(--rst-sp-5) var(--rst-sp-4) var(--rst-sp-6);
+}
+
+/* rastrillo.Icon emits class="icon" with no width or height, so the icon
+ sizes from here and tracks the text beside it. */
+.icon {
+ block-size: 1em;
+ flex: none;
+ inline-size: 1em;
+}
+
+/* Focus is visible everywhere inside a rastrillo component. :where()
+ contributes no specificity, so any rule below can override it. */
+:where(.rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination) :focus-visible {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 2px;
+}
+
+/* Visually hidden, but in the DOM and the tab order — and it un-hides on
+ keyboard focus, because a focused control a sighted keyboard user
+ cannot see is a trap. */
+.rst-sr-only {
+ clip-path: inset(50%);
+ block-size: 1px;
+ inline-size: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+}
+.rst-sr-only:focus-visible {
+ background: var(--rst-accent);
+ border: 0;
+ border-radius: var(--rst-radius-sm);
+ block-size: auto;
+ clip-path: none;
+ color: var(--rst-on-accent);
+ cursor: pointer;
+ font: inherit;
+ font-size: var(--rst-fs-sm);
+ inline-size: auto;
+ overflow: visible;
+ padding: 0.3rem 0.6rem;
+ position: static;
+ white-space: normal;
+}
+
+/* ── Buttons: page-header's primary action, empty-state's CTA. ────── */
+.rst-btn {
+ align-items: center;
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: var(--rst-fs-sm);
+ font-weight: 600;
+ gap: var(--rst-sp-2);
+ line-height: 1.4;
+ padding: 0.45rem 0.75rem;
+ text-decoration: none;
+}
+.rst-btn:hover {
+ border-color: var(--rst-accent);
+ color: var(--rst-accent);
+}
+.rst-btn--primary {
+ background: var(--rst-accent);
+ border-color: var(--rst-accent);
+ color: var(--rst-on-accent);
+}
+.rst-btn--primary:hover {
+ background: var(--rst-accent-strong);
+ border-color: var(--rst-accent-strong);
+ color: var(--rst-on-accent);
+}
+
+/* ── page-header ──────────────────────────────────────────────────── */
+.rst-page-header {
+ align-items: baseline;
+ border-bottom: 1px solid var(--rst-line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ justify-content: space-between;
+ margin: 0 0 var(--rst-sp-5);
+ padding-bottom: var(--rst-sp-3);
+ position: relative;
+}
+/* The rake line: one short accent stroke laid over the header's rule.
+ The library's one flourish — the tool it is named for, drawn once per
+ screen and nowhere else. Absolutely positioned, so it is not a flex
+ item. */
+.rst-page-header::after {
+ background: var(--rst-accent);
+ block-size: 2px;
+ content: "";
+ inline-size: 2.5rem;
+ inset-block-end: -1px;
+ inset-inline-start: 0;
+ position: absolute;
+}
+.rst-page-header__titles {
+ flex: 1 1 auto;
+ min-inline-size: 0;
+}
+.rst-page-header h1 {
+ color: var(--rst-text);
+ font-size: 1.375rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.25;
+ margin: 0;
+}
+.rst-page-header__sub {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ margin: var(--rst-sp-1) 0 0;
+ max-inline-size: 44rem;
+}
+
+/* ── The list card, its toolbar, and its search field ─────────────── */
+.rst-list {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ overflow: hidden;
+}
+
+/* list-bar: the toolbar strip. The search field is capped at 20rem so
+ the space to its right stays reserved — that is where the filter and
+ sort controls attach when the dropdown slice lands. */
+.rst-lbar {
+ align-items: center;
+ background: var(--rst-surface-2);
+ border-bottom: 1px solid var(--rst-line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+}
+.rst-lbar > .rst-search {
+ flex: 0 1 20rem;
+}
+
+/* list-bar-search renders this form, which is the field box itself. Used
+ on its own inside a .rst-list card it just needs margin; inside a
+ .rst-lbar the strip supplies the padding. */
+.rst-search {
+ align-items: center;
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ display: flex;
+ gap: var(--rst-sp-2);
+ margin: 0;
+ padding: 0.3rem 0.55rem;
+}
+.rst-list > .rst-search {
+ margin: var(--rst-sp-3);
+}
+.rst-search .icon {
+ color: var(--rst-text-faint);
+}
+.rst-search input[type="search"] {
+ background: transparent;
+ border: 0;
+ color: var(--rst-text);
+ flex: 1;
+ font-family: inherit;
+ font-size: var(--rst-fs-sm);
+ min-inline-size: 0;
+ padding: 0.2rem 0;
+}
+.rst-search input[type="search"]::placeholder {
+ color: var(--rst-text-faint);
+}
+/* The field box carries the focus indicator for the whole control, so
+ the input's own default outline would only double it up. */
+.rst-search input[type="search"]:focus-visible {
+ outline: none;
+}
+.rst-search:focus-within {
+ border-color: var(--rst-accent);
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 1px;
+}
+
+/* ── list-row-action ──────────────────────────────────────────────── */
+.rst-row {
+ align-items: center;
+ border-top: 1px solid var(--rst-line);
+ display: flex;
+ gap: var(--rst-sp-3);
+ padding: var(--rst-sp-3);
+ position: relative;
+}
+.rst-row:first-child {
+ border-top: 0;
+}
+/* The toolbar strip already draws the line under itself. */
+.rst-lbar + .rst-row {
+ border-top: 0;
+}
+.rst-row:hover {
+ background: var(--rst-surface-2);
+}
+.rst-row__lead {
+ align-items: center;
+ background: var(--rst-tone-neutral-bg);
+ block-size: 1.9rem;
+ border-radius: 50%;
+ color: var(--rst-tone-neutral-fg);
+ display: flex;
+ flex: none;
+ font-size: var(--rst-fs-xs);
+ font-weight: 600;
+ inline-size: 1.9rem;
+ justify-content: center;
+}
+.rst-row__lead[data-lead="positive"] {
+ background: var(--rst-tone-positive-bg);
+ color: var(--rst-tone-positive-fg);
+}
+.rst-row__lead[data-lead="warning"] {
+ background: var(--rst-tone-warning-bg);
+ color: var(--rst-tone-warning-fg);
+}
+.rst-row__lead[data-lead="negative"] {
+ background: var(--rst-tone-negative-bg);
+ color: var(--rst-tone-negative-fg);
+}
+.rst-row__lead[data-lead="accent"] {
+ background: var(--rst-accent-soft);
+ color: var(--rst-accent);
+}
+.rst-row__main {
+ flex: 1;
+ min-inline-size: 0;
+}
+/* The name link's overlay makes the whole row clickable without nesting
+ one anchor inside another. The action pill sits above it. */
+.rst-row__main > a {
+ color: var(--rst-text);
+ font-weight: 600;
+ text-decoration: none;
+}
+.rst-row__main > a::after {
+ content: "";
+ inset: 0;
+ position: absolute;
+}
+.rst-row__main > a:hover {
+ text-decoration: underline;
+}
+.rst-row__sub {
+ color: var(--rst-text-muted);
+ display: block;
+ font-size: var(--rst-fs-sm);
+ margin-block-start: 0.1rem;
+}
+.rst-row__action {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-pill);
+ color: var(--rst-text-muted);
+ flex: none;
+ font-size: var(--rst-fs-sm);
+ padding: 0.2rem 0.7rem;
+ position: relative;
+ text-decoration: none;
+ z-index: 1;
+}
+.rst-row__action:hover {
+ border-color: var(--rst-accent);
+ color: var(--rst-accent);
+}
+
+/* ── status-pill. The dot is decoration; the label is the state. ──── */
+.rst-status {
+ align-items: center;
+ background: var(--rst-tone-neutral-bg);
+ border-radius: var(--rst-radius-pill);
+ color: var(--rst-tone-neutral-fg);
+ display: inline-flex;
+ font-size: var(--rst-fs-xs);
+ font-weight: 600;
+ gap: 0.35rem;
+ line-height: 1.6;
+ padding: 0.15rem 0.55rem;
+}
+.rst-status::before {
+ background: currentColor;
+ block-size: 0.375rem;
+ border-radius: 50%;
+ content: "";
+ flex: none;
+ inline-size: 0.375rem;
+}
+.rst-status[data-tone="positive"] {
+ background: var(--rst-tone-positive-bg);
+ color: var(--rst-tone-positive-fg);
+}
+.rst-status[data-tone="warning"] {
+ background: var(--rst-tone-warning-bg);
+ color: var(--rst-tone-warning-fg);
+}
+.rst-status[data-tone="negative"] {
+ background: var(--rst-tone-negative-bg);
+ color: var(--rst-tone-negative-fg);
+}
+
+/* ── empty-state ──────────────────────────────────────────────────── */
+.rst-empty {
+ background: var(--rst-surface);
+ border: 1px dashed var(--rst-line-strong);
+ border-radius: var(--rst-radius);
+ padding: var(--rst-sp-6) var(--rst-sp-4);
+ text-align: center;
+}
+/* Class-based, so it styles the partial's <h2> without inheriting any
+ <h2> default: an empty state's heading is a real heading in the
+ document outline, not a paragraph dressed up as one. */
+.rst-empty__title {
+ color: var(--rst-text);
+ font-size: 1.0625rem;
+ font-weight: 600;
+ margin: 0 0 var(--rst-sp-2);
+}
+.rst-empty__body {
+ color: var(--rst-text-muted);
+ margin: 0 auto;
+ max-inline-size: 34rem;
+}
+.rst-empty__cta {
+ margin: var(--rst-sp-4) 0 0;
+}
+
+/* ── pagination. The current page is a filled chip AND aria-current AND
+ bolder — three signals, so it never rests on colour alone. ────── */
+.rst-pagination {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ font-variant-numeric: tabular-nums;
+ gap: var(--rst-sp-1);
+ margin: var(--rst-sp-4) 0;
+}
+.rst-pagination a,
+.rst-pagination span {
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text-muted);
+ display: inline-block;
+ font-size: var(--rst-fs-sm);
+ min-inline-size: 2rem;
+ padding: 0.3rem 0.6rem;
+ text-align: center;
+ text-decoration: none;
+}
+.rst-pagination a:hover {
+ background: var(--rst-accent-soft);
+ border-color: var(--rst-accent);
+ color: var(--rst-text);
+}
+.rst-pagination [aria-current="page"] {
+ background: var(--rst-accent);
+ border-color: var(--rst-accent);
+ color: var(--rst-on-accent);
+ font-weight: 600;
+}
+.rst-pagination [aria-disabled="true"] {
+ border-style: dashed;
+ color: var(--rst-text-faint);
+}
+.rst-pagination .rst-pagination__gap {
+ border-color: transparent;
+ min-inline-size: 0;
+ padding-inline: var(--rst-sp-1);
+}
+
+/* ── Narrow screens ───────────────────────────────────────────────── */
+@media (max-width: 34rem) {
+ .rst-lbar > .rst-search {
+ flex: 1 1 100%;
+ }
+ .rst-row {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+ .rst-page-header h1 {
+ font-size: 1.25rem;
+ }
+}
+```
+
+- [ ] **Step 5: Write `ui/partials/status-pill.html`**
+
+```html
+{{/* status-pill — a status as a tinted pill.
+
+ The label is always visible text. State is never colour alone (spec
+ §5; WCAG 2.2 AA), and the leading dot is decoration on top of the
+ word, not a substitute for it.
+
+ Keys:
+ Label string, required — the visible status text
+ Tone string, optional — "neutral" (the default), "positive",
+ "warning" or "negative". tokens.css defines the
+ foreground/background pair for each.
+
+ Resolving an app's raw status value to a Tone/Label pair is product
+ logic and stays in the calling app. */}}
+{{define "status-pill"}}<span class="rst-status" data-tone="{{if .Tone}}{{.Tone}}{{else}}neutral{{end}}">{{.Label}}</span>{{end}}
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: PASS, every test in `funcs_test.go` and `ui_test.go`.
+
+Then confirm the whole module still builds: `go build ./... && go vet ./...` — expected: no output.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add ui/ui.go ui/tokens.css ui/partials/status-pill.html ui/ui_test.go
+git commit -m "feat(ui): embed pipeline, tokens.css and status-pill
+
+status-pill is the smallest partial there is, so it proves the whole
+delivery path — embed, fs.Sub, ParseFS(\"*.html\"), Execute — before any
+larger markup depends on it.
+
+tokens.css lands complete: ink-on-paper neutrals with an iron-gall violet
+accent, both themes authored, every text/background and pill pair
+measured against WCAG 2.2 AA and the ratios recorded in the file."
+```
+
+---
+
+### Task 4: `page-header` and `empty-state`
+
+The two partials that frame a screen: the title bar at the top, and what the screen says when there is nothing to list yet. Both are pure output with one optional call to action, which is why they pair.
+
+**Files:**
+- Create: `ui/partials/page-header.html`, `ui/partials/empty-state.html`
+- Modify: `ui/ui_test.go` (append tests)
+
+**Interfaces:**
+- Consumes: `parseAll`/`render` helpers and `Funcs()`/`Templates()` from Task 3.
+- Produces:
+ - Template `"page-header"`. Keys: `Title` (required, string); `Sub` (string); `ActionHref` (string), `ActionLabel` (string), `ActionIcon` (string — a `rastrillo.Icon` slug such as `"plus"`).
+ - Template `"empty-state"`. Keys: `Body` (required, string); `Title` (string); `ActionHref` + `ActionLabel` (a link CTA); or `PostAction` + `ActionLabel` + `Hidden` (a form CTA). `Hidden` is `[][2]string`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `ui/ui_test.go`:
+
+```go
+func TestPageHeaderMinimalFixture(t *testing.T) {
+ got := render(t, "page-header", map[string]any{"Title": "Posts"})
+ if !strings.Contains(got, "<h1>Posts</h1>") {
+ t.Errorf("missing title: %s", got)
+ }
+ if strings.Contains(got, "<a ") {
+ t.Errorf("no action was supplied, so no link should render: %s", got)
+ }
+ if strings.Contains(got, "rst-page-header__sub") {
+ t.Errorf("no Sub was supplied, so no subhead should render: %s", got)
+ }
+}
+
+func TestPageHeaderWithSubAndAction(t *testing.T) {
+ got := render(t, "page-header", map[string]any{
+ "Title": "Posts",
+ "Sub": "Everything you have written, newest first.",
+ "ActionHref": "/posts/new",
+ "ActionLabel": "Write a post",
+ "ActionIcon": "plus",
+ })
+ for _, want := range []string{
+ "<h1>Posts</h1>",
+ "Everything you have written, newest first.",
+ `href="/posts/new"`,
+ "Write a post",
+ "<svg", // the icon resolved through Funcs()
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q: %s", want, got)
+ }
+ }
+}
+
+// The action is a link with visible text, so its accessible name is the
+// text itself — the icon beside it must stay silent.
+func TestPageHeaderActionIconIsAriaHidden(t *testing.T) {
+ got := render(t, "page-header", map[string]any{
+ "Title": "Posts", "ActionHref": "/posts/new",
+ "ActionLabel": "Write a post", "ActionIcon": "plus",
+ })
+ if !strings.Contains(got, `aria-hidden="true"`) {
+ t.Errorf("the action icon is not aria-hidden: %s", got)
+ }
+}
+
+func TestEmptyStateMinimalFixture(t *testing.T) {
+ got := render(t, "empty-state", map[string]any{
+ "Body": "No posts yet. Your first one is a good place to start.",
+ })
+ if !strings.Contains(got, "No posts yet.") {
+ t.Errorf("missing body: %s", got)
+ }
+ if strings.Contains(got, "<form") || strings.Contains(got, "<a ") {
+ t.Errorf("no CTA was supplied, so none should render: %s", got)
+ }
+ if strings.Contains(got, "rst-empty__title") {
+ t.Errorf("no Title was supplied, so no heading should render: %s", got)
+ }
+}
+
+func TestEmptyStateLinkCTA(t *testing.T) {
+ got := render(t, "empty-state", map[string]any{
+ "Title": "Nothing here yet",
+ "Body": "No posts yet. Your first one is a good place to start.",
+ "ActionHref": "/posts/new",
+ "ActionLabel": "Write a post",
+ })
+ // A real heading, not a styled paragraph: styling a <p> to look like
+ // a heading is WCAG 2.2 failure F2 (1.3.1) and heading navigation
+ // skips it entirely.
+ if !strings.Contains(got, `<h2 class="rst-empty__title">Nothing here yet</h2>`) {
+ t.Errorf("title is not a real heading element: %s", got)
+ }
+ if !strings.Contains(got, `<a class="rst-btn rst-btn--primary" href="/posts/new">Write a post</a>`) {
+ t.Errorf("missing link CTA: %s", got)
+ }
+ if strings.Contains(got, "<form") {
+ t.Errorf("a link CTA must not also render a form: %s", got)
+ }
+}
+
+// The POST CTA is an ordinary form: it works with JavaScript off, and
+// hidden pairs (a CSRF token among them) are entirely app-supplied.
+func TestEmptyStatePostCTACarriesHiddenPairs(t *testing.T) {
+ got := render(t, "empty-state", map[string]any{
+ "Body": "No posts yet, and no sample data either.",
+ "PostAction": "/posts/seed",
+ "ActionLabel": "Add sample posts",
+ "Hidden": [][2]string{{"csrf", "tok-123"}, {"count", "5"}},
+ })
+ for _, want := range []string{
+ `method="post"`, `action="/posts/seed"`,
+ `<input type="hidden" name="csrf" value="tok-123">`,
+ `<input type="hidden" name="count" value="5">`,
+ `type="submit"`, "Add sample posts",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q: %s", want, got)
+ }
+ }
+}
+
+// A caller that sets neither CTA, and a caller that sets a POST CTA with
+// no Hidden pairs, must both render without an Execute error — ranging
+// over an absent key is the classic way a partial blows up.
+func TestEmptyStatePostCTAWithoutHidden(t *testing.T) {
+ got := render(t, "empty-state", map[string]any{
+ "Body": "No posts yet.",
+ "PostAction": "/posts/seed",
+ "ActionLabel": "Add sample posts",
+ })
+ if !strings.Contains(got, `action="/posts/seed"`) {
+ t.Errorf("missing form action: %s", got)
+ }
+ if strings.Contains(got, "type=\"hidden\"") {
+ t.Errorf("no Hidden pairs were supplied, so none should render: %s", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -run 'PageHeader|EmptyState' -v`
+Expected: FAIL — `ExecuteTemplate("page-header"): html/template: "page-header" is undefined`.
+
+- [ ] **Step 3: Write `ui/partials/page-header.html`**
+
+```html
+{{/* page-header — the screen's title bar: an <h1>, an optional subhead,
+ and at most one primary action.
+
+ Keys:
+ Title string, required — the screen's noun ("Posts")
+ Sub string, optional — one quiet line under the title
+ ActionHref string, optional — the primary action's target
+ ActionLabel string, optional — its visible text. Say what
+ happens: "Write a post", not "New".
+ ActionIcon string, optional — a rastrillo icon slug ("plus").
+ Decorative: the link's visible text is its
+ accessible name, and rastrillo.Icon already marks
+ every glyph aria-hidden.
+
+ No overflow/kebab menu — that arrives with the dropdown slice. */}}
+{{define "page-header"}}<header class="rst-page-header">
+ <div class="rst-page-header__titles">
+ <h1>{{.Title}}</h1>
+ {{- if .Sub}}
+ <p class="rst-page-header__sub">{{.Sub}}</p>
+ {{- end}}
+ </div>
+ {{- if .ActionHref}}
+ <a class="rst-btn rst-btn--primary" href="{{.ActionHref}}">{{if .ActionIcon}}{{icon .ActionIcon}}{{end}}{{.ActionLabel}}</a>
+ {{- end}}
+</header>{{end}}
+```
+
+- [ ] **Step 4: Write `ui/partials/empty-state.html`**
+
+```html
+{{/* empty-state — the blank-state card: a heading, one specific
+ sentence, and at most one call to action.
+
+ Empty states teach. "No posts yet. Your first one is a good place to
+ start." earns its space; "No records found" does not.
+
+ Keys:
+ Body string, required — the sentence
+ Title string, optional — a short heading above it. Renders
+ as a real <h2>: styling a paragraph to look like a
+ heading is WCAG 2.2 failure F2 (1.3.1), and a screen
+ reader user navigating by heading would skip it. The
+ app owns <h1> via page-header, so an empty state
+ nested under a page title is an h2.
+ ActionHref string, optional — a link-shaped CTA, with
+ ActionLabel string — its visible text
+ PostAction string, optional — a form-shaped CTA instead, for an
+ action that must be a POST (seeding sample data,
+ say) rather than a navigation. Uses ActionLabel too.
+ Hidden [][2]string, optional — extra name/value pairs the
+ POST form carries. Entirely app-supplied: a CSRF
+ token, where the app uses one, rides here like any
+ other hidden field. This package never generates one.
+
+ ActionHref wins if both are set. No illustration slot. */}}
+{{define "empty-state"}}<div class="rst-empty">
+ {{- if .Title}}
+ <h2 class="rst-empty__title">{{.Title}}</h2>
+ {{- end}}
+ <p class="rst-empty__body">{{.Body}}</p>
+ {{- if .ActionHref}}
+ <p class="rst-empty__cta"><a class="rst-btn rst-btn--primary" href="{{.ActionHref}}">{{.ActionLabel}}</a></p>
+ {{- else if .PostAction}}
+ <form class="rst-empty__cta" method="post" action="{{.PostAction}}">
+ {{- if .Hidden}}{{range .Hidden}}
+ <input type="hidden" name="{{index . 0}}" value="{{index . 1}}">
+ {{- end}}{{end}}
+ <button class="rst-btn rst-btn--primary" type="submit">{{.ActionLabel}}</button>
+ </form>
+ {{- end}}
+</div>{{end}}
+```
+
+Note the `{{if .Hidden}}` wrapper around `{{range .Hidden}}`. It is belt-and-braces plus whitespace control: `{{range}}` over an empty, nil or absent value renders nothing on its own, but the `{{if}}` keeps the surrounding trim markers doing one obvious job each and makes the "no pairs, no markup" intent explicit at the call site. Every `range` in this package is wrapped the same way, so none of them reads as an oversight.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add ui/partials/page-header.html ui/partials/empty-state.html ui/ui_test.go
+git commit -m "feat(ui): page-header and empty-state partials
+
+The two partials that frame a screen. empty-state's POST form carries
+only app-supplied hidden pairs — a CSRF token rides there like any other
+hidden field; the framework never generates one."
+```
+
+---
+
+### Task 5: `list-bar-search` (with `list-search-submit`) and `list-bar`
+
+The search toolbar, and the strip that will one day hold filter and sort beside it. `list-search-submit` lives inside `list-bar-search.html` because that is its only caller.
+
+The submit button is the whole point of this pair: an icon-first search bar stays visually quiet, but a real submit control has to stay in the DOM and the tab order. A form that only submits on `input` events is broken for a keyboard user with JavaScript off.
+
+**Files:**
+- Create: `ui/partials/list-bar-search.html`, `ui/partials/list-bar.html`
+- Modify: `ui/ui_test.go` (append tests)
+
+**Interfaces:**
+- Consumes: `parseAll`/`render` from Task 3; `rastrillo.Icon("search")` from Task 1; `dict` from Task 2.
+- Produces:
+ - Template `"list-search-submit"`. Keys: `Label` (string, default `"Search"`).
+ - Template `"list-bar-search"`. Keys: `Action` (string); `Query` (string); `Placeholder` (string — also the input's accessible name, default `"Search"`); `Hidden` (`[][2]string`); `Label` (string — passed straight through to `list-search-submit`). Renders a bare `<form class="rst-search">` with **no wrapper div**, so it composes into `.rst-lbar` or sits directly in a `.rst-list` card.
+ - Template `"list-bar"`. Keys: `SearchAction` (string); `Query` (string); `Placeholder` (string); `Hidden` (`[][2]string`). Renders `<div class="rst-lbar">` around a `list-bar-search`.
+
+**Why `list-bar-search` accepts a `Label` key.** Spec §5 gives `list-search-submit` a `Label` field with the same caller-override pattern `pagination` has, and also states that `list-search-submit` is called only by `list-bar-search`. `Label` is therefore only reachable if `list-bar-search` passes its data value through — which it does, with `{{template "list-search-submit" .}}`. `list-bar` keeps to its four documented fields and does not forward `Label`; a `list-bar` caller who needs to override the submit text calls `list-bar-search` directly inside their own `.rst-lbar`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `ui/ui_test.go`:
+
+```go
+func TestListBarSearchMinimalFixture(t *testing.T) {
+ got := render(t, "list-bar-search", map[string]any{"Action": "/posts"})
+ for _, want := range []string{
+ `<form class="rst-search"`, `role="search"`, `method="get"`, `action="/posts"`,
+ `type="search"`, `name="q"`, `<svg`, `type="submit"`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q: %s", want, got)
+ }
+ }
+ // No Query, no Placeholder: neither attribute should appear empty.
+ if strings.Contains(got, `value=""`) || strings.Contains(got, `placeholder=""`) {
+ t.Errorf("empty attributes rendered instead of being omitted: %s", got)
+ }
+}
+
+// The input always has a real accessible name, whether or not the caller
+// supplied a placeholder.
+func TestListBarSearchInputAlwaysHasAnAccessibleName(t *testing.T) {
+ bare := render(t, "list-bar-search", map[string]any{"Action": "/posts"})
+ if !strings.Contains(bare, `aria-label="Search"`) {
+ t.Errorf("no default accessible name on the input: %s", bare)
+ }
+ named := render(t, "list-bar-search", map[string]any{
+ "Action": "/posts", "Placeholder": "Search posts",
+ })
+ if !strings.Contains(named, `aria-label="Search posts"`) {
+ t.Errorf("Placeholder did not become the accessible name: %s", named)
+ }
+ if !strings.Contains(named, `placeholder="Search posts"`) {
+ t.Errorf("missing placeholder attribute: %s", named)
+ }
+}
+
+func TestListBarSearchCarriesQueryAndHidden(t *testing.T) {
+ got := render(t, "list-bar-search", map[string]any{
+ "Action": "/posts",
+ "Query": "release notes",
+ "Hidden": [][2]string{{"sort", "newest"}},
+ })
+ if !strings.Contains(got, `value="release notes"`) {
+ t.Errorf("query not preserved: %s", got)
+ }
+ if !strings.Contains(got, `<input type="hidden" name="sort" value="newest">`) {
+ t.Errorf("hidden pair not preserved across the search GET: %s", got)
+ }
+}
+
+// The submit control is present, is a real button, and defaults to
+// "Search" — a keyboard user with JS off has to be able to submit.
+func TestListSearchSubmitDefaultsAndOverrides(t *testing.T) {
+ def := render(t, "list-search-submit", map[string]any{})
+ if !strings.Contains(def, `<button class="rst-sr-only" type="submit">Search</button>`) {
+ t.Errorf("default submit control is wrong: %s", def)
+ }
+ over := render(t, "list-search-submit", map[string]any{"Label": "Buscar"})
+ if !strings.Contains(over, ">Buscar<") {
+ t.Errorf("Label override ignored: %s", over)
+ }
+}
+
+func TestListBarSearchPassesLabelThroughToSubmit(t *testing.T) {
+ got := render(t, "list-bar-search", map[string]any{"Action": "/posts", "Label": "Buscar"})
+ if !strings.Contains(got, ">Buscar<") {
+ t.Errorf("Label did not reach list-search-submit: %s", got)
+ }
+}
+
+func TestListBarWrapsTheSearchFormInAToolbarStrip(t *testing.T) {
+ got := render(t, "list-bar", map[string]any{
+ "SearchAction": "/posts",
+ "Query": "notes",
+ "Placeholder": "Search posts",
+ "Hidden": [][2]string{{"sort", "newest"}},
+ })
+ for _, want := range []string{
+ `<div class="rst-lbar">`, `<form class="rst-search"`,
+ `action="/posts"`, `value="notes"`, `placeholder="Search posts"`,
+ `<input type="hidden" name="sort" value="newest">`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q: %s", want, got)
+ }
+ }
+ // This slice renders no filter or sort control (spec §3).
+ if strings.Contains(got, "<details") {
+ t.Errorf("list-bar rendered a dropdown, which is a later slice: %s", got)
+ }
+}
+
+// A list-bar built with only its action — the minimal fixture. Every
+// other key is absent, so this is where a stray empty attribute or a
+// lost default would show up.
+func TestListBarMinimalFixture(t *testing.T) {
+ got := render(t, "list-bar", map[string]any{"SearchAction": "/posts"})
+ if !strings.Contains(got, `<div class="rst-lbar">`) {
+ t.Errorf("missing toolbar strip: %s", got)
+ }
+ if !strings.Contains(got, `action="/posts"`) {
+ t.Errorf("missing form action: %s", got)
+ }
+ // The default accessible name survives the trip through list-bar's dict.
+ if !strings.Contains(got, `aria-label="Search"`) {
+ t.Errorf("the search input lost its default accessible name: %s", got)
+ }
+ if strings.Contains(got, `value=""`) || strings.Contains(got, `placeholder=""`) {
+ t.Errorf("empty attributes rendered instead of being omitted: %s", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -run 'ListBar|ListSearch' -v`
+Expected: FAIL — `"list-bar-search" is undefined`.
+
+- [ ] **Step 3: Write `ui/partials/list-bar-search.html`**
+
+```html
+{{/* list-search-submit — the search form's real submit control.
+
+ Visually hidden, but in the DOM and the tab order, and it un-hides
+ on keyboard focus (see .rst-sr-only in tokens.css). Icon-first
+ search bars stay visually quiet; a form that only submits on input
+ events is broken for a keyboard user with JavaScript disabled.
+
+ Keys:
+ Label string, optional — the button's text, default "Search".
+ This package wires no localization catalog, so an app with
+ one passes its own string. list-bar-search hands its whole
+ data value through, so setting Label there sets it here. */}}
+{{define "list-search-submit"}}<button class="rst-sr-only" type="submit">{{if .Label}}{{.Label}}{{else}}Search{{end}}</button>{{end}}
+
+{{/* list-bar-search — the search form: a magnifying glass, one
+ <input type="search">, any hidden pairs the list needs carried
+ across the GET, and the submit control above.
+
+ An ordinary form method="get", so it works with JavaScript
+ disabled. This renders the <form> alone, with no wrapper: drop it
+ straight into a <div class="rst-list"> card for a list that only
+ searches, or let list-bar wrap it in a toolbar strip.
+
+ Keys:
+ Action string, optional — the form's GET target
+ Query string, optional — the current q value, echoed back
+ so a search survives paging
+ Placeholder string, optional — the field's placeholder AND its
+ accessible name. Defaults to "Search" for the
+ accessible name only: an input with no name at all
+ would fail WCAG 2.2 AA, so this one always has one.
+ Hidden [][2]string, optional — extra name/value pairs to
+ carry across the search (an existing sort order, a
+ page size)
+ Label string, optional — passed through to
+ list-search-submit as its screen-reader text */}}
+{{define "list-bar-search"}}<form class="rst-search" role="search" method="get" action="{{.Action}}">
+ {{icon "search"}}
+ <input type="search" name="q"{{if .Query}} value="{{.Query}}"{{end}}{{if .Placeholder}} placeholder="{{.Placeholder}}"{{end}} aria-label="{{if .Placeholder}}{{.Placeholder}}{{else}}Search{{end}}">
+ {{- if .Hidden}}{{range .Hidden}}
+ <input type="hidden" name="{{index . 0}}" value="{{index . 1}}">
+ {{- end}}{{end}}
+ {{template "list-search-submit" .}}
+</form>{{end}}
+```
+
+- [ ] **Step 4: Write `ui/partials/list-bar.html`**
+
+```html
+{{/* list-bar — the toolbar strip at the top of a list card.
+
+ In this slice it holds the search form and reserves the space a
+ filter/sort control attaches to when the dropdown slice lands: the
+ search field is capped at 20rem in tokens.css precisely so the room
+ to its right stays free. It renders no filter or sort control
+ itself.
+
+ Keys, all passed straight through to list-bar-search:
+ SearchAction string, optional — the search form's GET target
+ Query string, optional — the current q value
+ Placeholder string, optional — the field's placeholder and
+ accessible name
+ Hidden [][2]string, optional — name/value pairs carried
+ across the search
+
+ To override the submit button's screen-reader text, call
+ list-bar-search directly inside your own <div class="rst-lbar">. */}}
+{{define "list-bar"}}<div class="rst-lbar">
+ {{template "list-bar-search" dict "Action" .SearchAction "Query" .Query "Placeholder" .Placeholder "Hidden" .Hidden}}
+</div>{{end}}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add ui/partials/list-bar-search.html ui/partials/list-bar.html ui/ui_test.go
+git commit -m "feat(ui): list-bar-search, list-search-submit and list-bar
+
+An ordinary GET form with a real, focusable submit control: search works
+with JavaScript disabled, and the input always carries an accessible name
+whether or not the caller set a placeholder.
+
+list-bar reserves the space filter and sort attach to later; it renders
+neither."
+```
+
+---
+
+### Task 6: `list-row-action` and `pagination`
+
+The list's body and its footer. Both are ordinary links — the zero-JS baseline in its plainest form.
+
+**Files:**
+- Create: `ui/partials/list-row-action.html`, `ui/partials/pagination.html`
+- Modify: `ui/ui_test.go` (append tests)
+
+**Interfaces:**
+- Consumes: `parseAll`/`render` from Task 3.
+- Produces:
+ - Template `"list-row-action"`. Keys: `Href` (required, string), `Main` (required, string); `Sub` (string); `ActionHref` (string), `ActionLabel` (string), `ActionAria` (string); `Lead` (string — one of `neutral`, `positive`, `warning`, `negative`, `accent`), `LeadInitial` (string — one or two characters).
+ - Template `"pagination"`. Keys: `Items` (a slice; each item has `Label` string, `Href` string, `Current` bool, `Disabled` bool, `Gap` bool — exactly one of `Href`/`Current`/`Disabled`/`Gap` is meaningful per item); `Label` (string — the `<nav>`'s accessible name, default `"Pagination"`).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `ui/ui_test.go`:
+
+```go
+func TestListRowActionMinimalFixture(t *testing.T) {
+ got := render(t, "list-row-action", map[string]any{
+ "Href": "/posts/1", "Main": "Release notes, August",
+ })
+ if !strings.Contains(got, `<a href="/posts/1">Release notes, August</a>`) {
+ t.Errorf("missing primary link: %s", got)
+ }
+ if strings.Contains(got, "rst-row__lead") || strings.Contains(got, "rst-row__action") {
+ t.Errorf("optional parts rendered without data: %s", got)
+ }
+ if strings.Contains(got, "rst-row__sub") {
+ t.Errorf("no Sub was supplied, so no meta line should render: %s", got)
+ }
+}
+
+func TestListRowActionFullFixture(t *testing.T) {
+ got := render(t, "list-row-action", map[string]any{
+ "Href": "/posts/1", "Main": "Release notes, August",
+ "Sub": "Published 2 August · 4 min read",
+ "ActionHref": "/posts/1/edit", "ActionLabel": "Edit",
+ "ActionAria": "Edit Release notes, August",
+ "Lead": "positive", "LeadInitial": "RN",
+ })
+ for _, want := range []string{
+ `data-lead="positive"`, `aria-hidden="true"`, ">RN<",
+ "Published 2 August · 4 min read",
+ `href="/posts/1/edit"`, `aria-label="Edit Release notes, August"`, ">Edit<",
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q: %s", want, got)
+ }
+ }
+}
+
+// Two separate anchors, never one nested inside the other: nested
+// anchors are invalid HTML and the inner one is unreachable by keyboard.
+func TestListRowActionNeverNestsAnchors(t *testing.T) {
+ got := render(t, "list-row-action", map[string]any{
+ "Href": "/posts/1", "Main": "Release notes",
+ "ActionHref": "/posts/1/edit", "ActionLabel": "Edit",
+ })
+ first := strings.Index(got, "<a ")
+ firstClose := strings.Index(got, "</a>")
+ second := strings.LastIndex(got, "<a ")
+ if first == -1 || firstClose == -1 || second == first {
+ t.Fatalf("expected two anchors: %s", got)
+ }
+ if second < firstClose {
+ t.Errorf("the action anchor opens before the name anchor closes: %s", got)
+ }
+}
+
+func TestPaginationRendersEveryItemKind(t *testing.T) {
+ got := render(t, "pagination", map[string]any{
+ "Items": []any{
+ map[string]any{"Label": "Previous", "Disabled": true},
+ map[string]any{"Label": "1", "Current": true},
+ map[string]any{"Label": "2", "Href": "/posts?page=2"},
+ map[string]any{"Gap": true},
+ map[string]any{"Label": "9", "Href": "/posts?page=9"},
+ map[string]any{"Label": "Next", "Href": "/posts?page=2"},
+ },
+ })
+ for _, want := range []string{
+ `aria-label="Pagination"`,
+ `<span aria-disabled="true">Previous</span>`,
+ `<span aria-current="page">1</span>`,
+ `<a href="/posts?page=2">2</a>`,
+ `aria-hidden="true"`,
+ `<a href="/posts?page=9">9</a>`,
+ } {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q: %s", want, got)
+ }
+ }
+ // The gap item carries no Label, and a gap is never a link.
+ if strings.Contains(got, `<a href="">`) {
+ t.Errorf("the gap item rendered as an empty link: %s", got)
+ }
+}
+
+// The current page is marked in the accessibility tree, not only in the
+// stylesheet.
+func TestPaginationCurrentPageIsNotColourAlone(t *testing.T) {
+ got := render(t, "pagination", map[string]any{
+ "Items": []any{map[string]any{"Label": "3", "Current": true}},
+ })
+ if !strings.Contains(got, `aria-current="page"`) {
+ t.Errorf("current page carries no aria-current: %s", got)
+ }
+}
+
+func TestPaginationLabelOverride(t *testing.T) {
+ got := render(t, "pagination", map[string]any{
+ "Label": "Paginación",
+ "Items": []any{map[string]any{"Label": "1", "Current": true}},
+ })
+ if !strings.Contains(got, `aria-label="Paginación"`) {
+ t.Errorf("Label override ignored: %s", got)
+ }
+}
+
+// An empty page strip must render an empty nav, not an Execute error.
+func TestPaginationWithNoItems(t *testing.T) {
+ got := render(t, "pagination", map[string]any{})
+ if !strings.Contains(got, `<nav class="rst-pagination"`) {
+ t.Errorf("missing nav: %s", got)
+ }
+ if strings.Contains(got, "<a ") {
+ t.Errorf("no items were supplied, so no links should render: %s", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -run 'ListRowAction|Pagination' -v`
+Expected: FAIL — `"list-row-action" is undefined`.
+
+- [ ] **Step 3: Write `ui/partials/list-row-action.html`**
+
+```html
+{{/* list-row-action — one list row: the record's name as the primary
+ link, an optional meta line under it, and one secondary action pill
+ on the right.
+
+ Two separate <a> elements. An anchor nested inside an anchor is
+ invalid HTML and the inner one is unreachable by keyboard, so the
+ name link instead stretches an ::after overlay across the row (see
+ .rst-row__main > a::after in tokens.css) and the pill sits above it.
+ Both stay independently keyboard-reachable.
+
+ Wrap a run of rows in <div class="rst-list">…</div>.
+
+ Keys:
+ Href string, required — the row's primary target
+ Main string, required — the record's name
+ Sub string, optional — one meta line under the name
+ ActionHref string, optional — the secondary action pill, with
+ ActionLabel string — its visible text
+ ActionAria string, optional — an explicit aria-label for the
+ pill when its visible text alone is not a full
+ accessible name. "Edit" repeated down a list has to
+ say which row it edits.
+ Lead string, optional — a decorative leading marker: a
+ tone-tinted circle. One of "neutral", "positive",
+ "warning", "negative", "accent"; anything else falls
+ back to the neutral tint.
+ LeadInitial string, optional — the one or two characters inside
+ it. The marker is aria-hidden: it repeats
+ information the name already carries. */}}
+{{define "list-row-action"}}<div class="rst-row">
+ {{- if .Lead}}
+ <span class="rst-row__lead" data-lead="{{.Lead}}" aria-hidden="true">{{if .LeadInitial}}{{.LeadInitial}}{{end}}</span>
+ {{- end}}
+ <span class="rst-row__main"><a href="{{.Href}}">{{.Main}}</a>{{if .Sub}}<small class="rst-row__sub">{{.Sub}}</small>{{end}}</span>
+ {{- if .ActionHref}}
+ <a class="rst-row__action" href="{{.ActionHref}}"{{if .ActionAria}} aria-label="{{.ActionAria}}"{{end}}>{{.ActionLabel}}</a>
+ {{- end}}
+</div>{{end}}
+```
+
+- [ ] **Step 4: Write `ui/partials/pagination.html`**
+
+```html
+{{/* pagination — a numbered page strip of ordinary links, so paging
+ works with JavaScript disabled.
+
+ The current page carries aria-current="page" as well as a filled
+ chip and a heavier weight: three signals, never colour alone.
+
+ Keys:
+ Items slice, optional — one entry per control. Each has:
+ Label string — the visible text ("1", "Next")
+ Href string — where it goes
+ Current bool — this is the page being viewed
+ Disabled bool — present but not actionable
+ Gap bool — the "…" between page ranges
+ Exactly one of Href / Current / Disabled / Gap is
+ meaningful per item.
+ Label string, optional — the <nav>'s accessible name, default
+ "Pagination". This package wires no localization catalog,
+ so an app with one passes its own string. */}}
+{{define "pagination"}}<nav class="rst-pagination" aria-label="{{if .Label}}{{.Label}}{{else}}Pagination{{end}}">
+ {{- if .Items}}{{range .Items}}
+ {{- if .Gap}}<span class="rst-pagination__gap" aria-hidden="true">…</span>
+ {{- else if .Current}}<span aria-current="page">{{.Label}}</span>
+ {{- else if .Disabled}}<span aria-disabled="true">{{.Label}}</span>
+ {{- else}}<a href="{{.Href}}">{{.Label}}</a>
+ {{- end}}
+ {{- end}}{{end}}
+</nav>{{end}}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add ui/partials/list-row-action.html ui/partials/pagination.html ui/ui_test.go
+git commit -m "feat(ui): list-row-action and pagination partials
+
+Two separate anchors per row rather than one nested in another, with a
+CSS overlay giving the whole row a click target. Pagination's current
+page is marked with aria-current, not only a fill."
+```
+
+---
+
+### Task 7: Self-containment and the render-everything smoke test
+
+All eight partials exist. This task holds the set as a whole to the bar spec §8 sets: nothing this package renders reaches off-origin, and one pass renders every partial together without error.
+
+This is the library's equivalent of titogo's `/admin/styleguide` page — one place that renders every partial once — as a test fixture rather than a running app.
+
+**Files:**
+- Modify: `ui/ui_test.go` (append tests)
+
+**Interfaces:**
+- Consumes: every template from Tasks 3–6, plus `TokensCSS()`.
+- Produces: no new production code. This task adds tests only.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `ui/ui_test.go`:
+
+```go
+// allPartials is the shipped set, with a fixture exercising every
+// optional field at once. Every href is deliberately relative: the
+// self-containment check below bans absolute URLs outright, so anything
+// absolute in the output came from a partial rather than a caller.
+func allPartials() []struct {
+ Name string
+ Data map[string]any
+} {
+ return []struct {
+ Name string
+ Data map[string]any
+ }{
+ {"page-header", map[string]any{
+ "Title": "Posts", "Sub": "Everything you have written, newest first.",
+ "ActionHref": "/posts/new", "ActionLabel": "Write a post", "ActionIcon": "plus",
+ }},
+ {"list-bar", map[string]any{
+ "SearchAction": "/posts", "Query": "release", "Placeholder": "Search posts",
+ "Hidden": [][2]string{{"sort", "newest"}},
+ }},
+ {"list-bar-search", map[string]any{
+ "Action": "/posts", "Query": "release", "Placeholder": "Search posts",
+ "Hidden": [][2]string{{"sort", "newest"}}, "Label": "Search posts",
+ }},
+ {"list-search-submit", map[string]any{"Label": "Search posts"}},
+ {"list-row-action", map[string]any{
+ "Href": "/posts/1", "Main": "Release notes, August",
+ "Sub": "Published 2 August · 4 min read",
+ "ActionHref": "/posts/1/edit", "ActionLabel": "Edit",
+ "ActionAria": "Edit Release notes, August",
+ "Lead": "accent", "LeadInitial": "RN",
+ }},
+ {"status-pill", map[string]any{"Tone": "positive", "Label": "Published"}},
+ {"empty-state", map[string]any{
+ "Title": "Nothing here yet", "Body": "No posts yet. Your first one is a good place to start.",
+ "PostAction": "/posts/seed", "ActionLabel": "Add sample posts",
+ "Hidden": [][2]string{{"csrf", "tok-123"}},
+ }},
+ {"pagination", map[string]any{
+ "Label": "Pagination",
+ "Items": []any{
+ map[string]any{"Label": "Previous", "Disabled": true},
+ map[string]any{"Label": "1", "Current": true},
+ map[string]any{"Label": "2", "Href": "/posts?page=2"},
+ map[string]any{"Gap": true},
+ map[string]any{"Label": "9", "Href": "/posts?page=9"},
+ },
+ }},
+ }
+}
+
+// fixtureFor looks one fixture up by partial name. Assertions never index
+// allPartials() positionally: reordering that slice would otherwise
+// silently re-point a test at a different partial and still pass.
+func fixtureFor(t *testing.T, name string) map[string]any {
+ t.Helper()
+ for _, p := range allPartials() {
+ if p.Name == name {
+ return p.Data
+ }
+ }
+ t.Fatalf("no fixture defined for partial %q", name)
+ return nil
+}
+
+// All eight partials are present and named exactly as documented.
+func TestAllEightPartialsAreDefined(t *testing.T) {
+ tmpl := parseAll(t)
+ want := []string{
+ "page-header", "list-bar", "list-bar-search", "list-search-submit",
+ "list-row-action", "status-pill", "empty-state", "pagination",
+ }
+ for _, name := range want {
+ if tmpl.Lookup(name) == nil {
+ t.Errorf("partial %q is not defined", name)
+ }
+ }
+ if len(want) != 8 {
+ t.Fatalf("the shipped set is 8 partials, this list has %d", len(want))
+ }
+}
+
+// Rendered output reaches nothing outside the page: no off-origin fetch,
+// no script, no remote asset. Mirrors icons_test.go's
+// TestVendoredIconsAreSelfContained, adapted from one SVG string to one
+// rendered partial's HTML.
+func TestRenderedPartialsAreSelfContained(t *testing.T) {
+ for _, p := range allPartials() {
+ got := render(t, p.Name, p.Data)
+ for _, bad := range []string{
+ "http://", "https://", "//cdn", "<script", "<iframe", "<img",
+ "<link ", "url(", "xlink:href", "@import",
+ } {
+ if strings.Contains(got, bad) {
+ t.Errorf("partial %q reaches outside the page (%q):\n%s", p.Name, bad, got)
+ }
+ }
+ }
+}
+
+// The one non-partial asset this package ships gets the same bar as the
+// partials and the vendored icons.
+func TestTokensCSSIsSelfContained(t *testing.T) {
+ css := string(TokensCSS())
+ for _, bad := range []string{"@import", "url(", "http://", "https://", "//fonts", "src:"} {
+ if strings.Contains(css, bad) {
+ t.Errorf("tokens.css reaches outside the page (%q)", bad)
+ }
+ }
+}
+
+// Every interactive element renders with a real accessible name
+// (spec §10). Checked here rather than per-partial so a new partial
+// cannot quietly opt out.
+func TestEveryControlHasAnAccessibleName(t *testing.T) {
+ search := render(t, "list-bar-search", fixtureFor(t, "list-bar-search"))
+ if !strings.Contains(search, "aria-label=") {
+ t.Errorf("the search input has no accessible name: %s", search)
+ }
+ if !strings.Contains(search, `type="submit">Search posts</button>`) {
+ t.Errorf("the submit control has no text: %s", search)
+ }
+ row := render(t, "list-row-action", fixtureFor(t, "list-row-action"))
+ if !strings.Contains(row, `aria-label="Edit Release notes, August"`) {
+ t.Errorf("the row action pill has no disambiguating name: %s", row)
+ }
+ page := render(t, "pagination", fixtureFor(t, "pagination"))
+ if !strings.Contains(page, `aria-label="Pagination"`) {
+ t.Errorf("the pagination nav has no accessible name: %s", page)
+ }
+}
+
+// The styleguide equivalent: one pass renders every partial together,
+// the combined output is balanced, and each partial left its marker.
+func TestRenderEverythingSmoke(t *testing.T) {
+ tmpl := parseAll(t)
+ var buf strings.Builder
+ buf.WriteString(`<div class="rst-page">`)
+ for _, p := range allPartials() {
+ if err := tmpl.ExecuteTemplate(&buf, p.Name, p.Data); err != nil {
+ t.Fatalf("ExecuteTemplate(%q): %v", p.Name, err)
+ }
+ }
+ buf.WriteString(`</div>`)
+ out := buf.String()
+
+ markers := map[string]string{
+ "page-header": `<header class="rst-page-header">`,
+ "list-bar": `<div class="rst-lbar">`,
+ "list-bar-search": `<form class="rst-search"`,
+ "list-search-submit": `<button class="rst-sr-only" type="submit">`,
+ "list-row-action": `<div class="rst-row">`,
+ "status-pill": `<span class="rst-status"`,
+ "empty-state": `<div class="rst-empty">`,
+ "pagination": `<nav class="rst-pagination"`,
+ }
+ for name, marker := range markers {
+ if !strings.Contains(out, marker) {
+ t.Errorf("smoke output is missing %s (%q)", name, marker)
+ }
+ }
+
+ for _, tag := range []string{"div", "form", "header", "nav", "a", "p", "h1", "h2", "span", "small", "button", "svg"} {
+ open, closed := countOpenTags(out, tag), strings.Count(out, "</"+tag+">")
+ if open != closed {
+ t.Errorf("<%s> is unbalanced: %d opened, %d closed", tag, open, closed)
+ }
+ }
+}
+
+// countOpenTags counts opening tags for one element name, matching both
+// "<tag " and "<tag>" so <p> is never confused with <path>.
+func countOpenTags(s, tag string) int {
+ return strings.Count(s, "<"+tag+" ") + strings.Count(s, "<"+tag+">")
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail (or pass for the right reason)**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -run 'AllEight|SelfContained|AccessibleName|Smoke' -v`
+Expected: these tests compile and run against the eight partials from Tasks 3–6. Any failure here is a real defect in that markup, not a missing implementation — fix the partial, not the test. Before doing that, prove the suite can fail: temporarily rename `page-header`'s `{{define "page-header"}}` to `{{define "page-headerX"}}`, re-run, and confirm `TestAllEightPartialsAreDefined` and `TestRenderEverythingSmoke` both fail. Then undo the rename.
+
+- [ ] **Step 3: Run the full package suite**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./ui/ -v`
+Expected: PASS, every test.
+
+- [ ] **Step 4: Run the whole module**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go build ./... && go vet ./... && go test ./... -v`
+Expected: PASS, no vet output.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add ui/ui_test.go
+git commit -m "test(ui): self-containment and a render-everything smoke test
+
+The library's answer to titogo's /admin/styleguide page — one pass that
+renders all eight partials together — plus the off-origin bar icons_test.go
+already holds the vendored SVGs to, applied to rendered HTML and to
+tokens.css."
+```
+
+---
+
+### Task 8: `rastrillo new` delivers `tokens.css`
+
+`tokens.css` reaches an app once, at scaffold time, and is app-owned from then on — the same opt-in-by-file-existence convention every other scaffolded file uses. `rastrillo.Serve` still never serves it; the generated `main.go` serves the app's own `static/` directory, app-side.
+
+**Files:**
+- Modify: `cmd/rastrillo/new.go:26-51` (the `dirs` slice, the `files` map, the printed summary) and `cmd/rastrillo/new.go:110-111` (the `ctx :=` and `mux :=` lines inside `mainTemplate`)
+- Create: `cmd/rastrillo/new_test.go`
+
+**Interfaces:**
+- Consumes: `ui.TokensCSS() []byte` from Task 3.
+- Produces: a scaffolded app whose `static/tokens.css` is byte-identical to `ui.TokensCSS()`, and whose `cmd/<name>/main.go` serves `GET /static/` from `static/`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `cmd/rastrillo/new_test.go`:
+
+```go
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+// rastrillo new writes the design-token stylesheet into the new app's
+// static tree, once. From then on it is an ordinary app-owned file.
+func TestNewScaffoldsTokensCSS(t *testing.T) {
+ t.Chdir(t.TempDir())
+ if err := runNew([]string{"blogapp"}); err != nil {
+ t.Fatalf("runNew: %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join("blogapp", "static", "tokens.css"))
+ if err != nil {
+ t.Fatalf("expected a scaffolded stylesheet: %v", err)
+ }
+ if !bytes.Equal(got, ui.TokensCSS()) {
+ t.Errorf("scaffolded tokens.css is not ui.TokensCSS() verbatim (%d bytes vs %d)", len(got), len(ui.TokensCSS()))
+ }
+}
+
+// The scaffolded app still builds the same way it did before: go.mod,
+// the starter action, main.go, and a generated router.
+func TestNewStillScaffoldsTheRestOfTheApp(t *testing.T) {
+ t.Chdir(t.TempDir())
+ if err := runNew([]string{"blogapp"}); err != nil {
+ t.Fatalf("runNew: %v", err)
+ }
+ for _, rel := range []string{
+ "go.mod",
+ filepath.Join("actions", "index.GET.go"),
+ filepath.Join("cmd", "blogapp", "main.go"),
+ filepath.Join("gen", "router.go"),
+ } {
+ if _, err := os.Stat(filepath.Join("blogapp", rel)); err != nil {
+ t.Errorf("missing scaffolded file %s: %v", rel, err)
+ }
+ }
+}
+
+// The generated app serves its own static directory. rastrillo.Serve
+// never serves CSS — that is the app's job, in the app's own code.
+func TestMainTemplateServesTheStaticDir(t *testing.T) {
+ src := fmt.Sprintf(mainTemplate, "blogapp")
+ want := `mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))`
+ if !strings.Contains(src, want) {
+ t.Errorf("main.go does not serve static/:\n%s", src)
+ }
+ // It has to come after the router exists, or it has nothing to attach to.
+ if strings.Index(src, "gen.Router(") > strings.Index(src, want) {
+ t.Error("the static handler is registered before gen.Router builds the mux")
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./cmd/rastrillo/ -v`
+Expected: FAIL — `expected a scaffolded stylesheet: ... no such file or directory`, and `main.go does not serve static/`.
+
+- [ ] **Step 3: Scaffold the stylesheet**
+
+In `cmd/rastrillo/new.go`, add the import:
+
+```go
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/rastrillo/ui"
+)
+```
+
+Add `static/` to the directories created, and `tokens.css` to the files written:
+
+```go
+ dirs := []string{
+ name,
+ filepath.Join(name, "actions"),
+ filepath.Join(name, "cmd", name),
+ filepath.Join(name, "static"),
+ }
+ for _, d := range dirs {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ return err
+ }
+ }
+
+ files := map[string]string{
+ filepath.Join(name, "go.mod"): fmt.Sprintf(goModTemplate, name),
+ filepath.Join(name, "actions", "index.GET.go"): actionTemplate,
+ filepath.Join(name, "cmd", name, "main.go"): fmt.Sprintf(mainTemplate, name),
+ // The design-token stylesheet, delivered once. rastrillo.Serve
+ // never serves CSS at runtime; from here on this is an ordinary
+ // app-owned file that new/generate never touch again.
+ filepath.Join(name, "static", "tokens.css"): string(ui.TokensCSS()),
+ }
+```
+
+And add the summary line, after the `main.go` line:
+
+```go
+ fmt.Printf(" cmd/%s/main.go\n", name)
+ fmt.Println(" static/tokens.css")
+```
+
+- [ ] **Step 4: Serve the static dir from the generated `main.go`**
+
+In `cmd/rastrillo/new.go`, inside the `mainTemplate` string, replace **both** of `new.go:110-111` — the existing `ctx := &rastrillo.Ctx{Logger: logger}` line and the `mux := gen.Router(...)` line — with the block below. Replace both, not just the `mux :=` line: the block repeats the `ctx :=` line verbatim so the surrounding comment stays attached to it, and pasting it *above* the existing declaration instead of over it gives `ctx redeclared in this block`. Leave everything else in the template untouched.
+
+```go
+ ctx := &rastrillo.Ctx{Logger: logger}
+ mux := gen.Router(func(*http.Request) *rastrillo.Ctx { return ctx })
+
+ // The app serves its own static files — rastrillo.Serve never does.
+ // static/tokens.css was scaffolded here once by rastrillo new; edit
+ // it, replace it, or delete this handler if you serve assets some
+ // other way.
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
+```
+
+Two things to keep in mind. `mainTemplate` is consumed by `fmt.Sprintf`, so it must contain no literal `%` outside the existing `%[1]s` — the lines above contain none. And the scaffolded `main.go` already imports `net/http`, so the new handler needs no import change.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go test ./cmd/rastrillo/ -v`
+Expected: PASS, all three tests.
+
+- [ ] **Step 6: Verify a scaffolded app actually builds**
+
+`runNew` writes `module <name>` into the scaffolded `go.mod` verbatim, so the app has to be created with a bare name from inside the scratch directory — passing a path would produce an invalid module line.
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+go build -o "$TMPDIR/rastrillo-bin" ./cmd/rastrillo
+rm -rf "$TMPDIR/rastrillo-scaffold-check" && mkdir -p "$TMPDIR/rastrillo-scaffold-check"
+cd "$TMPDIR/rastrillo-scaffold-check" && "$TMPDIR/rastrillo-bin" new blogapp
+cd "$TMPDIR/rastrillo-scaffold-check/blogapp"
+go mod edit -replace github.com/carlosframework/rastrillo=/tmp/claude-1001/rastrillo-build/rastrillo
+go mod tidy
+go build ./cmd/blogapp
+ls -l static/tokens.css
+```
+Expected: `go build` succeeds and `static/tokens.css` is present and non-empty. (`go mod tidy` needs the module cache; if it is unavailable offline, `go vet ./cmd/rastrillo/` plus the passing tests above are sufficient evidence and this step can be recorded as skipped with that reason.)
+
+Then remove the scratch app: `rm -rf "$TMPDIR/rastrillo-scaffold-check"`.
+
+- [ ] **Step 7: Run the whole module one last time**
+
+Run: `cd /tmp/claude-1001/rastrillo-build/rastrillo && go build ./... && go vet ./... && go test ./... -v`
+Expected: PASS, no vet output.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /tmp/claude-1001/rastrillo-build/rastrillo
+git add cmd/rastrillo/new.go cmd/rastrillo/new_test.go
+git commit -m "feat(new): scaffold tokens.css and serve the app's static dir
+
+The stylesheet is delivered once, at scaffold time, and is app-owned from
+then on — the same opt-in-by-file-existence convention every other
+scaffolded file uses. Serving it is the generated app's own three lines of
+code, because rastrillo.Serve serves no CSS and this plan does not change
+that."
+```
+
+---
+
+## Acceptance check (run once, after Task 8)
+
+These are spec §10's criteria, restated as things to confirm before opening the PR. They are not a task — no code changes — but do not claim the plan is done without them.
+
+- [ ] `go build ./... && go vet ./... && go test ./...` all pass from a clean tree.
+- [ ] **Zero-JS baseline.** Grep the package for scripts: `grep -rn "<script\|onclick\|addEventListener" ui/` returns nothing. Search is a real GET form with a real submit button; pagination and row actions are real anchors; the empty state's CTA is a link or a plain form POST.
+- [ ] **WCAG 2.2 AA.** The ratio table at the top of `tokens.css` covers every text/background and pill-tone pair the components use, and every number in it is at or above its stated requirement. `status-pill` always renders a text label; `pagination`'s current page carries `aria-current`; every control has an accessible name (`TestEveryControlHasAnAccessibleName`).
+- [ ] **Titogo equivalence, rescoped.** Read `/home/paulca/github.com/tito/titogo/internal/instance/templates/admin_event_types.html`'s List state alongside the eight partials and confirm a hand-assembled rastrillo version would carry the same information and the same interactions for the parts this slice covers: the search bar, the row list, pagination, and the true blank state. The row kebab menu, the filter/sort dropdowns and bulk-select are out of scope per spec §3; their absence does not fail this check.
+- [ ] Open the PR against `vendor-lucide-icons` (not `main`) — this branch stacks on PR #2. Note in the PR body that it must merge after it.
diff --git a/docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md b/docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md
index 2d3751e31..1eee7647c 100644
--- a/docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md
+++ b/docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md
@@ -2,6 +2,9 @@
**Status:** approved 2026-08-02 (Paul).
+**Amended 2026-08-02:** §7 scaffold wiring now includes app-owned static
+serving (approved during planning).
+
**Implements** the first slice of §9 (Component vocabulary and UI
generation) of `2026-08-01-carlos-framework-design.md`, as amended by
`2026-08-02-rastrillo-ui-addendum-design.md` (the "UI addendum"). The
@@ -56,8 +59,9 @@ with what titogo's real List screens actually define as reusable
## 3. Non-goals (deferred)
- **Manifest and generator.** No `Resource{List, Form}` type, no codegen,
- no `rastrillo new`/`rastrillo generate` integration beyond the one
- scaffold-time file copy in §7. Titogo's own admin screens are hand-
+ no `rastrillo new`/`rastrillo generate` integration beyond the §7
+ scaffold-time wiring (one file copy plus the static-serving line in the
+ scaffolded `main.go`). Titogo's own admin screens are hand-
assembled from partials, not generated from a manifest — that's the
proof this package targets. Turning a typed manifest into generated
screens is its own future design pass.
@@ -307,7 +311,23 @@ writes them into the new app's static tree — the same
opt-in-by-file-existence convention the design doc uses for every other
scaffolded file (§3 of the design doc). Once `tokens.css` exists in the
app's tree, `rastrillo new`/`generate` never touches it again, and an
-app is free to edit it in place. The partial
+app is free to edit it in place.
+
+The scaffolded `cmd/<name>/main.go` also registers app-owned static
+serving, so the `tokens.css` it just wrote is actually reachable:
+
+```go
+mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
+```
+
+That line is ordinary generated app code, sitting in the app's own
+`main.go` beside its own `gen.Router` call — the app serves its static
+tree, the app can delete or replace the handler, and nothing about it
+reaches into `rastrillo.Serve`. §3's runtime-CSS-serving non-goal stands
+unchanged: the framework's own `Serve` still serves no CSS, ever. A
+scaffolded stylesheet no route can reach would be a file copy pretending
+to be a feature, which is why the wiring is one line rather than none.
+The partial
templates and `Funcs()` are not scaffolded as files at all — they stay
inside the `rastrillo/ui` import, referenced from the app's own
`ParseFS` call (§4), so an app gets the current version of each