A keyboard jump-to, in its own bundle
Control-K (Command-K on a Mac) from the list or from inside a document: type, and go straight to another one. Paul asked for this alongside the search box. It is an addition, never the way in. The list header's search form works with no JavaScript at all (previous commit, spec §6.2); this makes finding a document faster, not possible. If the bundle fails to load or throws, the app loses a shortcut and keeps search. It does not rank. Every query goes to GET /documents/search — the same Store.Search the list page renders through — debounced. Filtering a downloaded list of titles in the browser would mean two rankers, and two rankers that disagree put the same document in different places while each looks correct alone. One implementation cannot drift from itself. It also means no bulk export of a person's titles to the browser. Its own bundle, which is why assets.go grew a registry. The editor is ProseMirror and Yjs at 307KB and only a document needs it; the palette is 2.8KB and every signed-in page needs it. Bundling them together would make opening the list download an editor to offer a keyboard shortcut. "Exactly one committed bundle" becomes "exactly one per name", and the names are listed rather than discovered — a build that silently stopped emitting the palette would otherwise start cleanly and simply never offer it, which is the same silent failure the original rule existed to prevent. The ARIA contract is implemented rather than claimed. The field is a combobox controlling a listbox, which promises Down/Up to move, Enter to open, Escape to close, and aria-activedescendant to name the current option — all of it is here, including returning focus to wherever it came from on close, because a palette that closes and drops focus on the body leaves a keyboard user with no idea where they were. A role written without its behaviour is worse than no role; that is on this repo's record from a day ago, and this is the first thing written after it. Tab closes the palette rather than cycling within it. The panel holds two focusable things, and a general focus trap is where a palette usually strands somebody; Escape and Tab both lead out, and nothing behind the overlay is reachable while it is open. Verified in a real browser — 17 checks, including that Control-K opens it from both pages, that the arrow keys move the active option, that Escape returns focus, that a query matching nothing says so rather than showing an empty box, and that Enter navigates. Those checks are a scratch driver, deliberately not committed: 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. Filed as a task instead. The search field also stopped the header wrapping badly: with five controls on one row the submit 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 a field and its button. The search form no longer wraps internally; the group still wraps, so 320px puts each form on its own line intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
14 files changed,
+630
−94
.amadan/ci.d/10-gate+3 −3fixtures/build-bundles.mjs+74 −0fixtures/build-editor.mjs+0 −54fixtures/editor-entry.js+1 −1fixtures/jump-to.mjs+273 −0fixtures/package.json+1 −1internal/docs/assets.go+73 −34internal/docs/handlers.go+16 −0internal/docs/render.go+11 −1internal/docs/search.go+23 −0internal/docs/static/jumpto.bd5dba33e0e78f44.js+1 −0internal/docs/views/list.html+36 −0internal/docs/views/partials/jumpto.html+117 −0internal/docs/views/show.html+1 −0
CI passed — run details
Intent
Attributed to this commit: 548 g CO₂e (71341731 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on search, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index 88dffaa..d1c5185 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -45,7 +45,7 @@ echo "=== fixtures: the save queue's retry/drop logic (F1 regression)" |
| echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regression)" |
| (cd fixtures && node --test resync-controller.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 |
| @@ -56,9 +56,9 @@ echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regressi |
| # "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/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 4849618..9b18c76 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..1c5c6a0 |
| --- /dev/null |
| +++ b/fixtures/jump-to.mjs |
| @@ -0,0 +1,273 @@ |
| +// 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. (AGENTS.md's |
| +// accessibility conventions, learned on the formatting bar a day |
| +// earlier.) |
| + |
| +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([]) |
| + 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) => { |
| + 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/assets.go b/internal/docs/assets.go| index 057991b..05c9f61 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 |
| @@ -27,44 +27,79 @@ import ( |
| //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() |
| +// 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 mustResolveEditorBundleName() string { |
| +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") } |
| + |
| // editorBundleContentType is set explicitly (http.ServeContent would |
| // otherwise guess from the ".js" extension via the OS mime registry, |
| // which on some platforms maps it to "application/javascript" and on |
| @@ -75,19 +110,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 |
| @@ -106,10 +140,15 @@ 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) { |
| - if r.URL.Path == editorPath { |
| - serveEditorBundle(w, r) |
| + if name, ok := paths[r.URL.Path]; ok { |
| + serveBundle(w, r, name) |
| return |
| } |
| fallback.ServeHTTP(w, r) |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index b742489..4ad2d86 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -82,6 +82,13 @@ type listPage struct { |
| 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 |
| @@ -160,6 +167,8 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| 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 != "" { |
| @@ -258,6 +267,11 @@ type showPage struct { |
| // sync with the catalog by hand. |
| 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 |
| } |
| // show serves GET /d/{sqid}: the document read-only, its title and |
| @@ -305,6 +319,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(), |
| } |
| if err := h.render.page(w, r, "show", data); err != nil { |
| h.logger.Error("docs: render show", "err", err) |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 31906a6..1611514 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -187,7 +187,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 |
| // pageTemplates maps a page name to its file under views/. |
| @@ -239,6 +239,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| index 2f5bbf6..b835b54 100644 |
| --- a/internal/docs/search.go |
| +++ b/internal/docs/search.go |
| @@ -200,3 +200,26 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) { |
| 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/static/jumpto.bd5dba33e0e78f44.js b/internal/docs/static/jumpto.bd5dba33e0e78f44.js| new file mode 100644 |
| index 0000000..f47b7db |
| --- /dev/null |
| +++ b/internal/docs/static/jumpto.bd5dba33e0e78f44.js |
| @@ -0,0 +1 @@ |
| +function q(a){if(!a)return null;let i=a.querySelector("[docs-jumpto-input]"),f=a.querySelector("[docs-jumpto-list]"),u=a.querySelector("[docs-jumpto-status]"),k=a.querySelector("[docs-jumpto-close]");if(!i||!f||!u)return null;let w={empty:u.getAttribute("data-empty")||"",failed:u.getAttribute("data-failed")||""},c=!1,o=[],n=-1,b=null,p=0,l=null;function g(){c||(c=!0,l=document.activeElement,a.hidden=!1,i.setAttribute("aria-expanded","true"),i.value="",m([]),i.focus())}function s(){c&&(c=!1,a.hidden=!0,i.setAttribute("aria-expanded","false"),i.removeAttribute("aria-activedescendant"),clearTimeout(b),p++,l&&document.contains(l)&&l.focus(),l=null)}function y(e){u.textContent=e,u.hidden=e===""}function m(e){o=e,n=e.length>0?0:-1,f.textContent="";for(let t=0;t<e.length;t++){let r=document.createElement("li");r.id=`docs-jumpto-option-${t}`,r.setAttribute("role","option"),r.setAttribute("aria-selected",t===n?"true":"false"),r.dataset.href=e[t].href;let d=document.createElement("span");d.textContent=e[t].title;let E=document.createElement("span");E.setAttribute("docs-jumpto-when",""),E.textContent=e[t].when,r.append(d,E),r.addEventListener("mousedown",S=>{S.preventDefault(),A(t)}),f.append(r)}h()}function h(){let e=f.children;for(let t=0;t<e.length;t++)e[t].setAttribute("aria-selected",t===n?"true":"false");n>=0&&e[n]?(i.setAttribute("aria-activedescendant",e[n].id),e[n].scrollIntoView({block:"nearest"})):i.removeAttribute("aria-activedescendant")}function v(e){o.length!==0&&(n=(n+e+o.length)%o.length,h())}function A(e){let t=o[e];t&&(window.location.href=t.href)}async function j(e){let t=++p;if(e.trim()===""){m([]),y("");return}try{let r=await fetch(`/documents/search?q=${encodeURIComponent(e)}`,{credentials:"same-origin"});if(!r.ok)throw new Error(`search: ${r.status}`);let d=await r.json();if(t!==p||!c)return;m(d),y(d.length===0?w.empty:"")}catch(r){if(t!==p||!c)return;m([]),y(w.failed),console.error(r)}}return i.addEventListener("input",()=>{clearTimeout(b);let e=i.value;b=setTimeout(()=>j(e),120)}),i.addEventListener("keydown",e=>{switch(e.key){case"ArrowDown":e.preventDefault(),v(1);break;case"ArrowUp":e.preventDefault(),v(-1);break;case"Home":o.length&&(e.preventDefault(),n=0,h());break;case"End":o.length&&(e.preventDefault(),n=o.length-1,h());break;case"Enter":n>=0&&(e.preventDefault(),A(n));break;case"Escape":e.preventDefault(),s();break;case"Tab":e.preventDefault(),s();break;default:break}}),k&&k.addEventListener("click",()=>s()),a.addEventListener("mousedown",e=>{e.target===a&&s()}),addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),c?s():g();return}e.key==="Escape"&&c&&(e.preventDefault(),s())}),{show:g,hide:s,isOpen:()=>c}}var D=document.querySelector("[docs-jumpto]");if(D)try{q(D)}catch(a){console.error(a)}export{q as createJumpTo}; |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index f7ea163..bc7f80a 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -86,6 +86,41 @@ |
| inline-size: 14rem; |
| max-inline-size: 100%; |
| } |
| + /* [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> |
| @@ -157,4 +192,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..1ab42ee |
| --- /dev/null |
| +++ b/internal/docs/views/partials/jumpto.html |
| @@ -0,0 +1,117 @@ |
| +{{/* 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 — see AGENTS.md's accessibility conventions. |
| + |
| + 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 cc5637e..de4ed62 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -297,4 +297,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}} |