From 4a1decdb65680fe25f9df1171f6283fbee27cf71 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Tue, 25 Aug 2026 23:21:46 -0400 Subject: [PATCH] The word that starts everything the visor says: an audible anchor for drawer announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-voice discipline is pixels, and a screen reader consumes no pixels: app-frame text and visor speech arrive in one undifferentiated stream, so an app could render a sentence that SOUNDS like the visor. This gives the visor an audible anchor — a word rolled exactly once per identity (EFF short wordlist 2.0, minus the visor's own spoken vocabulary), persisted beside the anchor hue, spoken and never drawn: it prefixes host-emitted drawer lifecycle announcements ("«word»: storage picker open / closed / back", silent on suspend and rebuild, plain "visor" before the claim), it never crosses the visor API, and the settings sheet can say it or re-roll it but never shows it. speak() becomes a dwell-paced FIFO so synchronous sentence pairs (close→resume, teach→fresh-colour) both survive the live region. Every drawer tenant now declares a spoken, framework-vocabulary label, and a new e2e scenario pins the sentences, their order, suspend silence, and that the word never reaches drawn pixels. --- NOTES.md | 82 ++ demo/e2e/run.ts | 6 + demo/e2e/scenarios/drawer-announcements.ts | 323 +++++ demo/e2e/util.ts | 27 + demo/host/demo.ts | 8 + demo/host/solo.ts | 9 + spikes/todomvc/host/visor.ts | 5 + visor/README.md | 35 + visor/ui/entry.ts | 7 + visor/ui/sheets.ts | 64 + visor/ui/visor.css | 12 + visor/ui/visor.ts | 348 ++++- visor/ui/words.ts | 1451 ++++++++++++++++++++ 13 files changed, 2368 insertions(+), 9 deletions(-) create mode 100644 demo/e2e/scenarios/drawer-announcements.ts create mode 100644 visor/ui/words.ts diff --git a/NOTES.md b/NOTES.md index 7c217d54..f11e5b6f 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1572,6 +1572,88 @@ app voice never — a component is referred to by the user's word for it (the petname, resolved per drained batch) or described without naming; its provenance key and nickname never ride an announcement. +**Non-visual provenance: the audible anchor word** (2026-08-25). The +three voices above are marked in PIXELS, and a screen reader has no +pixels. AT linearizes the document: app-frame text and visor text +arrive in one stream, the plate and the weight and the quoting are all +gone, and iframe boundaries are not announced at all — so the entire +visor/app boundary, which sighted users read off position and colour +and an opaque frame background, is simply absent. An app can render, +inside its own rectangle, a sentence that SOUNDS exactly like the +visor speaking, and nothing in the audio stream contradicts it. The +anchor colour's whole secondary job — a spoof lottery an app cannot +read and can only guess — had no counterpart on this channel. + +So the colour gets an audible twin. A **word** is rolled once per +identity, at the same moment and by the same `claim()` the hue is +(`visor/ui/words.ts`, `loadVisorWord`), out of the EFF short wordlist +2.0 minus the visor's own spoken vocabulary — the EFF list really does +contain "visor", "device" and "anchor", and a word that IS vocabulary +destroys the seam the mechanism runs on. That list was chosen for +PHONETIC distinctness (unique three-letter prefixes, edit distance ≥ +2), which is the property a token learned by ear needs and a random +dictionary sample does not have. The word becomes the first token of +every drawer lifecycle sentence the host speaks — "«word»: storage +picker open", closed, back — with everything after the colon drawn +from `DrawerTenantSpec.spoken`, framework vocabulary fixed at tenant +registration. The announcements are emitted BY THE HOST, once, so no +tenant can forget one, and `spoken` is required rather than defaulted +from the diagnostic `name` because a default would have shipped +hyphenated identifiers into the ear of exactly the people who cannot +see the sheet it mislabels. Suspends are silent (audibly covered by +the displacing tenant's own open); the resume closes the pair with +"back". + +What makes it unguessable is the same structure that protects the +hue, one step stricter. It is **never rendered in pixels** — not in a +sheet, not in a title, not in an aria-label — so no screenshot, +recording, screen-share or compositing trick carries it; it never +leaves the device; it lives in visor-realm `localStorage` an +opaque-origin frame cannot read; and — the strictest part — **it never +crosses the visor API at all**. There is deliberately no +`committedWord()` to match `committedHue()`: the hue is returned +because consumers must paint with it, the word has no such use, and a +getter would be a door to rendering it. `speakWord()` and +`rerollWord()` are the only doors and both end in the live region. +Pre-claim the prefix is the literal word "visor" — a `deferClaim` +embedder puts its unseal picker in the drawer, so the drawer speaks +before any identity exists, and there is deliberately nothing personal +to say yet. + +The delivery needed one mechanism change: `speak()` became a FIFO +queue with a ~1.4s dwell. A live region holds one string and is read +asynchronously, so two writes in one synchronous block are not two +announcements — the second destroys the first. Two real sites do +exactly that: a close that resumes the occupant underneath (else the +user is never told the ceremony they were in ended), and the +fresh-word teach followed immediately by the consumer's fresh-colour +announcement. The queue is capped at 8, dropping oldest — a burst that +outruns speech is a burst nobody can listen to, and the recent +sentences are the ones describing the screen now. + +**Accepted residual leaks, recorded rather than hidden.** Anything +that captures AUDIO captures the word: a screen-share carrying system +sound, a call, a person in earshot. This is the same class of limit +the anchor colour has against someone looking over the user's +shoulder, and it is why `rerollWord()` exists — a user who believes +they were overheard can mint a new one (guaranteed different) without +erasing the visor. The word also does nothing for an app that never +tries to imitate the visor's voice; it is a provenance token, not a +capability. + +**Growth path.** The word currently prefixes drawer lifecycle +announcements only. It should extend to every CONSENT CEREMONY — +anything where the answer to "is this really the visor asking?" +decides whether a secret gets typed — which is the same reasoning that +put those ceremonies in the drawer in the first place. Beyond that, +the missing piece is a chokepoint for APP voice on the audio channel: +`foreignToken` is the visual funnel and has no spoken counterpart, so +there is currently no way to hear that a string came from a component. +A labeled landmark region around the app rectangle (so AT announces +entering and leaving app territory, which iframes fail to do) is the +structural half of the same fix, and the two together would give the +spoken channel something like the three voices rather than one token. + **The strip reorganized around the user's pair; "me" is a circle; the user's vocabulary opens wide** (2026-08-21, #22, executed same day). Three rulings. (1) The context cluster's lines SWAP: the top line is diff --git a/demo/e2e/run.ts b/demo/e2e/run.ts index 0db351ae..9738083e 100644 --- a/demo/e2e/run.ts +++ b/demo/e2e/run.ts @@ -53,6 +53,7 @@ import transportRefusal from "./scenarios/transport-refusal.ts"; import tenantPrecedence from "./scenarios/tenant-precedence.ts"; import storagePageNavigation from "./scenarios/storage-page-navigation.ts"; import storagePicker from "./scenarios/storage-picker.ts"; +import drawerAnnouncements from "./scenarios/drawer-announcements.ts"; import stripOwnership from "./scenarios/strip-ownership.ts"; import devicePairing from "./scenarios/device-pairing.ts"; import devicePairingMock from "./scenarios/device-pairing-mock.ts"; @@ -137,6 +138,11 @@ const SCENARIOS: Scenario[] = [ transportRefusal, tenantPrecedence, storagePicker, + // THE NON-VISUAL HALF of everything the two scenarios above assert + // visually: the same drawer, read through #visor-live. It follows them + // because a failure here with those green says the fault is in the + // spoken channel rather than in the drawer. + drawerAnnouncements, storagePageNavigation, // The two pairing ceremonies, run TWICE against the two // implementations of the same `PairingDriver` seam (shared acts in diff --git a/demo/e2e/scenarios/drawer-announcements.ts b/demo/e2e/scenarios/drawer-announcements.ts new file mode 100644 index 00000000..cf14892b --- /dev/null +++ b/demo/e2e/scenarios/drawer-announcements.ts @@ -0,0 +1,323 @@ +// THE AUDIBLE ANCHOR WORD — provenance for people who cannot see the +// anchor colour. +// +// WHAT THIS SCENARIO IS ABOUT. Every anti-spoofing property the visor +// ships is VISUAL: a colour an app can never sample, a strip no +// component may draw in, plated app-voice tokens, a sheet that hangs off +// a pinned bar. A screen reader flattens all of it. App-frame text and +// visor text arrive in one undifferentiated stream, iframe boundaries +// are not announced at all, and so an app can render, inside its own +// rectangle, a sentence that SOUNDS exactly like the visor speaking. +// +// The answer is the same shape as the colour: a word rolled once per +// identity, never rendered in pixels, never crossing the visor API, and +// spoken as the FIRST TOKEN of every drawer lifecycle sentence. The acts +// below are the properties that makes true: +// +// - the word PREFIXES the sentence, and the rest of the sentence is +// framework vocabulary (`DrawerTenantSpec.spoken`), so nothing an +// app could have influenced ever rides behind the user's own token; +// - open, close and resume ("back") each get a sentence, and they are +// spoken BY THE HOST, so a tenant cannot forget one; +// - a SUSPEND is silent — audibly covered by the displacing tenant's +// own open — and the resume is what closes the pair; +// - two sentences emitted in the SAME synchronous block (a close that +// resumes the occupant underneath) both survive, which is the whole +// reason `speak` is a queue rather than a bare live-region write; +// - and the word is spoken and NEVER DRAWN: nothing in the visor's +// pixels, sheets included, contains it. +// +// The word is SEEDED by the harness (`seedWord` in e2e/util.ts). It has +// to be: the sentence under test is ": ", which can +// only be asserted against a word the test chose. + +import type { Scenario } from "../run.ts"; +import { act, assert, assertEquals, hook, seedWord, UI_TIMEOUT, waitForSheet } from "../util.ts"; +import type { Page } from "npm:playwright@1.57.0"; + +/** START ACCUMULATING what `#visor-live` says, from now on. + * + * POLL-AND-ACCUMULATE, not a sample, and this is the only way to read + * this region honestly. A live region holds ONE string; the visor's + * speak queue deliberately replaces it every `SPEAK_DWELL_MS` so a + * screen reader gets each sentence in turn. A test that reads the region + * once therefore sees whichever sentence happens to be resident, which + * for a two-sentence transition is a coin flip. The MutationObserver + * installed here records every distinct value the region ever holds, so + * an assertion can ask "was this said?" instead of "is this showing?". + * + * Installed in the page rather than polled from the driver because a + * driver-side poll can miss a sentence entirely: the dwell is ~1.4s but + * nothing in the contract promises the driver a turn inside it. + */ +async function recordLive(page: Page): Promise { + await page.evaluate(() => { + const g = globalThis as unknown as Record; + // Idempotent: a scenario that calls this twice keeps ONE observer and + // one log, so a re-arm never doubles every sentence. + if (g.__liveLog !== undefined) return; + const log: string[] = []; + g.__liveLog = log; + const el = document.getElementById("visor-live"); + if (el === null) throw new Error("no #visor-live on this page"); + const push = () => { + const t = el.textContent ?? ""; + // The queue's clear-then-set writes "" between sentences; that is + // delivery machinery, not something anybody hears. + if (t !== "" && log[log.length - 1] !== t) log.push(t); + }; + push(); + new MutationObserver(push).observe(el, { + childList: true, + characterData: true, + subtree: true, + }); + }); +} + +/** Everything `#visor-live` has held since `recordLive`. */ +function liveLog(page: Page): Promise { + return page.evaluate(() => + ((globalThis as unknown as Record).__liveLog as string[] ?? []).slice() + ); +} + +/** Wait until the live region has SAID `sentence` at some point, and + * return the whole log. Bounded by `UI_TIMEOUT`, and the failure message + * carries the log — a wrong sentence is far more useful to read than a + * bare timeout. */ +async function waitSaid(page: Page, sentence: string): Promise { + await page.waitForFunction( + (want: string) => + (((globalThis as unknown as Record).__liveLog as string[]) ?? []).includes( + want, + ), + sentence, + { timeout: UI_TIMEOUT }, + ).catch(async (e) => { + const log = await liveLog(page); + throw new Error( + `#visor-live never said ${JSON.stringify(sentence)} (${e.message}); it said ${ + JSON.stringify(log) + }`, + ); + }); + return await liveLog(page); +} + +/** The index of a sentence in the log, or -1. Used for ORDER claims. */ +function at(log: string[], sentence: string): number { + return log.indexOf(sentence); +} + +/** The LAST index of a sentence, or -1 — for the claims that are about a + * sentence NOT being said again, where the first occurrence is exactly + * the one that must be ignored. */ +function lastAt(log: string[], sentence: string): number { + return log.lastIndexOf(sentence); +} + +/** Wait for the erase ceremony's own sheet to be mounted in the drawer. + * + * Read off the DOM rather than through `waitForSheet`, which only knows + * the tenants the demo publishes a predicate for (`__demo` has no + * `drawer` handle, and the erase ceremony's `__demo.reset.open` is the + * sheets module's predicate rather than a tenant one). `.reset-sheet` is + * the class the ceremony's own sheet carries, and `:not(.visor-swap-out)` + * is the same distinction the demo's picker handle draws: a sheet + * travelling off-stage is still in the DOM for the length of the motion + * and has already stopped being the occupant. */ +async function waitForResetSheet(page: Page, want: boolean): Promise { + await page.waitForFunction( + (want: boolean) => + (document.querySelectorAll("#visor-drawer-inner .reset-sheet:not(.visor-swap-out)").length > + 0) === want, + want, + { timeout: UI_TIMEOUT }, + ).catch((e) => { + throw new Error(`waiting for the erase sheet to be ${want ? "open" : "gone"}: ${e.message}`); + }); +} + +/** IS THE WORD DRAWN? — the pixel-policy probe, and the whole reason it + * needs care is that `#visor-live` legitimately CONTAINS the word. + * + * The live region is the audible channel. It is visually hidden by the + * clip-rect recipe rather than by `display:none` (a display:none live + * region is not announced at all — visor.ts says so where it builds the + * element), which means it is still in the layout and still in + * `innerText`. Excluding it by id would be a test that trusts the id; so + * this excludes it by MEASUREMENT instead — the region must actually be + * clipped to a degenerate box, and everything else on the page must not + * contain the word. A regression that made the live region visible + * therefore fails here rather than being excluded along with it. + * + * Returns the offending element descriptions, so a failure names what + * drew it. + * + * SCOPE: LIGHT DOM ONLY — the walk descends `children`, so it enters no + * shadow root and no same-origin iframe document. That is complete + * TODAY, because every pixel the visor draws is light DOM in this + * document (the app frame is a separate, opaque origin the word never + * reaches). Revisit if the visor ever renders into a shadow root. */ +async function drawnOccurrences(page: Page, word: string): Promise { + return await page.evaluate((w: string) => { + const hits: string[] = []; + const live = document.getElementById("visor-live"); + if (live !== null) { + const r = live.getBoundingClientRect(); + // The clip-rect recipe leaves a 1x1-ish box. Anything larger is a + // live region that became visible, which is a real leak. + if (r.width > 2 || r.height > 2) { + hits.push(`#visor-live is VISIBLE (${Math.round(r.width)}x${Math.round(r.height)})`); + } + } + const walk = (el: Element) => { + for (const child of Array.from(el.children)) { + if (child === live) continue; + walk(child); + } + // Own text only, so an ancestor is not blamed for a descendant. + const own = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent ?? "") + .join(""); + if (own.includes(w)) hits.push(`${el.tagName.toLowerCase()}.${el.className}: ${own.trim()}`); + // Attributes travel to pixels too — a title becomes a tooltip, and + // an aria-label is read out as though it were the control's name. + for (const attr of ["title", "aria-label", "placeholder", "value", "alt"]) { + const v = el.getAttribute(attr); + if (v !== null && v.includes(w)) hits.push(`${el.tagName.toLowerCase()}[${attr}]: ${v}`); + } + }; + if (document.body !== null) walk(document.body); + return hits; + }, word); +} + +const scenario: Scenario = { + name: "drawer-announcements", + why: + "every drawer sheet opens, closes and resumes with a spoken sentence prefixed by the user's own anchor word — and the word is never drawn", + page: {}, + + async run(page) { + const W = seedWord; + + await act("the seeded word is the visor's, and nothing on the page draws it", async () => { + await recordLive(page); + // The pixel policy, asserted against the DOM rather than against + // the page's own account of itself: the word is an AUDIBLE channel + // precisely because pixels travel (screenshots, screen-shares, + // recordings), so a rendered word would hand an app the one token + // it must never be able to guess. + const drawn = await drawnOccurrences(page, W); + assertEquals( + drawn.length, + 0, + `the anchor word ${JSON.stringify(W)} is drawn on the page: ${JSON.stringify(drawn)}`, + ); + // And no getter leaks it either — the Visor interface has none, by + // construction (visor/ui/visor.ts, `speakWord`). This is the + // structural half of the same claim. + const hasGetter = await page.evaluate(() => { + const d = (globalThis as unknown as Record).__demo as + | Record + | undefined; + return d !== undefined && "committedWord" in d; + }); + assertEquals(hasGetter, false, "a word getter on the driving handle"); + }); + + await act("opening the settings sheet speaks the word, then the sheet's own name", async () => { + await hook(page, "settings.openSheet"); + await waitForSheet(page, "settings", true); + const log = await waitSaid(page, `${W}: visor settings open`); + // FRAMEWORK VOCABULARY AFTER THE COLON. The sheet is announced by + // what the visor calls it, never by a component's own string — + // there is no way to plate app voice in a flat spoken sentence, so + // an app-influenced token here would arrive behind the user's own + // anchor word, wearing the exact provenance the word exists to + // make unforgeable. + assert( + !log.some((s) => s.includes("TodoMVC")), + `the live region carried a component's own name: ${JSON.stringify(log)}`, + ); + }); + + // The settings -> erase step is the framework's ONE suspension path + // on this page (visor/ui/sheets.ts's `settingsSuspends`), which makes + // it the only place the full open/suspend/close/resume quartet can be + // driven — so all three remaining claims live in this one act. + await act("a displacing sheet speaks its open; the suspended one stays silent", async () => { + await hook(page, "reset.openFromSettings"); + await waitForResetSheet(page, true); + const log = await waitSaid(page, `${W}: erase this visor open`); + // SUSPEND IS SILENT, on purpose: the sheet that displaced this one + // just announced itself, and a second sentence about the sheet + // going away would narrate machinery rather than the screen. It is + // the RESUME that closes the pair. + assert( + !log.includes(`${W}: visor settings closed`), + `a suspended sheet announced a close: ${JSON.stringify(log)}`, + ); + }); + + await act("cancelling speaks the close AND the resume — both, in order", async () => { + await hook(page, "reset.cancel"); + await waitForResetSheet(page, false); + // THE PAIR THE QUEUE EXISTS FOR. `close()` speaks its own "closed" + // and then, in the SAME synchronous block, resumes the occupant + // waiting underneath, which speaks "back". Against a bare live + // region the second write destroys the first and a non-visual user + // is never told the erase ceremony ended. Both must be present, + // and in this order. + await waitSaid(page, `${W}: erase this visor closed`); + const log = await waitSaid(page, `${W}: visor settings back`); + const closed = at(log, `${W}: erase this visor closed`); + const back = at(log, `${W}: visor settings back`); + assert( + closed !== -1 && back !== -1 && closed < back, + `expected the close before the resume, got ${JSON.stringify(log)}`, + ); + // "back", not "open": the user already heard this sheet open once, + // and the second half of a displacement is a return. LAST + // occurrence, deliberately — `at`/indexOf would find the ORIGINAL + // open from the first act and be satisfied by it, so a resume that + // ALSO re-announced an open would slip through. Asking where the + // open sentence was said LAST is the claim the comment makes. + assert( + lastAt(log, `${W}: visor settings open`) < closed, + `the settings sheet re-announced an open on resume: ${JSON.stringify(log)}`, + ); + }); + + await act("closing the last sheet speaks its close", async () => { + await hook(page, "settings.cancel"); + await waitForSheet(page, "settings", false); + await waitSaid(page, `${W}: visor settings closed`); + }); + + await act("the word is STILL not drawn, after four sheet transitions", async () => { + // Re-asserted at the end rather than only at the start: the sheets + // that came and went are exactly the surfaces that could have + // rendered it, and a leak into a sheet body would be invisible to + // the boot-time check above. + const drawn = await drawnOccurrences(page, W); + assertEquals( + drawn.length, + 0, + `the anchor word ${JSON.stringify(W)} reached the pixels: ${JSON.stringify(drawn)}`, + ); + // ...and it WAS spoken, several times over — the point is that the + // channel is audible-only, not that the mechanism went quiet. + const log = await liveLog(page); + assert( + log.filter((l) => l.startsWith(`${W}: `)).length >= 4, + `expected the word to have prefixed several sentences: ${JSON.stringify(log)}`, + ); + }); + }, +}; + +export default scenario; diff --git a/demo/e2e/util.ts b/demo/e2e/util.ts index 51341286..ccbb2cab 100644 --- a/demo/e2e/util.ts +++ b/demo/e2e/util.ts @@ -58,6 +58,13 @@ export interface FreshOptions { /** Let the demo pick (and ANNOUNCE) a fresh anchor colour. Off by * default: see `seedHue`. */ freshAnchor?: boolean; + /** Let the visor ROLL a fresh anchor WORD, and teach it out loud. Off + * by default for the same reason `freshAnchor` is (see `seedWord`): + * the teach sentence occupies `#visor-live`, which several scenarios + * read. Separate from `freshAnchor` because the two are independent + * channels — a scenario about the fresh-colour announcement has no + * reason to also take on an unpredictable word. */ + freshWord?: boolean; /** WHICH DOCUMENT to open, as a root-relative path. Defaults to the * demo's own `/index.html` (i.e. the served root). The solo page * (`/solo.html`) is a SECOND embedder over the same served artifacts, @@ -124,6 +131,7 @@ export function pageUrl( * it fails loudly — which is the point of a tripwire. */ export const KEYS = { hue: "pm-demo-visor-hue", + word: "pm-demo-visor-word", identity: "pm-demo-identity", marks: "pm-demo-surface-marks", storage: "pm-demo-storage", @@ -136,6 +144,7 @@ export const KEYS = { * not share an identity, or the second page is not a second device. */ export const SOLO_KEYS = { hue: "pm-solo-visor-hue", + word: "pm-solo-visor-word", identity: "pm-solo-identity", marks: "pm-solo-surface-marks", } as const; @@ -149,6 +158,20 @@ export const SOLO_KEYS = { * `freshAnchor: true`. */ const seedHue = "265"; +/** CONTRACT (visor/ui/words.ts): a boot that finds no stored anchor WORD + * rolls one and TEACHES it through `#visor-live` — the same live region + * the announcement mirror writes to, and the one several scenarios + * assert on. So the harness seeds a committed word, exactly as it seeds + * a committed hue, and a boot is the ordinary second-visit boot. + * + * FIXED, and it must be: the drawer's lifecycle announcements are + * ": ", so a scenario can only assert them against a + * word it chose. `walrus` is an ordinary member of the rollable list + * (the EFF short wordlist minus the visor's own vocabulary) — a value + * `loadVisorWord` accepts on read rather than a sentinel it would + * reject and silently re-roll. */ +export const seedWord = "walrus"; + // --- act discipline -------------------------------------------------------- let acts = 0; @@ -242,6 +265,10 @@ export async function newContext( // line the scenario reads. const hueKey = opts.path?.includes("solo") ? SOLO_KEYS.hue : KEYS.hue; if (!opts.freshAnchor && seed[hueKey] === undefined) seed[hueKey] = seedHue; + // THE WORD SEED FOLLOWS THE PAGE for exactly the same reason, and is + // opted out of separately (`freshWord`). + const wordKey = opts.path?.includes("solo") ? SOLO_KEYS.word : KEYS.word; + if (!opts.freshWord && seed[wordKey] === undefined) seed[wordKey] = seedWord; if (Object.keys(seed).length > 0) { await context.addInitScript((entries: [string, string][]) => { // Runs before every document's own scripts, which is the only diff --git a/demo/host/demo.ts b/demo/host/demo.ts index 20609632..cea3d00f 100644 --- a/demo/host/demo.ts +++ b/demo/host/demo.ts @@ -268,6 +268,10 @@ const VISOR_KEY = "pm-demo-visor-hue"; // CONTRACT: rename-only migration (chrome -> visor, GitHub issue #22); the // legacy key is read once by `initVisor` and then removed, never re-created. const LEGACY_CHROME_KEY = "pm-demo-chrome-hue"; +// The AUDIBLE anchor: the spoken twin of the colour, on its own key for +// exactly the same reason — two embedders on one origin are two devices +// and must not sound alike (visor/ui/words.ts). +const WORD_KEY = "pm-demo-visor-word"; const IDENTITY_KEY = "pm-demo-identity"; // The trust table: the surface marks, the first-sight timestamps and the @@ -970,6 +974,7 @@ async function boot() { const visor = initVisor({ hueKey: VISOR_KEY, legacyHueKey: LEGACY_CHROME_KEY, + wordKey: WORD_KEY, identityKey: IDENTITY_KEY, appSurface: () => appSurface, contextOverride: () => activePanel?.surface ?? null, @@ -1851,6 +1856,7 @@ async function boot() { * nor race the entry. */ const credentialTenant = visor.drawer.tenant({ name: "credentials", + spoken: "credentials", exclusive: true, armed: true, dim: true, @@ -2805,6 +2811,7 @@ async function boot() { const pickerTenant = visor.drawer.tenant({ name: "storage-picker", + spoken: "storage picker", // NOT EXCLUSIVE: the credential sheet this one hands off to is, and // an exclusive picker would refuse its own successor. Not `dim`med // either — the picker deliberately survives a walk to a config page @@ -3485,6 +3492,7 @@ async function boot() { * delay means nothing. */ const addDeviceTenant = visor.drawer.tenant<{ container: HTMLElement }>({ name: "add-device", + spoken: "add a device", exclusive: true, dim: true, context: () => ({ kind: "settings" }), diff --git a/demo/host/solo.ts b/demo/host/solo.ts index 9071eb8c..9d4525b7 100644 --- a/demo/host/solo.ts +++ b/demo/host/solo.ts @@ -184,6 +184,10 @@ if (isAuthPopup) { // `usCacheKeys`), and because a colour and a name are exactly the two // things a boot must be able to paint the INSTANT the seal opens. const VISOR_KEY = "pm-solo-visor-hue"; +// The AUDIBLE anchor: the spoken twin of the colour, on its own key for +// exactly the same reason — two embedders on one origin are two devices +// and must not sound alike (visor/ui/words.ts). +const WORD_KEY = "pm-solo-visor-word"; const IDENTITY_KEY = "pm-solo-identity"; const MARKS_KEY = "pm-solo-surface-marks"; const US_CACHE_KEYS = usCacheKeys("pm-solo"); @@ -452,6 +456,7 @@ async function boot() { const appSlot: AppSlot = { surface: null }; const visor = initVisor({ hueKey: VISOR_KEY, + wordKey: WORD_KEY, identityKey: IDENTITY_KEY, deferClaim: true, // ONE app surface and no nested places: the strip's context falls @@ -939,6 +944,7 @@ interface RestoreCeremonyHost { function mountRestore(visor: Visor, host: RestoreCeremonyHost): void { const tenant = visor.drawer.tenant<{ root: HTMLElement }>({ name: "restore", + spoken: "restore", // EXCLUSIVE: this is a way IN, the same weight class as the picker, // and nothing may displace a half-entered recovery phrase. exclusive: true, @@ -1809,6 +1815,7 @@ async function startApp( const deviceTenant = visor.drawer.tenant<{ container: HTMLElement }>({ name: "this-device", + spoken: "this device", exclusive: true, dim: true, context: () => ({ kind: "settings" }), @@ -2286,6 +2293,7 @@ async function startApp( const storageTenant = visor.drawer.tenant<{ container: HTMLElement }>({ name: "storage", + spoken: "storage", exclusive: true, dim: true, context: () => ({ kind: "settings" }), @@ -4910,6 +4918,7 @@ async function startApp( const addTenant = visor.drawer.tenant<{ container: HTMLElement }>({ name: "add-device", + spoken: "add a device", exclusive: true, dim: true, context: () => ({ kind: "settings" }), diff --git a/spikes/todomvc/host/visor.ts b/spikes/todomvc/host/visor.ts index 9fdf4e5c..e0c2253d 100644 --- a/spikes/todomvc/host/visor.ts +++ b/spikes/todomvc/host/visor.ts @@ -39,6 +39,10 @@ import { registerVisorSheets } from "../../../visor/ui/sheets.ts"; // No legacy key here — todomvc never had a pre-rename ("chrome") key to // migrate, unlike the demo spike's #22 migration. const HUE_KEY = "pm-todomvc-visor-hue"; +// The AUDIBLE anchor: the spoken twin of the colour, on its own key for +// exactly the same reason — two embedders on one origin are two devices +// and must not sound alike (visor/ui/words.ts). +const WORD_KEY = "pm-todomvc-visor-word"; const IDENTITY_KEY = "pm-todomvc-identity"; const MARKS_KEY = "pm-todomvc-surface-marks"; @@ -59,6 +63,7 @@ export function initTodoVisor(artifactName: string): void { const visor = initVisor({ hueKey: HUE_KEY, + wordKey: WORD_KEY, identityKey: IDENTITY_KEY, // The strip's fallback surface: this page has exactly one artifact, // so there is exactly one row, and it is always the one on the strip. diff --git a/visor/README.md b/visor/README.md index f6b98cad..79fe60f0 100644 --- a/visor/README.md +++ b/visor/README.md @@ -120,6 +120,41 @@ the user's word for it — its petname, clamped at 40 — or described without naming when there is no petname; its provenance key and its nickname never ride an announcement. +**The three voices are marked in pixels — and a screen reader has +none.** AT linearizes the page: app-frame text and visor text arrive in +one undifferentiated stream, the plate and the weight and the quoting +are gone, and iframe boundaries are not announced at all. An app can +therefore render, inside its own rectangle, a sentence that *sounds* +exactly like the visor speaking. The answer is the **audible anchor +word** (`ui/words.ts`): a word rolled once per identity by the same +`claim()` that rolls the hue, from the EFF short wordlist 2.0 (chosen +for phonetic distinctness) minus the visor's own spoken vocabulary. It +prefixes every drawer lifecycle sentence the host speaks — "«word»: +storage picker open", `closed`, `back` — with everything after the +colon coming from `DrawerTenantSpec.spoken`, framework vocabulary fixed +at registration and subject to the same one-directional rule as +`announce()`. Before the claim there is no word yet and the prefix is +the literal "visor", which is the honest sentence for a `deferClaim` +embedder whose unseal picker lives in the drawer. + +**The channel decision: `speak()`, never `announce()`, and never +pixels.** These sentences go only to the visually-hidden `#visor-live` +region. Not `announce()`, because that spends the strip's bottom line, +and a lifecycle sentence at every sheet transition would steal the +visual line from the context it is meant to be holding. And never +rendered at all — not in a sheet, not in a `title`, not in an +`aria-label` — because pixels travel: a screenshot, a recording or a +screen-share hands a drawn word straight to whoever is watching, and an +app that learns the word can wear it. That is enforced structurally +rather than by convention: there is deliberately **no getter** for the +word on the `Visor` interface (contrast `committedHue()`, which exists +because consumers must paint with it). `speakWord()` and `rerollWord()` +are the only doors, and both end in the live region. Audio capture and +shoulder-listening remain accepted residual leaks; `rerollWord()` is +the remedy. `speak()` is a FIFO queue with a short dwell so that two +sentences emitted in one synchronous block — a close that resumes the +sheet underneath — both survive. + **Pet icons are user voice by construction**, which is why they carry no marker of their own: a glyph reaches the strip or a sheet only after the user adopted it in the naming ceremony. A component may *nominate* a diff --git a/visor/ui/entry.ts b/visor/ui/entry.ts index f6ff8873..6a1e4408 100644 --- a/visor/ui/entry.ts +++ b/visor/ui/entry.ts @@ -189,6 +189,10 @@ export function mountDevicePicker( ): { close(): void } { const tenant = visor.drawer.tenant<{ root: HTMLElement }>({ name: "device-picker", + // Pre-claim, so this is the sheet most often heard under the generic + // "visor" prefix (there is no anchor word until the seal opens — see + // `wordPrefix` in visor.ts). + spoken: "device picker", // EXCLUSIVE: this is the login. Nothing may displace it, and it // displaces everything — though in practice there is nothing to // displace, since it opens before any other tenant can exist. @@ -530,6 +534,9 @@ export function offerFirstRun( ): { joinHandle: JoinPaneHandle; close(): void } { const tenant = visor.drawer.tenant<{ root: HTMLElement }>({ name: "first-run", + // The resting state of an account-less device, described as what it + // offers rather than as the lifecycle stage it is named for. + spoken: "getting started", // NOT EXCLUSIVE, and SUSPENDABLE — a RULING, and the pair goes // together. // diff --git a/visor/ui/sheets.ts b/visor/ui/sheets.ts index 9e1920e9..fa38b08a 100644 --- a/visor/ui/sheets.ts +++ b/visor/ui/sheets.ts @@ -572,6 +572,12 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi * this object), so the host holds the object rather than a copy. */ const namingTenant = visor.drawer.tenant<{ surface: SurfaceIdentity; icon: string }>({ name: "naming", + // The sheet is the naming ceremony GROWN into everything the visor + // knows about one component, so it is announced by what it IS now, + // not by the identifier it kept. Framework vocabulary throughout: + // the component's own nickname is app-influenced and must never + // reach a flat spoken sentence. + spoken: "app settings", context: (s) => ({ ...s.surface, kind: "naming" }), dim: overNestedPlace, beforeShow: freezePlace, @@ -603,6 +609,10 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi * must be painted in the REAL one. */ const settingsTenant = visor.drawer.tenant<{ hueAtOpen: number }>({ name: "settings", + // "visor settings", not "settings": the app-settings sheet above is + // also settings, and a listener told only "settings open" cannot + // tell which of the two arrived. + spoken: "visor settings", context: () => ({ kind: "settings" }), suspendable: () => settingsSuspends, dim: overNestedPlace, @@ -633,6 +643,9 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi * while the user reads a statement of consequence). */ const resetTenant = visor.drawer.tenant>({ name: "reset", + // Named by the ACT, not by the noun: this is the one sheet where a + // user who mis-navigated needs to know it from the first syllable. + spoken: "erase this visor", armed: true, dim: true, context: () => ({ kind: "reset" }), @@ -1069,6 +1082,55 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi hueRow.append(b); } + // THE AUDIBLE ANCHOR — the colour row's twin, sitting directly under + // it because they are the same setting on two channels: one for + // people who see the bar, one for people who hear it. Everything + // above says "this is yours and no app learns it"; this row says the + // same thing about the word that opens every sentence the visor + // speaks. + // + // PIXEL POLICY, AND IT IS THE WHOLE DESIGN OF THIS ROW: THE WORD IS + // NEVER RENDERED. Not here, not as a hint, not in a `title`, not in + // an aria-label — the buttons SAY it and nothing draws it. The visor + // interface makes that structural rather than merely observed (there + // is no getter that returns the word; `speakWord`/`rerollWord` are + // the only doors, and both end in the live region). The reason is + // the leak this whole channel is chosen to avoid: pixels travel. A + // screenshot, a screen-recording, a shared window or a support + // session carries a rendered word straight to whoever is watching — + // and an app that learns the word can prefix its own text with it + // and sound exactly like the visor, which is the single failure the + // word exists to prevent. Audio leaks too (see words.ts), but it + // leaks to whoever is in the room rather than into a file, and the + // re-roll button is the answer when it does. + const wordLabel = document.createElement("div"); + wordLabel.className = "cred-line said"; + wordLabel.textContent = + "this visor's spoken word — said out loud, shown to nobody, and never given to an app"; + const wordRow = document.createElement("div"); + wordRow.className = "settings-word"; + const hearWordBtn = document.createElement("button"); + hearWordBtn.type = "button"; + hearWordBtn.id = "visor-settings-hear-word"; + // The text content IS the label — "hear your visor's word" says what + // the control does and what it produces — so no aria-label is added. + // A redundant one would only be a second string to keep in sync. + hearWordBtn.textContent = "hear your visor's word"; + hearWordBtn.onclick = () => visor.speakWord(); + const rollWordBtn = document.createElement("button"); + rollWordBtn.type = "button"; + rollWordBtn.id = "visor-settings-roll-word"; + rollWordBtn.textContent = "roll a new word"; + // NO ARMING AND NO CONFIRMATION, deliberately: a re-roll spends + // nothing and destroys nothing (the old word had no authority to + // lose), and the user who reaches for it is usually the user who + // just realised they were overheard — a delay there is a delay on a + // remedy. It COMMITS IMMEDIATELY for the same reason, unlike the + // colour swatches above: there is nothing to preview by ear, so a + // Save step would only be a way to forget to finish. + rollWordBtn.onclick = () => visor.rerollWord(); + wordRow.append(hearWordBtn, rollWordBtn); + const note = document.createElement("div"); note.className = "cred-note"; note.textContent = @@ -1205,6 +1267,8 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi iconRow, hueLabel, hueRow, + wordLabel, + wordRow, note, ); if (actions.length > 0) root.append(actionsBlock); diff --git a/visor/ui/visor.css b/visor/ui/visor.css index e43accf7..255f5a2b 100644 --- a/visor/ui/visor.css +++ b/visor/ui/visor.css @@ -872,6 +872,18 @@ border-radius: 3px; cursor: pointer; border: 1px solid rgba(255,255,255,.45); } .settings-sheet .picked { outline: 2px solid #fff; outline-offset: 1px; } +/* THE AUDIBLE ANCHOR ROW — the two word controls, sitting under the hue + swatches as the same setting on the other channel. Wrapping, ordinary + text buttons: unlike the swatches above there is NOTHING TO SHOW here, + by policy (the word is spoken and never rendered — see the pixel-policy + comment in sheets.ts), so the row is only its two verbs. */ +.settings-sheet .settings-word { display: flex; gap: .4em; flex-wrap: wrap; + margin: .1em 0 .6em; } +.settings-sheet .settings-word button { font: inherit; font-size: 12px; + padding: .25em .6em; cursor: pointer; + border-radius: 4px; color: inherit; + background: rgba(255,255,255,.1); + border: 1px solid rgba(255,255,255,.35); } .settings-sheet .cred-line.said { opacity: .8; font-size: 11px; margin-bottom: .15em; } /* THE HEAD ROW: the sheet's title on the left, the erase entry in the upper-right corner (see buildSettingsSheet). flex-start on the cross diff --git a/visor/ui/visor.ts b/visor/ui/visor.ts index 9c8036a0..44a7909e 100644 --- a/visor/ui/visor.ts +++ b/visor/ui/visor.ts @@ -21,6 +21,14 @@ // `initVisor` returns; the module holds only constants. Two visors in // two documents therefore cannot collide. +// The anchor colour's AUDIBLE TWIN lives next door, in its own module, +// because it is 1296 lines of borrowed wordlist and one roll helper — +// but it is the same idea on the other channel, and visor.ts is the only +// file that ever reads it. See words.ts's header for the threat (screen +// readers linearise the page, so the three voices are marked in pixels +// and silent in speech) and for what the word deliberately is NOT. +import { loadVisorWord, rollVisorWord } from "./words.ts"; + // --- visor appearance: the personal, undisclosed anchor ----------------------- // // The strip's colour is the user's own: RANDOMISED on first run, pickable @@ -596,6 +604,30 @@ export interface DrawerCloseOptions { export interface DrawerTenantSpec { /** Diagnostic only; the host does not render it. */ name: string; + /** WHAT THIS SHEET IS CALLED OUT LOUD — the noun the drawer's + * lifecycle announcements put after the anchor word ("walrus: storage + * picker open"). Lower-case, a short noun phrase, no punctuation and + * no verb: the host supplies the verb. + * + * REQUIRED, and required rather than defaulted from `name` on purpose. + * `name` is a diagnostic identifier ("add-device", "first-run"); this + * is a sentence fragment a person hears. Defaulting would have shipped + * hyphenated identifiers into a user's ear and nobody would have + * noticed, because the people it fails are the people who cannot see + * the sheet it mislabels. + * + * VOICE — THE SAME ONE-DIRECTIONAL RULE AS `announce`, and it binds + * harder here because the string is baked in at registration. This is + * FRAMEWORK VOICE. It may embed USER-voice words inline (the user's + * own word for a device), because the user's vocabulary is already + * something the visor may say in its own sentence. An APP-INFLUENCED + * string must NEVER appear here: the announcement is a flat spoken + * sentence, so there is no `foreignToken` to plate it with and no + * marking a listener could hear — a component's nickname placed here + * would arrive prefixed by the user's own anchor word, which is + * precisely the provenance claim the word exists to make + * unforgeable. Describe the sheet in the visor's vocabulary instead. */ + spoken: string; /** THE HIGHEST PRECEDENCE. An exclusive tenant is never evicted — every * other tenant's `open` refuses while it holds the drawer — and its own * open evicts everything else. In the demo this is the credential @@ -735,6 +767,16 @@ export interface VisorConfig { hueKey: string; /** Rename-only migration source, read once and removed. */ legacyHueKey?: string; + /** Where the committed anchor WORD lives — the audible twin of + * `hueKey`, and REQUIRED for the same reason that one is: a visor + * whose non-visual users have no provenance token is a visor with a + * hole in exactly the population that cannot see the colour, and an + * optional key would let an embedder ship that hole by omission. + * + * Its own key, not a field inside the identity record, so it obeys the + * same per-embedder rule the hue does: two pages on one origin are two + * devices and must not share a word. */ + wordKey: string; /** Where the identity record lives. */ identityKey: string; /** THE APP'S OWN ROW IN THE TRUST TABLE — what the strip's top line @@ -919,10 +961,53 @@ export interface Visor { * THROWS WHILE UNCLAIMED (`deferClaim`) — a pre-claim commit would * persist a choice the user never made; see `committedHue`. */ commitHue(hue: number): void; + /** SAY THE USER'S ANCHOR WORD, to assistive tech only — "remind me + * what my word is", for a user who has stopped hearing it as a word + * and started hearing it as noise, or who has just switched devices. + * + * NOTE WHAT IS MISSING HERE, and that its absence is the design: there + * is NO `committedWord()` to match `committedHue()`. The hue can be + * returned because a consumer needs to paint with it; the word has no + * such use — the only thing anyone does with it is HEAR it — so the + * value never crosses this interface at all. A getter would let a + * consumer render the word into pixels, and a rendered word is a word + * that a screenshot, a screen-recording or a screen-share hands + * straight to an app, which is the one thing that would end the + * mechanism. The channel is audible-only by construction, not by + * convention. + * + * THROWS WHILE UNCLAIMED (`deferClaim`), same discipline as + * `committedHue`: pre-claim there is no word, and speaking a + * placeholder would teach the user a token the visor will not use. */ + speakWord(): void; + /** MINT A NEW ANCHOR WORD and say it, for a user who believes the old + * one was overheard. + * + * The word's accepted residual leak is AUDIO — a screen-share carrying + * system sound, a call, a person within earshot (see words.ts). None + * of those are defended against, so the answer to all of them is the + * same one the anchor colour has: the user can change it. The new word + * is GUARANTEED DIFFERENT from the old one — a re-roll that returned + * the same word would look like a control that does nothing, and, far + * worse, would leave a user believing they had rotated away from a + * token that is still live. + * + * Persisted immediately (there is no Save step and no preview: unlike + * a colour, there is nothing to look at while deciding), and spoken — + * never rendered, and never through `announce`. + * + * THROWS WHILE UNCLAIMED (`deferClaim`) — a pre-claim re-roll would + * write the consumer's word key before the seal opens, exactly as + * `commitHue` would write the hue key. */ + rerollWord(): void; /** FORGET EVERYTHING THIS VISOR HOLDS ON THIS DEVICE — the storage half - * of the reset ceremony. The identity record, the committed anchor hue - * and (when the consumer configured one) the legacy hue key are - * removed; nothing else is touched. + * of the reset ceremony. The identity record, the committed anchor hue, + * the committed anchor WORD and (when the consumer configured one) the + * legacy hue key are removed; nothing else is touched. + * + * The word goes with the colour, deliberately: they are the two halves + * of one anchor, and an erase that took the colour but left the word + * would leave a re-minted visor still answering to the old one. * * THE CEREMONY IS NOT HERE. sheets.ts owns it — the statement of * consequence, the arming delay, the typed confirmation, and the OTHER @@ -971,6 +1056,31 @@ export function initVisor(config: VisorConfig): Visor { }; if (!deferred) rollHue(); + /** THE COMMITTED ANCHOR WORD, or "" while unclaimed. + * + * IT IS NEVER EXPOSED AS A STRING. There is no getter for it on the + * `Visor` interface and there deliberately never will be: the moment a + * consumer can read the word, a consumer can RENDER the word, and a + * word that reaches pixels is a word a screenshot, a screen-share or a + * compositing trick can carry to an app. Everything a caller may do + * with it — hear it, replace it — is a method that SPEAKS, and speech + * is a channel no app-side code can read back. This module-private + * binding is the whole extent of its reach. + * + * The empty string is not a placeholder to be spoken: every read site + * below either runs post-claim or falls back to the literal word + * "visor" (see `wordPrefix`). */ + let committedWord = ""; + let wordFresh = false; + /** Rolled EXACTLY WHERE `rollHue` is rolled, and for the same reason — + * the colour and the word are one identity arriving, and a word that + * could be rolled at a second site could be rolled twice. */ + const rollWord = () => { + const rolled = loadVisorWord(config.wordKey); + committedWord = rolled.word; + wordFresh = rolled.fresh; + }; + if (!deferred) rollWord(); // FIXED IDS. They are part of the trust model and of the e2e contract — // "the visor's pixels" is a claim about named elements a component // cannot reach — so they are not parameterised. @@ -1399,16 +1509,108 @@ export function initVisor(config: VisorConfig): Visor { return el; })(); + /** THE MINIMUM TIME A SENTENCE OWNS THE LIVE REGION, in ms. Long + * enough that a typical screen reader has begun (and mostly finished) + * speaking it before the next one replaces the text. */ + const SPEAK_DWELL_MS = 1400; + /** How many sentences may wait. A cap rather than an unbounded queue: + * a burst that outruns speech is a burst nobody can listen to anyway, + * and an unbounded one would keep talking about the past long after + * the screen moved on. OLDEST IS DROPPED, not newest — the most + * recent sentences are the ones describing what is on screen now. */ + const SPEAK_QUEUE_MAX = 8; + const speakQueue: string[] = []; + let speaking = false; + /** Say something to assistive tech only. CLEAR THEN SET, in two turns: * writing the same string a live region already holds is not a change, * and an unchanged live region announces nothing — so a repeated - * identical sentence would be silently dropped. */ + * identical sentence would be silently dropped. + * + * AND A FIFO QUEUE AROUND THAT, which is the part that is not obvious. + * A live region has ONE slot, and the screen reader reads it + * asynchronously, on its own schedule; writing twice in quick + * succession does not queue two announcements, it destroys the first. + * Two sites in this file do exactly that, synchronously, and both are + * correct behaviour that must not be made to take turns by hand: + * + * (a) `close()` on a non-suspended tenant speaks its own "closed" + * and then, in the same synchronous block, resumes the + * occupant that was waiting underneath — which speaks "back". + * Unqueued, a non-visual user hears only "back" and is never + * told the ceremony they were in ended. + * (b) `claim()` teaches a freshly-rolled word, and the consumer's + * very next statement is the fresh-anchor colour announcement. + * Unqueued, the teach — the one sentence the whole mechanism + * depends on the user hearing — is the one that loses. + * + * So each message holds the region for `SPEAK_DWELL_MS` before the + * next is written. `announce` and `pulseContext` call this exactly as + * they did and are ordered by it for free. */ const speak = (text: string) => { if (!text) return; - liveRegion.textContent = ""; - setTimeout(() => { - liveRegion.textContent = text; - }, 30); + speakQueue.push(text); + // Drop from the FRONT when full: see SPEAK_QUEUE_MAX. + while (speakQueue.length > SPEAK_QUEUE_MAX) speakQueue.shift(); + if (speaking) return; + const pump = () => { + const next = speakQueue.shift(); + if (next === undefined) { + speaking = false; + return; + } + speaking = true; + liveRegion.textContent = ""; + setTimeout(() => { + liveRegion.textContent = next; + // The dwell is measured from the moment the text LANDS, not from + // the enqueue — the 30ms blank is part of delivering this + // sentence, not part of the previous one's time on air. + setTimeout(pump, SPEAK_DWELL_MS); + }, 30); + }; + pump(); + }; + + /** THE PROVENANCE PREFIX every drawer lifecycle sentence opens with: + * the user's own word once there is one, and the literal word "visor" + * before that. + * + * THE PRE-CLAIM CASE IS REAL, not a defensive default. A `deferClaim` + * embedder (the solo page) puts its UNSEAL PICKER in the drawer — the + * login is trusted UI and trusted UI lives in visor territory — so the + * drawer opens and closes, and therefore speaks, before any identity + * exists. There is deliberately NO WORD YET at that point: the word is + * the user's, and nothing of the user's may be on screen (or in the + * ear) before the seal opens. "visor: this device open" is the honest + * sentence there — it names the speaker without claiming a provenance + * token that has not been minted. A user learns quickly that the + * generic prefix belongs to the pre-login world; an app that imitates + * it gains nothing, because after the claim the generic prefix is + * exactly what a spoofed sentence sounds like. */ + const wordPrefix = () => (committedWord === "" ? "visor" : committedWord); + + /** True once the fresh-word teach has been spoken, so it cannot be + * said twice by a second claim (or by a consumer that claims a visor + * that was never deferred). */ + let wordTaught = false; + /** THE ONE SENTENCE THE WHOLE MECHANISM DEPENDS ON. A word the user + * was never told is a word they cannot use to tell the visor from an + * app imitating it, so a FRESH roll teaches itself out loud. + * + * `speak`, NEVER `announce`: the visual line is not this sentence's to + * take. Sighted users already have the anchor colour, and an + * announcement here would spend the strip's bottom line — the line a + * fresh boot owes to the fresh-COLOUR announcement — on words that + * mean nothing to someone reading them. And the word must never reach + * pixels at all (see `committedWord`), which rules the visual channel + * out on its own. Routing through the queue is what guarantees the + * consumer's colour announcement, which follows within the same tick, + * does not clobber it. */ + const teachFreshWord = () => { + if (!wordFresh || wordTaught || committedWord === "") return; + wordTaught = true; + speak(`your visor's word is ${committedWord} — it will start everything your visor says`); }; /** The pulse's total on-screen life, in ms. MUST match the @@ -1705,6 +1907,30 @@ export function initVisor(config: VisorConfig): Visor { * if the predicate's answer changed while the sheet was up. */ let dimmedNow = false; + /** THE DRAWER'S NON-VISUAL LIFECYCLE LINE: "«word»: «sheet» «verb»". + * + * IMPLEMENTED IN THE HOST, ONCE, rather than left to each tenant — + * which is the same argument the live region itself was built on. A + * tenant that has to remember to announce its own open is a tenant + * that can forget to, and the failure is invisible to everyone who + * reviews the sheet by looking at it. Registering a tenant is what + * buys the announcement; `spoken` being REQUIRED is what makes it + * impossible to register one that cannot be announced. + * + * The prefix is the whole reason this exists. A sighted user knows + * this sheet is the visor's because it hangs off a strip no + * component can draw in; a listening user knows it because the + * sentence opens with a word only their visor knows. Everything + * after the colon is FRAMEWORK VOCABULARY (see `spoken`), so the + * sentence carries no string an app could have influenced. + * + * `speak`, never `announce`: the visual bottom line already says + * what the drawer is doing by SHOWING the sheet, and spending it on + * a lifecycle sentence at every sheet transition would trample the + * context the strip is meant to be holding — plus the word must + * never reach pixels at all. */ + const speakDrawer = (verb: string) => speak(`${wordPrefix()}: ${spec.spoken} ${verb}`); + const detach = () => { if (anchor) { globalThis.removeEventListener("resize", anchor); @@ -1867,6 +2093,13 @@ export function initVisor(config: VisorConfig): Visor { // named surface. restoreContext(); present(s, "left"); + // THE SHEET IS BACK, said out loud. A resume is invisible to a + // non-visual user otherwise: nothing about it changes focus, and + // the strip context it restores is RECOMPUTED (it may not even be + // this tenant's). "back" rather than "open" because it is the + // second half of a displacement the user already heard the first + // half of — the displacing tenant's own open. + speakDrawer("back"); }; const tenant: TenantImpl = { @@ -1912,6 +2145,17 @@ export function initVisor(config: VisorConfig): Visor { if (opts.context !== false && !wasSuspended) restoreContext(); spec.afterRestore?.(s, opts); if (!wasSuspended) { + // SPOKEN ONLY ON THE NON-SUSPENDED PATH. A suspended tenant + // does not own the drawer — its session is ending off-screen, + // and nothing a listener can perceive is happening — so + // announcing "closed" there would describe a sheet that left + // the screen some time ago, under a ceremony that is still up. + // + // STRICTLY BEFORE the resume below, and the pair is the reason + // `speak` had to become a queue: these two sentences are + // emitted in the same synchronous block, and against a bare + // live region the second would silently destroy the first. + speakDrawer("closed"); // THE SUSPENDED OCCUPANT COMES BACK, if there is one: this // close is the end of the ceremony that displaced it. Done // synchronously, before the deferred blank below, so the @@ -1953,6 +2197,30 @@ export function initVisor(config: VisorConfig): Visor { // it keeps its session and slides out (the travel is run by the // presentation below, which needs both sheets in the DOM at // once), and it comes back when this one closes. + // KNOWN WART, RECORDED RATHER THAN GUARDED — a PHANTOM "back". + // The two branches below interact through `close`, which resumes + // whatever is suspended: if this loop suspends tenant A (the + // first branch) and then evicts tenant B (the second), B's + // `close` finds A waiting and RESUMES it — mid-loop, on behalf + // of an opener that is itself about to displace A again. A is + // therefore suspended, resumed and re-suspended inside one + // `open`, and since the word change the resume is AUDIBLE: the + // user hears "«word»: «A» back" for a sheet that never came back. + // + // It needs a tenant configuration the demo never builds — an + // open NON-SUSPENDABLE, NON-EXCLUSIVE occupant, a suspended + // SUSPENDABLE one, and a third tenant opening over both — so + // there is nothing to fix against today. Note also that the + // STRUCTURE is pre-existing: the spurious resume (with its + // rebuild and its `restoreContext`) has always happened here; + // the announcement only made it perceptible, which is arguably + // the announcement doing its job. + // + // THE FOLLOW-UP SHAPE, if a real embedder ever grows that + // configuration: a suppress-during-eviction flag raised around + // this loop and read by `close`'s resume (and by `speakDrawer`), + // so a resume that is immediately undone by the same `open` + // neither speaks nor rebuilds. let displaced = false; for (const other of tenants) { if (other === tenant) continue; @@ -1989,6 +2257,25 @@ export function initVisor(config: VisorConfig): Visor { // right; one opening into an empty (or evicted) drawer grows up // out of the bar as it always has. present(s, displaced ? "right" : "up"); + // AFTER `present`, so the sentence is emitted only once the open + // has actually succeeded — every refusal path above returns + // before here, and a listener must not be told a sheet opened + // that an exclusive occupant turned away. + // + // A DISPLACEMENT SPEAKS ONCE, not twice: `suspend` is silent by + // design, because this very sentence is what tells the user + // something new took the drawer. `update` and `rebuild` are + // silent for the same economy — the sheet did not arrive or + // leave, it changed, and narrating every re-present would bury + // the transitions that matter. + // + // CONTRACT: this fires on every successful `open`, including the + // re-entry-with-the-same-session case (a reserved `claim` being + // revealed). That is the sheet's first appearance on screen, so + // one announcement is correct there; a caller that re-opened an + // ALREADY-PRESENTED session would get a second one, and should + // be calling `rebuild` instead. + speakDrawer("open"); return true; }, }; @@ -2006,6 +2293,14 @@ export function initVisor(config: VisorConfig): Visor { }, }; + // THE TEACH FOR AN ORDINARY (NON-DEFERRED) EMBEDDER. Its word was + // rolled up at `rollWord()` above, where `speak` did not exist yet, so + // the teaching half waits until here — the last thing `initVisor` + // does, and still strictly BEFORE the consumer's own fresh-anchor + // announcement, which cannot run until this function has returned. + // (A deferred embedder is taught by `claim()` instead; see there.) + if (!deferred) teachFreshWord(); + return { // A GETTER, not the boot's value captured: under `deferClaim` the // answer legitimately changes once, at the claim, and a consumer @@ -2024,6 +2319,19 @@ export function initVisor(config: VisorConfig): Visor { if (claimed) return { fresh }; claimed = true; rollHue(); + // THE WORD ARRIVES WITH THE COLOUR, in the same call, for the same + // reason the identity cluster does: they are one identity becoming + // this user's, and a word rolled at any other moment would either + // exist before the seal opened (something personal, pre-login) or + // arrive later as a second, unexplained event. Same once-only + // guarantee, too — `claimed` gates both rolls, so neither the + // colour nor the word can be re-minted by a second call. + rollWord(); + // Taught BEFORE `renderIdentity`, and — because `speak` is a + // queue — strictly before whatever the consumer announces about + // the fresh colour on the very next line of its own claim + // handler. + teachFreshWord(); renderIdentity(); return { fresh }; }, @@ -2067,13 +2375,35 @@ export function initVisor(config: VisorConfig): Visor { localStorage.setItem(config.hueKey, String(h)); } catch { /* not durable here */ } }, + speakWord: () => { + // A LOUD REFUSAL, not silence and not a placeholder: same reasoning + // as `committedHue`. Pre-claim there is no word to say, and a + // sentence naming any other token would teach the user something + // false about what the visor will sound like. + if (!claimed) throw new Error("the visor is unclaimed: no anchor word before claim()"); + speak(`your visor's word is ${committedWord}`); + }, + rerollWord: () => { + if (!claimed) throw new Error("the visor is unclaimed: no word re-roll before claim()"); + // DIFFERENT BY CONSTRUCTION (see `rollVisorWord`'s `avoid`): the + // whole point of the control is that the old token stops working, + // and the user has to be able to hear that it did. + committedWord = rollVisorWord(committedWord); + try { + localStorage.setItem(config.wordKey, committedWord); + } catch { /* not durable here */ } + // "new", so a user who fires this twice can tell the second + // sentence from an echo of the first — and so the sentence itself + // says what just happened rather than merely reporting state. + speak(`your visor's new word is ${committedWord}`); + }, erase() { // Best-effort per key, and each in its own try: storage can throw // (a locked-down embedding, a quota-ish failure on some engines), // and one key refusing must not leave the others behind — a // partial erase should be as small as the failure, not as large as // whatever happened to be first in the list. - for (const key of [config.identityKey, config.hueKey, config.legacyHueKey]) { + for (const key of [config.identityKey, config.hueKey, config.legacyHueKey, config.wordKey]) { if (key === undefined) continue; try { localStorage.removeItem(key); diff --git a/visor/ui/words.ts b/visor/ui/words.ts new file mode 100644 index 00000000..749ebfb2 --- /dev/null +++ b/visor/ui/words.ts @@ -0,0 +1,1451 @@ +// THE AUDIBLE ANCHOR WORD — the anchor colour's twin for people who do +// not see it. +// +// THE THREAT THIS ANSWERS. Everything the visor's anti-spoofing story +// rests on is VISUAL: an anchor colour an app can never sample, a strip +// no component may draw in, plated app-voice tokens, a drawer sheet +// hanging off a pinned bar. A screen reader linearises the page and all +// of that vanishes — app-frame text and visor text arrive as the same +// undifferentiated stream, and iframe boundaries are not announced at +// all. So an app can render, inside its own rectangle, a sentence that +// SOUNDS exactly like the visor speaking, and a non-visual user has no +// channel on which to tell the two apart. The three voices are marked +// in pixels; in speech they are silent. +// +// THE ANSWER, structurally the same trick as the colour: a token the +// user learns and an app cannot guess. A word is rolled ONCE per +// identity, at the same moment the anchor hue is (see `claim()` in +// visor.ts), and it becomes the first thing the visor says on every +// drawer lifecycle announcement. It is unguessable by an app for the +// same reasons the hue is — it is never rendered in pixels (so no +// screenshot, screen-recording or compositing trick reaches it), it +// never leaves the device, it never crosses the visor API to a +// consumer, and it lives in visor-realm localStorage that an +// opaque-origin frame cannot read. +// +// RESIDUAL LEAKS, ACCEPTED AND WRITTEN DOWN. Anything that captures the +// user's AUDIO captures the word: a screen-share with system audio, a +// call, a person standing nearby. This is a real limit, not a defect +// being hidden — the same class of limit the anchor colour has against +// someone looking over the user's shoulder. NOTES.md records it, and +// `rerollWord()` on the Visor exists so a user who believes the word +// has been overheard can mint a new one without erasing the visor. + +/** THE EFF SHORT WORDLIST 2.0 — 1296 words, verbatim and in the + * upstream order. + * + * Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_2_0.txt + * Licence: CC-BY 3.0 (Electronic Frontier Foundation). + * + * WHY THIS LIST AND NOT A HAND-PICKED HANDFUL. It was built for + * PHONETIC DISTINCTNESS, which is exactly (and unusually) what a SPOKEN + * anchor needs: every word has a unique three-letter prefix, no two + * words are within edit distance 2 of each other, and the whole list + * avoids the homophone and near-homophone traps that make a + * mis-heard token indistinguishable from a guessed one. A user who + * learns their word by ear must be able to notice when a DIFFERENT word + * is said; a list optimised for that property does the noticing work + * that a random dictionary sample would not. + * + * 1296 entries is also plenty of entropy for the job. The word is not a + * secret against an offline search — it defends against an app that + * must produce the right token BLIND, on the first try, in a sentence + * the user is listening to. */ +export const VISOR_WORDS: readonly string[] = [ + "aardvark", + "abandoned", + "abbreviate", + "abdomen", + "abhorrence", + "abiding", + "abnormal", + "abrasion", + "absorbing", + "abundant", + "abyss", + "academy", + "accountant", + "acetone", + "achiness", + "acid", + "acoustics", + "acquire", + "acrobat", + "actress", + "acuteness", + "aerosol", + "aesthetic", + "affidavit", + "afloat", + "afraid", + "aftershave", + "again", + "agency", + "aggressor", + "aghast", + "agitate", + "agnostic", + "agonizing", + "agreeing", + "aidless", + "aimlessly", + "ajar", + "alarmclock", + "albatross", + "alchemy", + "alfalfa", + "algae", + "aliens", + "alkaline", + "almanac", + "alongside", + "alphabet", + "already", + "also", + "altitude", + "aluminum", + "always", + "amazingly", + "ambulance", + "amendment", + "amiable", + "ammunition", + "amnesty", + "amoeba", + "amplifier", + "amuser", + "anagram", + "anchor", + "android", + "anesthesia", + "angelfish", + "animal", + "anklet", + "announcer", + "anonymous", + "answer", + "antelope", + "anxiety", + "anyplace", + "aorta", + "apartment", + "apnea", + "apostrophe", + "apple", + "apricot", + "aquamarine", + "arachnid", + "arbitrate", + "ardently", + "arena", + "argument", + "aristocrat", + "armchair", + "aromatic", + "arrowhead", + "arsonist", + "artichoke", + "asbestos", + "ascend", + "aseptic", + "ashamed", + "asinine", + "asleep", + "asocial", + "asparagus", + "astronaut", + "asymmetric", + "atlas", + "atmosphere", + "atom", + "atrocious", + "attic", + "atypical", + "auctioneer", + "auditorium", + "augmented", + "auspicious", + "automobile", + "auxiliary", + "avalanche", + "avenue", + "aviator", + "avocado", + "awareness", + "awhile", + "awkward", + "awning", + "awoke", + "axially", + "azalea", + "babbling", + "backpack", + "badass", + "bagpipe", + "bakery", + "balancing", + "bamboo", + "banana", + "barracuda", + "basket", + "bathrobe", + "bazooka", + "blade", + "blender", + "blimp", + "blouse", + "blurred", + "boatyard", + "bobcat", + "body", + "bogusness", + "bohemian", + "boiler", + "bonnet", + "boots", + "borough", + "bossiness", + "bottle", + "bouquet", + "boxlike", + "breath", + "briefcase", + "broom", + "brushes", + "bubblegum", + "buckle", + "buddhist", + "buffalo", + "bullfrog", + "bunny", + "busboy", + "buzzard", + "cabin", + "cactus", + "cadillac", + "cafeteria", + "cage", + "cahoots", + "cajoling", + "cakewalk", + "calculator", + "camera", + "canister", + "capsule", + "carrot", + "cashew", + "cathedral", + "caucasian", + "caviar", + "ceasefire", + "cedar", + "celery", + "cement", + "census", + "ceramics", + "cesspool", + "chalkboard", + "cheesecake", + "chimney", + "chlorine", + "chopsticks", + "chrome", + "chute", + "cilantro", + "cinnamon", + "circle", + "cityscape", + "civilian", + "clay", + "clergyman", + "clipboard", + "clock", + "clubhouse", + "coathanger", + "cobweb", + "coconut", + "codeword", + "coexistent", + "coffeecake", + "cognitive", + "cohabitate", + "collarbone", + "computer", + "confetti", + "copier", + "cornea", + "cosmetics", + "cotton", + "couch", + "coverless", + "coyote", + "coziness", + "crawfish", + "crewmember", + "crib", + "croissant", + "crumble", + "crystal", + "cubical", + "cucumber", + "cuddly", + "cufflink", + "cuisine", + "culprit", + "cup", + "curry", + "cushion", + "cuticle", + "cybernetic", + "cyclist", + "cylinder", + "cymbal", + "cynicism", + "cypress", + "cytoplasm", + "dachshund", + "daffodil", + "dagger", + "dairy", + "dalmatian", + "dandelion", + "dartboard", + "dastardly", + "datebook", + "daughter", + "dawn", + "daytime", + "dazzler", + "dealer", + "debris", + "decal", + "dedicate", + "deepness", + "defrost", + "degree", + "dehydrator", + "deliverer", + "democrat", + "dentist", + "deodorant", + "depot", + "deranged", + "desktop", + "detergent", + "device", + "dexterity", + "diamond", + "dibs", + "dictionary", + "diffuser", + "digit", + "dilated", + "dimple", + "dinnerware", + "dioxide", + "diploma", + "directory", + "dishcloth", + "ditto", + "dividers", + "dizziness", + "doctor", + "dodge", + "doll", + "dominoes", + "donut", + "doorstep", + "dorsal", + "double", + "downstairs", + "dozed", + "drainpipe", + "dresser", + "driftwood", + "droppings", + "drum", + "dryer", + "dubiously", + "duckling", + "duffel", + "dugout", + "dumpster", + "duplex", + "durable", + "dustpan", + "dutiful", + "duvet", + "dwarfism", + "dwelling", + "dwindling", + "dynamite", + "dyslexia", + "eagerness", + "earlobe", + "easel", + "eavesdrop", + "ebook", + "eccentric", + "echoless", + "eclipse", + "ecosystem", + "ecstasy", + "edged", + "editor", + "educator", + "eelworm", + "eerie", + "effects", + "eggnog", + "egomaniac", + "ejection", + "elastic", + "elbow", + "elderly", + "elephant", + "elfishly", + "eliminator", + "elk", + "elliptical", + "elongated", + "elsewhere", + "elusive", + "elves", + "emancipate", + "embroidery", + "emcee", + "emerald", + "emission", + "emoticon", + "emperor", + "emulate", + "enactment", + "enchilada", + "endorphin", + "energy", + "enforcer", + "engine", + "enhance", + "enigmatic", + "enjoyably", + "enlarged", + "enormous", + "enquirer", + "enrollment", + "ensemble", + "entryway", + "enunciate", + "envoy", + "enzyme", + "epidemic", + "equipment", + "erasable", + "ergonomic", + "erratic", + "eruption", + "escalator", + "eskimo", + "esophagus", + "espresso", + "essay", + "estrogen", + "etching", + "eternal", + "ethics", + "etiquette", + "eucalyptus", + "eulogy", + "euphemism", + "euthanize", + "evacuation", + "evergreen", + "evidence", + "evolution", + "exam", + "excerpt", + "exerciser", + "exfoliate", + "exhale", + "exist", + "exorcist", + "explode", + "exquisite", + "exterior", + "exuberant", + "fabric", + "factory", + "faded", + "failsafe", + "falcon", + "family", + "fanfare", + "fasten", + "faucet", + "favorite", + "feasibly", + "february", + "federal", + "feedback", + "feigned", + "feline", + "femur", + "fence", + "ferret", + "festival", + "fettuccine", + "feudalist", + "feverish", + "fiberglass", + "fictitious", + "fiddle", + "figurine", + "fillet", + "finalist", + "fiscally", + "fixture", + "flashlight", + "fleshiness", + "flight", + "florist", + "flypaper", + "foamless", + "focus", + "foggy", + "folksong", + "fondue", + "footpath", + "fossil", + "fountain", + "fox", + "fragment", + "freeway", + "fridge", + "frosting", + "fruit", + "fryingpan", + "gadget", + "gainfully", + "gallstone", + "gamekeeper", + "gangway", + "garlic", + "gaslight", + "gathering", + "gauntlet", + "gearbox", + "gecko", + "gem", + "generator", + "geographer", + "gerbil", + "gesture", + "getaway", + "geyser", + "ghoulishly", + "gibberish", + "giddiness", + "giftshop", + "gigabyte", + "gimmick", + "giraffe", + "giveaway", + "gizmo", + "glasses", + "gleeful", + "glisten", + "glove", + "glucose", + "glycerin", + "gnarly", + "gnomish", + "goatskin", + "goggles", + "goldfish", + "gong", + "gooey", + "gorgeous", + "gosling", + "gothic", + "gourmet", + "governor", + "grape", + "greyhound", + "grill", + "groundhog", + "grumbling", + "guacamole", + "guerrilla", + "guitar", + "gullible", + "gumdrop", + "gurgling", + "gusto", + "gutless", + "gymnast", + "gynecology", + "gyration", + "habitat", + "hacking", + "haggard", + "haiku", + "halogen", + "hamburger", + "handgun", + "happiness", + "hardhat", + "hastily", + "hatchling", + "haughty", + "hazelnut", + "headband", + "hedgehog", + "hefty", + "heinously", + "helmet", + "hemoglobin", + "henceforth", + "herbs", + "hesitation", + "hexagon", + "hubcap", + "huddling", + "huff", + "hugeness", + "hullabaloo", + "human", + "hunter", + "hurricane", + "hushing", + "hyacinth", + "hybrid", + "hydrant", + "hygienist", + "hypnotist", + "ibuprofen", + "icepack", + "icing", + "iconic", + "identical", + "idiocy", + "idly", + "igloo", + "ignition", + "iguana", + "illuminate", + "imaging", + "imbecile", + "imitator", + "immigrant", + "imprint", + "iodine", + "ionosphere", + "ipad", + "iphone", + "iridescent", + "irksome", + "iron", + "irrigation", + "island", + "isotope", + "issueless", + "italicize", + "itemizer", + "itinerary", + "itunes", + "ivory", + "jabbering", + "jackrabbit", + "jaguar", + "jailhouse", + "jalapeno", + "jamboree", + "janitor", + "jarring", + "jasmine", + "jaundice", + "jawbreaker", + "jaywalker", + "jazz", + "jealous", + "jeep", + "jelly", + "jeopardize", + "jersey", + "jetski", + "jezebel", + "jiffy", + "jigsaw", + "jingling", + "jobholder", + "jockstrap", + "jogging", + "john", + "joinable", + "jokingly", + "journal", + "jovial", + "joystick", + "jubilant", + "judiciary", + "juggle", + "juice", + "jujitsu", + "jukebox", + "jumpiness", + "junkyard", + "juror", + "justifying", + "juvenile", + "kabob", + "kamikaze", + "kangaroo", + "karate", + "kayak", + "keepsake", + "kennel", + "kerosene", + "ketchup", + "khaki", + "kickstand", + "kilogram", + "kimono", + "kingdom", + "kiosk", + "kissing", + "kite", + "kleenex", + "knapsack", + "kneecap", + "knickers", + "koala", + "krypton", + "laboratory", + "ladder", + "lakefront", + "lantern", + "laptop", + "laryngitis", + "lasagna", + "latch", + "laundry", + "lavender", + "laxative", + "lazybones", + "lecturer", + "leftover", + "leggings", + "leisure", + "lemon", + "length", + "leopard", + "leprechaun", + "lettuce", + "leukemia", + "levers", + "lewdness", + "liability", + "library", + "licorice", + "lifeboat", + "lightbulb", + "likewise", + "lilac", + "limousine", + "lint", + "lioness", + "lipstick", + "liquid", + "listless", + "litter", + "liverwurst", + "lizard", + "llama", + "luau", + "lubricant", + "lucidity", + "ludicrous", + "luggage", + "lukewarm", + "lullaby", + "lumberjack", + "lunchbox", + "luridness", + "luscious", + "luxurious", + "lyrics", + "macaroni", + "maestro", + "magazine", + "mahogany", + "maimed", + "majority", + "makeover", + "malformed", + "mammal", + "mango", + "mapmaker", + "marbles", + "massager", + "matchstick", + "maverick", + "maximum", + "mayonnaise", + "moaning", + "mobilize", + "moccasin", + "modify", + "moisture", + "molecule", + "momentum", + "monastery", + "moonshine", + "mortuary", + "mosquito", + "motorcycle", + "mousetrap", + "movie", + "mower", + "mozzarella", + "muckiness", + "mudflow", + "mugshot", + "mule", + "mummy", + "mundane", + "muppet", + "mural", + "mustard", + "mutation", + "myriad", + "myspace", + "myth", + "nail", + "namesake", + "nanosecond", + "napkin", + "narrator", + "nastiness", + "natives", + "nautically", + "navigate", + "nearest", + "nebula", + "nectar", + "nefarious", + "negotiator", + "neither", + "nemesis", + "neoliberal", + "nephew", + "nervously", + "nest", + "netting", + "neuron", + "nevermore", + "nextdoor", + "nicotine", + "niece", + "nimbleness", + "nintendo", + "nirvana", + "nuclear", + "nugget", + "nuisance", + "nullify", + "numbing", + "nuptials", + "nursery", + "nutcracker", + "nylon", + "oasis", + "oat", + "obediently", + "obituary", + "object", + "obliterate", + "obnoxious", + "observer", + "obtain", + "obvious", + "occupation", + "oceanic", + "octopus", + "ocular", + "office", + "oftentimes", + "oiliness", + "ointment", + "older", + "olympics", + "omissible", + "omnivorous", + "oncoming", + "onion", + "onlooker", + "onstage", + "onward", + "onyx", + "oomph", + "opaquely", + "opera", + "opium", + "opossum", + "opponent", + "optical", + "opulently", + "oscillator", + "osmosis", + "ostrich", + "otherwise", + "ought", + "outhouse", + "ovation", + "oven", + "owlish", + "oxford", + "oxidize", + "oxygen", + "oyster", + "ozone", + "pacemaker", + "padlock", + "pageant", + "pajamas", + "palm", + "pamphlet", + "pantyhose", + "paprika", + "parakeet", + "passport", + "patio", + "pauper", + "pavement", + "payphone", + "pebble", + "peculiarly", + "pedometer", + "pegboard", + "pelican", + "penguin", + "peony", + "pepperoni", + "peroxide", + "pesticide", + "petroleum", + "pewter", + "pharmacy", + "pheasant", + "phonebook", + "phrasing", + "physician", + "plank", + "pledge", + "plotted", + "plug", + "plywood", + "pneumonia", + "podiatrist", + "poetic", + "pogo", + "poison", + "poking", + "policeman", + "poncho", + "popcorn", + "porcupine", + "postcard", + "poultry", + "powerboat", + "prairie", + "pretzel", + "princess", + "propeller", + "prune", + "pry", + "pseudo", + "psychopath", + "publisher", + "pucker", + "pueblo", + "pulley", + "pumpkin", + "punchbowl", + "puppy", + "purse", + "pushup", + "putt", + "puzzle", + "pyramid", + "python", + "quarters", + "quesadilla", + "quilt", + "quote", + "racoon", + "radish", + "ragweed", + "railroad", + "rampantly", + "rancidity", + "rarity", + "raspberry", + "ravishing", + "rearrange", + "rebuilt", + "receipt", + "reentry", + "refinery", + "register", + "rehydrate", + "reimburse", + "rejoicing", + "rekindle", + "relic", + "remote", + "renovator", + "reopen", + "reporter", + "request", + "rerun", + "reservoir", + "retriever", + "reunion", + "revolver", + "rewrite", + "rhapsody", + "rhetoric", + "rhino", + "rhubarb", + "rhyme", + "ribbon", + "riches", + "ridden", + "rigidness", + "rimmed", + "riptide", + "riskily", + "ritzy", + "riverboat", + "roamer", + "robe", + "rocket", + "romancer", + "ropelike", + "rotisserie", + "roundtable", + "royal", + "rubber", + "rudderless", + "rugby", + "ruined", + "rulebook", + "rummage", + "running", + "rupture", + "rustproof", + "sabotage", + "sacrifice", + "saddlebag", + "saffron", + "sainthood", + "saltshaker", + "samurai", + "sandworm", + "sapphire", + "sardine", + "sassy", + "satchel", + "sauna", + "savage", + "saxophone", + "scarf", + "scenario", + "schoolbook", + "scientist", + "scooter", + "scrapbook", + "sculpture", + "scythe", + "secretary", + "sedative", + "segregator", + "seismology", + "selected", + "semicolon", + "senator", + "septum", + "sequence", + "serpent", + "sesame", + "settler", + "severely", + "shack", + "shelf", + "shirt", + "shovel", + "shrimp", + "shuttle", + "shyness", + "siamese", + "sibling", + "siesta", + "silicon", + "simmering", + "singles", + "sisterhood", + "sitcom", + "sixfold", + "sizable", + "skateboard", + "skeleton", + "skies", + "skulk", + "skylight", + "slapping", + "sled", + "slingshot", + "sloth", + "slumbering", + "smartphone", + "smelliness", + "smitten", + "smokestack", + "smudge", + "snapshot", + "sneezing", + "sniff", + "snowsuit", + "snugness", + "speakers", + "sphinx", + "spider", + "splashing", + "sponge", + "sprout", + "spur", + "spyglass", + "squirrel", + "statue", + "steamboat", + "stingray", + "stopwatch", + "strawberry", + "student", + "stylus", + "suave", + "subway", + "suction", + "suds", + "suffocate", + "sugar", + "suitcase", + "sulphur", + "superstore", + "surfer", + "sushi", + "swan", + "sweatshirt", + "swimwear", + "sword", + "sycamore", + "syllable", + "symphony", + "synagogue", + "syringes", + "systemize", + "tablespoon", + "taco", + "tadpole", + "taekwondo", + "tagalong", + "takeout", + "tallness", + "tamale", + "tanned", + "tapestry", + "tarantula", + "tastebud", + "tattoo", + "tavern", + "thaw", + "theater", + "thimble", + "thorn", + "throat", + "thumb", + "thwarting", + "tiara", + "tidbit", + "tiebreaker", + "tiger", + "timid", + "tinsel", + "tiptoeing", + "tirade", + "tissue", + "tractor", + "tree", + "tripod", + "trousers", + "trucks", + "tryout", + "tubeless", + "tuesday", + "tugboat", + "tulip", + "tumbleweed", + "tupperware", + "turtle", + "tusk", + "tutorial", + "tuxedo", + "tweezers", + "twins", + "tyrannical", + "ultrasound", + "umbrella", + "umpire", + "unarmored", + "unbuttoned", + "uncle", + "underwear", + "unevenness", + "unflavored", + "ungloved", + "unhinge", + "unicycle", + "unjustly", + "unknown", + "unlocking", + "unmarked", + "unnoticed", + "unopened", + "unpaved", + "unquenched", + "unroll", + "unscrewing", + "untied", + "unusual", + "unveiled", + "unwrinkled", + "unyielding", + "unzip", + "upbeat", + "upcountry", + "update", + "upfront", + "upgrade", + "upholstery", + "upkeep", + "upload", + "uppercut", + "upright", + "upstairs", + "uptown", + "upwind", + "uranium", + "urban", + "urchin", + "urethane", + "urgent", + "urologist", + "username", + "usher", + "utensil", + "utility", + "utmost", + "utopia", + "utterance", + "vacuum", + "vagrancy", + "valuables", + "vanquished", + "vaporizer", + "varied", + "vaseline", + "vegetable", + "vehicle", + "velcro", + "vendor", + "vertebrae", + "vestibule", + "veteran", + "vexingly", + "vicinity", + "videogame", + "viewfinder", + "vigilante", + "village", + "vinegar", + "violin", + "viperfish", + "virus", + "visor", + "vitamins", + "vivacious", + "vixen", + "vocalist", + "vogue", + "voicemail", + "volleyball", + "voucher", + "voyage", + "vulnerable", + "waffle", + "wagon", + "wakeup", + "walrus", + "wanderer", + "wasp", + "water", + "waving", + "wheat", + "whisper", + "wholesaler", + "wick", + "widow", + "wielder", + "wifeless", + "wikipedia", + "wildcat", + "windmill", + "wipeout", + "wired", + "wishbone", + "wizardry", + "wobbliness", + "wolverine", + "womb", + "woolworker", + "workbasket", + "wound", + "wrangle", + "wreckage", + "wristwatch", + "wrongdoing", + "xerox", + "xylophone", + "yacht", + "yahoo", + "yard", + "yearbook", + "yesterday", + "yiddish", + "yield", + "yo-yo", + "yodel", + "yogurt", + "yuppie", + "zealot", + "zebra", + "zeppelin", + "zestfully", + "zigzagged", + "zillion", + "zipping", + "zirconium", + "zodiac", + "zombie", + "zookeeper", + "zucchini", +]; + +/** WORDS THE VISOR ALREADY SAYS, and therefore words it must never be + * ABLE to roll. + * + * The anchor word works by being the token that does not belong to the + * sentence around it — "walrus: storage picker open" is legible because + * "walrus" is arbitrary and the rest is vocabulary. Roll a word that IS + * part of the vocabulary and the seam disappears: "device: this device + * back" reads as a stutter, and worse, an app that guesses the visor + * says the words "device" or "visor" would be guessing the anchor + * itself. The EFF list REALLY DOES contain `visor`, `device` and + * `anchor` — this is not a hypothetical. + * + * The list is deliberately broader than what the list actually + * contains today: it names the visor's spoken vocabulary, so that a + * future upstream list revision (or a new spoken label) cannot quietly + * reintroduce a collision. Entries that match nothing are free. */ +export const VISOR_WORD_DENYLIST: readonly string[] = [ + // Lifecycle verbs the host speaks after the word (see the drawer + // announcements in visor.ts). + "open", + "closed", + "back", + "restore", + // The visor's nouns for itself and for what it holds. + "visor", + "device", + "anchor", + "settings", + "storage", + "credentials", + "identity", + "word", + "name", + "colour", + "color", +]; + +/** The list the roll actually draws from: the EFF list MINUS the visor's + * own vocabulary. Filtered once at module load rather than re-checked + * per roll, and it is also what `loadVisorWord` validates a PERSISTED + * value against — so a word stored by an older build (or by a hand-edited + * storage entry) that has since become vocabulary is re-rolled rather + * than kept. */ +export const VISOR_ROLLABLE_WORDS: readonly string[] = VISOR_WORDS.filter( + (w) => !VISOR_WORD_DENYLIST.includes(w), +); + +/** Roll a word that is not `avoid`. Shared by the first roll and by the + * re-roll, whose whole contract is "a DIFFERENT word": a user who asks + * for a new one and hears the same one has been told the control does + * nothing. Same `Math.random` style as `rollHue`. */ +export function rollVisorWord(avoid?: string): string { + // The loop terminates trivially — 1200-odd candidates against one + // excluded value — and re-rolling is the honest way to say "uniform + // over everything but that", rather than an index shuffle that is + // harder to read than the property it implements. + for (;;) { + const w = VISOR_ROLLABLE_WORDS[ + Math.floor(Math.random() * VISOR_ROLLABLE_WORDS.length) + ]; + if (w !== avoid) return w; + } +} + +/** Read the committed anchor WORD, or roll a fresh one — the exact shape + * of `loadVisorHue`, deliberately, down to the storage-unavailable + * fallback: the two are the same ceremony on two channels and they + * should be readable side by side. + * + * No legacy-key migration: the word has never had another key. + * + * As with the hue, the KEY is the consumer's, so two embedders on one + * origin do not share a word — two pages that are two devices must + * sound like two devices. */ +export function loadVisorWord( + wordKey: string, +): { word: string; fresh: boolean } { + try { + const raw = localStorage.getItem(wordKey); + // MEMBERSHIP-VALIDATED, not merely non-empty. The value is spoken in + // the visor's own voice as the token that proves the voice, so + // anything that did not come out of this list — a stale word, a + // hand-written storage entry, a truncated read — is treated as no + // word at all and re-rolled. + if (raw !== null && VISOR_ROLLABLE_WORDS.includes(raw)) { + return { word: raw, fresh: false }; + } + } catch { /* storage unavailable: fall through to a fresh pick */ } + // First run (or eviction). A fresh word is TAUGHT out loud by + // `claim()`, never rolled silently: a user who was never told their + // word cannot use it, and one whose word changed without being told + // would learn that the anchor drifts — which is the exact training the + // hue's announced-reset rule exists to prevent. + const word = rollVisorWord(); + try { + localStorage.setItem(wordKey, word); + } catch { /* nothing durable to write to */ } + return { word, fresh: true }; +}