oficina / docs Public

The palette's keyboard contract is a gate, not a memory

Branch task 22 on discussion #14. Those checks existed and passed —
seventeen of them — but in a scratch driver deliberately not committed,
because the harness they belonged in was on formatting-and-export and
not yet on main. Both branches have landed, so this is the fold. Until
now what protected the palette's keyboard contract was one person having
run a script once, which is what this repo explicitly does not count as
a gate.

Twenty checks, not seventeen: three are new, and two of the seventeen
turned out to be checks that could not fail.

Every query is DERIVED from the titles actually on the list rather than
typed in as "s" and "waterworks". Those worked against the six seeded
documents and would have gone red the day somebody edited seeddata,
reporting "the palette is broken" when it was not. The single-result
case queries a whole title so the exact-match rank decides which row is
first; asserting on "the first result" after a query that merely matches
something is asserting on the tie-break by accident.

Three checks that were not in the scratch driver:

  - Reopening the palette does not repeat the last empty-result
    message. This is the regression Fable's review of #14 found: closed
    on "No documents match", it reopened still saying it over an empty
    query, and being a live region it announced the stale answer again.
    It was fixed with no test; now there is one.
  - It reopens with an empty field.
  - The exact title ranks first.

Two of the folded checks were rewritten because deliberately breaking
the code did not turn them red:

  - "focus is no longer held in the field" passed with the focus-return
    call DELETED — 44/44. Closing the palette hides the input, so focus
    leaves it whether or not anything put focus back. It now focuses a
    named element, opens from there, and asserts Escape brings focus
    back to that exact element. Broken, it reports "focus is on BODY".
  - "Up moves back to the first" passed with the arrow handler gutted,
    because the first option is selected both when Up walked back to it
    and when nothing moved at all. It is now guarded on Down having
    actually moved, and says so when it did not.

Each of the five deliberate breaks was confirmed to turn the intended
checks red and no others:

  | broken thing                        | checks that caught it |
  |-------------------------------------|-----------------------|
  | the Control-K binding               | 12, cascading         |
  | aria-activedescendant never set     | 1, exactly            |
  | arrow keys stop moving              | 2 (was 1)             |
  | the status not cleared on reopen    | 1, quoting the stale text |
  | focus never returned on Escape      | 1 (was 0)             |

The first of those exposed a fault in this file rather than in the
palette: the throw ended the whole run at 31 checks instead of 44, with
the 320px reflow checks never reached and simply ABSENT from the output
rather than reported. A failing check that deletes other checks is the
same "cannot tell no from I can't see it" shape, so groups now run
through group(), an aborted group is its own named failure, and the
probes that used to dereference a missing <li> read through a null-safe
one.

Verified: 44/44 green, and red exactly as tabulated above for each
break, each time with the bundle actually rebuilt — the first attempt
"passed" only because fixtures/node_modules was missing, the bundle
build failed silently, and the drive ran the OLD bundle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxucgadpWLE9XNWvfHBsnb
Paul Campbell pushed by paul@keymail.dev 55a4ac048df86a7ca0c9f75c02b8021cf4685e0c parent c20b62f
1 file changed, +337 −1
  • fixtures/browser-drive.mjs +337 −1

CI cancelled — superseded by a newer push — run details

diff --git a/fixtures/browser-drive.mjs b/fixtures/browser-drive.mjs
index 319205a..d59cf73 100644
--- a/fixtures/browser-drive.mjs
+++ b/fixtures/browser-drive.mjs
@@ -97,6 +97,35 @@ async function editorHTML() {
return evaluate(`document.querySelector('[docs-editor]').innerHTML`)
}
+// pressKey dispatches one key the way a keyboard does.
+//
+// rawKeyDown rather than keyDown for the non-printing keys: a keyDown
+// carrying no text is what CDP turns into a char event, and a stray
+// char is how an Escape ends up inserting nothing visible while the
+// handler that should have run never did. Printing keys pass `text`
+// and get the char event they need.
+//
+// modifiers is CDP's bitfield — 2 is Control, which is the one the
+// palette is bound to.
+async function pressKey(key, { code, vk = 0, modifiers = 0, text } = {}) {
+ const base = { key, code: code || key, windowsVirtualKeyCode: vk, modifiers }
+ await send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', ...base, ...(text ? { text } : {}) })
+ await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base })
+}
+
+// typeText types a string one character at a time, with a real gap
+// between them. The palette debounces its query, so a burst delivered
+// instantly is not the input it was written for.
+async function typeText(text) {
+ for (const ch of text) {
+ await send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, key: ch })
+ await send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch })
+ await sleep(20)
+ }
+}
+
+const CONTROL = 2
+
// The editor writes through the CRDT and re-renders from it, so a
// change is not in the DOM the instant a command returns. settle waits
// for a condition rather than for a fixed delay, which is what keeps
@@ -168,6 +197,21 @@ function check(name, ok, detail) {
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`)
}
+// group runs a set of checks so that one throwing does not delete the
+// ones that come after it.
+//
+// Without this, a group that dies partway through simply stops, the
+// later groups never run, and the summary line reports a smaller total
+// that looks like a smaller suite rather than like missing coverage.
+// The abort becomes its own named failure instead.
+async function group(name, fn) {
+ try {
+ await fn()
+ } catch (err) {
+ check(`${name} ran to completion`, false, err.message)
+ }
+}
+
// caretInfo reports where the caret is, read from the DOM selection
// rather than from ProseMirror's own state.
//
@@ -361,11 +405,303 @@ async function run() {
await caretChecks()
- await reflowChecks()
+ // Each of these is wrapped so that a failure inside one cannot take
+ // the checks after it down with it. Proven necessary rather than
+ // assumed: breaking the palette's Control-K binding threw on the
+ // first null it produced, and the run ended at 31 checks instead of
+ // 44 — with the 320px reflow checks never reached and simply absent
+ // from the output rather than reported as anything. A check that
+ // silently removes other checks when it fails is the same "cannot
+ // tell no from I can't see it" shape this file exists to avoid, so
+ // an aborted group is now itself a failed check that names the group.
+ await group('the jump-to palette checks', paletteChecks)
+ await group('the 320px reflow checks', reflowChecks)
check('the page reported no JavaScript errors', pageProblems.length === 0, pageProblems.join(' | '))
}
+// The jump-to palette (Control-K), and the plain search URL under it.
+//
+// These are the seventeen checks discussion #14 ran and could not
+// commit: the harness they belonged in was on another branch at the
+// time, and shipping a second harness to avoid waiting would have been
+// worse than waiting. Both branches have landed, so this is that fold —
+// branch task 22. What protected the palette's keyboard contract until
+// now was one person having run a scratch script once, which is exactly
+// what this repo does not count as a gate.
+//
+// Two things are different from the scratch version, both because a
+// check that depends on the fixture data is a check that fails for
+// reasons that have nothing to do with the code:
+//
+// - The queries are DERIVED from the titles actually on the list,
+// not typed in as "s" and "waterworks". Those worked against the
+// six seeded documents and would go red the day somebody edits
+// seeddata — reporting "the palette is broken" when it is not,
+// which is the failure this file's own header warns about.
+// - The single-result case queries a whole title, so the exact-match
+// rank guarantees which row lands first. Asserting on "the first
+// result" after a query that merely matches something is asserting
+// on the ranker's tie-break by accident.
+//
+// It runs before reflowChecks because that one leaves a phone-sized
+// viewport behind, and a 320px palette is a different check from this
+// one.
+async function paletteChecks() {
+ // Where the palette matters most: inside a document, which is where
+ // the run() above has already left us. A shortcut that works on the
+ // list and not in the editor is the case worth catching, because the
+ // editor is the page with a keymap of its own to collide with.
+ await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL })
+ await sleep(400)
+ check('Control-K opens the palette inside a document', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`))
+ await pressKey('Escape', { vk: 27 })
+ await sleep(300)
+
+ // Everything else from the list.
+ await send('Page.navigate', { url: base + '/' })
+ await sleep(1500)
+
+ // The titles this instance actually has, with the hrefs, so every
+ // query below is derived rather than assumed.
+ const docs = await evaluate(`(() => {
+ return Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => ({
+ href: a.getAttribute('href'),
+ title: a.textContent.trim(),
+ })).filter(d => d.title.length > 0)
+ })()`)
+ if (!docs || docs.length === 0) throw new Error('the document list has no titles to search for')
+
+ // A query that matches more than one, found rather than guessed: the
+ // shortest single letter contained in at least two titles. The arrow
+ // keys need two options to move between, and "there are two" is a
+ // property of the data that this asks about instead of assuming.
+ const multi = await evaluate(`(() => {
+ const titles = ${JSON.stringify(docs.map((d) => d.title.toLowerCase()))}
+ for (const ch of 'etaoinsrhldcumfpgwybvkxjqz') {
+ if (titles.filter(t => t.includes(ch)).length >= 2) return ch
+ }
+ return null
+ })()`)
+
+ // --- The no-JS path. Search is a plain page at a plain URL, and
+ // that is what lets the palette be an addition rather than the only
+ // way in. Asserted with the palette's own bundle irrelevant.
+ const one = docs[0]
+ const others = docs.slice(1).map((d) => d.title)
+ await send('Page.navigate', { url: base + '/?q=' + encodeURIComponent(one.title) })
+ await sleep(1200)
+ const searched = await evaluate(`document.body.innerText`)
+ check('a search URL renders the matching document', searched.includes(one.title), searched.slice(0, 160))
+ // Only meaningful if some other title would have shown on an
+ // unfiltered list — with one document there is nothing to hide, and
+ // a check that cannot fail is not a check.
+ const hidden = others.filter((t) => !searched.includes(t))
+ check(
+ 'and hides the ones that do not match',
+ others.length === 0 || hidden.length > 0,
+ `none of the other ${others.length} titles were filtered out`,
+ )
+
+ await send('Page.navigate', { url: base + '/' })
+ await sleep(1500)
+
+ check(
+ 'the palette is on the page but hidden',
+ await evaluate(`(()=>{const e=document.querySelector('[docs-jumpto]');return !!e && e.hidden})()`),
+ )
+
+ await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL })
+ await sleep(400)
+ check('Control-K opens the palette from the list', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`))
+ check(
+ 'and focus lands in the field',
+ await evaluate(`document.activeElement === document.querySelector('[docs-jumpto-input]')`),
+ )
+ check(
+ 'aria-expanded says it is open',
+ (await evaluate(`document.querySelector('[docs-jumpto-input]').getAttribute('aria-expanded')`)) === 'true',
+ )
+
+ let options = 0
+ if (multi) {
+ await typeText(multi)
+ try {
+ options = await settleOn(
+ 'results',
+ () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`),
+ (n) => n > 0,
+ )
+ check('typing produces results', true)
+ } catch (err) {
+ check('typing produces results', false, err.message)
+ }
+ } else {
+ check('typing produces results', false, 'no letter is shared by two titles — cannot form a query')
+ }
+
+ // Read through a null-safe probe rather than dereferencing the first
+ // <li> directly. With no results that dereference throws, and a throw
+ // here used to end the whole run — so the failure of one check
+ // deleted every check after it instead of reporting itself.
+ const firstOption = await evaluate(`(() => {
+ const li = document.querySelector('[docs-jumpto-list] li')
+ const input = document.querySelector('[docs-jumpto-input]')
+ return {
+ exists: !!li,
+ selected: li ? li.getAttribute('aria-selected') : null,
+ id: li ? li.id : null,
+ activeDescendant: input ? input.getAttribute('aria-activedescendant') : null,
+ }
+ })()`)
+ check(
+ 'the first result is the active option',
+ firstOption.selected === 'true',
+ firstOption.exists ? `aria-selected is ${firstOption.selected}` : 'there are no results at all',
+ )
+ check(
+ 'aria-activedescendant names the active option',
+ firstOption.exists && firstOption.activeDescendant === firstOption.id,
+ `aria-activedescendant is ${firstOption.activeDescendant}, the first option's id is ${firstOption.id}`,
+ )
+
+ // Which option is selected, as an index — one probe, so a movement
+ // is asserted as "it moved from here to there" rather than as two
+ // separate facts about two separate elements.
+ const selectedIndex = () =>
+ evaluate(
+ `Array.from(document.querySelectorAll('[docs-jumpto-list] li')).findIndex(l => l.getAttribute('aria-selected') === 'true')`,
+ )
+
+ if (options > 1) {
+ await pressKey('ArrowDown', { vk: 40 })
+ await sleep(250)
+ const afterDown = await selectedIndex()
+ check('Down moves to the second result', afterDown === 1, `the selected option is ${afterDown}`)
+
+ await pressKey('ArrowUp', { vk: 38 })
+ await sleep(250)
+ const afterUp = await selectedIndex()
+ // Guarded on Down having actually moved. "The first option is
+ // selected" is true both when Up walked back to it and when
+ // nothing moved at all, so on its own this check passes against an
+ // arrow-key handler that does nothing — verified: breaking move()
+ // turned Down red and left this one green. It is only evidence
+ // when there was somewhere to come back FROM.
+ check(
+ 'Up moves back to the first',
+ afterDown === 1 && afterUp === 0,
+ afterDown !== 1
+ ? `Down never moved (selected ${afterDown}), so returning to 0 proves nothing`
+ : `the selected option is ${afterUp}`,
+ )
+ } else {
+ check('Down moves to the second result', false, `only ${options} result(s) to move through`)
+ check('Up moves back to the first', false, `only ${options} result(s) to move through`)
+ }
+
+ // Escape closes and hands focus back. Tab is deliberately NOT a
+ // cycle here — the panel holds two focusable things, and a general
+ // focus trap is where a palette usually strands somebody.
+ await pressKey('Escape', { vk: 27 })
+ await sleep(400)
+ check('Escape closes the palette', await evaluate(`document.querySelector('[docs-jumpto]').hidden`))
+
+ // Focus returns to WHERE IT CAME FROM, asserted by naming that
+ // element rather than by observing that it is no longer in the
+ // field.
+ //
+ // "activeElement is not the palette input" was the obvious way to
+ // write this and it is worthless: closing the palette hides the
+ // input, so focus leaves it whether or not anything put focus back,
+ // and the check passes either way. Verified — deleting the
+ // returnFocusTo.focus() call left it green at 44/44, which is a
+ // check that cannot tell "no" from "I can't see it", and this file
+ // exists to not do that.
+ //
+ // So focus a known element first, open from there, and assert that
+ // Escape brings focus back to that exact element. A keyboard user
+ // who opens the palette from the search field and dismisses it
+ // should be back in the search field, not at the top of the page.
+ const returned = await evaluate(`(() => {
+ const f = document.querySelector('[docs-search-input]')
+ if (!f) return null
+ f.focus()
+ return document.activeElement === f
+ })()`)
+ if (returned) {
+ await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL })
+ await sleep(400)
+ await pressKey('Escape', { vk: 27 })
+ await sleep(400)
+ check(
+ 'and focus goes back to whatever opened it',
+ await evaluate(`document.activeElement === document.querySelector('[docs-search-input]')`),
+ `focus is on ${await evaluate(`document.activeElement ? document.activeElement.tagName + (document.activeElement.className || '') : 'nothing'`)}`,
+ )
+ } else {
+ check('and focus goes back to whatever opened it', false, 'no [docs-search-input] to open the palette from')
+ }
+
+ // A query matching nothing says so rather than showing an empty box.
+ await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL })
+ await sleep(300)
+ await typeText('zzzznothingmatchesthis')
+ try {
+ await settleOn(
+ 'the empty-result message',
+ () => evaluate(`(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent})()`),
+ (t) => t.length > 0,
+ )
+ check('a query matching nothing says so', true)
+ } catch (err) {
+ check('a query matching nothing says so', false, err.message)
+ }
+
+ // Closing on that message and reopening must not still be saying it.
+ //
+ // This one is a regression check rather than a contract check: the
+ // Fable review of #14 found the palette reopening over an EMPTY query
+ // still showing "No documents match", and because the status is a
+ // live region it announced the stale answer again to a screen reader
+ // — an answer to a question nobody had asked yet.
+ await pressKey('Escape', { vk: 27 })
+ await sleep(300)
+ await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL })
+ await sleep(400)
+ const stale = await evaluate(
+ `(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent.trim()})()`,
+ )
+ check('reopening it does not repeat the last empty-result message', stale === '', `it still says ${JSON.stringify(stale)}`)
+ check(
+ 'and it reopens with an empty field',
+ (await evaluate(`document.querySelector('[docs-jumpto-input]').value`)) === '',
+ )
+
+ // Enter opens the chosen document. Queried by a whole title, so the
+ // exact-match rank decides which row is first rather than the
+ // tie-break happening to.
+ await typeText(one.title)
+ try {
+ await settleOn(
+ 'a result for a whole title',
+ () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`),
+ (n) => n > 0,
+ )
+ const target = await evaluate(
+ `(()=>{const l=document.querySelector('[docs-jumpto-list] li');return l?l.dataset.href:null})()`,
+ )
+ check('the exact title ranks first', target === one.href, `first result was ${target}, wanted ${one.href}`)
+ await pressKey('Enter', { vk: 13 })
+ await sleep(2000)
+ const url = await evaluate(`location.pathname`)
+ check('Enter opens the chosen document', url === target, `went to ${url}, wanted ${target}`)
+ } catch (err) {
+ check('the exact title ranks first', false, err.message)
+ check('Enter opens the chosen document', false, err.message)
+ }
+}
+
// 320px reflow — WCAG 2.2 SC 1.4.10, which AGENTS.md takes with no
// exemptions.
//