oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# Plan 6 — finding a document by its title
2
3Brief, from Paul, 2026-09-01: *"shall we do search now, so i can find
4docs by title?"* — and, asked how much: a search box on the list **plus**
5a keyboard jump-to.
6
7Spec §15.2 already rules on the scope: *"Title and metadata search
8ships; full text waits for a design, not for a decision."* This is that,
9and nothing more. Full-text search is still unbuilt and still needs a
10design compatible with §12's plaintext-grep gate.
11
12## D1 — filtering happens in Go, over the owner's own rows, not in SQL
13
14The obvious implementation is `WHERE title LIKE '%' || ? || '%'`. This
15plan does not use it.
16
17Spec §2 wants document titles sealed — per-row encryption, the same as
18`update_bin`. A SQL `LIKE` stops working the instant they are, and
19"search silently returns nothing" is a bad way to find that out.
20Filtering in Go over the rows `Store.List` already loads costs one pass
21over one person's *titles* — a few hundred short strings — and survives
22sealing unchanged, because at that point the rows are decrypted on the
23way past anyway.
24
25This is not in tension with §15.2's objection to full-text search.
26That objection is that decrypting the *corpus* per query is O(corpus).
27Titles are not the corpus; they are one short string per document.
28
29It also keeps §4.2's scoping automatic rather than restated: the filter
30runs over the result of the same owner-scoped query every other document
31route uses, so a document belonging to someone else cannot appear in
32results by construction, not by a second `WHERE` somebody has to
33remember.
34
35## D2 — the search box works with no JavaScript; the palette is additive
36
37`GET /?q=…` is a plain form in the list header. It filters
38server-side and renders the same list, so search works exactly as far as
39reading does (§6.2). The jump-to palette is layered on top and adds
40speed, not capability — a person with no JavaScript loses the keyboard
41shortcut and keeps search.
42
43Paul chose the palette knowing §6.4 cut filter chrome from this screen
44and §6.2 pushes against new JS. The rulings it sits against were about
45*chrome that does not earn itself*: a template carousel, an "Owned by
46anyone" filter, grid/list/AZ toggles. A search field on a list of
47documents is the thing those toggles were a poor substitute for.
48
49## D3 — one ranker, on the server
50
51The palette could filter client-side over a downloaded title list. It
52does not: it queries the same endpoint the list uses, debounced.
53
54Two rankers that disagree is exactly the kind of defect nobody notices —
55the palette and the search box would put the same document in different
56places, and each would look correct on its own. One implementation
57cannot drift from itself. It also means no bulk export of every title to
58the browser, and no cap to tune.
59
60The cost is that the palette needs the network per keystroke. On a
61document editor already streaming updates that is not a new dependency,
62and the palette shows its own state when a request fails.
63
64Ranking is deliberately explainable rather than clever: an exact
65title match, then a title that starts with the query, then a title with a
66*word* starting with it, then a match anywhere; ties broken by most
67recently updated. Somebody should be able to look at a result order and
68say why it is that order.
69
70## D4 — the palette implements its ARIA contract, or it does not claim one
71
72A combobox that says `role="combobox"`/`aria-expanded` is promising
73Down/Up to move through results, Enter to open, Escape to close, and
74`aria-activedescendant` to say which option is current. This branch
75implements all of it — including that Escape gets out and focus returns
76to where it came from, because a modal that traps focus with no way back
77is the failure mode a palette has.
78
79**That behaviour is not held by a committed check, and this plan said it
80was.** It was exercised by seventeen checks in a real browser, and they
81all pass, but they live in a scratch driver that is deliberately not in
82the repo: the harness they belong in (`fixtures/browser-drive.mjs`) is
83on the `formatting-and-export` branch and not yet on `main`, and
84shipping a second harness to avoid waiting is worse than waiting. So
85what protects this today is one person having run it once, which is
86exactly the thing this repo does not count as a gate. Folding them in is
87a filed task, and until that happens the honest statement is the one in
88this paragraph rather than the one that was here before.
89
90That rule is on this repo's own record (AGENTS.md, accessibility
91conventions) because the formatting bar broke it a day ago.
92
93## The work
94
951. `internal/docs/search.go` — `Store.Search`, the ranking, and
96 `GET /documents/search` returning JSON for the palette.
972. `GET /?q=` — the list handler filters, and renders an ungrouped,
98 ranked result list with its own empty state. Recency grouping is what
99 an *unfiltered* list is for; a ranked result list grouped by "Today"
100 would be ordering the results twice, differently.
1013. `fixtures/jump-to.mjs` — the palette, its own committed bundle so the
102 list page does not pay for ProseMirror to get a keyboard shortcut.
103 `assets.go` grows from "the editor bundle" to a small registry, one
104 committed artifact per name.
1054. Tests: the ranking, the scoping (someone else's document is not
106 findable), the no-JS path, and browser-drive checks for the palette.
107
108## Risks
109
110- **A second committed bundle** touches `assets.go`'s "exactly one" init
111 invariant. It becomes "exactly one per name", which is the same
112 guarantee, but it is the kind of check that is easy to weaken by
113 accident while generalising it.
114- **`/documents/search` is a new authenticated endpoint returning
115 titles.** It goes inside the `RequireSession` group and through the
116 same owner scope; that it does is worth a test rather than a reading.
117