From e07301f74a85b33fafdeb36f9d179eb514f8e419 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 2 Nov 2026 08:18:19 -0500 Subject: [PATCH] docs(standard): screenshot-free UI documentation standard + UI-label gate The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are replaced by a text standard: screenshots rot silently and cannot be gate-checked, while this UI's labels are server-rendered templ literals that can. Layers: pattern-first prose (concepts survive redesigns) > capability inventory > label->outcome flows > quoted labels > generated references where a registry exists (filters/fields.md precedent). The filter docs stay hands-off exemplars; user-doc tone is plain language by contract, dev/admin tone explicitly deferred. writing-ui-docs.md is the standard (page template, findability notes, tone-by-audience, process rule: a UI change is not done until the docs audit runs). ui-controls.md is the canonical control-label glossary the docs quote from; TestUIControlLabels greps every entry against templates/*.templ (source: ts override; also:-fragments for Alpine-composed labels like the Match/Group header) so renames fail make test in the same commit as the rename. Gate proven both ways: a planted bogus label failed; restored set passes. --- docs/developer/ui-controls.md | 166 +++++++++++++++++++++++ docs/developer/writing-ui-docs.md | 117 ++++++++++++++++ internal/docs/ui_labels_test.go | 215 ++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 docs/developer/ui-controls.md create mode 100644 docs/developer/writing-ui-docs.md create mode 100644 internal/docs/ui_labels_test.go diff --git a/docs/developer/ui-controls.md b/docs/developer/ui-controls.md new file mode 100644 index 00000000..a839752d --- /dev/null +++ b/docs/developer/ui-controls.md @@ -0,0 +1,166 @@ + + +# UI controls glossary + +The canonical spellings for controls cited across the UI documentation. Docs +quote the label exactly as this page lists it — one entry per control; two +spellings of one control is a defect. The `controls` block is machine-read: +when a doc first names a control, add its entry in the same commit; when a +rename lands, update the entry in the same commit and the gate goes green +again. + +Entry fields: + +- `label` — the exact rendered text users see +- `where` — semantic path (page → section/card), never spatial; see the + [writing standard](writing-ui-docs.md) +- `does` — what interacting with it produces (the outcome) +- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src` +- `also` — optional, pipe-separated extra literal fragments to search when + the visible label is Alpine-composed rather than static (OR-checked with `label`) +- `note` — optional clarifier + +Form-field labels inside a single modal (Name, Description…) are cited +inline in docs and not tracked here; this list carries the controls people +navigate by. + +## Collections + +```controls +label: New Collection +where: Collections page +does: Opens the create-collection modal. +note: the empty page offers the same action as "Create Your First Collection" + +label: Create Your First Collection +where: Collections page, when no collections exist +does: Opens the create-collection modal. + +label: Create Collection +where: create-collection modal → submit +does: Saves the new collection and returns to the Collections page. + +label: Update Collection +where: edit-collection modal → submit +does: Saves your changes to the collection. + +label: Static +where: create-collection modal → Type +does: Chooses a hand-picked collection — you add and remove items yourself. + +label: ⚡ Smart +where: create-collection modal → Type; also shown as a badge on smart collections page and detail +does: Chooses a rules-matched collection — its membership stays current by itself. + +label: Rules scope +where: create-collection modal, when Smart is chosen +does: Asks what the rules ask about — the two buttons below it. +also: 📚 Books|🎧 Podcast episodes +note: "Books" and "Podcast episodes" as the two scope buttons + +label: Restore System +where: Collections page +does: Opens a pick-a-shelf modal to bring back a system collection you deleted. + +label: Restore +where: Restore System Collection modal → submit +does: Brings the selected system collection back. + +label: Edit Rules +where: collection detail page +does: Opens the smart-collection rules editor for this collection. + +label: Filter tree +where: smart-collection rules editor +does: The area where the group and rules are built. +note: read/write rules — the how is the filter guide + +label: Apply to Collection +where: smart-collection rules editor → save +does: Saves the filter tree and makes the collection match those rules from now on. + +label: Add Items +where: collection detail page +does: Opens the item picker window. + +label: Add Selected +where: item picker → footer +does: Adds everything checked in the picker to the collection. + +label: Remove Selected +where: collection detail page +does: Opens a confirm dialog to remove the checked items from the collection. + +label: Search within collection... +where: collection detail page → search box +does: Filters the displayed items — nothing is removed. + +label: Play as Sequence +where: collection detail page (when it has a downloaded episode) +does: Plays the collection's episodes one after another in the listening order you pick. + +label: Hide finished books (reading-queue mode) +where: edit-collection modal +does: Finished books disappear from the collection's shelf. + +label: Show Up Next strip +where: edit-collection modal +does: Adds this collection's Up Next strip to the podcast page. +``` + +## Everywhere else + +```controls +label: Filters +where: All Books / Podcasts page → toolbar +does: Opens the filter drawer for building and applying rules. + +label: Register Device +where: Devices page +does: Submits a device registration request for the pending-approval flow. + +label: Download All +where: Podcast page → episodes area +does: Queues every episode of the feed for download. + +label: Download order +where: Podcast page → feed settings +does: Sets the ordering used when feed episodes are downloaded. + +label: Match ALL / Group ANY header +where: filter drawer → group header (also smart collections → Rules and dashboard sections) +does: Sets how the group combines its rules; clicking the header flips ALL/ANY. +also: ? 'Match' : 'Group'|? 'ANY' : 'ALL' +note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY + +label: Reset to Scanned +where: book detail → edit modal → scan information area +does: Restores scanner-extracted metadata over manual edits. + +label: Auto-Scan +where: Settings page → Scanning section +does: Enables automatic periodic library scanning on the interval below it. + +label: Scan Interval (seconds) +where: Settings page → Scanning section +does: Sets the seconds between automatic scans. + +label: Resume rewind +where: audio player → player settings; profile settings +does: Rewinds playback a configured few seconds when you resume. +also: Resume Rewind +note: profile page spells it capitalized + +label: Reading Direction +where: book detail → edit modal +does: Chooses the direction pages advance (publisher convention — e.g. manga). + +label: Scan Info +where: book detail page → info block +does: Displays scanner-supplied notes attached to the item. +also: Scan Information +note: the editable form label in the modal reads "Scan Information" +``` diff --git a/docs/developer/writing-ui-docs.md b/docs/developer/writing-ui-docs.md new file mode 100644 index 00000000..b7ec9aac --- /dev/null +++ b/docs/developer/writing-ui-docs.md @@ -0,0 +1,117 @@ +# Writing UI documentation without screenshots + +Bookhoard's UI documentation uses no screenshots. This page is the standard +for writing them. The flagship example of the style is the +[filter system documentation](../user/filters/index.md): one system taught +once, the places it appears listed as a *pattern*, samples for every field in +a [generated reference](../user/filters/fields.md) that cannot drift from the +UI. + +## Why no screenshots + +Screenshots fail in every way the rest of this repo's rot is already gated +against: + +- **They rot silently.** No gate can diff pixels against the running UI. A + renamed button makes the screenshot wrong and nothing fails. +- **They cannot be verified.** Shown text can: this app's UI is + server-rendered from `templates/*.templ`, so every stable label and heading + is a literal in the source — checkable, like routes and API examples are. +- **They die with a redesign.** Text keyed to *function* survives a + re-layout. Text keyed to a layout dies with its redesign. +- **They exclude** (screen readers, text search) and bloat diffs. + +The one thing screenshots do better — "which button is it?" — text does via +**exact quoted labels** plus a semantic path, and the label gate then holds +that text against the source. + +## The layering — what is load-bearing + +Write every UI page in this order. The top layers are why users don't get +lost when the UI changes: + +| # | Layer | Content | Lifespan | +| --- | --- | --- | --- | +| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof | +| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof | +| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes | +| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today | +| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift | + +Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must +make the page followable even if that label is renamed next week. + +## The page template + +1. **The pattern first.** The concept, its invariants, and everywhere it + appears — "one system, three places". Never page cartography. +2. **Function inventory.** Capabilities without control names: "you can pin + any narrowing to a named filter you can reopen later". +3. **How-to with examples.** Each step: the label of what you interact with, + then the outcome ("the count at the bottom shows how many books will + match"). An example beats an adjective. +4. **Findability note.** One line telling the reader how to re-locate the + feature *by function again* after any UI change — deliberately + label-independent ("it's the drawer opened from any books or podcasts + list — the only place whose header can read Match ALL"). +5. **Current labels.** Quoted exactly as rendered, one canonical spelling + per control ([arbitrated by the glossary](ui-controls.md)). + +## Writing rules + +- **Semantic placement, never spatial.** No "top-right" or "at the bottom of + the screen" talk. "Devices page → the **Register Device** button" survives + any re-layout. +- **One canonical label per control.** Two spellings of the same control is + a defect, not a synonym. +- **Status updates get a what-it-means table**, not a color description. +- **Screenshots are never load-bearing.** Default is none. + +## Tone by audience + +- **User Guide (`docs/user/`) — plain language, deliberately.** Short + sentences. Everyday words. No developer jargon; any term the reader + genuinely needs (say, *rule*) is explained once, in place, then used + consistently. The reader is assumed to know books, while the app's own + words are the only technical vocabulary allowed. +- **Developer and Administration sections — tone is a separate discussion, + not yet decided.** Write them plainly and hand-off-rich as well; do not + import opinions about their voice until that pass happens. + +## Generate what has a registry + +Anything enumerable gets generated from source with a currency test, not +hand-typed. The precedent: every filter field is +[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent` +from the same registry the menus render — the doc cannot disagree with the +UI. Hand-typed lists drift fastest; when no registry exists yet, say so in +the page and lean on the pattern layers. + +## The gates + +- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in + the [glossary](ui-controls.md) must exist as a literal in + `templates/*.templ` (or `web/src` for TS-rendered surfaces, marked + `source: ts`). Renames fail `make test` in the same commit as the rename. +- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to + a real file. + +Naming a new control in prose? Add its glossary entry in the same commit. +Form labels inside one modal (Name, Description…) are cited inline and not +glossary-tracked; the glossary carries the controls people navigate by — +buttons, tabs, badges, page titles. + +## The process rule + +**A UI change is not finished until the docs audit runs.** Moved card, +renamed button, restructured page — all of it triggers: labels still +resolve, links still resolve, affected pages re-read against the new +surface. The pattern layers keep users oriented between audits; the audit +keeps layers 3–5 honest. + +## What this does not replace + +The filter documentation (and the older filtering overview) is hands-off +exemplar territory — read it as the model, never restyle it here. New UI +surfaces (scanning, devices, settings, podcasts, the reader, collections) +are written this way from the start. diff --git a/internal/docs/ui_labels_test.go b/internal/docs/ui_labels_test.go new file mode 100644 index 00000000..f4f7b457 --- /dev/null +++ b/internal/docs/ui_labels_test.go @@ -0,0 +1,215 @@ +package docs + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestUIControlLabels holds the UI documentation to the source of the UI. +// The glossary (docs/developer/ui-controls.md) is the canonical spelling of +// every control the docs cite; this test greps that spelling against the +// server-rendered templates, so a renamed button fails make test in the +// same commit as the rename — the class of rot screenshots could never be +// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also` +// fragments instead of requiring the composed string to exist. +// +// Scope is substring-level (not word-boundary): the point is catching +// renames and deletions, not stopping coincidental substrings. New controls +// cited in prose get a glossary entry in the same commit (writing-ui-docs.md +// is the standard; the filter docs are the hands-off exemplar). + +const ( + labelGlossaryRel = "../../docs/developer/ui-controls.md" + labelTemplRootRel = "../../templates" + labelTSRootRel = "../../web/src" +) + +type docGlossaryEntry struct { + label string + where string + does string + source string + reason string + also []string + skip bool +} + +// docCorpus concatenates every file under root ending in suffix. +func docCorpus(t *testing.T, root string, suffix string) string { + t.Helper() + abs, err := filepath.Abs(root) + if err != nil { + t.Fatalf("abs %s: %v", root, err) + } + var b strings.Builder + err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, suffix) { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + b.Write(raw) + b.WriteString("\n") + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + return b.String() +} + +func corpusName(source string) string { + if source == "ts" { + return "web/src" + } + return "templ" +} + +func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry { + t.Helper() + + var block strings.Builder + inBlock, foundAny := false, false + for _, line := range strings.Split(raw, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "```controls"): + inBlock, foundAny = true, true + continue + case trimmed == "```" && inBlock: + inBlock = false + continue + } + if inBlock { + block.WriteString(line) + block.WriteString("\n") + } + } + if !foundAny { + t.Fatalf("no ```controls block found in %s", labelGlossaryRel) + } + + var entries []docGlossaryEntry + haveCur := false + cur := docGlossaryEntry{} + flush := func() { + if haveCur { + entries = append(entries, cur) + haveCur = false + } + } + for _, line := range strings.Split(block.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, ":") + if !ok { + t.Fatalf("malformed controls line %q (want `key: value`)", line) + } + key, val = strings.TrimSpace(key), strings.TrimSpace(val) + switch key { + case "label": + flush() + haveCur = true + cur = docGlossaryEntry{label: val} + case "where": + cur.where = val + case "does": + cur.does = val + case "source": + cur.source = val + case "also": + for _, frag := range strings.Split(val, "|") { + if frag = strings.TrimSpace(frag); frag != "" { + cur.also = append(cur.also, frag) + } + } + case "skip": + cur.skip = val == "true" + case "reason": + cur.reason = val + case "note": + // free text for humans + default: + t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key) + } + } + flush() + return entries +} + +func TestUIControlLabels(t *testing.T) { + raw, err := os.ReadFile(labelGlossaryRel) + if err != nil { + t.Fatalf("read glossary: %v", err) + } + entries := parseUIControlsBlock(t, string(raw)) + if len(entries) == 0 { + t.Fatalf("glossary has no entries — add at least one before citing controls") + } + + tmplRequired, tsRequired := false, false + for _, e := range entries { + if e.source == "ts" { + tsRequired = true + } else { + tmplRequired = true + } + } + corpora := map[string]string{} + if tmplRequired { + corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ") + } + if tsRequired { + corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts") + } + + seen := map[string]int{} + for _, e := range entries { + if e.label == "" { + t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does) + continue + } + seen[e.label]++ + if e.where == "" || e.does == "" { + t.Errorf("control %q: `where` and `does` are required", e.label) + } + if e.skip && e.reason == "" { + t.Errorf("control %q: skip=true requires a reason", e.label) + } + switch e.source { + case "", "templ", "ts": + default: + t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source) + } + if e.skip { + continue + } + + fragments := append([]string{e.label}, e.also...) + found := false + for _, frag := range fragments { + if strings.Contains(corpora[corpusName(e.source)], frag) { + found = true + break + } + } + if !found { + t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.", + e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source)) + } + } + for label, n := range seen { + if n > 1 { + t.Errorf("control %q listed %d times — one entry per control", label, n) + } + } +}