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>
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. |