oficina / docs Public

Impeccable pass: PRODUCT.md, and four design fixes on the three screens

Squash of worktree-impeccable-init (5 commits). Reviewed as one diff by
claude-fable-5 per AGENTS.md's highest-capability rule — it returned DO
NOT MERGE on the first read, and MERGE after the two blockers below were
fixed at the root. Discussion 13 is the record.

PRODUCT.md is written from repository evidence rather than an interview:
the run was unattended, which the design skill's own autonomy rule treats
as permission to infer from the brief. The file says so at the top and
marks what is a reading rather than a citation.

Four fixes, all found by running the app and measuring rendered pages at
1440px and at the 320px reflow width spec §10.3 commits to:

1. A document had no way back to the list. GET /d/{sqid} rendered
   sign-out and no link at all, so arriving by bookmark or mailed link —
   the two ways in that do not pass through GET / — stranded a person
   there. [docs-back] is a breadcrumb whose label reuses docs.list.title
   rather than inventing a key that could drift from the screen it
   points at across twelve locales. No new user-visible string.

2. Sign-in was an unlaid-out form: one email field stretched to 950px
   inside a 992px box with its submit button flush against its
   underside, zero gap. Now one 32rem measure shared by the box and its
   callouts, field and button side by side.

3. The mobile header spent 175px of a 720px viewport before the first
   document, because a fixed 14rem field could not share a 288px column
   with its button. 175px -> 133px. A wrapping flex line breaks before
   it shrinks, so the first attempt measured exactly as tall as before
   it; nowrap is what forces the field to yield width instead.

4. No favicon; /favicon.ico 404'd on every page. A hand-drawn sheet mark
   on the day theme's accent, in the shell's documented "head" block,
   defined once in the base template so all three pages inherit it.

A fifth claimed fix was withdrawn during review, and the reason is worth
keeping. It "made the whole list row clickable" — but rastrillo's own
tokens.css already ships that: [rst-row] position: relative (:500), an
un-gated :hover tint (:515), and [rst-row-main] > a::after with inset: 0
(:558), under rastrillo's comment "The name link's overlay makes the
whole row clickable without nesting one anchor inside another." Our
markup already matched. The "7% clickable" measurement came from
getBoundingClientRect on the anchor, which cannot see a positioned
pseudo-element, and the hit test that "confirmed" the fix was hitting
rastrillo's overlay — it would have passed identically beforehand, which
is the check that was never run. list.html now carries a comment saying
so, because the next reader will otherwise measure 268x16 and reach for
the same non-fix.

PRODUCT.md also asserted, present tense, that content is encrypted in
SQLite under a DEK with a plaintext byte-grep test enforcing it. Neither
is true: Store.Text's doc comment says sealing is out of scope for the
plan that built it, and no such test exists. Rewritten as the spec
commitment it is, with an explicit instruction not to describe an
instance as encrypting content at rest.

Two implementation traps recorded, both now held by a test rather than
by care:
- icon.svg's first version named a CSS custom property in an XML comment
  with its leading "--", which XML forbids. It served a flawless 200
  with the right Content-Type and byte count and no browser could parse
  it. TestIconIsWellFormedXML decodes every token.
- It was first written to internal/docs/static/, which is the editor
  build's output — the gate rebuilds there, fails on any dirt, then
  git-cleans it, so the file reddened CI and CI deleted it.
  Hand-authored assets live in brand/, and
  TestExactlyOneEditorBundleIsEmbedded asserts static/ holds nothing
  else, so this now fails in go test rather than in CI.

Rebased onto a6fa843: main gained the live-updates/SSE work while this
branch was open, which touches show.html and render.go. The merge was
textually clean, and was re-verified beyond that — gate exits 0 on the
merged tree, and in a browser the back link, the favicon on all three
pages, the 133px mobile header, the still-clickable row, and typing into
the ProseMirror editor (whose mount the live-sync work rewrote) all
behave, with no console errors or horizontal overflow at either width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsrkL4m68poXhtf1tGaoek
Paul Campbell pushed by paul@keymail.dev cafb6968c6e49be7fa3b245bb498d829ee4507f2 parent a6fa843
11 files changed, +745 −25
  • PRODUCT.md +222 −0
  • internal/docs/assets.go +60 −6
  • internal/docs/assets_test.go +110 −0
  • 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 −0
  • internal/docs/views/head.html +25 −0
  • internal/docs/views/list.html +65 −4
  • internal/docs/views/show.html +45 −0
  • internal/docs/views/signin.html +58 −15

landed from worktree-impeccable-init

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/internal/docs/assets.go b/internal/docs/assets.go
index 057991b..6671e34 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/
@@ -65,6 +81,40 @@ func editorScriptPath() string {
return "/static/" + editorBundleName
}
+// 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
@@ -90,9 +140,9 @@ func serveEditorBundle(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, editorBundleName, 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
@@ -108,9 +158,13 @@ func serveEditorBundle(w http.ResponseWriter, r *http.Request) {
func staticHandler(fallback http.Handler) http.Handler {
editorPath := editorScriptPath()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == editorPath {
+ switch r.URL.Path {
+ case editorPath:
serveEditorBundle(w, r)
return
+ 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..55cfe0b 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"
@@ -29,6 +32,21 @@ func TestExactlyOneEditorBundleIsEmbedded(t *testing.T) {
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 editor
+ // 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).
+ if len(entries) != 1 {
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ t.Errorf("static/ holds %v; it must hold only the editor build's output — put hand-authored assets in brand/", names)
+ }
}
// The bundle must be served without a session, or a signed-out reader
@@ -56,3 +74,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 26df63e..3708292 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -205,6 +205,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
@@ -264,6 +282,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 5e09493..6cd05ba 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -167,6 +167,13 @@ const DefaultLocale = "en"
//go:embed views/*.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",
@@ -210,6 +217,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/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 d1565f5..b7fea4c 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;
}
</style>
<header rst-page-header>
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index cc5637e..8a147a0 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}}