carlos / platform Public

spec: rastrillo ui component library — eight List-screen partials

First implementation slice of the framework design's §9: a rastrillo/ui
package shipping embedded List-screen partials, tokens.css, and the
template funcs they need. Library only — manifest/generator, dropdown/
filter-sort, and form fields are explicitly deferred.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell afbe532261392b6dcbdf38aeb18b1ae598c9bdfc parent 5586c07
1 file changed, +396 −0
  • docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md +396 −0
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
new file mode 100644
index 000000000..2d3751e31
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-rastrillo-ui-component-library-design.md
@@ -0,0 +1,396 @@
+# Rastrillo `ui` package: a component library for List screens
+
+**Status:** approved 2026-08-02 (Paul).
+
+**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
+addendum's §4 (WCAG 2.2 AA, enforced) and §5 (component set, zero-JS-first)
+are the direct inputs here; this spec is where §5's component list gets a
+first real, working, from-scratch implementation instead of a name on a
+list.
+
+## 1. Overview and motivation
+
+Rastrillo's `server` shape commits to server-rendered `{{define}}`
+partials as its default UI vocabulary (§9), grounded in titogo's
+production admin system — a 438-line `admin_partials.html` with 37
+partials, a 1,693-line CSS design system, and `docs/design-system.md`'s
+codified conventions. That system is real and proven, but nothing in
+rastrillo today embodies it: no template parsing, no shipped partials,
+no token CSS. An app building a `server`-shape admin screen today starts
+from a blank file.
+
+This spec adds `rastrillo/ui`: a new package shipping a small, real
+starter set of List-screen partials, a design-token stylesheet, and the
+template helpers they need — vendored the same way `Icon`/`icons.go`
+already vendors Lucide, so an app pulls in a working component with an
+import and a `ParseFS` call, not a hand-copy from titogo. It is a
+component *library*: partials an app's own templates call by name.
+Nothing here generates a screen, decides a route, or owns rendering. The
+eight-partial set itself (§5) is §9's named component list intersected
+with what titogo's real List screens actually define as reusable
+`{{define}}` partials — not identical to either list read on its own.
+
+## 2. Goals
+
+- Ship eight List-screen partials, a `tokens.css`, and a `template.FuncMap`
+ as embedded, importable Go package content (`Templates() fs.FS`,
+ `TokensCSS() []byte`, `Funcs() template.FuncMap`).
+- Match the addendum's non-negotiables: zero-JS baseline (every partial
+ works with JavaScript disabled) and WCAG 2.2 AA (color tokens meet
+ contrast; state is never color-only; every control has a real
+ accessible name).
+- Give each partial a data contract that extracts the commonalities
+ titogo's real partials and call sites actually exhibit, generalized to
+ drop titogo-specific product logic (order semantics, event-scoped nav,
+ monogram avatar palettes) — not a mechanical port of titogo's markup or
+ CSS.
+- Validate the set against titogo's real ticket-types list screen: a
+ hand-assembled rastrillo version should look and behave equivalently
+ for the parts this slice covers (§10).
+- Test each partial in the style already established by `icons_test.go`:
+ parses and executes cleanly, output is self-contained, plus one
+ render-everything smoke test.
+
+## 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-
+ 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.
+- **`dropdown` and the filter/sort toolbar.** The addendum's §5 already
+ names `dropdown` as its own follow-on slice (`<details>/<summary>`,
+ generalizing titogo's kebab menu and its `lbar-dropdown` filter/sort
+ control — two configurations, not one). Nothing here renders a filter
+ or sort menu; `list-bar` reserves the space, `page-header` ships
+ without titogo's kebab overflow menu — both arrive with `dropdown`.
+- **Form/field partials.** Titogo's `field`/`field-select`/
+ `field-textarea`/`daterange-field` family and the Basics/Advanced
+ `seg-tabs` convention are Show/Edit/New concerns. This slice is List
+ screens only.
+- **Runtime CSS serving.** `rastrillo.Serve` does not serve `tokens.css`;
+ it is scaffolded once at `rastrillo new` time (§7) and from then on is
+ an ordinary app-owned static file.
+- **Illustration assets.** Titogo's `empty-state` pairs a message with a
+ hand-drawn illustration keyed by section (`admin_blankart.html`) —
+ titogo's own art, not vendored. Rastrillo's `empty-state` has no
+ illustration slot.
+- **i18n catalog wiring.** No localization catalog is wired into this
+ slice. Every fixed string a partial renders (`list-search-submit`'s
+ button text, `pagination`'s default `aria-label`) is a Go default the
+ caller can override by passing its own value — not a call into the
+ design doc's §10 (Localization) catalog, which this package doesn't
+ depend on.
+
+## 4. Package design
+
+New package `rastrillo/ui`, directory `ui/` at the repo root, import path
+`<module>/ui` — a sibling of `ctx.go`/`serve.go`, not a subpackage of an
+existing one, matching how `icons.go` sits at the root today.
+
+```
+ui/
+ ui.go — Templates() and TokensCSS() accessors, doc comment
+ funcs.go — Funcs(), dict, list
+ tokens.css — design tokens (embedded + scaffold source, §7)
+ partials/
+ page-header.html
+ list-bar.html
+ list-bar-search.html
+ list-row-action.html
+ status-pill.html
+ empty-state.html
+ pagination.html
+ ui_test.go — parse/execute, self-containment, smoke tests (§8)
+```
+
+(`list-search-submit` is defined inside `list-bar-search.html`, the one
+partial that actually calls it — see §5 — rather than in its own file;
+file layout mirrors *usage*, not the 8-item list 1:1.)
+
+`Templates()`/`TokensCSS()` (in `ui.go`) and `Funcs()`/`dict`/`list` (in
+`funcs.go`) are shown together below for brevity — the split across the
+two files doesn't change either one's signature or behavior:
+
+```go
+package ui
+
+import (
+ "embed"
+ "html/template"
+ "io/fs"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+//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"))
+// tmpl = template.Must(tmpl.ParseFS(appTemplateFS, "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 (§7) to write into a new app's static directory.
+func TokensCSS() []byte { return tokensCSS }
+
+// Funcs returns the helpers ui's partials call: dict/list-style map
+// builders for composing partial data inline, plus icon (rastrillo.Icon)
+// so a partial carrying an icon resolves it the same way any other
+// rastrillo app template does.
+func Funcs() template.FuncMap {
+ return template.FuncMap{"dict": dict, "list": list, "icon": rastrillo.Icon}
+}
+```
+
+**Rendering ownership.** Rastrillo does not own rendering, before or
+after this package. An app builds its own `*html/template.Template`,
+calls `.Funcs(ui.Funcs())`, and `.ParseFS(ui.Templates(), "*.html")`
+alongside its own `ParseFS` over its own template directory — exactly
+titogo's `shop.go` pattern (`template.New("").Funcs(...).ParseFS(templateFS,
+"templates/*.html")`), generalized only so vendored partials are
+available by name in that same tree. `rastrillo.Serve` (`serve.go`) is
+unchanged: `Options.Mux` is still the whole story for what an app serves.
+
+### 4.1 `icon` and `dict`/`list`
+
+`icon` wraps the root package's existing vendored-Lucide lookup
+(`rastrillo.Icon(slug string) template.HTML`, `icons.go`) —
+`rastrillo/ui` imports `github.com/carlosframework/rastrillo` for this
+one symbol rather than duplicating the icon set, so `{{icon "search"}}`
+resolves identically inside a vendored partial and inside an app's own
+templates. Importing the root package pulls in its own dependency tree
+(`serve.go`'s SQLite driver among it) — accepted here: `rastrillo/ui` is
+only useful inside a rastrillo app, and every rastrillo app already
+imports the root package for `rastrillo.Serve`.
+
+This slice also adds the icon slugs its own partials call — at minimum
+`search`, for `list-bar`/`list-bar-search`'s magnifying-glass — to the
+root package's `icons.go`, in the same vendored-Lucide idiom as the
+existing `chevron-down`/`check`/`plus` set, with
+`TestVendoredIconsAreSelfContained`-style coverage extended to the new
+slug(s). Without this, `{{icon "search"}}` would still compile and pass
+every test in §8 while rendering nothing — an unregistered slug renders
+empty rather than erroring (`Icon`'s documented behavior) — so the new
+slug(s) are a real, load-bearing part of this slice, not an
+afterthought.
+
+`dict(pairs ...any) (map[string]any, error)` and `list(items ...any)
+[]any` are the standard sprig-style helpers: every partial in §5 takes
+one data value, and `dict` is how a caller builds that value inline in a
+template call without a Go-side view-model type for every combination.
+An odd argument count to `dict` is an error (fails loudly at `Execute`,
+never silently drops the last key).
+
+## 5. The starter partial set
+
+Eight partials, List screens only. Each is built from scratch — no
+copied titogo markup or CSS — but each is checked against titogo's real
+ticket-types list screen (`internal/instance/admin_event_types.html` and
+its `adminEventTypes` handler) for the acceptance question in §10: does a
+hand-assembled rastrillo version look and behave equivalently. Data
+contracts below extract the shape those real call sites actually need,
+generalized to drop app-specific logic (status semantics, illustration
+keys, monogram palettes) that titogo's own partials interleave but a
+framework default can't know about.
+
+**`page-header`** — the screen's title bar: an `<h1>` plus, optionally,
+one primary action link. Fields: `Title` (required), `Sub` (subhead
+line), `ActionHref`/`ActionLabel`/`ActionIcon` (a primary action, icon
+via `{{icon}}` beside the label). No overflow/kebab menu (§3).
+
+**`list-bar`** — the toolbar strip at the top of a list card. In this
+slice it wraps the search form (`list-bar-search`, below) and reserves
+the layout position a future `dropdown`-based filter/sort control
+attaches to; it does not itself render one. Fields: `SearchAction`,
+`Query`, `Placeholder` (passed through to the embedded search form),
+`Hidden` — a `[][2]string` of extra `name`/`value` pairs a list screen
+needs preserved across the search GET (e.g. an existing sort order),
+mirroring titogo's own `.Hidden` use in `list-bar`.
+
+**`list-bar-search`** — the search-only toolbar: a `<form method="get">`
+with a magnifying-glass icon, an `<input type="search">`, and the hidden
+submit control (below). Usable standalone by a list that only needs
+search — titogo's own check-in lists work this way — or embedded inside
+`list-bar`. Fields: `Action`, `Query`, `Placeholder`, `Hidden`.
+
+**`list-search-submit`** — a screen-reader-only `<button type="submit">`,
+defined inside `list-bar-search.html` and called only by `list-bar-search`
+(and so, indirectly, by `list-bar`, which wraps it). Icon-first search
+bars stay visually quiet, but a real submit control stays in the DOM and
+tab order — a form that only submits on `input` events breaks for a
+keyboard user with JS disabled. Fields: `Label` (the button's
+screen-reader text, defaulting to `"Search"` — the same override
+pattern as `pagination`'s `Label`, since this slice wires no i18n
+catalog; §3).
+
+**`list-row-action`** — one list row: a primary link (name, optional meta
+line under it) plus one secondary action pill on the right. Two separate
+`<a>` elements (never an anchor nested in an anchor), both independently
+keyboard-reachable. Fields: `Href`, `Main` (required); `Sub` (meta
+line); `ActionHref`, `ActionLabel` (the pill); `ActionAria` (an explicit
+`aria-label` for the pill when its visible text alone isn't a full
+accessible name — "Edit" needs to disambiguate which row); `Lead`,
+`LeadInitial` (an optional decorative leading marker — a palette-toned
+circle with one or two characters — generalizing titogo's per-row avatar
+chip without its specific monogram-palette logic, which stays
+app-level).
+
+**`status-pill`** — a status as a tinted pill, label always visible text
+(never color alone, per WCAG 2.2 AA — addendum §4). Fields: `Tone` (a
+small fixed vocabulary — `neutral`, `positive`, `warning`, `negative` —
+`tokens.css` defines the pair for each), `Label`. Unlike titogo's
+`status-pill`, which calls an app-specific `statusTone`/`statusLabel`
+helper pair to map a raw status value to a tone, rastrillo's version
+takes the already-resolved `Tone`/`Label` directly — that mapping is
+product logic that belongs in the calling app.
+
+**`empty-state`** — the blank-state card: a title, a body sentence, and
+one optional call to action. Fields: `Title` (optional — a short heading
+above the body); `Body` (required — warm, specific copy, not "No records
+found"); `ActionHref`+`ActionLabel` (a link-shaped CTA) or
+`PostAction`+`ActionLabel`+`Hidden` (a form-shaped CTA, for actions that
+must be a POST — e.g. "seed sample data" — not a navigation); `Hidden` —
+`[][2]string`, the same type as `list-bar`'s, extra `name`/`value` pairs
+the POST form needs. Hidden pairs are entirely app-supplied: a CSRF
+token, where the app uses one, rides in `Hidden` like any other hidden
+field — the framework does not generate it. No illustration slot (§3).
+
+**`pagination`** — a numbered page strip. Fields: `Items` — a slice of
+`{Label string; Href string; Current, Disabled, Gap bool}` (exactly one
+of `Href`/`Current`/`Disabled`/`Gap` meaningful per item); `Label` (an
+optional `aria-label` for the `<nav>`, defaulting to a fixed English
+string — this package has no i18n catalog wiring, so a caller with a
+localization layer overrides it). The current page is
+`aria-current="page"`, never color alone.
+
+## 6. `tokens.css`
+
+A design-token stylesheet: CSS custom properties for color and spacing,
+declared on `:root` (light) with a `@media (prefers-color-scheme: dark)`
+block plus `:root[data-theme]` override hooks so an explicit toggle wins
+in both directions — the same dual-authored-themes shape titogo's own
+token table uses, but with from-scratch values, not titogo's cornflower
+palette. Scope is only what the eight partials above actually use
+(background/surface/border/text tiers, an accent, the `status-pill` tone
+set, a small spacing scale) — not a recreation of titogo's 1,693-line
+`layout.html`, which also carries `app-shell`/nav/mobile-drawer
+structural CSS this slice doesn't need. Every text-on-background and
+pill-tone pairing meets WCAG 2.2 AA contrast (4.5:1 normal text, 3:1
+large text/UI components) — verified by hand for this initial set, per
+the addendum's §4 framing of contrast as something token values must
+satisfy even before a `carlos vet` check automates it.
+
+## 7. Scaffold-time delivery
+
+`tokens.css` is not served by `rastrillo.Serve` at runtime. It is copied
+into a new app's static directory by `rastrillo new`, once, at scaffold
+time: the scaffolder calls `ui.TokensCSS()` for the bytes (§4) and
+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
+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
+partial's logic on every `go get`/`go mod` update, and opts out of an
+individual partial simply by not calling it.
+
+## 8. Testing strategy
+
+Package-level Go tests in `ui/ui_test.go`, following `icons_test.go`'s
+existing shape:
+
+- **Parses and executes.** Every one of the eight partials parses via
+ `template.New("").Funcs(Funcs()).ParseFS(Templates(), "*.html")` and
+ executes without error against minimal fixture data covering each
+ partial's required fields (e.g. `list-row-action` with only
+ `Href`/`Main` set) — mirroring `TestIconWorksAsTemplateFunc`'s "must
+ work as a real FuncMap entry end to end" standard, not just a parse
+ check.
+- **Self-containment.** Rendered output for every partial, across a
+ fixture exercising every optional field at once, contains no
+ off-origin reference — no `http://`/`https://` outside a
+ caller-supplied link target, no `<script src=`, no `url()` pointing
+ outside the page — mirroring `TestVendoredIconsAreSelfContained`'s
+ `bad`-string list, adapted from "one SVG string" to "one rendered
+ partial's HTML." The same check runs over `ui.TokensCSS()`'s raw
+ bytes: no `@import`, no `url(...)` pointing at an off-origin host —
+ the one non-partial embedded asset this package ships gets the same
+ self-containment bar as the partials and the vendored icons.
+- **Smoke test.** A single test renders one instance of every partial in
+ one pass with representative fixture data and asserts the combined
+ output is well-formed and contains each partial's expected marker
+ content — the test-fixture equivalent of titogo's `/admin/styleguide`
+ page (`admin_styleguide.go`, "one page that renders every partial
+ once"), appropriate for a library rather than a running app.
+
+## 9. Error handling
+
+Standard `html/template` semantics, no special-casing — including what
+that actually means for `dict`-built data, which is how most callers
+build a partial's argument (§4.1). With `map`-typed data (what `dict`
+returns), a key the caller forgot to set does **not** fail at
+`Execute`: `html/template` renders the zero value for a missing map key
+silently, and `Execute` returns no error. A call like `{{template
+"list-row-action" dict "Href" "/x"}}` — no `Main` set — renders an empty
+name rather than failing; that is ordinary Go `html/template` behavior
+over map data, not a rastrillo-specific gap, and this design accepts it
+rather than adding a validation layer to close it. `Execute` only
+returns an error for a malformed template call (wrong argument
+count/type to `dict`, an undefined partial name) or for a **struct**-
+typed data value whose named field genuinely doesn't exist on the type —
+a caller that wants missing-field detection gets it by passing a Go
+struct instead of a `dict`-built map. `Icon`'s unknown-slug-renders-empty
+behavior (`icons.go`) is a separate, deliberate, narrow exception
+confined to icon lookups (§4.1) and sets no precedent for this package's
+own partials.
+
+## 10. Acceptance criteria
+
+- **Titogo equivalence check, rescoped to this slice.** A rastrillo app
+ hand-assembling the parts of titogo's real ticket-types list screen
+ (`admin_event_types.html`'s List state) that these eight partials
+ actually cover — the search bar, the row list, pagination, and the
+ screen's true blank state (no ticket types exist yet; titogo does not
+ render its empty-state illustration for a filter that simply matches
+ nothing, and neither does this check) — produces a screen that looks
+ and behaves equivalently for those parts: same information present,
+ same zero-JS interaction (search is a real GET form submit, pagination
+ links are real anchors), same accessible names on every control. The
+ row's kebab `<details>` menu, the filter/sort dropdowns, and
+ bulk-select are explicitly out of this slice per §3; their absence
+ from the hand-assembled version does not fail acceptance.
+- **WCAG 2.2 AA.** Every `tokens.css` text/background and pill-tone pair
+ meets the 4.5:1 (normal text) / 3:1 (large text, UI components)
+ contrast ratio. No partial conveys state by color alone (`status-pill`
+ always renders a text label; `pagination`'s current page carries
+ `aria-current`, not just a visual highlight). Every interactive
+ element renders with a real accessible name — `list-search-submit`'s
+ screen-reader-only text, `list-row-action`'s `ActionAria` fallback,
+ `pagination`'s `aria-label`.
+- **Zero-JS baseline.** All eight partials are fully functional — every
+ primary action reachable and operable — with JavaScript disabled:
+ search submits via 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.
+- **Package tests pass** per §8, including the smoke test rendering all
+ eight partials together without error.