oficina / docs Public

Merge main, and act on the whole-branch review

The Fable review found one blocking thing, and it was not in this
branch's code: main had moved to cafb696 four seconds after the previous
merge point, and cafb696 adds assets_test.go's
TestExactlyOneEditorBundleIsEmbedded, which asserts static/ holds
exactly one entry. This branch commits a second bundle. Squash-merging
as it stood would have put a red build on main — and branch CI could not
have warned, because the failing test only exists on main. That is the
whole argument for reading a branch as one diff against where it will
actually land, rather than against where it started.

Reconciled rather than deleted. The test's real job is not the number
one, it is that static/ holds only the bundle build's output — the tab
icon was written there once and silently deleted by the gate's own
cleanup, which is why it is a test. It is now "exactly one file per name
in bundleKinds, and nothing present that no kind accounts for", which
keeps the stray-file protection that assets.go's registry cannot
provide: the registry looks up names it knows and would never notice a
third file nobody named. Verified by putting a stray file in static/ and
watching it fail.

assets.go's conflict was both sides adding to the same two places —
cafb696's icon serving and this branch's bundle registry. Both kept; the
handler tries the bundle map first and falls through to the icon case,
because a case per bundle is the shape that eventually forgets one.

Three review findings fixed while in the files:

- The palette hijacked IME composition keys. Mid-composition in
  Japanese, Chinese or Korean, Enter commits a conversion and Escape
  cancels it, so the palette navigated away while somebody was still
  typing the word they meant to search for. This app declares twelve
  base locales; that is not an edge case.
- A palette closed on "No documents match" reopened still saying it,
  over an empty query — and being a live region, announced the stale
  answer again.
- A catalog key nobody read (docs.list.search.results). Twelve locales
  would have translated it for nothing.

And one claim withdrawn. The plan's D4 said a browser-drive check holds
the palette's keyboard contract. It does not: the checks exist and pass,
but in a scratch driver deliberately not committed, because the harness
they belong in is on another branch. The plan now says that, because
"one person ran it once" is exactly what this repo does not count as a
gate. Two comments citing an AGENTS.md entry that lives on that other
branch now state their reasoning instead of citing a file that may not
carry it yet, so they hold whichever order the branches land in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
Paul Campbell pushed by paul@keymail.dev 0bab4987ee74e97552fdb3684027237bc2f40b27 parent 9c55c7eparent cafb696
16 files changed, +824 −44
  • PRODUCT.md +222 −0
  • docs/superpowers/plans/2026-09-01-search.md +14 −4
  • fixtures/jump-to.mjs +22 −3
  • internal/docs/assets.go +64 −5
  • internal/docs/assets_test.go +146 −10
  • internal/docs/brand/icon.svg +44 −0
  • internal/docs/handlers.go +19 −0
  • internal/docs/list_test.go +80 −0
  • internal/docs/render.go +17 −1
  • internal/docs/static/jumpto.a37448e7fcd21855.js +1 −0
  • internal/docs/static/jumpto.bd5dba33e0e78f44.js +0 −1
  • internal/docs/views/head.html +25 −0
  • internal/docs/views/list.html +65 −4
  • internal/docs/views/partials/jumpto.html +2 −1
  • internal/docs/views/show.html +45 −0
  • internal/docs/views/signin.html +58 −15

CI passed — run details

Intent

Human

<task-notification> <task-id>af0e99a8252db176a</task-id> <tool-use-id>toolu_01LmhuKACpRSyLx2PxVjYLTb</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-docs/8c293727-d922-4f5a-bfde-9457b649670f/tasks/af0e99a8252db176a.output</output-file> <status>completed</status> <summary>Agent "Fable review: search" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result># Whole-branch review: `search` at 9c55c7e (Fable, per AGENTS.md's highest-capability rule) Read as one diff, `git diff origin/main...search`, after a fetch. The merge commit 9c55c7e was verified with `--remerge-diff` rather than trusted: its only conflict was `.amadan/ci.d/10-gate`, resolved exactly as claimed — the live-updates test step (`node --test live-updates.test.mjs`, kept at lines 57–58 of the final file) is intact and only the build-script name was repointed to `build-bundles.mjs`. The AGENTS.md additions in the merge commit are the branch's own two "tools that answer wrongly" entries, correctly visible in the three-dot diff; nothing inherited masquerades as branch work and no branch work hides in inherited code. Verified directly: `go test ./internal/docs` passes (17.3s), `gofmt -l` and `go vet` clean, and `node build-bundles.mjs` reproduces both committed bundles byte-for-byte (hashes unchanged, `git status` on `internal/docs/static/` clean). The commit's "307KB editor / 2.8KB palette" figures are accurate (309210 / 2843 bytes). ## BLOCKING **B1. The branch is stale against `origin/main`, and the collision is semantic, not just textual — a squash-merge today puts a red build on `main`.** `origin/main` is now cafb696 ("Impeccable pass"), committed four seconds after this branch's merge of a6fa843, and `git merge-base --is-ancestor` confirms it is not in the branch. cafb696 adds `/home/paulca/amadan.net/oficina/docs/internal/docs/assets_test.go` (on main, absent from this branch), whose `TestExactlyOneEditorBundleIsEmbedded` asserts `static/` holds **exactly one entry** ("And nothing else in static/ at all… `if len(entries) != 1`"). This branch commits a second file, `internal/docs/static/jumpto.bd5dba33e0e78f44.js`. Failure scenario: squash-merge onto current main → `go test` fails immediately on main, the incident-on-main shape the shipping cadence exists to prevent — and branch CI cannot catch it, because the failing test only exists on main. On top of that, cafb696 rewrote `internal/docs/assets.go` (+66 lines, brand/icon serving through the same `staticHandler` this branch rewrote), and touched `handlers.go`, `render.go`, `views/list.html` (mobile-header fix in the same `[docs-header-actions]` block the search form was added to), and `views/show.html` — so the squash will conflict, and the resolved tree is not the tree this review read. Required before merge: merge main again, reconcile the exactly-one test into "exactly one per `bundleKinds` name and nothing unaccounted-for" (main's stray-file protection is real — the branch's registry checks only the two known prefixes, so a stray `foo.js` in `static/` would go undetected without it), re-run the gate, and have the reconciliation delta reviewed. This is a merge-hygiene blocker, not a defect in the branch's own code. ## NON-BLOCKING (ranked) **N1. Plan D4 claims a check the tree does not carry, and both palette files cite an AGENTS.md entry that does not exist on this branch or on main.** `/home/paulca/amadan.net/oficina/docs/docs/superpowers/plans/2026-09-01-search.md` D4: "This branch implements all of it, and **a browser-drive check holds it**." No committed check of the palette exists — no browser drive, and no `node --test` either, although `fixtures/jump-to.mjs` is factored as an exported `createJumpTo(root)` and the fixtures harness pattern (`live-updates.test.mjs`) is sitting right there. Commit 9ed5842 is honest about this (17 checks in a scratch driver, deliberately not committed, harness pending on another branch, "filed as a task") — but the plan file ships in the repo stating it in the present tense, which is exactly the "comment claiming something the code does not do" shape. Separately, `fixtures/jump-to.mjs:31` and `views/partials/jumpto.html:15` both cite "AGENTS.md's accessibility conventions" for the ARIA-role-is-a-promise rule; grep of AGENTS.md at this tip **and** at origin/main finds no such entry (only the WCAG line) — it presumably lives on the formatting-and-export branch, so if this branch lands first the citations dangle. Fix: soften D4's sentence to match the commit's honesty, and either land a node-level test of the state machine now or ensure the filed task really exists. The keyboard contract itself, on manual reading, **is** implemented: Down/Up with wraparound, Home/End, Enter→`go()`, Escape→`hide()` with focus return guarded by `document.contains`, `aria-activedescendant` set/removed in `reflectActive()` with `scrollIntoView`, Tab and backdrop-mousedown both close, so nothing behind the overlay is keyboard-reachable while open, and the palette is openable (Ctrl/Cmd-K) and escapable by keyboard alone. The race handling is correct too: `sequence` bumps on every search and on `hide()`, and replies check `mine !== sequence || !open` before rendering, covering out-of-order, after-close, and failed requests (failure renders `copy.failed`, not an empty list). **N2. IME composition keys are hijacked** — `fixtures/jump-to.mjs:212/:218` (and the arrows). During CJK composition, Enter-to-commit navigates to the active option and Escape-to-cancel closes the palette. Scenario: a Japanese user types a query, presses Enter to commit the conversion → the browser navigates to result 0 instead. For an app with twelve base locales this is a real defect; the guard is one line (`if (event.isComposing) return`) at the top of the keydown handler. **N3. Stale status on reopen** — `fixtures/jump-to.mjs:66` `show()` resets the input and list but never calls `setStatus('')`. Scenario: type "zzz" → "No documents match" → Escape → Ctrl-K → the palette opens empty but still displays (and, via `role="status"`, may re-announce) "No documents match". **N4. The 50-result cap truncates silently** — `internal/docs/search.go:28` bounds the response with no "more matched" signal on either surface; 51 matches renders 50 rows that look complete. Mild §4.2 echo: a person can conclude a document doesn't exist. Best-first ranking and query refinement make this low-severity. Note the cap does **not** bound the work — `Store.Search` → `Store.List` loads every one of the owner's rows per debounced keystroke — but D1's reasoning holds up: `Document` rows are slim (models.go:47 — no content columns; updates live in `doc_updates`), the same unbounded `List` already backs `GET /`, and a SQL `LIKE` pushdown would silently die when titles are sealed. Linear, not a cliff; worth a comment if sharing ever multiplies visible rows. **N5. Dead catalog key** — `internal/docs/render.go:72` `"docs.list.search.results": "Results"` has no reader anywhere. Twelve locales would translate it for nothing; remove or use. **N6. No NFC normalisation** — `normaliseForSearch` (search.go): deliberate accent non-folding is documented and tested, but NFC-vs-NFD of the *same* text ("café" precomposed vs decomposed) also fails to match, and that is not accent folding — it's the same string. Rare in practice (most input is NFC); a `norm.NFC` pass would be locale-safe. **N7. Expired session in the palette reads as an outage** — a fetch that follows `RequireSession`'s redirect gets the sign-in HTML, `res.json()` throws, and the palette says "Search is unavailable right now" rather than anything about signing in. Safe, mildly misleading. ## What was hunted for and not found **Scoping (the review's #1 concern): holds.** Both routes resolve the owner via `h.owner` → `EnsurePerson`, and `owned()` (store.go:142) fails closed (`owner &lt;= 0` → `WHERE 1=0`). The query string never touches SQL — normalisation output is only ever compared in Go — so there is no crafted/empty/long/unicode query that widens the scope; invalid UTF-8 in `q` degrades to U+FFFD comparisons, empty returns `nil`/`[]`. `TestSearchNeverCrossesOwners` covers the store, the JSON route, *and* the `/?q=` page route separately, which is the right paranoia. **Escaping: holds** — `html/template` (render.go:7) contextually escapes `value="{{.Query}}"` (plus a test), and `json.Marshal` HTML-escapes titles into a `application/json; charset=utf-8`, `Cache-Control: no-store` response. **The assets invariant was generalised, not weakened, within the branch** — per-name exactly-one, kinds listed rather than discovered so a missing bundle panics at init, and `staticHandler`'s map serves only the two exact embedded filenames (the stray-unknown-file gap is main's test's job; see B1). **Ranking is deterministic** — ordered ranks, `sort.SliceStable` over List's `updated_at DESC, id DESC`. **§6.4's spirit is respected**: one field, one button, ungrouped ranked results, no filter chrome, no toggles, and the no-JS GET form is the primary path with the palette strictly additive. ## Judgement The branch's own work is good — genuinely good: the scoping is held by construction and by tests on every reachable path, the D1 reasoning about sealing survives scrutiny, the ranking is explainable and deterministic, the palette implements the ARIA contract it claims (with honest disclosure of the uncommitted browser checks), and the assets invariant came through the generalisation intact, with committed artifacts that byte-reproduce. Nothing in the diff itself blocks. But it must not squash-merge as it stands: `origin/main` moved four seconds after this branch's merge point, and main's `TestExactlyOneEditorBundleIsEmbedded` directly contradicts the second bundle this branch commits, alongside textual conflicts in five files this branch also touched — merging now produces a red `main`, and branch CI cannot warn about it. Merge main once more, reconcile that test into the per-name invariant, run the gate green on the merged tree, get the small reconciliation delta re-read, and fix N2/N3 while in the file (they are five lines between them) — then merge.</result> <usage><subagent_tokens>107064</subagent_tokens><tool_uses>30</tool_uses><duration_ms>412237</duration_ms></usage> </task-notification>

Attributed to this commit: 185 g CO₂e (24533453 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on search, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..f3ac319
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,222 @@
+# Product
+
+<!-- impeccable:product-schema 1 -->
+
+> **How this file was written.** Impeccable's `init` normally interviews the
+> user before writing anything here. This file was written overnight, on
+> Paul's explicit instruction to proceed unattended, so every fact below was
+> derived from repository evidence rather than confirmed in an interview:
+> `AGENTS.md`, `docs/superpowers/specs/2026-08-30-docs-design.md` (revision 2),
+> the four plans in `docs/superpowers/plans/`, and the shipped code in
+> `internal/docs/`. Facts traceable to a spec ruling or to code are cited.
+> Anything that is a reading rather than a citation is marked
+> **[inferred — confirm]** and should be corrected rather than trusted.
+
+## Platform
+
+web
+
+## Users
+
+**Primary: a member of one team, writing and reading that team's documents.**
+The instance boundary *is* the team (spec §1, decision 1) — one deployed
+instance per team, not one multi-tenant app with teams inside it. Sharing a
+document is therefore membership rather than a link-permission matrix.
+
+**Secondary: an outside guest**, admitted as a guest member rather than as an
+anonymous URL holder (spec §1, decision 1; §8.2). A guest is a real row with a
+real identity, which is what lets author colours key off it (§10.2).
+
+Access today is a magic link, gated by `$DOCS_ALLOWED_ADDRESSES`
+(`internal/docs/auth.go`) — a per-instance allowlist. **Instance-local**
+passkeys are ruled out on purpose, not deferred: `passkey.Config.Origin`
+derives its relying party from the instance's own host, so a person in several
+teams would enrol several credentials for one product (spec §8.1). That is a
+ruling about *this* app enrolling them, not about passkeys as such — §8.1.1's
+suite path deliberately keeps them, at the home's single relying party.
+
+**[inferred — confirm]** The team is assumed to be small enough that an
+allowlist of addresses is an appropriate admission mechanism. Nothing in the
+repo states a target team size.
+
+## Product Purpose
+
+CARLOS Docs is a **WYSIWYG, paginated, collaborative document editor** with
+DOCX, PDF and Markdown in and out (spec preamble). It is one of three moving
+parts in the `oficina` suite, alongside CARLOS Sheets (`../sheets`) and a
+shared suite home; Docs is a declared consumer of the home contract (§9) and
+of rastrillo's colour engine (§10.2).
+
+Success is a document that is genuinely *the same document* everywhere it is
+seen: same pagination, same layout, same colour, whether it is being edited in
+a browser, replayed server-side into HTML for a reader with no JavaScript, or
+exported to PDF.
+
+## Positioning
+
+Three things a neighbouring editor could not truthfully copy without rebuilding
+its foundations:
+
+1. **One wire format, two implementations, fixtures as the contract.** The
+ browser runs JS `yjs`; the server runs Rust `y-crdt` under wazero. They
+ agree because checked-in fixtures generated by the JS side must be
+ reproduced by the Rust side (spec §0). This is what makes the server able
+ to render, export and thumbnail a document without a headless browser.
+2. **The engine owns page geometry; the browser owns line breaking inside
+ it** (spec §0, decision 5). Page breaks are ProseMirror decorations, never
+ nodes (decision 14), so page geometry never enters the CRDT and two
+ collaborators on different page setups never conflict.
+3. **Sealed at rest, with a stated leak list — designed and ruled, not yet
+ shipped.** The spec's §2 commits to content encrypted in SQLite under a DEK
+ held by the running instance, enforced by a test that greps raw SQLite bytes
+ and bucket objects for fixture plaintext, and it enumerates the six things
+ that *do* leak (§2.1) rather than eliding them.
+ **None of that is implemented today.** `Store.Text`
+ (`internal/docs/store.go`) says so in its own doc comment — sealing is out
+ of scope for the plan that built it — and no plaintext-grep test exists in
+ the repo. Do not describe this instance as encrypting content at rest, and
+ do not let this entry be read as a shipped property; it is the position the
+ product is being built toward, and the thing that makes the position
+ defensible is the leak list, which is honest about what the trade costs.
+
+## Operating Context
+
+- **One Go binary per instance**, `CGO_ENABLED=0`, cross-compiled to
+ `linux/arm64`, on the CARLOS platform (spec §3). SQLite, Litestream-replicated.
+- **Routes shipped today** (`internal/docs/app.go`): `GET /` (document list),
+ `POST /documents` (create), `GET /d/{sqid}` (read/edit), `GET|POST
+ /d/{sqid}/updates` (the CRDT update log), plus the sign-in set (`GET|POST
+ /signin`, `GET /auth/callback`, `GET /auth/verify`, `POST /signout`) and
+ `/static/*`. Every document route sits behind `RequireSession`.
+- **Local development** is `./scripts/run-dev.sh` — `localhost:8099`, a scratch
+ SQLite database that starts empty every run, magic links logged to the
+ terminal rather than mailed.
+- **The scriptless path is a product requirement, not a fallback** (spec §6.2):
+ reading a document, the list, search, sharing, settings, and reading/replying
+ to/resolving comments all work without JavaScript. Editing prose does not,
+ and that boundary is stated rather than discovered.
+- **Work happens on a worktree branch and reaches `main` only by squash merge**,
+ after a highest-capability-model review, Paul's approval, and green CI
+ (`AGENTS.md`, Paul's rule 2026-08-30). The review record is an amadan branch
+ discussion, not a GitHub PR — this repo is hosted on an amadan hub.
+
+## Capabilities and Constraints
+
+**Shipped:** real per-person sign-in with an admission allowlist; the document
+list grouped by recency; document creation; server-rendered read view replayed
+from the CRDT through wazero; a ProseMirror editor mounted over that same
+container; a save queue with retry, a resync controller, and a save-status
+indicator.
+
+**Deliberately not built yet** (spec §14): suggestion mode, footnotes with
+tracked-changes import, the version-history timeline, a pageless mode,
+full-text search (title and metadata search only — decision 15).
+
+**Binding technical constraints:**
+
+- **Markup uses the bare `rst-*` attribute grammar** (`<div rst-inspector>`),
+ variants matched with `~=`. Unparked by Paul 2026-08-31 once `v0.21.0`
+ shipped both spellings. Two rules travel with it: **adopt per screen, not per
+ element**, and **pin `v0.21.0` or later**. Held by
+ `render_test.go`'s `TestOurTemplatesUseTheAttributeGrammar`. Currently on
+ `rastrillo v0.22.0`, where the framework itself writes the same spelling.
+- **Anything Docs contributes to estilo takes an `estilo-*` prefix, never
+ `rst-*`** — that namespace is the framework's and a future version may claim
+ a name we took (spec §10.4).
+- **`ui` is rastrillo's vocabulary, not ours.** This app's own selectors are
+ spelled `[docs-*]` (`[docs-thumb]`, `[docs-canvas]`, `[docs-page]`,
+ `[docs-text]`, `[docs-save-status]`).
+- **Every user-visible string is a catalog key**, resolved through
+ `rastrillo.T` at render time (`internal/docs/render.go`'s `Catalog`). Twelve
+ base locales are the target; one (`en`) is declared today. A hardcoded
+ English string is a bug. Document titles and timestamps are *data*, not
+ authored copy, and are correctly not keys.
+- **Tokens are vendored through `ui.TokensCSS()` / `ui.ThemeCSS(name)`**, never
+ copied by hand. `rastrillo doctor` mechanises the re-copy step as of `v0.21.0`.
+- **A spec ruling, code on `main`, and code in the latest release are three
+ different things.** Cite `origin/main` after a fetch, or a tag, and say which.
+
+## Brand Commitments
+
+- **Name:** CARLOS Docs, in the `oficina` namespace at
+ `amadan.net/oficina/docs`, hosted per instance at
+ `<app>.<sqid>.oncarlos.com` (spec §9.2).
+- **The design system is rastrillo's**, themed `day` with the `column` shell
+ (`internal/docs/render.go`). The suite variant is **estilo**
+ (`amadan.net/oficina/estilo`), named by Paul. Docs owns three primitives —
+ **inspector panel**, **presence stack**, **comment thread**; Sheets owns
+ toolbar groups (§10.4).
+- **The canvas is not themed chrome.** A document is a piece of paper and does
+ not follow the reader's dark-mode preference; the canvas opts out with
+ `color-scheme: light` and literal colours, never theme tokens (§10.2, spec
+ decision 9: canvas light by default, dark is a per-person preference, export
+ always renders true colours).
+- **Chrome is a short toolbar plus an inspector on demand** (decision 10) —
+ explicitly rejecting the dense undifferentiated toolbar, the always-on
+ inspector, and the floating-only model.
+- **The document list is a list with real rendered page-one thumbnails**
+ (decision 11). Explicitly cut: the template carousel, "Owned by anyone" and
+ "Last opened by me" filters, grid/list/AZ/folder toggles, and all commercial
+ and platform chrome.
+- **Voice, as evidenced by the shipped catalog:** plain, unhedged, second
+ person, no exclamation marks, no product cheer. "No documents yet"
+ (`docs.list.empty.title`, no full stop — a heading, not a sentence).
+ "Documents you create will show up here, newest first." Error copy states
+ what happened in the past tense and what helps, and was corrected once
+ precisely because it described a recovery the person could no longer perform.
+
+## Evidence on Hand
+
+- **Specs:** `docs/superpowers/specs/2026-08-30-docs-design.md` (revision 2,
+ 1000+ lines, itself the product of a Fable review that returned seventeen
+ defects against revision 1).
+- **Plans:** four, in `docs/superpowers/plans/` — the Rust core and wire
+ compatibility, the app's first screens, sign-in and samples, editing.
+- **Sibling specs:** Sheets at
+ `docs/superpowers/specs/2026-08-30-sheets-design.md`; the design system at
+ `2026-08-28-design-system-design.md` in the rastrillo repo.
+- **Published guidance:** https://rastrillo.org/docs/design-system (2026-08-31).
+- **Seed content:** `internal/docs/seeddata/` and `fixtures/generate-seed.mjs`
+ produce sample documents for a newly created person's space.
+- **What does not exist and must not be invented:** no customers, no
+ testimonials, no benchmarks, no pricing, no usage numbers, no launch date,
+ no press. `[docs-thumb]` is reserved space, not a rendered thumbnail — the
+ real page-one rendering arrives with a later plan's layout engine.
+
+## Product Principles
+
+1. **State the degradation rather than let it be discovered.** Where something
+ does not work — scriptless comment creation anchoring to a block, layout
+ divergence at the line level, the six-item leak list — the product says so
+ in the place a reader will look, and the spec refuses to claim zero.
+2. **A verification that cannot distinguish "no" from "I can't see it" is not
+ a verification.** It is why `git branch --merged` is banned here, why the
+ editor bundle is checked by rebuilding it rather than by trusting it, and
+ why the seal, when it lands, is specified as a byte-grep over raw storage
+ rather than as a code review. The principle is live; the seal is not built
+ yet (see Positioning 3).
+3. **The empty state is a designed screen, not a blank page** (Paul's ruling,
+ in the catalog's own comments).
+4. **Chrome is quiet so the document is loud.** Everything around the page is
+ design system; the canvas is the one place that diverges, and it diverges
+ because paper has different requirements from an admin screen.
+5. **A string a person can read is a catalog key.** Localisation is a
+ structural property of the app, not a phase.
+
+## Accessibility & Inclusion
+
+**WCAG 2.2 AA, no exemptions** (spec §10.3, restated in `AGENTS.md`). Three
+requirements are called out by name because they bind concrete decisions:
+
+- **24×24 minimum target size.**
+- **320px reflow** — the page must work at that width without a horizontal
+ scroll of the page itself.
+- **Colour never carries meaning alone**, which binds the presence stack and
+ author colours directly: past the colour engine's separation capacity, the
+ name carries identity (§10.2).
+
+Twelve base locales are the target set. Today exactly one exists: `Locales =
+[]string{"en"}` (`internal/docs/render.go`). The spec's plan for the other
+eleven — machine-drafted catalogs, with each shared primitive shipping prose
+keys (§10.4) — is a commitment, not a shipped state, and future work should
+not read this section as though those catalogs are sitting in the repo.
diff --git a/docs/superpowers/plans/2026-09-01-search.md b/docs/superpowers/plans/2026-09-01-search.md
index 7b309bf..cca02d4 100644
--- a/docs/superpowers/plans/2026-09-01-search.md
+++ b/docs/superpowers/plans/2026-09-01-search.md
@@ -72,10 +72,20 @@ say why it is that order.
A combobox that says `role="combobox"`/`aria-expanded` is promising
Down/Up to move through results, Enter to open, Escape to close, and
`aria-activedescendant` to say which option is current. This branch
-implements all of it, and a browser-drive check holds it — including
-that Escape gets out and focus returns to where it came from, because a
-modal that traps focus with no way back is the failure mode a palette
-has.
+implements all of it — including that Escape gets out and focus returns
+to where it came from, because a modal that traps focus with no way back
+is the failure mode a palette has.
+
+**That behaviour is not held by a committed check, and this plan said it
+was.** It was exercised by seventeen checks in a real browser, and they
+all pass, but they live in a scratch driver that is deliberately not in
+the repo: the harness they belong in (`fixtures/browser-drive.mjs`) is
+on the `formatting-and-export` branch and not yet on `main`, and
+shipping a second harness to avoid waiting is worse than waiting. So
+what protects this today is one person having run it once, which is
+exactly the thing this repo does not count as a gate. Folding them in is
+a filed task, and until that happens the honest statement is the one in
+this paragraph rather than the one that was here before.
That rule is on this repo's own record (AGENTS.md, accessibility
conventions) because the formatting bar broke it a day ago.
diff --git a/fixtures/jump-to.mjs b/fixtures/jump-to.mjs
index 1c5c6a0..ed90bde 100644
--- a/fixtures/jump-to.mjs
+++ b/fixtures/jump-to.mjs
@@ -28,9 +28,14 @@
// and aria-activedescendant names which option is current. All of it is
// here, because a role written without the behaviour behind it is worse
// than no role — the person acts on it, arrow keys are what they try
-// first, and silence reads as their own mistake. (AGENTS.md's
-// accessibility conventions, learned on the formatting bar a day
-// earlier.)
+// first, and silence reads as their own mistake.
+//
+// (That rule was learned on this app's formatting bar, which declared
+// role="toolbar" and implemented none of its keyboard contract. It is
+// written into AGENTS.md's accessibility conventions on the branch that
+// fixed it, which may land before or after this one — so the reasoning
+// is stated here rather than only cited, and this comment does not
+// depend on which order they merge in.)
const DEBOUNCE_MS = 120
@@ -71,6 +76,12 @@ export function createJumpTo(root) {
input.setAttribute('aria-expanded', 'true')
input.value = ''
render([])
+ // The status has to be cleared with the field. Without this, a
+ // palette closed on "No documents match" reopens still saying it,
+ // over an empty query that has matched nothing yet — and because
+ // the status is a live region, a screen reader announces the stale
+ // answer to a question nobody has asked again.
+ setStatus('')
input.focus()
}
@@ -186,6 +197,14 @@ export function createJumpTo(root) {
})
input.addEventListener('keydown', (event) => {
+ // Never take a key that belongs to an input method. Mid-composition
+ // in Japanese, Chinese or Korean, Enter commits the conversion and
+ // Escape cancels it — so a palette that claims those keys navigates
+ // away while somebody is still typing the word they meant to search
+ // for, and closes when they try to take it back. This app declares
+ // twelve base locales (render.go), so that is not an edge case, it
+ // is most of them.
+ if (event.isComposing || event.keyCode === 229) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
diff --git a/internal/docs/assets.go b/internal/docs/assets.go
index 05c9f61..017fea4 100644
--- a/internal/docs/assets.go
+++ b/internal/docs/assets.go
@@ -17,8 +17,24 @@ import (
"time"
)
-// staticFS embeds this app's own committed static assets — today,
-// only the vendored editor bundle. internal/docs/static/, not a
+// brandFS embeds this app's own hand-authored assets — today, the tab
+// icon served at iconPath.
+//
+// A directory of its own, deliberately not static/ below. static/ is
+// the editor build's output: .amadan/ci.d/10-gate rebuilds the bundle
+// into it and then fails on any dirt at all in that directory, before
+// restoring it with git checkout and git clean. So a hand-written file
+// there does not merely sit oddly beside generated output — it turns
+// the gate red, and the gate's own cleanup then deletes it. That is
+// not hypothetical; icon.svg was written to static/ first and this is
+// what happened to it.
+//
+//go:embed brand
+var brandFS embed.FS
+
+// staticFS embeds the editor build's committed output — the vendored
+// editor bundle, and nothing else, for the reason brandFS's own doc
+// comment gives. internal/docs/static/, not a
// top-level static/: go:embed cannot reach outside (or above) the
// directory containing the .go file that embeds it — no ".." is ever
// allowed in an embed pattern — so this lives under internal/docs/
@@ -100,6 +116,40 @@ func editorScriptPath() string { return bundlePath("editor") }
// fixtures/jump-to.mjs), loaded by both the list and a document.
func jumpToScriptPath() string { return bundlePath("jumpto") }
+// iconPath is the absolute URL path this app's tab icon is served at,
+// and views/head.html's <link rel="icon"> href. Fixed rather than
+// content-hashed the way the editor bundle above is: a favicon is
+// requested by the browser for every page and changes about never, so
+// the hash would buy nothing, and a stable path is also what a client
+// that ignores the <link> and asks for a conventional location can be
+// pointed at.
+//
+// assets_test.go holds this against what views/head.html actually
+// writes, so the two can never drift into a 404 that only ever shows
+// up as a missing tab icon nobody files a bug about.
+const iconPath = "/static/icon.svg"
+
+// iconContentType is set explicitly for the same reason
+// editorBundleContentType below is: not left to the host's mime
+// registry. A browser will not render an icon served as
+// text/plain or application/octet-stream.
+const iconContentType = "image/svg+xml; charset=utf-8"
+
+// serveIcon writes the embedded tab icon. A day's cache, not the
+// editor bundle's immutable year: the path carries no content hash,
+// so a cached copy has to expire on its own for a redrawn icon ever
+// to reach anyone.
+func serveIcon(w http.ResponseWriter, r *http.Request) {
+ b, err := fs.ReadFile(brandFS, "brand/icon.svg")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", iconContentType)
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ http.ServeContent(w, r, "icon.svg", time.Time{}, bytes.NewReader(b))
+}
+
// editorBundleContentType is set explicitly (http.ServeContent would
// otherwise guess from the ".js" extension via the OS mime registry,
// which on some platforms maps it to "application/javascript" and on
@@ -124,9 +174,9 @@ func serveBundle(w http.ResponseWriter, r *http.Request, fileName string) {
http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(b))
}
-// staticHandler composes this app's own committed static assets
-// (today, only the editor bundle, at its one fixed, resolved-at-init
-// path) with fallback — render.go's rastrillo.Assets tree (the
+// staticHandler composes this app's own committed static assets (the
+// editor bundle, at its one fixed resolved-at-init path, and the tab
+// icon at iconPath) with fallback — render.go's rastrillo.Assets tree (the
// vendored design-system CSS/JS) — into the single handler app.go
// mounts at "/static/*". Both trees share that URL prefix, so this is
// one composed handler rather than two competing route registrations
@@ -147,10 +197,19 @@ func staticHandler(fallback http.Handler) http.Handler {
paths["/static/"+name] = name
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The committed bundles first, by exact path. A map rather
+ // than a case per bundle: there are two now (editor and
+ // jumpto) and the switch below would have to grow a case for
+ // each, which is the shape that eventually forgets one.
if name, ok := paths[r.URL.Path]; ok {
serveBundle(w, r, name)
return
}
+ switch r.URL.Path {
+ case iconPath:
+ serveIcon(w, r)
+ return
+ }
fallback.ServeHTTP(w, r)
})
}
diff --git a/internal/docs/assets_test.go b/internal/docs/assets_test.go
index 9cfcec2..23d0036 100644
--- a/internal/docs/assets_test.go
+++ b/internal/docs/assets_test.go
@@ -1,6 +1,9 @@
package docs
import (
+ "bytes"
+ "encoding/xml"
+ "io"
"io/fs"
"net/http"
"net/http/httptest"
@@ -12,22 +15,63 @@ import (
// is present and that the path the template emits resolves to it — a
// mismatch would serve a 404 and leave the editor silently absent,
// which looks identical to "editing is not implemented yet".
-func TestExactlyOneEditorBundleIsEmbedded(t *testing.T) {
+func TestExactlyOneBundlePerKindIsEmbedded(t *testing.T) {
entries, err := fs.ReadDir(staticFS, "static")
if err != nil {
t.Fatalf("read static: %v", err)
}
- var found []string
- for _, e := range entries {
- if strings.HasPrefix(e.Name(), "editor.") && strings.HasSuffix(e.Name(), ".js") {
- found = append(found, e.Name())
- }
+
+ // One committed file per bundle kind. This was "exactly one editor
+ // bundle" until the jump-to palette became a second one: it ships
+ // separately so the document list does not download 300KB of
+ // ProseMirror in order to offer a keyboard shortcut (assets.go's
+ // bundleNames). The invariant has not weakened — it is still
+ // exactly one file per name, and bundleKinds is a written list
+ // rather than whatever happens to be on disk, so a build that
+ // silently stopped emitting one panics at init instead of starting
+ // without it.
+ paths := map[string]string{
+ "editor": editorScriptPath(),
+ "jumpto": jumpToScriptPath(),
}
- if len(found) != 1 {
- t.Fatalf("want exactly one editor bundle, got %v", found)
+ accounted := map[string]bool{}
+ for _, kind := range bundleKinds {
+ var found []string
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), kind+".") && strings.HasSuffix(e.Name(), ".js") {
+ found = append(found, e.Name())
+ }
+ }
+ if len(found) != 1 {
+ t.Fatalf("want exactly one %q bundle, got %v", kind, found)
+ }
+ accounted[found[0]] = true
+ want, ok := paths[kind]
+ if !ok {
+ t.Fatalf("bundleKinds names %q but this test knows no path accessor for it", kind)
+ }
+ if want != "/static/"+found[0] {
+ t.Errorf("%s path = %q, want %q", kind, want, "/static/"+found[0])
+ }
}
- if got := editorScriptPath(); got != "/static/"+found[0] {
- t.Errorf("editorScriptPath() = %q, want %q", got, "/static/"+found[0])
+
+ // And nothing else in static/ at all. That directory is the bundle
+ // build's output: .amadan/ci.d/10-gate rebuilds into it and fails
+ // on any dirt whatsoever there, then restores it with git checkout
+ // and git clean — so an extra file both reddens the gate and is
+ // deleted by it. The tab icon was written here first and lost
+ // exactly that way, which is why this is a test and not a note.
+ // Hand-authored assets go in brand/ (brandFS).
+ //
+ // Kept as "everything present is accounted for" rather than as a
+ // count, so it still catches a stray file now that the expected
+ // number is no longer one. assets.go's own registry cannot do this
+ // job: it looks up the names it knows and would never notice a
+ // third file nobody named.
+ for _, e := range entries {
+ if !accounted[e.Name()] {
+ t.Errorf("static/ holds %q, which no bundle kind accounts for — it must hold only the bundle build's output; put hand-authored assets in brand/", e.Name())
+ }
}
}
@@ -56,3 +100,95 @@ func TestEditorBundleIsServed(t *testing.T) {
t.Errorf("Cache-Control = %q, want immutable (the filename carries the content hash)", cc)
}
}
+
+// The tab icon must be served, unauthenticated, with an image content
+// type — and the markup that links it must point at the constant this
+// package actually routes on. Those two drifting apart is the exact
+// failure this pins: a wrong href does not break a page, it quietly
+// restores the blank-page tab mark the icon exists to replace, which
+// nobody files a bug about and no other test here would notice.
+func TestIconIsServedAndLinkedConsistently(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ // No session on this request, on purpose: the sign-in page needs
+ // the icon as much as any signed-in screen does, and it is
+ // reached without one.
+ req := httptest.NewRequest(http.MethodGet, iconPath, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", iconPath, rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") {
+ t.Errorf("Content-Type = %q, want image/svg+xml", ct)
+ }
+ if body := rec.Body.String(); !strings.Contains(body, "<svg") {
+ t.Errorf("body does not look like an SVG: %.80q", body)
+ }
+
+ // And the link must reach a real page. Asserted on rendered output
+ // rather than on views/head.html's bytes: the file containing the
+ // href proves nothing on its own, because "head" only reaches a
+ // page if newRenderer parses it into the base template ahead of the
+ // per-page clones (render.go). Were that wiring dropped, a
+ // bytes-of-the-file check would stay green while every page lost
+ // its icon — the icon would still serve, the template would still
+ // contain the href, and nothing would be able to tell. Rendering
+ // the page subsumes the file check and closes that gap.
+ //
+ // The sign-in page, because it needs no session and so exercises
+ // the same unauthenticated path the icon itself is served on.
+ pageReq := httptest.NewRequest(http.MethodGet, "/signin", nil)
+ pageRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(pageRec, pageReq)
+ if pageRec.Code != http.StatusOK {
+ t.Fatalf("GET /signin = %d, want 200", pageRec.Code)
+ }
+ if wantHref := `href="` + iconPath + `"`; !strings.Contains(pageRec.Body.String(), wantHref) {
+ t.Errorf("the rendered sign-in page does not contain %s — the shell's head block is not carrying the icon link", wantHref)
+ }
+}
+
+// The icon must be well-formed XML, because SVG is XML and a browser
+// will not render one that is not — it fails closed, showing the same
+// blank tab mark as no icon at all.
+//
+// This exists because the first version of icon.svg was not: its
+// comment named a CSS custom property with the two leading hyphens a
+// custom property has, and two consecutive hyphens are illegal inside
+// an XML comment. Everything a request-level check can see was
+// correct — 200, image/svg+xml, a plausible Content-Length, the
+// expected bytes — and the file was still unparseable. Only rendering
+// it showed anything wrong, which is exactly the failure shape
+// AGENTS.md warns about: a verification that cannot tell "no" from
+// "I can't see it".
+//
+// Decoding every token, rather than checking the root element, is the
+// point: a malformed comment or attribute halfway down is precisely
+// what a shallower check misses.
+func TestIconIsWellFormedXML(t *testing.T) {
+ b, err := fs.ReadFile(brandFS, "brand/icon.svg")
+ if err != nil {
+ t.Fatalf("read icon: %v", err)
+ }
+ dec := xml.NewDecoder(bytes.NewReader(b))
+ var root string
+ for {
+ tok, err := dec.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("icon.svg is not well-formed XML, so no browser will render it: %v", err)
+ }
+ if se, ok := tok.(xml.StartElement); ok && root == "" {
+ root = se.Name.Local
+ }
+ }
+ if root != "svg" {
+ t.Errorf("icon.svg root element = %q, want svg", root)
+ }
+}
diff --git a/internal/docs/brand/icon.svg b/internal/docs/brand/icon.svg
new file mode 100644
index 0000000..52f4376
--- /dev/null
+++ b/internal/docs/brand/icon.svg
@@ -0,0 +1,44 @@
+<!-- CARLOS Docs' tab icon. Drawn here rather than taken from an icon
+ set: rastrillo ships no page or document glyph (rastrillo/icons.go
+ carries thirteen, none of them this), and a Unicode character
+ standing in for a mark is not an icon.
+
+ A sheet with a turned corner, on a tile in the day theme's accent
+ (#2464e0, the light value of the rst-accent custom property in
+ ui/themes/day.css). Taken as a literal because an SVG served as a
+ static file cannot read a CSS custom property, and because this
+ mark must not repaint with a reader's colour scheme the way the
+ chrome around it does.
+
+ Sized for 16px first: one filled tile, one white silhouette, one
+ turned corner, and two rules for the text. Everything is at least
+ 1.5 units across at a 32 unit viewBox, so nothing thins below a
+ device pixel at tab size. The rules are what make it read as a
+ document rather than a blank file, and they stop at two because a
+ third closes the gap between them into grey mush.
+
+ Why brand/ and not static/, which is where this file was first
+ written: internal/docs/static/ is build output, not a place to
+ keep things. The gate rebuilds the editor bundle into it and then
+ fails on any dirt at all in that directory
+ (.amadan/ci.d/10-gate's "the editor bundle must match a fresh
+ build"), before restoring it with git checkout and git clean.
+ A hand written file there is therefore not merely untidy: it
+ turns the gate red, and then the gate's own cleanup deletes it.
+ It did, once. Hand authored assets belong beside the build
+ output, never inside it.
+
+ Note for whoever edits this comment: an XML comment may not
+ contain two consecutive hyphens, so a CSS custom property cannot
+ be written here with its leading pair. The first version of this
+ file did exactly that, and the result was a well formed HTTP 200
+ serving a document no browser could parse: right status, right
+ Content-Type, right byte count, broken image. assets_test.go's
+ TestIconIsWellFormedXML exists because of it. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
+ <rect width="32" height="32" rx="7" fill="#2464e0"/>
+ <path d="M9.5 8.5A2 2 0 0 1 11.5 6.5h6.6L23.5 11.9V23.5a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2Z" fill="#ffffff"/>
+ <path d="M18.1 6.5 23.5 11.9h-5.4Z" fill="#a9c4f4"/>
+ <rect x="12.9" y="15.6" width="7.6" height="1.7" rx="0.85" fill="#2464e0"/>
+ <rect x="12.9" y="19.2" width="5.2" height="1.7" rx="0.85" fill="#2464e0"/>
+</svg>
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index da4d881..4abc9f9 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -268,6 +268,24 @@ type showPage struct {
Title string
HTML template.HTML
SignOutLabel string // see listPage.SignOutLabel's own doc comment
+
+ // BackLabel is the document page's one route back to the list
+ // (views/show.html's [docs-back]). Until this landed, GET
+ // /d/{sqid} offered no in-app navigation at all — its header held
+ // sign-out and nothing else — so a person who arrived by a
+ // bookmark or a mailed link, rather than by clicking through from
+ // GET /, had no way back to their own documents except the
+ // browser's back button, which that arrival gives them nothing to
+ // go back *to*.
+ //
+ // It deliberately reuses docs.list.title rather than adding a
+ // docs.show.back of its own: this is a breadcrumb naming its
+ // destination screen, and a breadcrumb that can drift from the
+ // title of the screen it points at is a translation bug waiting
+ // to happen in eleven locales nobody in this repo reads. One key,
+ // one string, and the link can never disagree with the page it
+ // leads to.
+ BackLabel string
Sqid string // views/show.html's [docs-editor] mount point's data-sqid, the editor bundle's own base for /d/{sqid}/updates
EditorScript string // views/show.html's <script src>, from editorScriptPath() — never a literal, so it can never drift from what staticHandler actually serves
@@ -332,6 +350,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
Title: doc.Title,
HTML: html,
SignOutLabel: rastrillo.T(r, "docs.signout.label"),
+ BackLabel: rastrillo.T(r, "docs.list.title"),
Sqid: doc.Sqid,
EditorScript: editorScriptPath(),
SaveRetrying: rastrillo.T(r, "docs.editor.status.retrying"),
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index a047183..91c7a7e 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -197,3 +197,83 @@ func TestUnknownDocument404s(t *testing.T) {
t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code)
}
}
+
+// A document page must offer a way back to the list. Before
+// [docs-back] landed, GET /d/{sqid} rendered sign-out and no link at
+// all, so arriving by a bookmark or a mailed link — the two ways of
+// reaching a document that do not pass through GET / on the way —
+// stranded a person on it with nothing but the browser's own back
+// button, which such an arrival gives nothing to go back to.
+//
+// Asserted on the rendered page rather than the template's bytes: the
+// href has to survive the handler populating it, not merely be
+// written somewhere in views/.
+func TestDocumentPageLinksBackToTheList(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ doc, err := app.Store().Create(ctx, owner, "Board minutes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/d/"+doc.Sqid, nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+
+ body := rec.Body.String()
+ if !strings.Contains(body, `<a docs-back href="/">`) {
+ t.Error("no [docs-back] link to / on the document page — a document reached directly has no route to the list")
+ }
+
+ // The label is docs.list.title, deliberately shared with the screen
+ // it points at rather than given a docs.show.back of its own
+ // (showPage.BackLabel's doc comment). Assert that by rendering the
+ // list and comparing the two, rather than by looking for the
+ // literal "Documents": a direct App.Handler() call installs no
+ // locale middleware, so rastrillo.T here returns the key verbatim
+ // (render.go's Locales comment) and the English string never
+ // appears. Comparing the two pages holds the invariant that
+ // actually matters — the link and its destination's own title are
+ // one string — and holds it in every locale rather than only in the
+ // one this test happens to run in.
+ listReq := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ listReq.AddCookie(c)
+ }
+ listRec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(listRec, listReq)
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("list status %d", listRec.Code)
+ }
+ listTitle := betweenTags(t, listRec.Body.String(), "<h1>", "</h1>")
+ backLabel := betweenTags(t, body, `<a docs-back href="/">`, "</a>")
+ if backLabel == "" || backLabel != listTitle {
+ t.Errorf("back link label = %q, want the list's own <h1> (%q)", backLabel, listTitle)
+ }
+}
+
+// betweenTags returns the text between the first open and the next
+// close after it, for the small structural assertions above. Not a
+// general HTML parser and not trying to be one — it is used only on
+// markup this package itself writes, two lines apart.
+func betweenTags(t *testing.T, body, open, close string) string {
+ t.Helper()
+ i := strings.Index(body, open)
+ if i < 0 {
+ return ""
+ }
+ rest := body[i+len(open):]
+ j := strings.Index(rest, close)
+ if j < 0 {
+ return ""
+ }
+ return rest[:j]
+}
diff --git a/internal/docs/render.go b/internal/docs/render.go
index ad22919..a9ac723 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -69,7 +69,6 @@ var Catalog = rastrillo.Catalog{
"docs.list.search.none.title": "No documents match",
"docs.list.search.none.body": "Nothing here has that in its title. Check the spelling, or try a shorter word.",
"docs.list.search.clear": "Clear search",
- "docs.list.search.results": "Results",
// docs.jumpto.* — the keyboard jump-to palette
// (fixtures/jump-to.mjs), opened with Control-K or Command-K from
@@ -199,6 +198,13 @@ const DefaultLocale = "en"
//go:embed views/*.html views/partials/*.html
var viewsFS embed.FS
+// headTemplate is the app-wide "head" definition newRenderer parses
+// into the base template — the shell's own app slot in <head>, which
+// today carries the tab icon. Deliberately outside pageTemplates
+// below: it defines "head", never "content", so it is not a page and
+// must not be cloned per page.
+const headTemplate = "views/head.html"
+
// pageTemplates maps a page name to its file under views/.
var pageTemplates = map[string]string{
"list": "views/list.html",
@@ -242,6 +248,16 @@ func newRenderer() (*renderer, error) {
return nil, fmt.Errorf("docs: parse rastrillo shell %q: %w", shellLayout, err)
}
+ // This app's own "head" definition, parsed into base *after* the
+ // shell so it overrides the shell's empty {{block "head"}}
+ // default, and before the per-page clones below so all three
+ // pages inherit the one definition rather than each carrying a
+ // copy of the icon link. Not in pageTemplates: it defines no
+ // "content" and is not a page.
+ if base, err = base.ParseFS(viewsFS, headTemplate); err != nil {
+ return nil, fmt.Errorf("docs: parse head template: %w", err)
+ }
+
pages := make(map[string]*template.Template, len(pageTemplates))
for name, path := range pageTemplates {
page, err := base.Clone()
diff --git a/internal/docs/static/jumpto.a37448e7fcd21855.js b/internal/docs/static/jumpto.a37448e7fcd21855.js
new file mode 100644
index 0000000..a586444
--- /dev/null
+++ b/internal/docs/static/jumpto.a37448e7fcd21855.js
@@ -0,0 +1 @@
+function S(a){if(!a)return null;let i=a.querySelector("[docs-jumpto-input]"),f=a.querySelector("[docs-jumpto-list]"),u=a.querySelector("[docs-jumpto-status]"),E=a.querySelector("[docs-jumpto-close]");if(!i||!f||!u)return null;let g={empty:u.getAttribute("data-empty")||"",failed:u.getAttribute("data-failed")||""},o=!1,c=[],n=-1,y=null,p=0,l=null;function w(){o||(o=!0,l=document.activeElement,a.hidden=!1,i.setAttribute("aria-expanded","true"),i.value="",h([]),m(""),i.focus())}function s(){o&&(o=!1,a.hidden=!0,i.setAttribute("aria-expanded","false"),i.removeAttribute("aria-activedescendant"),clearTimeout(y),p++,l&&document.contains(l)&&l.focus(),l=null)}function m(e){u.textContent=e,u.hidden=e===""}function h(e){c=e,n=e.length>0?0:-1,f.textContent="";for(let t=0;t<e.length;t++){let r=document.createElement("li");r.id=`docs-jumpto-option-${t}`,r.setAttribute("role","option"),r.setAttribute("aria-selected",t===n?"true":"false"),r.dataset.href=e[t].href;let d=document.createElement("span");d.textContent=e[t].title;let k=document.createElement("span");k.setAttribute("docs-jumpto-when",""),k.textContent=e[t].when,r.append(d,k),r.addEventListener("mousedown",j=>{j.preventDefault(),D(t)}),f.append(r)}b()}function b(){let e=f.children;for(let t=0;t<e.length;t++)e[t].setAttribute("aria-selected",t===n?"true":"false");n>=0&&e[n]?(i.setAttribute("aria-activedescendant",e[n].id),e[n].scrollIntoView({block:"nearest"})):i.removeAttribute("aria-activedescendant")}function A(e){c.length!==0&&(n=(n+e+c.length)%c.length,b())}function D(e){let t=c[e];t&&(window.location.href=t.href)}async function C(e){let t=++p;if(e.trim()===""){h([]),m("");return}try{let r=await fetch(`/documents/search?q=${encodeURIComponent(e)}`,{credentials:"same-origin"});if(!r.ok)throw new Error(`search: ${r.status}`);let d=await r.json();if(t!==p||!o)return;h(d),m(d.length===0?g.empty:"")}catch(r){if(t!==p||!o)return;h([]),m(g.failed),console.error(r)}}return i.addEventListener("input",()=>{clearTimeout(y);let e=i.value;y=setTimeout(()=>C(e),120)}),i.addEventListener("keydown",e=>{if(!(e.isComposing||e.keyCode===229))switch(e.key){case"ArrowDown":e.preventDefault(),A(1);break;case"ArrowUp":e.preventDefault(),A(-1);break;case"Home":c.length&&(e.preventDefault(),n=0,b());break;case"End":c.length&&(e.preventDefault(),n=c.length-1,b());break;case"Enter":n>=0&&(e.preventDefault(),D(n));break;case"Escape":e.preventDefault(),s();break;case"Tab":e.preventDefault(),s();break;default:break}}),E&&E.addEventListener("click",()=>s()),a.addEventListener("mousedown",e=>{e.target===a&&s()}),addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),o?s():w();return}e.key==="Escape"&&o&&(e.preventDefault(),s())}),{show:w,hide:s,isOpen:()=>o}}var v=document.querySelector("[docs-jumpto]");if(v)try{S(v)}catch(a){console.error(a)}export{S as createJumpTo};
diff --git a/internal/docs/static/jumpto.bd5dba33e0e78f44.js b/internal/docs/static/jumpto.bd5dba33e0e78f44.js
deleted file mode 100644
index f47b7db..0000000
--- a/internal/docs/static/jumpto.bd5dba33e0e78f44.js
+++ /dev/null
@@ -1 +0,0 @@
-function q(a){if(!a)return null;let i=a.querySelector("[docs-jumpto-input]"),f=a.querySelector("[docs-jumpto-list]"),u=a.querySelector("[docs-jumpto-status]"),k=a.querySelector("[docs-jumpto-close]");if(!i||!f||!u)return null;let w={empty:u.getAttribute("data-empty")||"",failed:u.getAttribute("data-failed")||""},c=!1,o=[],n=-1,b=null,p=0,l=null;function g(){c||(c=!0,l=document.activeElement,a.hidden=!1,i.setAttribute("aria-expanded","true"),i.value="",m([]),i.focus())}function s(){c&&(c=!1,a.hidden=!0,i.setAttribute("aria-expanded","false"),i.removeAttribute("aria-activedescendant"),clearTimeout(b),p++,l&&document.contains(l)&&l.focus(),l=null)}function y(e){u.textContent=e,u.hidden=e===""}function m(e){o=e,n=e.length>0?0:-1,f.textContent="";for(let t=0;t<e.length;t++){let r=document.createElement("li");r.id=`docs-jumpto-option-${t}`,r.setAttribute("role","option"),r.setAttribute("aria-selected",t===n?"true":"false"),r.dataset.href=e[t].href;let d=document.createElement("span");d.textContent=e[t].title;let E=document.createElement("span");E.setAttribute("docs-jumpto-when",""),E.textContent=e[t].when,r.append(d,E),r.addEventListener("mousedown",S=>{S.preventDefault(),A(t)}),f.append(r)}h()}function h(){let e=f.children;for(let t=0;t<e.length;t++)e[t].setAttribute("aria-selected",t===n?"true":"false");n>=0&&e[n]?(i.setAttribute("aria-activedescendant",e[n].id),e[n].scrollIntoView({block:"nearest"})):i.removeAttribute("aria-activedescendant")}function v(e){o.length!==0&&(n=(n+e+o.length)%o.length,h())}function A(e){let t=o[e];t&&(window.location.href=t.href)}async function j(e){let t=++p;if(e.trim()===""){m([]),y("");return}try{let r=await fetch(`/documents/search?q=${encodeURIComponent(e)}`,{credentials:"same-origin"});if(!r.ok)throw new Error(`search: ${r.status}`);let d=await r.json();if(t!==p||!c)return;m(d),y(d.length===0?w.empty:"")}catch(r){if(t!==p||!c)return;m([]),y(w.failed),console.error(r)}}return i.addEventListener("input",()=>{clearTimeout(b);let e=i.value;b=setTimeout(()=>j(e),120)}),i.addEventListener("keydown",e=>{switch(e.key){case"ArrowDown":e.preventDefault(),v(1);break;case"ArrowUp":e.preventDefault(),v(-1);break;case"Home":o.length&&(e.preventDefault(),n=0,h());break;case"End":o.length&&(e.preventDefault(),n=o.length-1,h());break;case"Enter":n>=0&&(e.preventDefault(),A(n));break;case"Escape":e.preventDefault(),s();break;case"Tab":e.preventDefault(),s();break;default:break}}),k&&k.addEventListener("click",()=>s()),a.addEventListener("mousedown",e=>{e.target===a&&s()}),addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),c?s():g();return}e.key==="Escape"&&c&&(e.preventDefault(),s())}),{show:g,hide:s,isOpen:()=>c}}var D=document.querySelector("[docs-jumpto]");if(D)try{q(D)}catch(a){console.error(a)}export{q as createJumpTo};
diff --git a/internal/docs/views/head.html b/internal/docs/views/head.html
new file mode 100644
index 0000000..feaffe5
--- /dev/null
+++ b/internal/docs/views/head.html
@@ -0,0 +1,25 @@
+{{/* head — this app's slot in the shell's <head>.
+
+ rastrillo's three shells all end their head with
+ {{block "head" .}}{{end}}, described in ui/layouts/column.html as
+ "the app's own slot in <head>: a favicon, a meta tag, one more
+ stylesheet". This file redefines that block once, in the base
+ template every page is cloned from (render.go's newRenderer), so
+ all three pages inherit it and there is exactly one place the
+ icon is declared. A page that ever needs head content of its own
+ defines "head" in its own template and overrides this, which is
+ ordinary block behaviour and needs nothing added here.
+
+ Before this existed the app declared no icon at all, so every
+ browser fell back to asking for /favicon.ico, got a 404, and
+ showed the generic blank page mark — on a document editor, where
+ the tab strip is the thing a person navigates between open
+ documents with, and where two sibling apps (Sheets, and the
+ suite home) sit in the same tab strip under the same suite.
+
+ The href is a literal rather than {{asset}}: icon.svg is served
+ by assets.go's own staticHandler at a fixed, unhashed iconPath,
+ not out of the rastrillo.Assets tree {{asset}} fingerprints.
+ assets_test.go asserts this markup and that constant agree, so
+ the pair cannot drift into a silent 404. */}}
+{{define "head"}}<link rel="icon" type="image/svg+xml" href="/static/icon.svg">{{end}}
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index bc7f80a..e755010 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -64,6 +64,27 @@
color: var(--rst-text-muted);
font-size: var(--rst-fs-sm);
}
+ /* No row-click rule here on purpose, and it is worth saying why,
+ because the measurement that argues for one is misleading.
+ A row's title link measures 268×16 — well under the 24×24 WCAG
+ 2.2 AA target minimum, with the thumbnail and the whole right
+ half of the row apparently dead. That reading is wrong, and
+ getBoundingClientRect is what makes it wrong: it reports an
+ element's own box and cannot see an absolutely positioned
+ pseudo-element. rastrillo already stretches this link over the
+ whole row. tokens.css gives [rst-row] position: relative, gives
+ [rst-row]:hover the --rst-surface-2 tint, and gives
+ [rst-row-main] > a::after content:"" with inset: 0, under its own
+ comment: "The name link's overlay makes the whole row clickable
+ without nesting one anchor inside another."
+ Our markup already matches that selector, so the row is fully
+ clickable, fully hover-tinted and past 24×24 with nothing of ours
+ involved. An earlier version of this file added [docs-row] and
+ [docs-row-link]::after restating those exact declarations, and a
+ hit test then "confirmed" a fix that had changed nothing — the
+ probe was hitting rastrillo's overlay both before and after.
+ If you are about to add one: hit-test the row before you change
+ anything, not only after. */
/* [docs-header-actions]: the header's one flex child opposite the
titles — [rst-page-header]'s space-between rule places it there
with no layout rule of its own beyond this; everything it holds
@@ -76,15 +97,55 @@
gap: var(--rst-sp-3);
}
/* [docs-create-form]: the title field and its submit button as one
- inline row. */
+ inline row — nowrap, deliberately, and this is the part that took
+ two attempts.
+ A wrapping flex line does not shrink its items to fit; it breaks
+ first and shrinks only what still does not fit afterwards. So
+ with wrap on, the button dropped to a second line and the field —
+ now alone on the first one, with room to grow — sprang back to
+ its 14rem maximum, which is how a fix aimed at making the header
+ shorter measured exactly as tall as before it (175px, unchanged).
+ nowrap is what forces the field to give up width to its button
+ instead of the button giving up the line.
+ The button itself is flex: none so the whole shortfall comes out
+ of the field. Shared proportionally, both would shrink, and
+ "New document" would start wrapping or clipping inside a control
+ that had no reason to be narrower.
+ One thing to watch when the other eleven catalogs land: nowrap
+ plus a non-shrinking button means a locale whose
+ docs.list.create.label is wider than about 270px overflows the
+ 320px column rather than wrapping out of it. English is 131px and
+ is the only catalog today, so this is a note rather than a bug —
+ but it is a real edge, and the fix when it arrives is a
+ wrap allowance keyed to the label, not a smaller field. */
[docs-create-form] {
display: flex;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
gap: var(--rst-sp-2);
}
+ [docs-create-form] button {
+ flex: none;
+ }
+ /* The title field shares its line with the submit button instead of
+ holding a fixed 14rem and pushing it onto a line of its own.
+ Measured at 320px — the reflow width spec §10.3 commits to —
+ before this changed: a fixed 14rem (224px) field could not sit
+ beside a 131px button in a 288px content column, so the field,
+ "New document" and "Sign out" each took a full row and the header
+ stood 175px tall, putting the first document 223px down a 720px
+ viewport. Just under a third of the screen spent on chrome before
+ a single thing a person came here to read.
+ flex-basis 10rem with room to shrink is what lets the field and
+ its button share one line in that column; the 14rem cap keeps the
+ desktop header exactly as it was, since the form still sizes to
+ its max-content there. min-inline-size: 0 is load-bearing — a
+ flex item's automatic minimum size is its content, so without it
+ an <input> refuses to shrink below its default size and the row
+ overflows rather than fitting. */
[docs-create-form] [rst-input] {
- inline-size: 14rem;
- max-inline-size: 100%;
+ flex: 1 1 10rem;
+ min-inline-size: 0;
+ max-inline-size: 14rem;
}
/* [docs-search-form] is the same inline row as the create form, with
one difference: it does NOT wrap. The header now carries five
diff --git a/internal/docs/views/partials/jumpto.html b/internal/docs/views/partials/jumpto.html
index 1ab42ee..a1f9f6e 100644
--- a/internal/docs/views/partials/jumpto.html
+++ b/internal/docs/views/partials/jumpto.html
@@ -12,7 +12,8 @@
implements that contract: Down/Up move, Enter opens, Escape
closes and returns focus, aria-activedescendant names the current
option. A role written without its behaviour is worse than no
- role — see AGENTS.md's accessibility conventions.
+ role, because the person acts on it — see fixtures/jump-to.mjs's
+ own header for where that was learned.
hidden, not display:none in a class: fixtures/jump-to.mjs toggles
the property, and [hidden] is what a screen reader honours
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index de4ed62..27e6673 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -36,6 +36,35 @@
holds it against this file too. */}}
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}<style>
+ /* [docs-back]: the route to the document list, above the title.
+ Chrome, so it takes the app's theme tokens rather than the
+ canvas's literals. Muted at rest and full-strength on hover and
+ focus, so it reads as secondary to the document's own title
+ without dropping under the 4.5:1 body-text floor at rest.
+ min-block-size states the 24×24 floor (WCAG 2.2 AA SC 2.5.8)
+ outright rather than hoping padding adds up to it: measured, the
+ first version of this rule — --rst-fs-sm plus a --rst-sp-1
+ padding either side — came to 22px, because --rst-fs-sm is
+ 12.5px here and not the 14px it was assumed to be. A target size
+ derived from two tokens is a target size that changes when either
+ token does, silently. inline-flex is what makes min-block-size
+ apply to a link at all and centres the label inside it. */
+ [docs-back] {
+ display: inline-flex;
+ align-items: center;
+ min-block-size: 24px;
+ padding-block: var(--rst-sp-1);
+ font-size: var(--rst-fs-sm);
+ line-height: 1.15;
+ color: var(--rst-text-muted);
+ text-decoration: none;
+ }
+ [docs-back]:hover,
+ [docs-back]:focus-visible {
+ color: var(--rst-text);
+ text-decoration: underline;
+ text-underline-offset: 0.2em;
+ }
/* [docs-canvas] is the tray the page sits in — it is chrome around
paper, so it stays close to the surrounding theme's tone, but it
still opts out of the scheme (color-scheme: light) because its
@@ -247,6 +276,22 @@
</style>
<header rst-page-header>
<div rst-page-header-titles>
+ {{/* [docs-back] is this page's only in-app navigation, and until
+ it landed there was none: the header below held sign-out and
+ nothing else, so arriving here by a bookmark or a mailed link
+ left a person with no route to their own list at all. It sits
+ inside [rst-page-header-titles] rather than beside it because
+ [rst-page-header]'s space-between rule wants at most two
+ top-level children (titles, one action area) — the same
+ constraint list.html's [docs-header-actions] comment records
+ from the other direction.
+
+ A breadcrumb, not an arrow: the label is the destination
+ screen's own name (showPage.BackLabel, which *is*
+ docs.list.title), so it needs no glyph to be understood and
+ nothing about it has to be mirrored for the RTL locales in
+ this app's twelve. */}}
+ <a docs-back href="/">{{.BackLabel}}</a>
<h1>{{.Title}}</h1>
</div>
{{/* Sign-out control (POST /signout, auth.go) — same reasoning as
diff --git a/internal/docs/views/signin.html b/internal/docs/views/signin.html
index 8517e0e..80bc0a9 100644
--- a/internal/docs/views/signin.html
+++ b/internal/docs/views/signin.html
@@ -26,26 +26,69 @@
bytes at all — only the {{template}} call does, which is exactly
what that test walks. */}}
{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}
+{{define "content"}}<style>
+ /* [docs-signin] gives this screen one measure, shared by the
+ callouts and the box below them.
+ Measured before it existed: the box filled the shell's whole
+ 992px column, and the single email field inside it stretched to
+ 950px with its submit button sitting flush against the underside
+ — no gap at all — so the eye had to travel the full width of the
+ field and come back to find the control that acts on it. A
+ one-field form rendered at the width of a data table is the
+ shape of markup nobody has laid out yet, and this is the app's
+ front door: the one screen a person meets before they have any
+ reason to extend it credit.
+ Left-aligned rather than centred, so the box still hangs off the
+ same axis as the "Sign in" heading above it and the rest of the
+ app's screens. */
+ [docs-signin] {
+ max-inline-size: 32rem;
+ }
+ /* The field and its button share a line, with real space between
+ them. flex-basis 12rem with room to shrink and min-inline-size: 0
+ is what keeps that true down to the 320px reflow width spec §10.3
+ commits to — an <input>'s automatic minimum size is its content
+ width, so without the explicit 0 it refuses to shrink and the row
+ overflows instead of fitting. Below that the button wraps under
+ the field rather than either one being squeezed unusable. */
+ [docs-signin-form] {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--rst-sp-3);
+ }
+ [docs-signin-form] [rst-input] {
+ flex: 1 1 12rem;
+ min-inline-size: 0;
+ }
+</style>
<header rst-page-header>
<div rst-page-header-titles>
<h1>{{.Title}}</h1>
</div>
</header>
-{{if .Sent}}
-{{template "callout" dict "Body" .Sent "Tone" "positive"}}
-{{end}}
-{{if .ErrorMessage}}
-{{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}}
-{{end}}
-<div rst-box>
- <div rst-box-head>
- <p>{{.Body}}</p>
+<div docs-signin>
+ {{/* The callouts live inside [docs-signin] with the box, not above
+ it at the shell's full width: a confirmation or an error about
+ this form is part of the form's own column, and one that ran
+ twice the width of the thing it referred to read as a banner
+ about the app rather than an answer to what was just
+ submitted. */}}
+ {{if .Sent}}
+ {{template "callout" dict "Body" .Sent "Tone" "positive"}}
+ {{end}}
+ {{if .ErrorMessage}}
+ {{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}}
+ {{end}}
+ <div rst-box>
+ <div rst-box-head>
+ <p>{{.Body}}</p>
+ </div>
+ <form docs-signin-form method="post" action="/signin">
+ <input type="hidden" name="force" value="1">
+ <input rst-input type="email" name="address" placeholder="{{.AddressLabel}}" aria-label="{{.AddressLabel}}" autocomplete="email" required>
+ <button rst-btn="primary" type="submit">{{.SubmitLabel}}</button>
+ </form>
</div>
- <form method="post" action="/signin">
- <input type="hidden" name="force" value="1">
- <input rst-input type="email" name="address" placeholder="{{.AddressLabel}}" aria-label="{{.AddressLabel}}" autocomplete="email" required>
- <button rst-btn="primary" type="submit">{{.SubmitLabel}}</button>
- </form>
</div>
{{end}}