carlos / platform Public

spec: rastrillo blog example — the north-star app, from stock components

examples/blog: ten file-routed actions, SQLite via the app's own Open
(recording the real F4 friction: Serve's DBPath never reaches Ctx), all
eight ui partials in real use, hand-rolled forms via the blog.css
extension convention, and an eight-entry friction log for the next
framework slices. Assumptions logged — designed while the user was away.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8fb39a3ff6a93b6ae8cb434e1eb537e42b591737 parent b08df01
1 file changed, +887 −0
  • docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md +887 −0
diff --git a/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md b/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md
new file mode 100644
index 000000000..2e8c49e95
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md
@@ -0,0 +1,887 @@
+# `examples/blog`: the north-star app, built from stock parts
+
+**Status:** approved 2026-08-03 (Paul) — every decision in this document
+was made before writing; see §14 for the ones made while the user was
+away.
+
+**Builds on** `2026-08-02-rastrillo-ui-component-library-design.md` (the
+"library spec") — the eight partials, `tokens.css`, `Funcs()`, and its §5
+data contracts are this app's entire component vocabulary — and on
+`2026-08-01-carlos-framework-design.md` §4 (filesystem routing) and §5
+(the `Serve` bootstrap), as implemented in the rastrillo repo's
+`ui-component-library` branch.
+
+## 1. Overview and motivation
+
+The library spec proved its eight partials in a test file: `ui_test.go`'s
+`TestRenderEverythingSmoke` renders every one of them into a
+`strings.Builder` and checks the markers are present. That is the right
+bar for a library, and it is not the bar the project set itself. The
+north star is blunter: **a simple blog app that looks half-decent, built
+from stock components and simple actions.** A `strings.Builder` cannot
+tell you whether `list-row-action` has a slot for the one piece of
+information a blog list is actually about, or whether an app that needs
+its database inside `Ctx` can use `rastrillo.Serve`'s database options at
+all. Only building the app tells you that.
+
+So `examples/blog` is a real, single-user blog: a public reading side
+(index, one post per page) and an admin section for writing, editing,
+publishing and deleting posts. It ships in the rastrillo repo beside
+`examples/helloworld`, checked in the same way — `gen/` committed,
+`replace` directive to the parent module, buildable with one `go build`.
+
+It exists for three reasons, in this order:
+
+1. **Proof.** The eight partials compose into a coherent screen that
+ looks finished, in light and dark, with JavaScript disabled.
+2. **Documentation.** Rastrillo's docs get a canonical example bigger
+ than "Hello, World" — a whole app whose every route, template and test
+ is readable in an afternoon.
+3. **Pressure.** Building it drives out what the library and the
+ framework are still missing. Those findings get *recorded* — §12 and
+ the example's README — not quietly patched around. An example that
+ silently reaches past the framework's seams to look good is worse than
+ no example: it hides exactly the information it was built to produce.
+
+`examples/helloworld` proved the framework's plumbing (scaffold, generate,
+serve, deploy). `examples/blog` proves its *shape*: that an app assembled
+from stock parts, with no JavaScript and no hand-written CSS beyond
+roughly fifty lines, is a thing a person would ship.
+
+## 2. Goals
+
+- A working blog — public index, public post page, and a full admin CRUD
+ loop (create, edit, publish, unpublish, delete) — in ten actions on
+ rastrillo's filesystem router.
+- **Every one of the eight partials used at least once**, in its intended
+ role, with its data built the way the library spec documents (§4.1's
+ `dict`, or a Go struct where the data is dynamic).
+- SQLite storage through `Ctx.DB`, one additive migration, no ORM, no
+ query builder, no third-party dependency of any kind beyond the SQLite
+ driver rastrillo itself already carries.
+- **Zero JavaScript.** No `<script>` tag anywhere in the app, no
+ off-origin reference on any page. Every flow — searching, paging,
+ creating, publishing, deleting — is a plain link or a plain form.
+- The look comes from `tokens.css` unedited, plus one small app-owned
+ stylesheet that only styles what the library deliberately doesn't ship
+ (§10).
+- Route-level tests over the real generated mux, in the rastrillo repo's
+ established stdlib style — `httptest`, no assertion library.
+- A friction log: what building this revealed the library and framework
+ still need.
+
+## 3. Non-goals
+
+- **Auth.** There is none. This is a single-user example run locally, and
+ the framework design doc's auth layers (§8's actors, WebAuthn, the
+ crypto core) are designed but unbuilt — see the repo README's "Not
+ built yet". A real deployment would put the whole `/admin/…` subtree
+ behind an authenticated session and set `Ctx.Actor` from it instead of
+ the hardcoded human actor in §9; nothing else about the app would
+ change shape. Recorded in §12 as a gap, not worked around here.
+- **Multi-user, comments, drafts-with-history, tags, feeds, uploads.**
+ A blog is the smallest app that exercises a list screen honestly. Every
+ feature beyond that is a feature this example would have to test and
+ document without learning anything new about the library.
+- **Markdown, or any rendering dependency.** Post bodies are plain text;
+ blank lines separate paragraphs (§8). A Markdown library would be the
+ example's only non-framework dependency and would prove nothing.
+- **Form partials.** The library spec's §3 defers the `field`/
+ `field-textarea` family to a later slice, and this example does not
+ invent its own version of it. The forms are hand-rolled HTML, and the
+ gap is the single loudest entry in the friction log.
+- **A `dropdown`-based filter or sort control.** Also deferred by the
+ library spec's §3. The admin list searches; it does not filter by
+ status or re-sort. `list-bar` reserves the space, as designed.
+- **Changing anything outside `examples/blog/`.** The branch is
+ `example-blog`, stacked on `ui-component-library`. Every friction
+ finding in §12 is a note in this spec and the example's README, and
+ zero lines in the framework.
+
+## 4. Shape on disk
+
+The example looks like a scaffolded app that grew — `rastrillo new`'s
+output (`cmd/rastrillo/new.go:28-53`: `go.mod`, `actions/`,
+`cmd/<name>/main.go`, `static/tokens.css`) with routes, templates, and one
+internal package added:
+
+```
+examples/blog/
+ go.mod, go.sum module blog; replace => ../.. (helloworld's convention)
+ README.md what it is, how to run, the friction log
+ actions/
+ index.GET.go GET /
+ posts/[id]/index.GET.go GET /posts/{id}
+ admin/posts/index.GET.go GET /admin/posts
+ admin/posts/index.POST.go POST /admin/posts
+ admin/posts/new.GET.go GET /admin/posts/new
+ admin/posts/[id]/edit.GET.go GET /admin/posts/{id}/edit
+ admin/posts/[id]/index.POST.go POST /admin/posts/{id}
+ admin/posts/[id]/publish.POST.go POST /admin/posts/{id}/publish
+ admin/posts/[id]/unpublish.POST.go POST /admin/posts/{id}/unpublish
+ admin/posts/[id]/delete.POST.go POST /admin/posts/{id}/delete
+ cmd/blog/main.go flags, DB, Ctx factory, static serving, Serve
+ gen/ committed generator output (router.go + gen/actions/…)
+ internal/blog/
+ store.go Open, the migration, Post, every query
+ view.go the template tree, Render, the view-model types
+ templates/
+ layout.html {{define "layout"}} — html/head/body
+ pages/*.html one file per screen, each {{define "content"}}
+ internal/blogtest/ route + render tests (test files only)
+ static/
+ tokens.css scaffolded by rastrillo new, unedited
+ blog.css the app's own ~50 lines (§10)
+```
+
+**Why `internal/blog/` exists, and why it has to.** The generator never
+compiles `actions/` in place: it walks the tree, matches
+`^(.+)\.(GET|POST|…)\.go$` only (`internal/generate/generate.go:33,70-73`),
+rewrites each matching file's package clause, and copies *that file alone*
+into its own directory under `gen/actions/` (`generate.go:174-197`). A
+sibling `helpers.go` in `actions/` is skipped by `Discover` and never
+copied, so an action referring to it would compile in `actions/` and fail
+in `gen/`. There is no escape hatch: `Discover` also skips any file whose
+base name starts with `_` (`generate.go:67-69` — the `_middleware.go`
+rule), so `_helpers.go` is dropped for a second, independent reason.
+Shared app code therefore lives in an ordinary importable
+package, and every action imports `blog/internal/blog` like any other Go
+package — imports travel unmodified through the rewrite
+(`generate.go:172-173`). This is a real convention the framework implies
+but nowhere states; §12 records it.
+
+## 5. Data model and storage
+
+One table, one additive migration string, applied at startup:
+
+```sql
+CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ published INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+```
+
+Timestamps are RFC3339 strings in UTC (`time.Now().UTC().Format(time.RFC3339)`),
+the family convention SQLite makes cheapest to sort and read. `published`
+is 0/1, SQLite's boolean. The migration is idempotent by construction —
+`CREATE TABLE IF NOT EXISTS` — matching `Options.Migrations`'s
+additive-only contract (`serve.go:50-53`) even though, per §9, this app
+applies it itself.
+
+`internal/blog/store.go` exposes plain functions over `*sql.DB`, so
+`Ctx.DB` (`ctx.go:19`) flows straight from the action into the query with
+nothing in between:
+
+```go
+func Open(path string) (*sql.DB, error) // pragmas + migration
+func Get(db *sql.DB, id int64) (Post, error) // sql.ErrNoRows when absent
+func ListPublished(db *sql.DB, offset, limit int) ([]Post, error)
+func CountPublished(db *sql.DB) (int, error)
+func List(db *sql.DB, q string, offset, limit int) ([]Post, error)
+func Count(db *sql.DB, q string) (int, error)
+func Create(db *sql.DB, title, body string) (int64, error)
+func Update(db *sql.DB, id int64, title, body string) error
+func SetPublished(db *sql.DB, id int64, v bool) error
+func Delete(db *sql.DB, id int64) error
+```
+
+`Post` is `{ID int64; Title, Body string; Published bool; CreatedAt,
+UpdatedAt time.Time}` — the RFC3339 strings are parsed on scan so
+formatting happens once, in Go, and templates never format a date.
+
+Ordering is `created_at DESC, id DESC` everywhere (the id tiebreak matters
+because two posts created in the same second are ordinary in a test).
+`ListPublished` adds `WHERE published = 1`.
+
+**Search** (`?q=`) matches the title only:
+`WHERE title LIKE ? ESCAPE '\'`, with the parameter built by an
+`likePattern` helper that escapes `\`, `%` and `_` in the user's string
+before wrapping it in `%…%`. Parameterised, so injection is not the
+concern; the escape is so a reader searching for `100%` gets a literal
+match rather than a wildcard. SQLite's `LIKE` is ASCII-case-insensitive
+by default, which is the behaviour a title search wants.
+
+## 6. Routes
+
+Ten actions. The `Route` column is exactly what
+`internal/generate/generate.go`'s `routeFor` emits for that path
+(`generate.go:114-138`): a `[param]` directory becomes `{param}`, and a
+file named `index` contributes no segment (`generate.go:119-121`).
+
+| Action file (under `actions/`) | Route | What it does |
+| --- | --- | --- |
+| `index.GET.go` | `GET /` | Published posts, newest first: title + date rows, paginated. Empty state when nothing is published. |
+| `posts/[id]/index.GET.go` | `GET /posts/{id}` | One published post. 404 for a draft, a missing id, or a non-numeric id. |
+| `admin/posts/index.GET.go` | `GET /admin/posts` | The List screen — the showcase. Search (`?q=`), pagination (`?page=`). |
+| `admin/posts/new.GET.go` | `GET /admin/posts/new` | The create form. |
+| `admin/posts/index.POST.go` | `POST /admin/posts` | Create. 303 → `/admin/posts/{id}/edit`. Empty title re-renders the form, 400. |
+| `admin/posts/[id]/edit.GET.go` | `GET /admin/posts/{id}/edit` | The edit form plus the status area. 404 for a missing id. |
+| `admin/posts/[id]/index.POST.go` | `POST /admin/posts/{id}` | Update title and body. 303 → the same edit page. Empty title re-renders, 400. |
+| `admin/posts/[id]/publish.POST.go` | `POST /admin/posts/{id}/publish` | `published = 1`. 303 → the edit page. |
+| `admin/posts/[id]/unpublish.POST.go` | `POST /admin/posts/{id}/unpublish` | `published = 0`. 303 → the edit page. |
+| `admin/posts/[id]/delete.POST.go` | `POST /admin/posts/{id}/delete` | Delete. 303 → `/admin/posts`. |
+
+Every POST is a plain `<form method="post">` and answers with
+`http.StatusSeeOther` (303) and a `Location` — redirect-after-POST, so a
+reload never re-submits. No POST renders a page on success; the two
+validation failures are the only POSTs that render, and they render the
+form they came from with the submitted values still in the fields.
+
+**Three route-level rules the reality of the mux forces:**
+
+- **`GET /` is a catch-all.** Go 1.22's `ServeMux` treats `/` as a prefix
+ pattern, so the index action receives every unmatched GET — `/nonsense`
+ included. `index.GET.go` therefore opens with
+ `if r.URL.Path != "/" { http.NotFound(w, r); return }`. Every rastrillo
+ app with an `actions/index.GET.go` needs that guard; §12 records it.
+- **Path params are strings.** `r.PathValue("id")` goes through
+ `strconv.ParseInt`; a parse failure is a 404, not a 400 — a
+ non-numeric id is a URL that was never ours.
+- **`/static/` outranks `/`.** The static handler registered in `main.go`
+ (`GET /static/`, §9) is a longer pattern than `GET /`, so the stdlib
+ mux prefers it. No ordering care is needed.
+- **Trailing-slash variants get no redirect.** None of these routes is
+ registered with a trailing slash, so `/admin/posts/` and `/posts/1/`
+ match nothing but the `GET /` prefix pattern and land in the index
+ action, where the path guard above turns them into a 404. Go's mux only
+ redirects to a trailing-slash form when a subtree pattern is
+ registered, and this app registers none — so a stray slash is a
+ not-found, not a bounce.
+- **A POST to an unmatched path is a 405, not a 404.** `GET /` matches
+ the *path* of `POST /nonsense` and `POST /posts/1` but not the method,
+ so the stdlib mux answers `405 Method Not Allowed` with an `Allow`
+ header before any action runs. The index guard never sees those
+ requests. Worth knowing before someone reads a 405 as a routing bug.
+
+## 7. Screens, and exactly which stock components build them
+
+Five screens. Between them they use all eight partials; the table after
+this section is the map.
+
+### 7.1 `GET /admin/posts` — the List screen
+
+The showcase, and the screen the library was designed for.
+
+- **`page-header`** — `dict "Title" "Posts" "ActionHref" "/admin/posts/new"
+ "ActionLabel" "New post" "ActionIcon" "plus"`. The `plus` slug is
+ already vendored (`icons.go:77`), so this needs no new icon.
+- **`list-bar`** — `dict "SearchAction" "/admin/posts" "Query" .Query
+ "Placeholder" "Search posts" "Hidden" .Carry`. It wraps
+ `list-bar-search`, which wraps `list-search-submit`
+ (`ui/partials/list-bar.html:19-21`, `list-bar-search.html:37-44`), so
+ one call exercises three partials — including the screen-reader-only
+ submit button a keyboard user with JS off actually needs. `.Carry` is
+ empty on this screen: there is no sort order or page size to preserve
+ across a search, and a new search deliberately returns to page 1.
+- **Rows** — one `list-row-action` per post, inside the app's own
+ `<div class="rst-list">` card (the container class the library
+ documents but does not emit, `ui/ui.go:22-25`):
+
+ | Key | Value |
+ | --- | --- |
+ | `Href` | `/admin/posts/{id}/edit` — the row goes where the work is |
+ | `Main` | the post title |
+ | `Sub` | `Published 2 August 2026`, or `Draft · edited 2 August 2026` |
+ | `ActionHref` | `/posts/{id}` — published posts only |
+ | `ActionLabel` | `View` |
+ | `ActionAria` | `View <title>` |
+
+ Two deliberate decisions here. **Status rides in `Sub` as text**,
+ because the stock row contract has no slot for a pill
+ (`list-row-action.html:13-29`: `Href`, `Main`, `Sub`, the action pill,
+ and a decorative `Lead`). The one thing a blog list is most about —
+ is this post live? — has nowhere structural to go. It is legible and it
+ is accessible; it is also the loudest thing this example learned, and it
+ is friction finding F1 rather than a library patch in this slice.
+ **Drafts get no action pill**, because a draft has no public page to
+ send anyone to; the partial renders nothing when `ActionHref` is empty
+ (`list-row-action.html:35-37`), so this needs no conditional markup of
+ our own.
+
+ `Lead`/`LeadInitial` go unused, on purpose. Tinting a leading circle by
+ publish state would encode status in colour, on an element the partial
+ marks `aria-hidden` (`list-row-action.html:32`) — a WCAG 2.2 AA failure
+ dressed as decoration. There is no other meaningful monogram for a post.
+- **`pagination`** — page size 10, `?page=N`, with `q` preserved in every
+ page link. `Items` is a `[]blog.PageItem` (`{Label, Href string;
+ Current, Disabled, Gap bool}`), built in Go: Previous (`Disabled` on
+ page 1), the page numbers, Next (`Disabled` on the last page). Up to
+ seven pages every number renders; beyond that it is first · gap ·
+ current±1 · gap · last. In day-to-day use — and in every test fixture
+ here, the largest of which is eleven posts — the blog exercises the
+ strip's common kinds: numbered links, the `Current` page, and
+ `Disabled` Previous/Next at the ends. `Gap` needs 71 posts to appear,
+ so it stays covered by the library's own fixtures
+ (`ui_test.go:408-435`) rather than by this app; the builder emits it
+ correctly when a blog gets that long.
+
+ The strip renders only when there is more than one page — and that is
+ the app's decision, not the partial's: `pagination` emits its `<nav>`
+ even with no `Items` (`ui/partials/pagination.html:19-27`,
+ `ui_test.go:459-467`), so an unguarded call would leave an empty
+ landmark on every single-page list. The page template guards it with
+ `{{if .Pagination.Show}}`, and the view model sets `Show` only when the
+ total exceeds the page size.
+- **`empty-state`** — for the true blank state only, when the blog has no
+ posts at all: `Title "No posts yet"`, `Body "Every blog starts empty.
+ Write the first one and it will show up here."`, `ActionHref
+ "/admin/posts/new"`, `ActionLabel "Write your first post"`.
+
+ **A search that matches nothing is not an empty state.** It renders a
+ plain `<p class="blog-note">No posts match “<q>”.</p>` inside the list
+ card instead — following the library spec's own titogo observation
+ (library spec §10: "titogo does not render its empty-state illustration
+ for a filter that simply matches nothing, and neither does this
+ check"). A dashed blank-state card telling a writer with forty posts
+ that their blog is empty is a lie the component would happily tell.
+
+### 7.2 `GET /admin/posts/{id}/edit` — the edit screen
+
+- **`page-header`** — `Title` is the post's own title, `Sub` is
+ `"Editing"`. No `ActionHref`: the actions on this screen act on the
+ post and must be POSTs, which a header link cannot be.
+- **`status-pill`** — `dict "Tone" .StatusTone "Label" .StatusLabel`,
+ resolved in Go to `neutral`/`Draft` or `positive`/`Published`. The
+ partial takes the resolved pair by design; mapping a raw value to a
+ tone is product logic that stays in the app
+ (`ui/partials/status-pill.html:12-14`).
+- **The status area** — a `<div class="blog-status">` holding, in order:
+ the pill, the publish-or-unpublish form, a `View` link when published,
+ and the delete form. Both forms are `<form method="post">` with a single
+ `<button class="rst-btn">`; they are **siblings of** the edit form, not
+ inside it, because nested forms are invalid HTML and the browser drops
+ the inner one. That constraint is why the publish toggle is its own
+ route rather than a second submit button on the edit form.
+- **The edit form** — hand-rolled: a `<label for>`/`<input type="text"
+ id name="title" required>` pair and a `<label for>`/`<textarea id
+ name="body" rows="18">` pair, then `<button class="rst-btn
+ rst-btn--primary" type="submit">Save</button>`. Real labels bound by
+ `for`/`id`, so every field has an accessible name without an
+ `aria-label` in sight. A validation failure renders
+ `<p class="blog-error" role="alert">` above the fields.
+
+### 7.3 `GET /admin/posts/new` — the create screen
+
+`page-header` (`Title "New post"`, `Sub "It starts as a draft — nothing
+is public until you publish it."`), then the same hand-rolled form
+posting to `/admin/posts`, with a `Cancel` link back to the list. No
+status pill: an unsaved post has no status.
+
+### 7.4 `GET /` — the public index
+
+- **`page-header`** — `Title "The blog"`, `Sub "Notes, in the order they
+ were written."` No action: readers have nothing to do here but read.
+- **Rows** — `list-row-action` with `Href` `/posts/{id}`, `Main` the
+ title, `Sub` the published date, and **no action pill**: the row already
+ goes to the only place it could go, and a "Read" pill beside a link to
+ the same URL is noise with a tab stop.
+- **`empty-state`** — `Body "Nothing published yet. Check back soon."`,
+ no `Title`, no CTA. A reader cannot act on an empty blog, and a
+ blank-state card with a dead button is worse than one without.
+- **`pagination`** — the same builder as the admin list, over
+ `/?page=N`, rendered only past ten published posts.
+- No `list-bar`: public search over one author's blog is a feature this
+ example does not need, and adding it to fill a component slot would be
+ the exact dishonesty §1 warns about.
+
+### 7.5 `GET /posts/{id}` — the public post
+
+`page-header` with the post's title and the published date as `Sub`,
+then `<article class="blog-article">` containing one `<p>` per paragraph.
+Paragraphs are split in Go on blank lines and rendered as ordinary
+`{{.}}` values — escaped by `html/template`, never `template.HTML`. Post
+bodies are author-supplied plain text and stay that way; there is no path
+in this app by which a body becomes markup.
+
+### 7.6 The partial-to-screen map
+
+| Partial | Where it appears |
+| --- | --- |
+| `page-header` | all five screens |
+| `list-bar` | `/admin/posts` |
+| `list-bar-search` | `/admin/posts`, via `list-bar` |
+| `list-search-submit` | `/admin/posts`, via `list-bar-search` |
+| `list-row-action` | `/admin/posts`, `/` |
+| `status-pill` | `/admin/posts/{id}/edit` |
+| `empty-state` | `/admin/posts` (no posts), `/` (nothing published) |
+| `pagination` | `/admin/posts`, `/` |
+
+## 8. Template architecture
+
+Exactly the pattern the library spec documents (§4, and `ui/ui.go:11-13`):
+the app owns rendering; `ui` contributes names to the app's tree.
+
+**One base tree, cloned once per page, at startup.** `internal/blog/view.go`:
+
+```go
+//go:embed templates/layout.html templates/pages/*.html
+var templateFS embed.FS
+
+var pages = buildPages() // panics at init on a broken template
+
+func buildPages() map[string]*template.Template {
+ base := template.Must(template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html"))
+ base = template.Must(base.ParseFS(templateFS, "templates/layout.html"))
+
+ names, err := fs.Glob(templateFS, "templates/pages/*.html")
+ // …
+ out := make(map[string]*template.Template, len(names))
+ for _, n := range names {
+ t := template.Must(base.Clone())
+ t = template.Must(t.ParseFS(templateFS, n))
+ out[strings.TrimSuffix(filepath.Base(n), ".html")] = t
+ }
+ return out
+}
+```
+
+The clone is what makes the layout work. Every page file defines
+`{{define "content"}}`, which is the *same name* in every file: parsed
+into one shared tree they would overwrite each other silently, last file
+wins, and the app would render the wrong screen with no error anywhere.
+Cloning the base per page gives each screen its own `content`, so
+`layout.html` can call `{{template "content" .}}` with a constant name —
+the only thing `html/template` allows. Five clones happen once, at
+process start; nothing clones per request. This is the standard Go idiom
+for layout-plus-pages, and it is chosen here over the alternative
+(a distinct define name per page, invoked through a switch) because that
+alternative pushes a name-matching problem into every action.
+
+`layout.html` is one `{{define "layout"}}` containing `<html lang="en">`,
+a head with `<meta charset>`, `<meta name="viewport">`,
+`<title>{{.Head.Title}} · The blog</title>`, and two stylesheet links in
+this order:
+
+```html
+<link rel="stylesheet" href="/static/tokens.css">
+<link rel="stylesheet" href="/static/blog.css">
+```
+
+— then `<body>`, `<div class="rst-page">` (the content column the library
+documents as the app's job, `ui/ui.go:24`), `{{template "content" .}}`,
+and a two-link `<footer class="blog-footer">` (`The blog` ·
+`Posts admin`). The footer sits after the content, so it adds no
+repeated-block-before-content problem for WCAG 2.4.1 and needs no skip
+link. There is no `<script>` tag, here or anywhere.
+
+**Rendering.** One helper, used by every action:
+
+```go
+func Render(ctx *rastrillo.Ctx, w http.ResponseWriter, page string, status int, data any)
+```
+
+It looks the page up, executes `"layout"` into a `bytes.Buffer` first,
+and only then writes the status code and the bytes — so a template error
+produces a clean 500 rather than half a page followed by a stack of
+garbage. On error it logs through `ctx.Logger`, which is the second real
+use of `Ctx` in this app after `Ctx.DB`.
+
+**Error paths, so the actions stay thin.** A sibling helper —
+`fail(ctx, w, "loading posts", err)` — logs through `ctx.Logger` and
+writes a plain 500, and every store error that is not `sql.ErrNoRows`
+goes through it in one line; `sql.ErrNoRows` is the only error an action
+interprets, and it always means 404. Each POST action opens by wrapping
+the body — `r.Body = http.MaxBytesReader(w, r.Body, 1<<20)` — before
+calling `r.ParseForm()`, and a `ParseForm` error is a 400: a megabyte is
+a generous post and an unbounded `ParseForm` on a public endpoint is a
+memory-exhaustion hole no example should teach. Those three lines plus
+the id parse are the whole preamble of every mutating action.
+
+**View models are Go structs, never `dict`-built maps.** Each screen has
+a type in `internal/blog` — `HomeView`, `PostView`, `AdminListView`,
+`AdminFormView` — each with a `Head Head` field carrying the `<title>`.
+Struct data is a deliberate choice against the library spec's §9 warning:
+with map data a mistyped field renders empty and `Execute` returns no
+error, while a struct field that doesn't exist fails loudly. The `dict`
+helper is still used, in the templates, for the *literal* argument lists
+that call a partial — that is what it is for. The dividing line: data
+that came from the database travels as a struct; data that is a constant
+string in a template stays in the template.
+
+**The app registers no template funcs of its own.** `ui.Funcs()` is the
+whole FuncMap. Every date format, every "Draft · edited …" string, every
+pagination item is computed in Go, in a view model. This is not
+asceticism: a template func is app logic in a place tests reach last, and
+an example that needed five of them would be arguing against its own
+component library.
+
+## 9. `main.go`, and the truth about `Serve`'s database options
+
+`cmd/blog/main.go` is the scaffolded `main.go` (`cmd/rastrillo/new.go:93-137`)
+plus a `-db` flag and a real Ctx factory:
+
+```go
+socket := flag.String("socket", "", "unix socket to listen on")
+addr := flag.String("addr", "", "TCP host:port to listen on")
+dbPath := flag.String("db", "blog.db", "SQLite database file")
+flag.Parse()
+
+logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+db, err := blog.Open(*dbPath) // pragmas, then the migration
+if err != nil { logger.Error("open database", "err", err); os.Exit(1) }
+defer db.Close()
+
+mux := gen.Router(func(*http.Request) *rastrillo.Ctx {
+ return &rastrillo.Ctx{DB: db, Logger: logger, Actor: rastrillo.Actor{Human: true}}
+})
+mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
+
+err = rastrillo.Serve(rastrillo.Options{Mux: mux, Socket: *socket, Addr: *addr, Logger: logger})
+```
+
+A fresh `Ctx` per request, exactly as the scaffold's comment tells you to
+do "once it does" have per-request state (`new.go:115-118`). `Actor.Human`
+is `true` and `Actor.Name` empty — honest for an app with no auth
+(`ctx.go:11-14`), and the one line a real deployment would replace.
+
+**`Options.DBPath` and `Options.Migrations` are not set. They cannot be.**
+This was verified against `serve.go`, and the finding is real:
+
+- `Serve` opens the database itself when `DBPath` is set, keeps the
+ handle in a local variable, and defers `db.Close()` (`serve.go:80-88`).
+- It never returns the handle, never writes it into an `Options` field,
+ never passes it to a callback. `Options` has no `Ctx` factory field and
+ no `DB` field (`serve.go:38-65`).
+- `openDB` — the function carrying the pragma-ordering fix the design
+ doc §5 exists to centralise — is unexported (`serve.go:152`). The root
+ package exports exactly `BuildVersion`, `Options`, `Serve`, `Ctx`,
+ `Actor` and `Icon`. There is no accessor.
+- `gen.Router` takes the Ctx factory *before* `Serve` runs
+ (`internal/generate/generate.go:214-223`), so even a handle produced
+ later inside `Serve` could not be threaded back into it without a
+ mutable box the framework does not provide.
+
+So: **any app whose actions use `Ctx.DB` — which is every app with a
+database, since `Ctx.DB` is the field the framework put there for it —
+must open its own `*sql.DB` and leave `DBPath` empty.** `Options.DBPath`
+and `Options.Migrations` are usable only by an app that never reads its
+own database from an action, which is not an app.
+
+This example therefore opens its own handle in `blog.Open`, reproducing
+`serve.go:152-169` by hand: `sql.Open("sqlite", path +
+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")` — busy_timeout
+before journal_mode, the ordering titogo paid for — then
+`SetMaxOpenConns(1)`, then the migration. Hand-copying that DSN into an
+example app is precisely the hand-propagation the framework was built to
+end, which is what makes this finding worth writing down rather than
+shrugging at. It is friction finding **F4**, and the fix belongs in the
+next framework slice, not in this branch: either `Serve` returns (or
+accepts) the opened `*sql.DB`, or `Options` grows a
+`Ctx func(*http.Request) *rastrillo.Ctx` field and `Serve` builds the
+router itself. Both are small; neither is this example's to make.
+
+One dependency consequence: `examples/blog/go.mod` requires
+`modernc.org/sqlite` directly, because `blog.Open` blank-imports the
+driver itself. (Rastrillo's own `serve.go:27` blank-imports it too — the
+`// indirect` marker beside it in the root `go.mod` is an untidied file,
+not a statement about how the package uses it.) The example naming the
+driver is a visible symptom of the same finding: an app that could use
+`DBPath` would never import a SQLite driver at all.
+
+## 10. `blog.css`: the extension convention, demonstrated
+
+Roughly fifty lines in `static/blog.css`, loaded *after* `tokens.css`, and
+scoped to exactly two things the library deliberately does not ship:
+
+1. **Form controls** — `.blog-form` (the field stack), `.blog-form label`,
+ `.blog-form input[type="text"]`, `.blog-form textarea`,
+ `.blog-form__actions` (the button row), `.blog-error` (the validation
+ message), `.blog-status` (the edit screen's pill-and-buttons row),
+ `.blog-note` (the search-found-nothing paragraph), `.blog-footer`.
+2. **Prose measure** — `.blog-article` on the public post page: a
+ `max-inline-size` around 34rem and paragraph rhythm, because a column
+ of body text at the list card's width is unreadable.
+
+Two rules govern it, and both are testable:
+
+- **Every value comes from a token.** Colours are `var(--rst-text)`,
+ `var(--rst-surface)`, `var(--rst-line-strong)`, `var(--rst-accent)`,
+ `var(--rst-tone-negative-fg)`; spacing is `var(--rst-sp-3)`; radii are
+ `var(--rst-radius-sm)`. No literal hex, no literal px spacing. That is
+ the whole mechanism by which the forms track the dark theme without
+ `blog.css` knowing dark exists — `tokens.css` redeclares every colour
+ token three times (light, `prefers-color-scheme: dark`,
+ `[data-theme="dark"]`), and anything built on `var()` follows for free.
+- **It never styles an `rst-` class.** No selector in the file contains
+ `.rst-`. Buttons on the forms *use* `.rst-btn`/`.rst-btn--primary` in
+ their markup — using a library class is the point of a library — but
+ no rule in `blog.css` redefines one. An example that shipped
+ `.rst-row { padding: … }` would be teaching every reader to fork the
+ design system on day one.
+
+The focus ring is the one place worth naming explicitly: `tokens.css`
+scopes its `:focus-visible` rule to library containers
+(`:where(.rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty,
+.rst-pagination)`), so nothing on a form screen inherits it — not the
+inputs, and not the `.rst-btn` buttons beside them. `blog.css` therefore
+gives `.blog-form :focus-visible` and `.blog-status :focus-visible` their
+own `outline: 2px solid var(--rst-accent); outline-offset: 2px` — same
+token, same shape, restated because the library's scope ends at its own
+containers.
+Discovering that gap by building a form is exactly what this example is
+for; it is friction finding F2's smaller half.
+
+## 11. Testing strategy
+
+Plain stdlib — `testing`, `net/http/httptest`, `strings` — mirroring
+`ui/ui_test.go` and `icons_test.go`. All tests live in
+`examples/blog/internal/blogtest/`, a directory containing only `_test.go`
+files. That is a valid Go package: `go test ./...` runs it and
+`go build ./...`/`go vet ./...` pass over it silently (verified).
+
+The tests import `blog/gen`, which imports the generated actions, which
+import `blog/internal/blog` — so they cannot be an *internal* test of
+`internal/blog`, which would be a cycle. An external test package
+(`package blog_test` inside `internal/blog/`) would break the cycle
+equally well; the separate directory is **chosen** over it because it
+keeps `internal/blog` a directory of production code only, and because
+the tests then see the app exactly as `main.go` does — through `gen`,
+with no package-local shortcut available even by accident.
+
+**The harness.** One helper builds a whole app per test:
+
+```go
+func newApp(t *testing.T) (http.Handler, *sql.DB)
+```
+
+— `blog.Open(filepath.Join(t.TempDir(), "blog.db"))` for a fresh
+file-backed SQLite database per test (a file, not `:memory:`, because
+`SetMaxOpenConns(1)` plus WAL is the configuration being exercised), then
+`gen.Router` with a Ctx factory closing over it, exactly as `main.go`
+does. Requests go through `httptest.NewRecorder`. Seeding goes through
+`blog.Create`/`blog.SetPublished`, not raw SQL, so the tests exercise the
+store too.
+
+**Route-level tests**, one or more per action:
+
+- `GET /` — published posts only, newest first; drafts absent; the empty
+ state when nothing is published; `?page=2` shows posts 11+.
+- **The catch-all's edges**: `GET /nonsense` is 404 (the index guard, not
+ the homepage); `GET /admin/posts/` and `GET /posts/1/` are 404 too, with
+ no redirect; `POST /nonsense` is **405**, not 404, because `GET /`
+ matches the path but not the method (§6).
+- `GET /posts/{id}` — 200 for published; **404 for a draft**; 404 for a
+ missing id; 404 for `/posts/abc`.
+- `GET /admin/posts` — drafts and published both listed; `?q=` filters by
+ title and is case-insensitive; `?q=` with no match renders the plain
+ note and *not* `rst-empty`; no posts at all renders `rst-empty` and
+ *not* the note; `?page=2` pages, and `q` survives into every page link;
+ the pagination strip is absent with ten posts and present with eleven.
+
+ One escaping trap worth stating, because it costs an afternoon
+ otherwise: `html/template` escapes `&` inside an attribute value, so the
+ preserved-query assertion must look for
+ `href="/admin/posts?q=go&amp;page=2"`, not `…q=go&page=2`. The rendered
+ page is correct either way — a browser unescapes it — but a test
+ asserting the raw ampersand fails against working output.
+- `POST /admin/posts` — 303 with `Location: /admin/posts/{id}/edit` for
+ the id actually created; the row then appears on the list; an empty
+ title returns 400, renders the form again, and creates nothing.
+- `GET /admin/posts/{id}/edit` — 200 with the current values in the
+ fields; the pill reads `Draft` then `Published` after publishing; 404
+ for a missing id.
+- `POST /admin/posts/{id}` — fields updated, `updated_at` moved, 303 back
+ to the edit page; empty title is 400 and changes nothing.
+- `POST …/publish` and `…/unpublish` — the flag flips, 303 back to the
+ edit page, and the post appears on / disappears from `GET /`.
+- `POST …/delete` — the row is gone, 303 to `/admin/posts`, and a
+ subsequent `GET /posts/{id}` is 404.
+
+**One rendered-HTML sanity test per screen**, asserting the stock
+component actually reached the page — `rst-page-header` on all five,
+`rst-lbar` + `rst-search` + `rst-sr-only` + `rst-row` + `rst-pagination`
+on the admin list, `rst-status` with `data-tone="positive"` on a published
+post's edit screen, `rst-empty` on both empty states — plus a non-empty
+`<title>` on every screen. One further test asserts, in one place, that
+**all eight partial markers appear across the app's own screens**, using
+the same marker strings as `ui_test.go:615-624`. That is the acceptance
+criterion in §13 made executable — and because the strip is guarded
+(§7.1), that test must seed **eleven** posts before rendering the admin
+list, or `rst-pagination` legitimately never appears and the assertion
+fails on a correct app.
+
+**A zero-JS and self-containment sweep** over every rendered page, in the
+style of `ui_test.go`'s `TestRenderedPartialsAreSelfContained`: render
+every screen in both its populated and its empty state, and assert the
+HTML contains none of `<script`, `<iframe`, `onload=`, `onclick=`,
+`http://`, `https://`, `//cdn`, `srcset`, `@import`; and that every
+`href="` and `action="` value starts with `/`, `?` or `#`. The layout's
+two `<link rel="stylesheet">` tags are the only asset references in the
+app and both are same-origin `/static/…` paths, so unlike the library's
+own sweep this one permits `<link ` and checks the target instead. The
+same self-containment check runs over `static/blog.css` — no `@import`,
+no `url(`, no `http` — and one further test asserts `blog.css` contains
+no `.rst-` selector, enforcing §10's second rule.
+
+**Generator freshness is not a Go test.** It would like to be, and it
+cannot be: `internal/generate` is internal to the rastrillo module and
+unimportable from `blog`, and the CLI always writes to `<dir>/gen` and
+reads the module path from `<dir>/go.mod`, so it cannot be pointed at a
+scratch output directory from inside a test. So it is a documented
+procedure in the example's README (development section) rather than a
+`_test.go` file:
+
+```
+tmp=$(mktemp -d) && cp -R actions go.mod "$tmp"/
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate "$tmp"
+diff -r gen "$tmp/gen"
+```
+
+Copying `go.mod` is what makes the generated import paths match; the
+`actions/` tree and that one file are the generator's whole input. This
+is regeneration hygiene for CI and for whoever adds an action next — the
+everyday guard against a stale `gen/` is the route-level tests above,
+which run through the committed router and fail immediately if it no
+longer matches the actions.
+
+## 12. Friction log — what building this revealed
+
+Recorded, not fixed. Every one of these is a note in the example's
+`README.md` too, so the finding lives where the next person reads.
+
+**F1 — `list-row-action` has no status slot.** A blog list's most
+load-bearing fact per row is "draft or published", and the row contract
+offers `Main`, `Sub`, one action pill, and a decorative `aria-hidden`
+lead marker (`list-row-action.html:13-29`). Status goes into `Sub` as
+prose. It works and it is accessible; it is also why titogo's own rows
+carry a `status-pill` inline. The row partial wants a `Status`
+`Tone`/`Label` pair that renders `status-pill` in the row's right-hand
+group, beside the action pill.
+
+**F2 — no form partials, and the focus ring stops at the library's
+edge.** The `field`/`field-textarea` family is deferred by the library
+spec's §3, so every form here is hand-rolled and `blog.css` re-states the
+focus outline for controls outside `tokens.css`'s `:where(...)` scope.
+Roughly half of `blog.css` would disappear the day form partials land.
+
+**F3 — no `dropdown`, so "show drafts only" is missing.** Also deferred
+(library spec §3). The obvious next control on this screen — a status
+filter — has nowhere to attach, and the example ships without it rather
+than hand-rolling a control the library has already scheduled.
+
+**F4 — `Serve`'s `DBPath`/`Migrations` are unusable by any app that puts
+the DB in `Ctx`.** Verified in §9: `Serve` opens the handle, defers its
+`Close`, and never exposes it (`serve.go:80-88`); `openDB` is unexported
+(`serve.go:152`). The example hand-copies the pragma DSN — the exact
+hand-propagation the framework exists to end. Next slice: return the
+handle from `Serve`, or let `Options` carry the Ctx factory.
+
+**F5 — `actions/` cannot hold shared code, by two rules.** The generator
+copies only files matching `<name>.<VERB>.go`
+(`generate.go:33,70-73,174-197`), and separately skips any file whose base
+name starts with `_` (`generate.go:67-69`) — so neither `helpers.go` nor
+`_helpers.go` ever reaches `gen/`, and an action calling into one
+compiles in `actions/` and fails in `gen/`. Shared code lives in a normal
+package (`internal/blog`); that is the only path, not a preference.
+Correct behaviour, undocumented convention — it belongs in the repo
+README and in `rastrillo new`'s scaffold comments.
+
+**F6 — `actions/index.GET.go` is a catch-all.** `routeFor` maps `index`
+to `GET /` (`generate.go:119-121`) and Go's mux treats `/` as a prefix,
+so every unmatched GET lands in the index action. Every app needs the
+`r.URL.Path != "/"` guard, or the generator should emit a 404 fallback
+for it.
+
+**F7 — no auth, because there is none to use.** The `/admin/…` subtree is
+open. The design doc's actor/session layers exist on paper only (repo
+README, "Not built yet"), so the example runs locally and says so
+loudly in its README rather than inventing a password field.
+
+**F8 — static serving is relative to the working directory.** The
+scaffolded line is `http.FileServer(http.Dir("static"))`
+(`new.go:125`), so the binary must be run from the app root or the
+stylesheets 404 and every screen renders unstyled. Parked during the
+library review; confirmed here as a thing a reader hits on their first
+run, which is why the README documents `cd examples/blog && ./blog`
+explicitly.
+
+## 13. Acceptance criteria
+
+- **All eight partials are used by the running app**, each in its
+ intended role, proven by the cross-screen marker test in §11 — not by a
+ fixture.
+- **The screens look coherent in light and dark.** `tokens.css` ships
+ unedited; `blog.css` contains no literal colour and no `.rst-`
+ selector, so both themes are inherited whole. Verified by eye in both
+ `prefers-color-scheme` states and with `data-theme` forced each way,
+ and by the `blog.css` tests in §11.
+- **Every flow works with JavaScript disabled, end to end**: create a
+ post → edit it → publish it → read it on the public side → search for
+ it in the admin list → page past it → unpublish → delete. Links and
+ form POSTs only, 303 after every mutation. The zero-JS sweep (§11)
+ enforces that no page contains a script or an off-origin reference.
+- **The friction log is captured** in §12 and in the example's README,
+ and **nothing outside `examples/blog/` changed** on the `example-blog`
+ branch — verifiable with
+ `git diff --name-only ui-component-library...example-blog`, whose every
+ line starts `examples/blog/`.
+- **`go build ./cmd/blog` and `go test ./...` pass** in
+ `examples/blog/`, and the committed `gen/` tree matches what the
+ generator produces, checked once by the README's regeneration procedure
+ (§11) rather than by a Go test.
+- **The README answers three questions**: what this is, how to run it
+ (including the run-from-the-app-root requirement, F8, and that
+ `go generate` is not needed because `gen/` is committed as
+ `helloworld`'s is), and what building it revealed — plus a short
+ development section carrying the regeneration procedure.
+
+## 14. Assumptions log
+
+Decisions made while the user was away. Each is a real choice with a
+plausible alternative; each is listed so it can be reversed in one
+reading.
+
+1. **Single-user blog, no auth** — over a multi-user example with a fake
+ login. The auth layers do not exist yet; faking one would be the
+ example lying about the framework (§3, F7).
+2. **Status as `Sub` text** — over adding a status slot to
+ `list-row-action` in this branch. Recording friction beats patching
+ the library from an example (§7.1, F1).
+3. **Filter-miss is a plain paragraph, not `empty-state`** — following
+ the library spec's own §10 titogo observation.
+4. **Struct view models, `dict` only for template-literal argument
+ lists** — over `dict` everywhere. Struct data fails loudly on a
+ mistyped field; map data renders empty (library spec §9).
+5. **Clone-per-page at startup, every page file defining `content`** —
+ over distinct define names per page. One layout, one constant
+ `{{template "content" .}}`, no per-request cloning (§8).
+6. **No app-owned template funcs** — all formatting in Go view models.
+7. **Plain-text bodies split on blank lines** — over a Markdown
+ dependency (§3).
+8. **The app opens its own `*sql.DB`; `Options.DBPath` unused** — forced
+ by `serve.go`, not chosen (§9, F4). The alternative — actions opening
+ their own connections — would be worse in every way.
+9. **Shared code in `internal/blog`** — forced by the generator's copy
+ and `_`-prefix skip rules (§4, F5). **Tests in `internal/blogtest`** —
+ *chosen*, not forced: an external `package blog_test` inside
+ `internal/blog` would break the import cycle too, but a separate
+ directory keeps `internal/blog` production-code-only and makes the
+ tests see the app the way `main.go` does, through `gen` (§11).
+10. **`index.GET.go` guards `r.URL.Path != "/"`** — over letting the
+ index answer every unmatched URL (§6, F6).
+11. **Page size 10, `?page=N`, `?q=` over titles only** — the smallest
+ parameters that produce a real page strip and exercise the search
+ bar's plumbing. (The `Gap` item kind needs 71 posts to appear, so the
+ app builds it correctly but never renders it; the library's own
+ fixtures cover it — §7.1.)
+12. **303 See Other after every mutation**, and validation failures
+ re-rendering with 400 — over 302, and over redirecting a failed
+ submission and losing what the writer typed.
+13. **`Lead`/`LeadInitial` deliberately unused** — tinting an
+ `aria-hidden` circle by publish state is colour-only status (§7.1).
+14. **A two-link footer as the app's only chrome** — over a nav bar,
+ which would need a skip link and would put structural CSS in
+ `blog.css` that `tokens.css` explicitly does not cover (§8).
+15. **POST bodies bounded at 1 MiB via `http.MaxBytesReader`** before
+ `ParseForm`, with a `ParseForm` error answered 400 — over trusting
+ `ParseForm`'s defaults on a route anyone can reach (§8).
+16. **Generator freshness is a README procedure, not a Go test** —
+ `internal/generate` is unimportable from the example and the CLI
+ cannot be pointed at a scratch output directory, so the check moves
+ to CI/regeneration hygiene and the route tests carry the everyday
+ guard (§11).