oficina / docs Public

Merge branch 'search' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 81f64d4ad99c6fdb7e3610b3677957706f91a92b parent eefa1a4parent 1b5cce9
19 files changed, +1501 −121
  • .amadan/ci.d/10-gate +3 −3
  • AGENTS.md +24 −0
  • docs/superpowers/plans/2026-09-01-search.md +116 −0
  • fixtures/build-bundles.mjs +74 −0
  • fixtures/build-editor.mjs +0 −54
  • fixtures/editor-entry.js +1 −1
  • fixtures/jump-to.mjs +292 −0
  • fixtures/package.json +1 −1
  • internal/docs/app.go +5 −0
  • internal/docs/assets.go +80 −36
  • internal/docs/assets_test.go +43 −17
  • internal/docs/handlers.go +77 −7
  • internal/docs/render.go +42 −1
  • internal/docs/search.go +225 −0
  • internal/docs/search_test.go +324 −0
  • internal/docs/static/jumpto.a37448e7fcd21855.js +1 −0
  • internal/docs/views/list.html +74 −1
  • internal/docs/views/partials/jumpto.html +118 −0
  • internal/docs/views/show.html +1 −0

CI passed — run details

diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index 3663e03..c41e6bc 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -57,7 +57,7 @@ echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regressi
echo "=== fixtures: the live-update cursor and connection policy"
(cd fixtures && node --test live-updates.test.mjs)
-# fixtures/build-editor.mjs is byte-reproducible: no sourcemaps, no
+# fixtures/build-bundles.mjs is byte-reproducible: no sourcemaps, no
# banners, no timestamps, no absolute paths baked into the output, and
# esbuild's actual work happens in a pinned native binary (0.28.2),
# not interpreted JS, so the Node version running it does not enter
@@ -68,9 +68,9 @@ echo "=== fixtures: the live-update cursor and connection policy"
# "the embedded wasm must match a fresh build" below, for the same
# reason that check is safe rather than randomly red.
echo "=== the editor bundle must match a fresh build"
-(cd fixtures && node build-editor.mjs)
+(cd fixtures && node build-bundles.mjs)
if [ -n "$(git status --porcelain -- internal/docs/static/)" ]; then
- echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-editor.mjs' and commit the result." >&2
+ echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-bundles.mjs' and commit the result." >&2
# The rebuild above just mutated a committed directory as a side
# effect of checking it. Restore it to what's actually committed —
# tracked changes via checkout, any new hashed file the rebuild
diff --git a/AGENTS.md b/AGENTS.md
index 1c1978e..bf1bf8b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -320,6 +320,19 @@ time in a single afternoon:
empty list that reads as "never merged" while `main` carries every line,
because a squashed branch is not an ancestor. Compare content, not
ancestry.
+- **`carlos channels` after a promote.** It reports which release a
+ channel *points at*, which is not the same question as which release
+ the box is *serving*. Adoption runs on an hourly timer, so the two can
+ disagree for up to an hour after `carlos promote`: observed
+ 2026-09-01, `edge` reading the new version while the live host served
+ the previous one for five minutes straight, closed within seconds by
+ `carlos restart`. Neither command fails and nothing says "not live
+ yet". Trust `x-carlos-version` on the real host, or use `carlos
+ deploy`, which ships, promotes and then watches the URL until it
+ actually serves the new version. `carlos channels` cannot distinguish
+ "serving it" from "pointed at it".
+ *(Recorded from the live-sync deploy; not re-verified since, so the
+ timer's exact behaviour is one observation rather than a measurement.)*
- **A library function that ignores its own "dry run" argument.**
`y-prosemirror`'s exported `undo` is `state => ...undoManager?.undo()`
@@ -345,6 +358,17 @@ after writing one, break the thing it covers and watch it go red, then
put it back. `scripts/browser-drive.sh` was kept only after it was
confirmed to fail on the build whose bug it had just found — without
that step a green run means "passed" and "could not see" equally.
+
+**Do not pass on somebody else's finding as your own verification.** A
+report from another session is evidence about what they saw, not a fact
+you have checked, and relaying it flatly launders it into one. Say where
+it came from, or check it — and note that "two of us noticed
+independently" is not corroboration when the second of you is the one
+who mentioned it first. (Written after exactly that: a merge commit on
+`main` was reported here as a breach of the shipping cadence and passed
+on to Paul as one, when `amadan branch merge` produces a merge commit
+itself and the branch had landed correctly. One minute with the tool
+would have caught it.)
- **Re-copy `tokens.css` deliberately on every rastrillo upgrade.** It is
frozen into `static/` at scaffold time while the partials upgrade with the
module. `rastrillo doctor` will make this mechanical; until it lands it is
diff --git a/docs/superpowers/plans/2026-09-01-search.md b/docs/superpowers/plans/2026-09-01-search.md
new file mode 100644
index 0000000..cca02d4
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-01-search.md
@@ -0,0 +1,116 @@
+# Plan 6 — finding a document by its title
+
+Brief, from Paul, 2026-09-01: *"shall we do search now, so i can find
+docs by title?"* — and, asked how much: a search box on the list **plus**
+a keyboard jump-to.
+
+Spec §15.2 already rules on the scope: *"Title and metadata search
+ships; full text waits for a design, not for a decision."* This is that,
+and nothing more. Full-text search is still unbuilt and still needs a
+design compatible with §12's plaintext-grep gate.
+
+## D1 — filtering happens in Go, over the owner's own rows, not in SQL
+
+The obvious implementation is `WHERE title LIKE '%' || ? || '%'`. This
+plan does not use it.
+
+Spec §2 wants document titles sealed — per-row encryption, the same as
+`update_bin`. A SQL `LIKE` stops working the instant they are, and
+"search silently returns nothing" is a bad way to find that out.
+Filtering in Go over the rows `Store.List` already loads costs one pass
+over one person's *titles* — a few hundred short strings — and survives
+sealing unchanged, because at that point the rows are decrypted on the
+way past anyway.
+
+This is not in tension with §15.2's objection to full-text search.
+That objection is that decrypting the *corpus* per query is O(corpus).
+Titles are not the corpus; they are one short string per document.
+
+It also keeps §4.2's scoping automatic rather than restated: the filter
+runs over the result of the same owner-scoped query every other document
+route uses, so a document belonging to someone else cannot appear in
+results by construction, not by a second `WHERE` somebody has to
+remember.
+
+## D2 — the search box works with no JavaScript; the palette is additive
+
+`GET /?q=…` is a plain form in the list header. It filters
+server-side and renders the same list, so search works exactly as far as
+reading does (§6.2). The jump-to palette is layered on top and adds
+speed, not capability — a person with no JavaScript loses the keyboard
+shortcut and keeps search.
+
+Paul chose the palette knowing §6.4 cut filter chrome from this screen
+and §6.2 pushes against new JS. The rulings it sits against were about
+*chrome that does not earn itself*: a template carousel, an "Owned by
+anyone" filter, grid/list/AZ toggles. A search field on a list of
+documents is the thing those toggles were a poor substitute for.
+
+## D3 — one ranker, on the server
+
+The palette could filter client-side over a downloaded title list. It
+does not: it queries the same endpoint the list uses, debounced.
+
+Two rankers that disagree is exactly the kind of defect nobody notices —
+the palette and the search box would put the same document in different
+places, and each would look correct on its own. One implementation
+cannot drift from itself. It also means no bulk export of every title to
+the browser, and no cap to tune.
+
+The cost is that the palette needs the network per keystroke. On a
+document editor already streaming updates that is not a new dependency,
+and the palette shows its own state when a request fails.
+
+Ranking is deliberately explainable rather than clever: an exact
+title match, then a title that starts with the query, then a title with a
+*word* starting with it, then a match anywhere; ties broken by most
+recently updated. Somebody should be able to look at a result order and
+say why it is that order.
+
+## D4 — the palette implements its ARIA contract, or it does not claim one
+
+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 — 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.
+
+## The work
+
+1. `internal/docs/search.go` — `Store.Search`, the ranking, and
+ `GET /documents/search` returning JSON for the palette.
+2. `GET /?q=` — the list handler filters, and renders an ungrouped,
+ ranked result list with its own empty state. Recency grouping is what
+ an *unfiltered* list is for; a ranked result list grouped by "Today"
+ would be ordering the results twice, differently.
+3. `fixtures/jump-to.mjs` — the palette, its own committed bundle so the
+ list page does not pay for ProseMirror to get a keyboard shortcut.
+ `assets.go` grows from "the editor bundle" to a small registry, one
+ committed artifact per name.
+4. Tests: the ranking, the scoping (someone else's document is not
+ findable), the no-JS path, and browser-drive checks for the palette.
+
+## Risks
+
+- **A second committed bundle** touches `assets.go`'s "exactly one" init
+ invariant. It becomes "exactly one per name", which is the same
+ guarantee, but it is the kind of check that is easy to weaken by
+ accident while generalising it.
+- **`/documents/search` is a new authenticated endpoint returning
+ titles.** It goes inside the `RequireSession` group and through the
+ same owner scope; that it does is worth a test rather than a reading.
diff --git a/fixtures/build-bundles.mjs b/fixtures/build-bundles.mjs
new file mode 100644
index 0000000..a2e42e1
--- /dev/null
+++ b/fixtures/build-bundles.mjs
@@ -0,0 +1,74 @@
+// fixtures/build-bundles.mjs — run by a person, output committed.
+// The app performs no build step (spec §6.5); this produces the
+// artifact that makes that true: a single, minified, content-hashed
+// JS bundle committed to internal/docs/static/, embedded into the Go
+// binary by internal/docs/assets.go's //go:embed static.
+//
+// internal/docs/static/, not a top-level static/: Go's //go:embed
+// cannot reach outside (or above) the directory of the .go file doing
+// the embedding — no ".." is allowed in an embed pattern — so the
+// tree assets.go embeds has to live under internal/docs/, the same
+// way internal/docs/views/*.html already does for this app's page
+// templates.
+//
+// Paths below are resolved from this file's own location
+// (fixtures/generate.mjs's own pattern), not from the caller's
+// working directory, so `node build-bundles.mjs` works the same
+// whether it's run from fixtures/ or anywhere else.
+import { build } from 'esbuild'
+import { createHash } from 'node:crypto'
+import { readFileSync, writeFileSync, rmSync, readdirSync, mkdirSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const staticDir = join(here, '..', 'internal', 'docs', 'static')
+
+mkdirSync(staticDir, { recursive: true })
+
+// Two bundles, built and hashed the same way but kept separate on
+// purpose. The editor is ProseMirror and Yjs — about 300KB — and only
+// a document page needs it. The jump-to palette is a few KB and every
+// signed-in page needs it, including the list. Bundling them together
+// would make opening the list download an editor in order to offer a
+// keyboard shortcut.
+//
+// The names here are the names internal/docs/assets.go's bundleKinds
+// expects; that file panics at init if one of them has no committed
+// file, so a build that silently stopped emitting one cannot start.
+const bundles = [
+ { name: 'editor', entry: 'editor-entry.js' },
+ { name: 'jumpto', entry: 'jump-to.mjs' },
+]
+
+for (const { name, entry } of bundles) {
+ const outfile = join(staticDir, `${name}.js`)
+ await build({
+ entryPoints: [join(here, entry)],
+ bundle: true,
+ format: 'esm',
+ minify: true,
+ target: 'es2020',
+ outfile
+ })
+
+ // Content hash in the filename so the asset is immutable-cacheable
+ // and a stale browser cache cannot serve a bundle that disagrees
+ // with the server (internal/docs/assets.go's own doc comment).
+ const bytes = readFileSync(outfile)
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
+
+ // Exactly one file per bundle must ever be committed — assets.go
+ // panics at init if it finds more or less than one — so a rebuild
+ // must remove every previous hashed file for THIS name before
+ // writing the new one. The regex is anchored to the name so
+ // rebuilding one bundle never deletes the other's.
+ const stale = new RegExp(`^${name}\\.[0-9a-f]{16}\\.js$`)
+ for (const f of readdirSync(staticDir)) {
+ if (stale.test(f)) rmSync(join(staticDir, f))
+ }
+ writeFileSync(join(staticDir, `${name}.${hash}.js`), bytes)
+ rmSync(outfile)
+
+ console.log(`wrote internal/docs/static/${name}.${hash}.js (${bytes.length} bytes)`)
+}
diff --git a/fixtures/build-editor.mjs b/fixtures/build-editor.mjs
deleted file mode 100644
index ef83afb..0000000
--- a/fixtures/build-editor.mjs
+++ /dev/null
@@ -1,54 +0,0 @@
-// fixtures/build-editor.mjs — run by a person, output committed.
-// The app performs no build step (spec §6.5); this produces the
-// artifact that makes that true: a single, minified, content-hashed
-// JS bundle committed to internal/docs/static/, embedded into the Go
-// binary by internal/docs/assets.go's //go:embed static.
-//
-// internal/docs/static/, not a top-level static/: Go's //go:embed
-// cannot reach outside (or above) the directory of the .go file doing
-// the embedding — no ".." is allowed in an embed pattern — so the
-// tree assets.go embeds has to live under internal/docs/, the same
-// way internal/docs/views/*.html already does for this app's page
-// templates.
-//
-// Paths below are resolved from this file's own location
-// (fixtures/generate.mjs's own pattern), not from the caller's
-// working directory, so `node build-editor.mjs` works the same
-// whether it's run from fixtures/ or anywhere else.
-import { build } from 'esbuild'
-import { createHash } from 'node:crypto'
-import { readFileSync, writeFileSync, rmSync, readdirSync, mkdirSync } from 'node:fs'
-import { join, dirname } from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-const here = dirname(fileURLToPath(import.meta.url))
-const staticDir = join(here, '..', 'internal', 'docs', 'static')
-const outfile = join(staticDir, 'editor.js')
-
-mkdirSync(staticDir, { recursive: true })
-
-await build({
- entryPoints: [join(here, 'editor-entry.js')],
- bundle: true,
- format: 'esm',
- minify: true,
- target: 'es2020',
- outfile
-})
-
-// Content hash in the filename so the asset is immutable-cacheable and
-// a stale browser cache cannot serve an editor that disagrees with the
-// server's ABI (internal/docs/assets.go's own doc comment).
-const bytes = readFileSync(outfile)
-const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
-
-// Exactly one bundle must ever be committed — assets.go panics at
-// init if it finds more or less than one — so a rebuild must remove
-// every previous hashed file before writing the new one.
-for (const f of readdirSync(staticDir)) {
- if (/^editor\.[0-9a-f]{16}\.js$/.test(f)) rmSync(join(staticDir, f))
-}
-writeFileSync(join(staticDir, `editor.${hash}.js`), bytes)
-rmSync(outfile)
-
-console.log(`wrote internal/docs/static/editor.${hash}.js (${bytes.length} bytes)`)
diff --git a/fixtures/editor-entry.js b/fixtures/editor-entry.js
index caa193a..c7b67d3 100644
--- a/fixtures/editor-entry.js
+++ b/fixtures/editor-entry.js
@@ -1,5 +1,5 @@
// fixtures/editor-entry.js — the vendored editor bundle's entire
-// source. fixtures/build-editor.mjs bundles this one file (and
+// source. fixtures/build-bundles.mjs bundles this one file (and
// everything it imports) into internal/docs/static/editor.<hash>.js;
// this is the whole program that runs in the browser, not a library
// surface something else consumes — the app performs no build step
diff --git a/fixtures/jump-to.mjs b/fixtures/jump-to.mjs
new file mode 100644
index 0000000..ed90bde
--- /dev/null
+++ b/fixtures/jump-to.mjs
@@ -0,0 +1,292 @@
+// fixtures/jump-to.mjs — the keyboard jump-to palette.
+//
+// Control-K (Command-K on a Mac) from anywhere signed in: type, and go
+// straight to a document. Its own bundle, a few KB, because the list
+// page should not download ProseMirror to offer a keyboard shortcut
+// (fixtures/build-bundles.mjs, internal/docs/assets.go).
+//
+// # It is an addition, never the way in
+//
+// The list header carries a plain GET search form that works with no
+// JavaScript at all (spec §6.2, and the search plan's D2). This makes
+// finding a document *faster*; it does not make it *possible*. If this
+// file fails to load, or throws, the app loses a shortcut and keeps
+// search.
+//
+// # It does not rank
+//
+// Every query goes to GET /documents/search, which is the same
+// Store.Search the list page renders through. Filtering a downloaded
+// list of titles here would mean two rankers, and two rankers that
+// disagree put the same document in different places while each looks
+// correct on its own. One implementation cannot drift from itself.
+//
+// # The ARIA contract is implemented, not merely claimed
+//
+// The field is a combobox controlling a listbox. That says: Down and Up
+// move through results, Enter opens the current one, Escape closes,
+// 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.
+//
+// (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
+
+export function createJumpTo(root) {
+ if (!root) return null
+
+ const input = root.querySelector('[docs-jumpto-input]')
+ const list = root.querySelector('[docs-jumpto-list]')
+ const status = root.querySelector('[docs-jumpto-status]')
+ const closeButton = root.querySelector('[docs-jumpto-close]')
+ if (!input || !list || !status) return null
+
+ // The copy is rendered into the page server-side (render.go's
+ // docs.jumpto.*) and read back from it here, so this bundle carries
+ // no strings of its own to keep in sync with the catalog by hand —
+ // the same rule the editor's save-status and the formatting bar
+ // follow.
+ const copy = {
+ empty: status.getAttribute('data-empty') || '',
+ failed: status.getAttribute('data-failed') || '',
+ }
+
+ let open = false
+ let results = []
+ let active = -1
+ let timer = null
+ let sequence = 0
+ // The element focus came from, so Escape can put it back. A palette
+ // that closes and drops focus on the body leaves a keyboard user at
+ // the top of the document with no idea where they were.
+ let returnFocusTo = null
+
+ function show() {
+ if (open) return
+ open = true
+ returnFocusTo = document.activeElement
+ root.hidden = false
+ 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()
+ }
+
+ function hide() {
+ if (!open) return
+ open = false
+ root.hidden = true
+ input.setAttribute('aria-expanded', 'false')
+ input.removeAttribute('aria-activedescendant')
+ clearTimeout(timer)
+ // Bump the sequence so a reply already in flight cannot render
+ // into a closed palette.
+ sequence++
+ if (returnFocusTo && document.contains(returnFocusTo)) returnFocusTo.focus()
+ returnFocusTo = null
+ }
+
+ function setStatus(text) {
+ status.textContent = text
+ status.hidden = text === ''
+ }
+
+ function render(rows) {
+ results = rows
+ active = rows.length > 0 ? 0 : -1
+ list.textContent = ''
+ for (let i = 0; i < rows.length; i++) {
+ const option = document.createElement('li')
+ option.id = `docs-jumpto-option-${i}`
+ option.setAttribute('role', 'option')
+ option.setAttribute('aria-selected', i === active ? 'true' : 'false')
+ option.dataset.href = rows[i].href
+
+ const title = document.createElement('span')
+ title.textContent = rows[i].title
+ const when = document.createElement('span')
+ when.setAttribute('docs-jumpto-when', '')
+ when.textContent = rows[i].when
+ option.append(title, when)
+
+ // mousedown rather than click, and prevented: the field must not
+ // lose focus before the navigation happens.
+ option.addEventListener('mousedown', (e) => {
+ e.preventDefault()
+ go(i)
+ })
+ list.append(option)
+ }
+ reflectActive()
+ }
+
+ function reflectActive() {
+ const options = list.children
+ for (let i = 0; i < options.length; i++) {
+ options[i].setAttribute('aria-selected', i === active ? 'true' : 'false')
+ }
+ if (active >= 0 && options[active]) {
+ input.setAttribute('aria-activedescendant', options[active].id)
+ // The active option has to be visible, not merely current: a
+ // list that scrolls and an aria-activedescendant that does not
+ // is a keyboard user reading nothing.
+ options[active].scrollIntoView({ block: 'nearest' })
+ } else {
+ input.removeAttribute('aria-activedescendant')
+ }
+ }
+
+ function move(delta) {
+ if (results.length === 0) return
+ active = (active + delta + results.length) % results.length
+ reflectActive()
+ }
+
+ function go(index) {
+ const row = results[index]
+ if (!row) return
+ window.location.href = row.href
+ }
+
+ async function search(query) {
+ const mine = ++sequence
+ if (query.trim() === '') {
+ render([])
+ setStatus('')
+ return
+ }
+ try {
+ const res = await fetch(`/documents/search?q=${encodeURIComponent(query)}`, {
+ credentials: 'same-origin',
+ })
+ if (!res.ok) throw new Error(`search: ${res.status}`)
+ const rows = await res.json()
+ // A reply that arrived after a newer keystroke, or after the
+ // palette closed, must not overwrite what is on screen.
+ if (mine !== sequence || !open) return
+ render(rows)
+ setStatus(rows.length === 0 ? copy.empty : '')
+ } catch (err) {
+ if (mine !== sequence || !open) return
+ // Said out loud rather than left as an empty list: "no results"
+ // and "the search did not happen" are different, and a person
+ // who cannot tell them apart concludes the document is gone.
+ render([])
+ setStatus(copy.failed)
+ console.error(err)
+ }
+ }
+
+ input.addEventListener('input', () => {
+ clearTimeout(timer)
+ const query = input.value
+ timer = setTimeout(() => search(query), DEBOUNCE_MS)
+ })
+
+ 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()
+ move(1)
+ break
+ case 'ArrowUp':
+ event.preventDefault()
+ move(-1)
+ break
+ case 'Home':
+ if (results.length) {
+ event.preventDefault()
+ active = 0
+ reflectActive()
+ }
+ break
+ case 'End':
+ if (results.length) {
+ event.preventDefault()
+ active = results.length - 1
+ reflectActive()
+ }
+ break
+ case 'Enter':
+ if (active >= 0) {
+ event.preventDefault()
+ go(active)
+ }
+ break
+ case 'Escape':
+ event.preventDefault()
+ hide()
+ break
+ case 'Tab':
+ // The palette is modal, and it holds exactly two focusable
+ // things: the field and its close button. Rather than a
+ // general focus trap — which is where a palette usually
+ // strands somebody — Tab simply closes it. Nothing behind the
+ // overlay is reachable while it is open, and Escape and Tab
+ // both lead out.
+ event.preventDefault()
+ hide()
+ break
+ default:
+ break
+ }
+ })
+
+ if (closeButton) {
+ closeButton.addEventListener('click', () => hide())
+ }
+
+ // A click on the backdrop closes it. Checked against the root
+ // itself so a click on the panel inside does not.
+ root.addEventListener('mousedown', (event) => {
+ if (event.target === root) hide()
+ })
+
+ addEventListener('keydown', (event) => {
+ if ((event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K')) {
+ event.preventDefault()
+ if (open) hide()
+ else show()
+ return
+ }
+ if (event.key === 'Escape' && open) {
+ event.preventDefault()
+ hide()
+ }
+ })
+
+ return { show, hide, isOpen: () => open }
+}
+
+const mount = document.querySelector('[docs-jumpto]')
+if (mount) {
+ try {
+ createJumpTo(mount)
+ } catch (err) {
+ // A palette that throws must not take the page with it: the search
+ // form in the list header is the real path to a document and does
+ // not depend on any of this.
+ console.error(err)
+ }
+}
diff --git a/fixtures/package.json b/fixtures/package.json
index b79121f..f72bdfd 100644
--- a/fixtures/package.json
+++ b/fixtures/package.json
@@ -2,7 +2,7 @@
"name": "docs-fixtures",
"private": true,
"type": "module",
- "description": "Generates Yjs wire fixtures (the JS side is the contract; Rust must reproduce these) and builds the vendored editor bundle (fixtures/build-editor.mjs) committed to internal/docs/static/.",
+ "description": "Generates Yjs wire fixtures (the JS side is the contract; Rust must reproduce these) and builds the vendored editor bundle (fixtures/build-bundles.mjs) committed to internal/docs/static/.",
"dependencies": {
"yjs": "13.6.27",
"lib0": "0.2.117",
diff --git a/internal/docs/app.go b/internal/docs/app.go
index dc1c4c7..757f415 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -443,6 +443,11 @@ func New(cfg Config) (*App, error) {
r.Use(authPlugin.RequireSession)
r.Get("/", h.list)
r.Post("/documents", h.create)
+ // Title search for the jump-to palette (search.go). Inside
+ // this group like every other document route, and owner-scoped
+ // through Store.Search → Store.List rather than through a
+ // check of its own — a person's document titles are theirs.
+ r.Get("/documents/search", h.searchJSON)
r.Get("/d/{sqid}", h.show)
// Task 5: the update log, what the browser's editor reads to
// build its Yjs doc (GET) and posts new edits back through
diff --git a/internal/docs/assets.go b/internal/docs/assets.go
index ee5a3eb..c85c0ec 100644
--- a/internal/docs/assets.go
+++ b/internal/docs/assets.go
@@ -1,6 +1,6 @@
// The editor bundle (spec §6.5: ProseMirror stays only because there
// is no build step) is a committed, prebuilt artifact — never built
-// by this app or by CI — produced by fixtures/build-editor.mjs and
+// by this app or by CI — produced by fixtures/build-bundles.mjs and
// committed under internal/docs/static/. This file embeds that tree
// and serves it, independent of everything render.go's
// vendoredStaticFS already vendors (rastrillo's own design-system
@@ -45,44 +45,79 @@ var brandFS embed.FS
//go:embed static
var staticFS embed.FS
-// editorBundleName is the filename (not path) of the single committed
-// editor bundle under static/ — e.g. "editor.f13f5e0ce6fa7467.js" —
+// bundleNames maps each committed bundle's name to its filename under
+// static/ — "editor" to "editor.f13f5e0ce6fa7467.js", and so on —
// resolved once, at package init, from staticFS.
//
-// Exactly one must ever be present. fixtures/build-editor.mjs's own
-// rmSync loop deletes every previous "editor.<hash>.js" before writing
-// the new one, so a rebuild that leaves two behind (a script edited
-// to skip that step, a hand-added file, a bad merge) is a build
-// mistake — this panics at init rather than silently picking one,
-// because a wrong bundle silently served is indistinguishable from a
-// correct one until it disagrees with the server's ABI at runtime.
-var editorBundleName = mustResolveEditorBundleName()
-
-func mustResolveEditorBundleName() string {
+// There are two. "editor" is ProseMirror and Yjs, about 300KB, and
+// only a document page needs it; "jumpto" is the keyboard palette
+// (fixtures/jump-to.mjs), a few KB, and every signed-in page needs it
+// including the list. Keeping them apart is the whole reason this is a
+// registry rather than one filename: opening the list should not
+// download an editor in order to offer a keyboard shortcut.
+//
+// Exactly one file must ever be present per name.
+// fixtures/build-bundles.mjs's own rmSync loop deletes every previous
+// "<name>.<hash>.js" before writing the new one, so a build that
+// leaves two behind (a script edited to skip that step, a hand-added
+// file, a bad merge) is a mistake — this panics at init rather than
+// silently picking one, because a wrong bundle silently served is
+// indistinguishable from a correct one until it disagrees with the
+// server at runtime.
+var bundleNames = mustResolveBundleNames()
+
+// bundleKinds is every bundle that must exist. Listed rather than
+// discovered from the directory: a build that produced no jump-to
+// bundle at all would otherwise start cleanly and simply never offer
+// the palette, which is the same silent-failure shape the
+// exactly-one rule above exists to prevent.
+var bundleKinds = []string{"editor", "jumpto"}
+
+func mustResolveBundleNames() map[string]string {
entries, err := fs.ReadDir(staticFS, "static")
if err != nil {
panic(fmt.Sprintf("docs: read embedded static/: %v", err))
}
- var found []string
- for _, e := range entries {
- if !e.IsDir() && strings.HasPrefix(e.Name(), "editor.") && strings.HasSuffix(e.Name(), ".js") {
- found = append(found, e.Name())
+ out := make(map[string]string, len(bundleKinds))
+ for _, kind := range bundleKinds {
+ var found []string
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") {
+ continue
+ }
+ if strings.HasPrefix(e.Name(), kind+".") {
+ found = append(found, e.Name())
+ }
}
+ if len(found) != 1 {
+ panic(fmt.Sprintf("docs: want exactly one %q bundle in static/, found %v — run fixtures/build-bundles.mjs and commit its output", kind, found))
+ }
+ out[kind] = found[0]
}
- if len(found) != 1 {
- panic(fmt.Sprintf("docs: want exactly one editor bundle in static/, found %v — run fixtures/build-editor.mjs and commit its output", found))
- }
- return found[0]
+ return out
}
-// editorScriptPath is the absolute URL path the committed editor
-// bundle is served at. Task 6's <script> tag in views/show.html uses
-// this rather than a literal, so it can never drift from what
-// staticHandler actually serves.
-func editorScriptPath() string {
- return "/static/" + editorBundleName
+// bundlePath is the absolute URL path a committed bundle is served at.
+// The templates' <script> tags use this rather than a literal, so they
+// can never drift from what staticHandler actually serves.
+func bundlePath(kind string) string {
+ name, ok := bundleNames[kind]
+ if !ok {
+ // Unreachable while every caller passes a member of
+ // bundleKinds, which init has already proved resolvable.
+ panic(fmt.Sprintf("docs: no committed bundle named %q", kind))
+ }
+ return "/static/" + name
}
+// editorScriptPath is the document editor's bundle — views/show.html's
+// <script> tag.
+func editorScriptPath() string { return bundlePath("editor") }
+
+// jumpToScriptPath is the keyboard palette's bundle (search.go,
+// 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
@@ -127,19 +162,18 @@ const editorBundleContentType = "text/javascript; charset=utf-8"
// serveEditorBundle writes the embedded bundle with a year-long,
// immutable Cache-Control. That is safe only because the filename
// carries the content hash: the URL changes whenever the bytes do
-// (fixtures/build-editor.mjs), so there is nothing for a stale
+// (fixtures/build-bundles.mjs), so there is nothing for a stale
// browser cache entry to collide with — see that script's own doc
// comment for the reasoning this depends on.
-func serveEditorBundle(w http.ResponseWriter, r *http.Request) {
- name := "static/" + editorBundleName
- b, err := fs.ReadFile(staticFS, name)
+func serveBundle(w http.ResponseWriter, r *http.Request, fileName string) {
+ b, err := fs.ReadFile(staticFS, "static/"+fileName)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", editorBundleContentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
- http.ServeContent(w, r, editorBundleName, time.Time{}, bytes.NewReader(b))
+ http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(b))
}
// staticHandler composes this app's own committed static assets (the
@@ -158,12 +192,22 @@ func serveEditorBundle(w http.ResponseWriter, r *http.Request) {
// shell, or the editor script itself, must not be redirected in a
// loop or 403 on essential assets.
func staticHandler(fallback http.Handler) http.Handler {
- editorPath := editorScriptPath()
+ // Resolved once, into a path→filename map, rather than compared
+ // against a growing list of paths on every request.
+ paths := make(map[string]string, len(bundleNames))
+ for _, name := range bundleNames {
+ paths["/static/"+name] = name
+ }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case editorPath:
- serveEditorBundle(w, r)
+ // 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
diff --git a/internal/docs/assets_test.go b/internal/docs/assets_test.go
index 55cfe0b..23d0036 100644
--- a/internal/docs/assets_test.go
+++ b/internal/docs/assets_test.go
@@ -15,37 +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(),
+ }
+ 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 len(found) != 1 {
- t.Fatalf("want exactly one editor bundle, got %v", found)
- }
- 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
+ // 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).
- if len(entries) != 1 {
- var names []string
- for _, e := range entries {
- names = append(names, e.Name())
+ //
+ // 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())
}
- t.Errorf("static/ holds %v; it must hold only the editor build's output — put hand-authored assets in brand/", names)
}
}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 11a95fe..dce9d6d 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -81,6 +81,31 @@ type listPage struct {
CreateLabel string
CreatePlaceholder string
SignOutLabel string
+
+ // Query is what was searched for, echoed back into the field so a
+ // result page is a page a person can edit and re-submit rather
+ // than one they have to retype into.
+ //
+ // Results is the ranked, ungrouped result list, used INSTEAD of
+ // Groups when Searching. Recency grouping is what an unfiltered
+ // list is for; grouping a ranked list under "Today" and "Earlier"
+ // would order the same rows twice, by two different rules, and
+ // show only the second.
+ Searching bool
+ Query string
+ Results []docRow
+ SearchLabel string
+ SearchPlace string
+ NoResultsHead string
+ NoResultsBody string
+ ClearLabel string
+
+ // JumpTo and JumpToScript back the keyboard palette
+ // (views/partials/jumpto.html), which every signed-in page
+ // carries so Control-K works from the list and from a document
+ // alike.
+ JumpTo jumpToView
+ JumpToScript string
}
// docGroup is one recency bucket ("Today", "This week", ...) and the
@@ -139,22 +164,60 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
return
}
- docs, err := h.store.List(r.Context(), owner)
- if err != nil {
- h.logger.Error("docs: list documents", "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
- return
- }
+ // The query is taken from the URL, so a search is a link: it can be
+ // bookmarked, reloaded, shared with yourself, and gone back to.
+ // That is also what makes the whole feature work with no
+ // JavaScript (spec §6.2) — the form is a GET and the page it lands
+ // on is an ordinary page.
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
data := listPage{
Title: rastrillo.T(r, "docs.list.title"),
- Groups: groupByRecency(r, time.Now().UTC(), docs),
EmptyTitle: rastrillo.T(r, "docs.list.empty.title"),
EmptyBody: rastrillo.T(r, "docs.list.empty.body"),
CreateLabel: rastrillo.T(r, "docs.list.create.label"),
CreatePlaceholder: rastrillo.T(r, "docs.list.create.placeholder"),
SignOutLabel: rastrillo.T(r, "docs.signout.label"),
+ Query: query,
+ SearchLabel: rastrillo.T(r, "docs.list.search.label"),
+ SearchPlace: rastrillo.T(r, "docs.list.search.placeholder"),
+ NoResultsHead: rastrillo.T(r, "docs.list.search.none.title"),
+ NoResultsBody: rastrillo.T(r, "docs.list.search.none.body"),
+ ClearLabel: rastrillo.T(r, "docs.list.search.clear"),
+ JumpTo: newJumpToView(r),
+ JumpToScript: jumpToScriptPath(),
+ }
+
+ if query != "" {
+ hits, err := h.store.Search(r.Context(), owner, query)
+ if err != nil {
+ h.logger.Error("docs: search documents", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ now := time.Now().UTC()
+ data.Searching = true
+ for _, d := range hits {
+ data.Results = append(data.Results, docRow{
+ Href: "/d/" + d.Sqid,
+ Title: d.Title,
+ When: formatWhen(now, d.UpdatedAt),
+ })
+ }
+ if err := h.render.page(w, r, "list", data); err != nil {
+ h.logger.Error("docs: render list", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ }
+ return
+ }
+
+ docs, err := h.store.List(r.Context(), owner)
+ if err != nil {
+ h.logger.Error("docs: list documents", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
}
+ data.Groups = groupByRecency(r, time.Now().UTC(), docs)
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
@@ -240,6 +303,11 @@ type showPage struct {
SaveRetrying string
SaveFailed string
+ // See listPage's own fields: the palette is on every signed-in
+ // page, not only the list.
+ JumpTo jumpToView
+ JumpToScript string
+
// Toolbar is the formatting bar's copy (export.go's
// newToolbarView) and FontFaces the @font-face rules for the core
// font set — both rendered into show.html rather than written into
@@ -300,6 +368,8 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
EditorScript: editorScriptPath(),
SaveRetrying: rastrillo.T(r, "docs.editor.status.retrying"),
SaveFailed: rastrillo.T(r, "docs.editor.status.failed"),
+ JumpTo: newJumpToView(r),
+ JumpToScript: jumpToScriptPath(),
Toolbar: newToolbarView(r),
FontFaces: fontFaces(),
diff --git a/internal/docs/render.go b/internal/docs/render.go
index fa92504..027195e 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -52,6 +52,37 @@ var Catalog = rastrillo.Catalog{
"docs.list.create.label": "New document",
"docs.list.create.placeholder": "Title",
+ // docs.list.search.* — finding a document by its title (spec
+ // §15.2). The field is a GET form, so a search is a link and works
+ // with no JavaScript; fixtures/jump-to.mjs adds the keyboard
+ // shortcut on top of it and takes its own copy from here rather
+ // than carrying any.
+ //
+ // "Search documents" rather than "Search": the field sits beside a
+ // create field on the same header, and a bare "Search" next to a
+ // bare "Title" says nothing about which list is being searched.
+ // The empty state names the query back, because "nothing found" and
+ // "nothing found FOR THIS" are different amounts of help when you
+ // have mistyped.
+ "docs.list.search.label": "Search documents",
+ "docs.list.search.placeholder": "Search",
+ "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.jumpto.* — the keyboard jump-to palette
+ // (fixtures/jump-to.mjs), opened with Control-K or Command-K from
+ // the list or from a document. Every string is rendered into the
+ // page server-side, the same rule the editor's save-status copy
+ // and the formatting bar follow: the bundle carries no copy of its
+ // own to keep in sync with this file by hand.
+ "docs.jumpto.label": "Jump to a document",
+ "docs.jumpto.placeholder": "Search documents",
+ "docs.jumpto.hint": "Type to search. Up and down to choose, Enter to open, Escape to close.",
+ "docs.jumpto.empty": "No documents match",
+ "docs.jumpto.failed": "Search is unavailable right now.",
+ "docs.jumpto.close": "Close",
+
// docs.error.generic is the one fallback error string this task
// has: a render failure with nowhere styled to land yet (SKILL.md
// §7's view.Fail/ErrorPage wiring is later work). Still a catalog
@@ -208,7 +239,7 @@ const DefaultLocale = "en"
// string (as Task 1's home page was): a page with a group loop and a
// style block reads better as markup than as a quoted constant.
//
-//go:embed views/*.html
+//go:embed views/*.html views/partials/*.html
var viewsFS embed.FS
// headTemplate is the app-wide "head" definition newRenderer parses
@@ -277,6 +308,16 @@ func newRenderer() (*renderer, error) {
if err != nil {
return nil, fmt.Errorf("docs: clone base template for %q: %w", name, err)
}
+ // This app's own shared partials (views/partials/*.html —
+ // today, the jump-to palette, which every signed-in page
+ // carries) are parsed into every page alongside it, the same
+ // way rastrillo's own partials are parsed into the base
+ // above. Per page rather than once into the base because the
+ // base is cloned, and a clone made before the partials were
+ // parsed would not have them.
+ if page, err = page.ParseFS(viewsFS, "views/partials/*.html"); err != nil {
+ return nil, fmt.Errorf("docs: parse shared partials for %q: %w", name, err)
+ }
if page, err = page.ParseFS(viewsFS, path); err != nil {
return nil, fmt.Errorf("docs: parse page %q: %w", name, err)
}
diff --git a/internal/docs/search.go b/internal/docs/search.go
new file mode 100644
index 0000000..b835b54
--- /dev/null
+++ b/internal/docs/search.go
@@ -0,0 +1,225 @@
+package docs
+
+// Finding a document by its title (spec §15.2's "title and metadata
+// search ships"). Full-text search is still unbuilt and still needs a
+// design compatible with §12's plaintext-grep gate; nothing here reads
+// a document's content.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "sort"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+// maxSearchResults caps what one query returns.
+//
+// The list this filters is one person's own documents, so there is no
+// unbounded corpus behind it — the cap is about the response and the
+// palette's own rendering, not about protecting the query. A person
+// with four hundred documents matching "notes" is not helped by four
+// hundred rows, and the ones past the first few dozen are ranked below
+// everything they were more likely to mean.
+const maxSearchResults = 50
+
+// searchRank is how well a title matches a query. Lower is better, and
+// the values are ordered rather than scored: the point is that somebody
+// can look at a result order and say why it is that order, which a
+// tuned numeric score does not give you.
+type searchRank int
+
+const (
+ rankExact searchRank = iota // the title IS the query
+ rankPrefix // the title starts with it
+ rankWordPrefix // some word in the title starts with it
+ rankContains // it appears somewhere
+ rankNone // it does not appear
+)
+
+// rankTitle scores one title against an already-normalised query.
+func rankTitle(title, query string) searchRank {
+ t := normaliseForSearch(title)
+ switch {
+ case t == query:
+ return rankExact
+ case strings.HasPrefix(t, query):
+ return rankPrefix
+ }
+ if !strings.Contains(t, query) {
+ return rankNone
+ }
+ // A word boundary is worth distinguishing from a match inside a
+ // word: someone typing "min" means "Minutes", not "Determining".
+ for _, word := range strings.Fields(t) {
+ if strings.HasPrefix(word, query) {
+ return rankWordPrefix
+ }
+ }
+ return rankContains
+}
+
+// normaliseForSearch lowercases and collapses whitespace, and strips
+// the punctuation that varies between how a title was typed and how it
+// is remembered — an em dash for a hyphen, a curly apostrophe for a
+// straight one. Deliberately NOT a full Unicode fold: "café" and "cafe"
+// stay different, because guessing at accent equivalence across twelve
+// locales (render.go's Locales) is a design and not a detail.
+func normaliseForSearch(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ lastSpace := true
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case unicode.IsSpace(r) || r == '—' || r == '–' || r == '-' || r == '_':
+ if !lastSpace {
+ b.WriteRune(' ')
+ lastSpace = true
+ }
+ case r == '\'' || r == '’':
+ // Dropped rather than spaced: "don't" and "don t" should
+ // not be different words.
+ default:
+ b.WriteRune(r)
+ lastSpace = false
+ }
+ }
+ return strings.TrimSpace(b.String())
+}
+
+// Search returns owner's documents whose titles match query, best
+// first, capped at maxSearchResults. An empty or whitespace-only query
+// returns nil rather than everything — "no query" is not "match
+// nothing", and the caller decides what to show for it.
+//
+// The filtering happens here, in Go, over the rows List already loads,
+// rather than in SQL. See the plan's D1: document titles are due to be
+// sealed (spec §2), at which point a `WHERE title LIKE ?` stops
+// matching anything and does so silently, while a pass over one
+// person's decrypted titles keeps working unchanged. It also means the
+// owner scope is the one List already applies, rather than a second
+// WHERE clause somebody has to remember to write.
+func (s *Store) Search(ctx context.Context, owner int64, query string) ([]*Document, error) {
+ q := normaliseForSearch(query)
+ if q == "" {
+ return nil, nil
+ }
+
+ docs, err := s.List(ctx, owner)
+ if err != nil {
+ return nil, err
+ }
+
+ type scored struct {
+ doc *Document
+ rank searchRank
+ }
+ var hits []scored
+ for _, d := range docs {
+ if r := rankTitle(d.Title, q); r != rankNone {
+ hits = append(hits, scored{doc: d, rank: r})
+ }
+ }
+
+ // SliceStable, and List already returns newest-first: that makes
+ // "most recently updated" the tie-break without re-reading a clock
+ // or comparing timestamps here.
+ sort.SliceStable(hits, func(i, j int) bool { return hits[i].rank < hits[j].rank })
+
+ if len(hits) > maxSearchResults {
+ hits = hits[:maxSearchResults]
+ }
+ out := make([]*Document, 0, len(hits))
+ for _, h := range hits {
+ out = append(out, h.doc)
+ }
+ return out, nil
+}
+
+// searchResult is one row of the JSON the palette reads.
+type searchResult struct {
+ Title string `json:"title"`
+ Href string `json:"href"`
+ When string `json:"when"`
+}
+
+// searchJSON serves GET /documents/search?q=… for fixtures/jump-to.mjs.
+//
+// The palette queries this rather than filtering a downloaded list of
+// every title, so there is exactly one ranking implementation and it
+// cannot drift from the one the list page uses (the plan's D3). It also
+// means no bulk export of a person's titles to the browser.
+//
+// Inside app.go's RequireSession group and owner-scoped through
+// Search → List, so a document belonging to someone else cannot appear
+// here — the same property every other document route has, by the same
+// mechanism rather than by a check of its own.
+func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) {
+ owner, err := h.owner(r)
+ if err != nil {
+ h.logger.Error("docs: resolve signed-in person", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ docs, err := h.store.Search(r.Context(), owner, r.URL.Query().Get("q"))
+ if err != nil {
+ h.logger.Error("docs: search documents", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ now := time.Now().UTC()
+ results := make([]searchResult, 0, len(docs))
+ for _, d := range docs {
+ results = append(results, searchResult{
+ Title: d.Title,
+ Href: "/d/" + d.Sqid,
+ When: formatWhen(now, d.UpdatedAt),
+ })
+ }
+
+ // Marshal before writing anything: a mid-encode failure must not
+ // leave a half-written 200 on the wire, the same reason
+ // renderer.page renders into a buffer first.
+ body, err := json.Marshal(results)
+ if err != nil {
+ h.logger.Error("docs: encode search results", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ // A person's own document titles, and they change as they type.
+ w.Header().Set("Cache-Control", "no-store")
+ if _, err := w.Write(body); err != nil {
+ h.logger.Error("docs: write search results", "err", err)
+ }
+}
+
+// jumpToView is the palette's copy, resolved from the catalog per
+// request. Both the list and a document carry it, so it lives here
+// with the rest of search rather than on either page's own view data.
+type jumpToView struct {
+ Label string
+ Placeholder string
+ Hint string
+ Empty string
+ Failed string
+ Close string
+}
+
+func newJumpToView(r *http.Request) jumpToView {
+ return jumpToView{
+ Label: rastrillo.T(r, "docs.jumpto.label"),
+ Placeholder: rastrillo.T(r, "docs.jumpto.placeholder"),
+ Hint: rastrillo.T(r, "docs.jumpto.hint"),
+ Empty: rastrillo.T(r, "docs.jumpto.empty"),
+ Failed: rastrillo.T(r, "docs.jumpto.failed"),
+ Close: rastrillo.T(r, "docs.jumpto.close"),
+ }
+}
diff --git a/internal/docs/search_test.go b/internal/docs/search_test.go
new file mode 100644
index 0000000..533baba
--- /dev/null
+++ b/internal/docs/search_test.go
@@ -0,0 +1,324 @@
+package docs
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// seedTitles creates documents with the given titles, in order, and
+// returns the owner. Created in order so the LAST one is the most
+// recently updated — which is what the rank tie-break relies on.
+func seedTitles(t *testing.T, app *App, address string, titles ...string) int64 {
+ t.Helper()
+ ctx := context.Background()
+ owner := app.TestPerson(t, address)
+ for _, title := range titles {
+ if _, err := app.Store().Create(ctx, owner, title); err != nil {
+ t.Fatalf("Create(%q): %v", title, err)
+ }
+ }
+ return owner
+}
+
+func titlesOf(docs []*Document) []string {
+ out := make([]string, 0, len(docs))
+ for _, d := range docs {
+ out = append(out, d.Title)
+ }
+ return out
+}
+
+// The order is the feature. Somebody should be able to look at a result
+// list and say why it is in that order, which is why the ranks are
+// ordered rather than scored.
+func TestSearchRanksExactThenPrefixThenWordThenAnywhere(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "writer@example.com",
+ "Determining the notes", // "notes" inside a word boundary, but late
+ "Notes on the waterworks", // starts with it
+ "Meeting notes", // word starts with it
+ "Notes", // exact
+ )
+
+ got, err := app.Store().Search(context.Background(), app.TestPerson(t, "writer@example.com"), "notes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "Notes",
+ "Notes on the waterworks",
+ "Determining the notes",
+ "Meeting notes",
+ }
+ // "Determining the notes" and "Meeting notes" are both word-prefix
+ // matches, so they tie and fall back to most-recently-updated.
+ // Created earlier means updated earlier, so "Meeting notes" (made
+ // third) sorts above "Determining the notes" (made first).
+ want = []string{"Notes", "Notes on the waterworks", "Meeting notes", "Determining the notes"}
+ if strings.Join(titlesOf(got), " | ") != strings.Join(want, " | ") {
+ t.Errorf("ranking\n got: %v\nwant: %v", titlesOf(got), want)
+ }
+}
+
+func TestSearchIsCaseAndPunctuationInsensitive(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ owner := seedTitles(t, app, "writer@example.com", "Packing List — Thursday Site Visit")
+
+ for _, query := range []string{
+ "packing",
+ "PACKING",
+ "packing list",
+ "packing list thursday", // the em dash reads as a space
+ "packing list - thursday", // and so does a hyphen
+ " packing list ", // and runs of whitespace collapse
+ } {
+ got, err := app.Store().Search(context.Background(), owner, query)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Errorf("query %q matched %d documents, want 1", query, len(got))
+ }
+ }
+}
+
+// An accent is not silently equated with its unaccented form: doing
+// that well across twelve locales is a design, and doing it badly makes
+// searches match things a person did not ask for. Stated as a test so
+// the behaviour is a decision rather than an accident.
+func TestSearchDoesNotFoldAccents(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ owner := seedTitles(t, app, "writer@example.com", "Café notes")
+
+ if got, _ := app.Store().Search(context.Background(), owner, "café"); len(got) != 1 {
+ t.Errorf(`"café" matched %d, want 1`, len(got))
+ }
+ if got, _ := app.Store().Search(context.Background(), owner, "cafe"); len(got) != 0 {
+ t.Errorf(`"cafe" matched %d, want 0 — accent folding is deliberately not done`, len(got))
+ }
+}
+
+// "No query" is not "match nothing", and it is not "match everything"
+// either. The caller decides what an empty query shows; Search says
+// nothing.
+func TestSearchOfAnEmptyQueryReturnsNothing(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ owner := seedTitles(t, app, "writer@example.com", "One", "Two")
+ for _, query := range []string{"", " ", "\t"} {
+ got, err := app.Store().Search(context.Background(), owner, query)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Errorf("query %q returned %d documents, want 0", query, len(got))
+ }
+ }
+}
+
+// The scoping property every document route has, held against the one
+// route that exists to return a LIST of titles — the place where a
+// missing scope leaks the most at once.
+func TestSearchNeverCrossesOwners(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "owner@example.com", "Secret plans")
+ stranger := seedTitles(t, app, "stranger@example.com", "My own notes")
+
+ got, err := app.Store().Search(context.Background(), stranger, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("a stranger found %v", titlesOf(got))
+ }
+
+ // And through the HTTP route, which is what is actually reachable.
+ rec := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/documents/search?q=secret", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got %d", rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), "Secret plans") {
+ t.Errorf("the search endpoint leaked another person's title: %s", rec.Body.String())
+ }
+
+ // The list page's own search path, too — it is a different handler
+ // reaching the same store method, and "the store is scoped" is not
+ // the same claim as "every caller passes the right owner".
+ page := serve(app, app.TestRequest(t, "stranger@example.com", http.MethodGet, "/?q=secret", nil))
+ if strings.Contains(page.Body.String(), "Secret plans") {
+ t.Errorf("the list page leaked another person's title")
+ }
+}
+
+func TestSearchEndpointReturnsJSON(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list")
+
+ rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=meeting", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
+ t.Errorf("Content-Type is %q", ct)
+ }
+ if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
+ t.Errorf("a person's own titles should not be cached: Cache-Control is %q", cc)
+ }
+
+ var results []searchResult
+ if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil {
+ t.Fatalf("the body is not the JSON the palette expects: %v", err)
+ }
+ if len(results) != 1 || results[0].Title != "Meeting notes" {
+ t.Fatalf("got %+v", results)
+ }
+ if !strings.HasPrefix(results[0].Href, "/d/") || results[0].When == "" {
+ t.Errorf("a result needs a link and a date: %+v", results[0])
+ }
+
+ // An empty query is an empty array, not null — the palette
+ // iterates the result without checking, and `null` would be a
+ // TypeError in the browser rather than an empty list.
+ empty := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/documents/search?q=", nil))
+ if strings.TrimSpace(empty.Body.String()) != "[]" {
+ t.Errorf("an empty query returned %q, want []", empty.Body.String())
+ }
+}
+
+func TestSearchEndpointRequiresASession(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "writer@example.com", "Meeting notes")
+
+ req, _ := http.NewRequest(http.MethodGet, "/documents/search?q=meeting", nil)
+ rec := serve(app, req)
+ if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "Meeting notes") {
+ t.Error("the search endpoint served titles to a signed-out request")
+ }
+}
+
+// The whole feature has to work with no JavaScript (spec §6.2): the
+// field is a GET form, so a search is an ordinary page at an ordinary
+// URL.
+func TestSearchWorksAsAPlainPageRequest(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "writer@example.com", "Meeting notes", "Packing list", "Waterworks history")
+
+ rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=packing", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Packing list") {
+ t.Error("the matching document is not on the page")
+ }
+ if strings.Contains(body, "Waterworks history") {
+ t.Error("a non-matching document is on the page")
+ }
+ // The query is echoed back, so the page can be edited and
+ // re-submitted rather than retyped.
+ if !strings.Contains(body, `value="packing"`) {
+ t.Error("the search field does not hold the query that produced the page")
+ }
+ // A search result list is ranked, so it must not also be grouped by
+ // recency — that would order the same rows twice by two rules.
+ //
+ // The RENDERED element, not the bare attribute name: list.html's
+ // own stylesheet contains "[docs-group-label]", so a substring
+ // check for the attribute matches every page whether or not a
+ // single group was rendered. That version of this check passed on
+ // a page with no groups on it at all.
+ if strings.Contains(body, "<h2 docs-group-label>") {
+ t.Error("search results are grouped by recency as well as ranked")
+ }
+}
+
+func TestSearchWithNoMatchesIsADesignedScreen(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "writer@example.com", "Meeting notes")
+
+ rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet, "/?q=zzzznothing", nil))
+ body := rec.Body.String()
+
+ // Asserted on the rendered structure, not on the English. A direct
+ // App.Handler() call installs no locale middleware, so rastrillo.T
+ // returns each key verbatim (render.go's own note on Locales) —
+ // comparing against Catalog's English fails here for a reason that
+ // has nothing to do with whether the screen is right.
+ if !strings.Contains(body, "rst-empty-title") {
+ t.Error("no empty state for a search that found nothing")
+ }
+ if strings.Contains(body, "<div rst-row>") {
+ t.Error("a search that matched nothing still rendered rows")
+ }
+ // And a way back out that does not require knowing the URL.
+ if !strings.Contains(body, "docs-search-clear") {
+ t.Error("no way to clear the search")
+ }
+}
+
+// A title is arbitrary text somebody typed, and it is rendered back
+// into an HTML attribute on the results page.
+func TestSearchQueryIsEscapedInTheField(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ seedTitles(t, app, "writer@example.com", "Meeting notes")
+
+ rec := serve(app, app.TestRequest(t, "writer@example.com", http.MethodGet,
+ `/?q=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E`, nil))
+ body := rec.Body.String()
+ if strings.Contains(body, "<script>alert(1)</script>") {
+ t.Error("the query was written into the page unescaped")
+ }
+}
+
+func TestSearchCapsItsResults(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+ owner := app.TestPerson(t, "writer@example.com")
+ for i := 0; i < maxSearchResults+10; i++ {
+ if _, err := app.Store().Create(ctx, owner, "Notes"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := app.Store().Search(ctx, owner, "notes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != maxSearchResults {
+ t.Errorf("got %d results, want the cap of %d", len(got), maxSearchResults)
+ }
+}
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/views/list.html b/internal/docs/views/list.html
index b7fea4c..e755010 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -147,12 +147,62 @@
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
+ controls, and with wrapping allowed the search button dropped onto
+ a line of its own under its own field while the create form stayed
+ whole — which reads as two rows of unrelated chrome rather than as
+ one field and its button. The whole group still wraps at
+ [docs-header-actions] above, so a 320px viewport (§10.3's reflow
+ width) puts each form on its own line intact rather than breaking
+ one of them in half. */
+ [docs-search-form] {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ }
+ [docs-search-form] [rst-input] {
+ inline-size: 11rem;
+ max-inline-size: 100%;
+ }
+ /* The submit button is for a pointer and for a screen reader; a
+ keyboard user submits with Enter from the field, which a
+ single-input form does natively. It stays visible rather than
+ becoming an icon because "Search documents" is also the field's
+ accessible name and the two agreeing is worth more than the
+ width. */
+ [docs-search-form] button {
+ white-space: nowrap;
+ }
+ /* The way back out of a result page, under the results rather than
+ in the header: it is about the list being shown, not about the
+ controls that produced it. */
+ [docs-search-clear] {
+ margin-block-start: var(--rst-sp-3);
+ font-size: var(--rst-fs-sm);
+ }
</style>
<header rst-page-header>
<div rst-page-header-titles>
<h1>{{.Title}}</h1>
</div>
<div docs-header-actions>
+ {{/* Search (spec §15.2's title search). A GET form, so a search
+ is a link — bookmarkable, reloadable, and reachable with the
+ back button — and so the whole feature works with no
+ JavaScript at all. fixtures/jump-to.mjs adds a keyboard
+ shortcut on top of this; it does not replace it.
+
+ type="search" rather than type="text": it gets the platform's
+ own clear control and its own keyboard on a phone. The value
+ is echoed back so a result page can be edited and
+ re-submitted rather than retyped. */}}
+ <form docs-search-form method="get" action="/" role="search">
+ <input rst-input docs-search-input type="search" name="q" value="{{.Query}}"
+ placeholder="{{.SearchPlace}}" aria-label="{{.SearchLabel}}" autocomplete="off">
+ <button rst-btn="ghost" type="submit">{{.SearchLabel}}</button>
+ </form>
<form docs-create-form method="post" action="/documents">
<input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off">
<button rst-btn="primary" type="submit">{{.CreateLabel}}</button>
@@ -162,7 +212,29 @@
</form>
</div>
</header>
-{{if .Groups}}
+{{/* Searching renders a ranked, UNGROUPED result list. Recency
+ grouping is what an unfiltered list is for; grouping ranked
+ results under "Today" and "Earlier" would order the same rows
+ twice by two different rules and show only the second. */}}
+{{if .Searching}}
+{{if .Results}}
+<div rst-list>
+ {{range .Results}}
+ <div rst-row>
+ <span docs-thumb aria-hidden="true"></span>
+ <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
+ <span docs-row-when>{{.When}}</span>
+ </div>
+ {{end}}
+</div>
+{{else}}
+<div rst-empty>
+ <h2 rst-empty-title>{{.NoResultsHead}}</h2>
+ <p rst-empty-body>{{.NoResultsBody}}</p>
+</div>
+{{end}}
+<p docs-search-clear><a href="/">{{.ClearLabel}}</a></p>
+{{else if .Groups}}
<div rst-list>
{{range .Groups}}
<h2 docs-group-label>{{.Label}}</h2>
@@ -181,4 +253,5 @@
<p rst-empty-body>{{.EmptyBody}}</p>
</div>
{{end}}
+{{template "docs-jumpto" .}}
{{end}}
diff --git a/internal/docs/views/partials/jumpto.html b/internal/docs/views/partials/jumpto.html
new file mode 100644
index 0000000..a1f9f6e
--- /dev/null
+++ b/internal/docs/views/partials/jumpto.html
@@ -0,0 +1,118 @@
+{{/* docs-jumpto — the keyboard jump-to palette's markup and styles,
+ shared by the list and a document because Control-K should work
+ from both. Rendered on every signed-in page; hidden until opened.
+
+ Every string here is resolved from the catalog server-side
+ (render.go's docs.jumpto.*) and read back out of the DOM by
+ fixtures/jump-to.mjs — the bundle carries no copy of its own to
+ keep in sync by hand, the same rule the editor's save-status
+ indicator and the formatting bar already follow.
+
+ The field is a combobox controlling a listbox, and the module
+ 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, 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
+ without the stylesheet having loaded. */}}
+{{define "docs-jumpto"}}<style>
+ [docs-jumpto] {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding-block-start: 12vh;
+ padding-inline: var(--rst-sp-4);
+ background: var(--rst-overlay);
+ }
+ [docs-jumpto][hidden] {
+ display: none;
+ }
+ [docs-jumpto-panel] {
+ inline-size: min(100%, 34rem);
+ max-block-size: 70vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-radius: var(--rst-radius);
+ background: var(--rst-surface);
+ box-shadow: var(--rst-shadow-pop);
+ }
+ [docs-jumpto-head] {
+ display: flex;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ padding: var(--rst-sp-3);
+ border-block-end: 1px solid var(--rst-line);
+ }
+ [docs-jumpto-input] {
+ flex: 1;
+ min-block-size: 32px;
+ padding: 0 var(--rst-sp-2);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm);
+ background: var(--rst-bg);
+ color: var(--rst-text);
+ font: inherit;
+ }
+ [docs-jumpto-list] {
+ margin: 0;
+ padding: var(--rst-sp-1);
+ overflow-y: auto;
+ list-style: none;
+ }
+ [docs-jumpto-list] li {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--rst-sp-3);
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+ border-radius: var(--rst-radius-sm);
+ cursor: pointer;
+ }
+ /* aria-selected is the state, and background alone would be colour
+ carrying meaning on its own (§10.3) — the weight changes too. */
+ [docs-jumpto-list] li[aria-selected="true"] {
+ background: var(--rst-accent-soft);
+ color: var(--rst-accent);
+ font-weight: 600;
+ }
+ [docs-jumpto-when] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ font-weight: 400;
+ white-space: nowrap;
+ }
+ [docs-jumpto-status] {
+ padding: var(--rst-sp-3);
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ }
+ [docs-jumpto-hint] {
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+ border-block-start: 1px solid var(--rst-line);
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+ }
+</style>
+<div docs-jumpto role="dialog" aria-modal="true" aria-label="{{.JumpTo.Label}}" hidden>
+ <div docs-jumpto-panel>
+ <div docs-jumpto-head>
+ <input docs-jumpto-input type="text" role="combobox" aria-expanded="false"
+ aria-controls="docs-jumpto-list" aria-autocomplete="list" autocomplete="off"
+ placeholder="{{.JumpTo.Placeholder}}" aria-label="{{.JumpTo.Placeholder}}">
+ <button docs-jumpto-close rst-btn="ghost" type="button">{{.JumpTo.Close}}</button>
+ </div>
+ <ul docs-jumpto-list id="docs-jumpto-list" role="listbox" aria-label="{{.JumpTo.Label}}"></ul>
+ {{/* role="status" so a result count or a failure is announced
+ without stealing focus from the field being typed into. */}}
+ <p docs-jumpto-status role="status" aria-live="polite" hidden
+ data-empty="{{.JumpTo.Empty}}" data-failed="{{.JumpTo.Failed}}"></p>
+ <p docs-jumpto-hint>{{.JumpTo.Hint}}</p>
+ </div>
+</div>
+<script type="module" defer src="{{.JumpToScript}}"></script>{{end}}
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index 085e1c3..cf34fbb 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -572,4 +572,5 @@
marks [docs-editor] data-editor-failed rather than clearing it — the
read view above stays exactly as rendered. */}}
<script type="module" defer src="{{.EditorScript}}"></script>
+{{template "docs-jumpto" .}}
{{end}}