From b56d12ea47a8419ebb85537feef5d43be10ca40f Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Wed, 26 Aug 2026 17:50:22 -0400 Subject: [PATCH] The event record: announcements split three ways; a badge on the identity circle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design memo #132, NOTES entry included. The strip's timed announcement was one transient line multiplexing three jobs; the split gives each its mechanism: moment cues stay push (announce/pulse/speak, record-less), standing conditions become visor-held keyed state (set/clear, lit while standing, session-live), and event records become a persistent acknowledgeable list behind a dot on #visor-settings — lit = unseen records ∪ standing conditions, opened through a visor-owned settings-sheet row into a 'recent events' drawer tenant (erase-entry suspend/resume motion; opening marks seen). The mechanical rule that makes wiring one line per host: every consequential announcement leaves a record — hooked in visorAnnounceSink's consequential branch only (statusWriter never records). solo's sync watch now sets/clears the 'sync' condition and the edge return replaces its hand-rolled syncFailureAnnounced boolean, preserving announce-once-per-crossing exactly. Gates: e2e 36/36 — visor-events new (seeded stale boot cache drives a real reconcile-announced event into badge, list, seen, reload persistence); store-outage-recovery extended (condition lights the badge, failure record outlives the ambient recovery announce, condition block clears); strip-geometry now measures with the badge lit. Invariants green; harness and hosts type-check green. --- NOTES.md | 52 +++ demo/e2e/run.ts | 8 + demo/e2e/scenarios/store-outage-recovery.ts | 104 +++++- demo/e2e/scenarios/strip-geometry.ts | 57 +++- demo/e2e/scenarios/visor-events.ts | 226 +++++++++++++ demo/host/demo.ts | 8 + demo/host/solo.ts | 89 +++++- visor/README.md | 27 ++ visor/ui/pairing.ts | 31 +- visor/ui/sheets.ts | 266 +++++++++++++++- visor/ui/visor.css | 73 ++++- visor/ui/visor.ts | 334 +++++++++++++++++++- 12 files changed, 1243 insertions(+), 32 deletions(-) create mode 100644 demo/e2e/scenarios/visor-events.ts diff --git a/NOTES.md b/NOTES.md index b5b67a86..76cefcf5 100644 --- a/NOTES.md +++ b/NOTES.md @@ -2490,6 +2490,58 @@ credentials alone; `solo-recovery-file` catches the real download and proves the wrong passphrase is one clean keyslot miss), pair/resume/check green, invariants 9/9. +**The event record: announcements split three ways; a badge on the +identity circle** (2026-08-26; design memo +[#132](https://github.com/polymorph-components/polyvisor/issues/132), +executed same day). The strip's timed announcement was one transient +line multiplexing three jobs, and every patch it has accumulated — +the sticky/priority windows, the pulseContext retreat, the spoken +FIFO, solo.ts's `syncFailureAnnounced` edge-trigger — was contention +between them. Worse, ANNOUNCED-NEVER-SILENT was hollow: a remote +revocation got 12 seconds on a strip nobody may be watching, and +multi-device means consequential events happen while you're away by +construction (the transparency roadmap's fork alarms make this acute: +"detection requires a response path", and a timed line is not one). +The split: **moment cues** stay push (announce/pulse/speak, +record-less — the fresh-colour teach is about NOW); **standing +conditions** become visor-held keyed state (set/clear, lit while +standing, session-live so a stale condition cannot outlive its +poller); **event records** become a persistent, acknowledgeable list. +The mechanical rule that makes wiring one-line-per-host: EVERY +CONSEQUENTIAL ANNOUNCEMENT LEAVES A RECORD — the sink already carries +the flag, so arrival push is unchanged and the badge is the memory, +not the alarm. The badge is a DOT (never a count) on +`#visor-settings`: zero layout shift (strip geometry is a measured +property), framework voice by construction (no words), lit = unseen +records ∪ standing conditions. THE SYMMETRY RULE: the badge sits on +the anchor of whoever the news is about — identity circle for +me/my-system news (built now); the PET ICON for system-authored news +about one surface (update landed, a pending version requests new +grants — specified in #132, dormant until such events exist). THE +AUTHOR RULE: only visor/engine-authored events light it — the subject +may be an app, the author never is; a self-badging primitive is "look +at me!" handed to components. Two more rules keep it meaningful: +entry is gated to the consequential class (ambient telemetry never +enters, or the one alarm that matters drowns in junk mail), and A +NOTIFICATION NEVER GRANTS — an entry may point at a ceremony, the +grant path stays the powerbox. The list itself is "recent events", a +light drawer tenant reached from a visor-owned settings-sheet row via +the erase entry's suspend/resume motion; conditions first, records +newest-first with coarse ages; OPENING MARKS SEEN (per-entry +dismissal waits for entries that carry actions); entries are flat +framework-voice strings under exactly `announce()`'s three-voices +policy — typed slots (petname/plated-foreign) are the recorded growth +path and the prerequisite for the surface scope. Records persist +under a consumer key (`erase()` wipes them); solo's sync watch now +sets/clears a condition and the edge return replaced its hand-rolled +boolean. Gates: e2e 36/36 (`visor-events` new — a seeded stale boot +cache drives a real reconcile-announced event into badge → list → +seen → reload persistence; `store-outage-recovery` extended to pin +the condition lighting the badge, the failure record outliving the +ambient recovery announce, and the condition block clearing; +`strip-geometry` now takes every measurement with the badge LIT), +invariants green, harness and hosts type-check green. + ## Parked and candidate non-goals - **Metadata privacy**: relays, push services, and origins see traffic diff --git a/demo/e2e/run.ts b/demo/e2e/run.ts index 9738083e..1d0cd8f7 100644 --- a/demo/e2e/run.ts +++ b/demo/e2e/run.ts @@ -47,6 +47,7 @@ import { type SeverableProxy, startTcpProxy } from "./proxy.ts"; import bootAppSurface from "./scenarios/boot-app-surface.ts"; import petnameCeremony from "./scenarios/petname-ceremony.ts"; import settingsIdentity from "./scenarios/settings-identity.ts"; +import visorEvents from "./scenarios/visor-events.ts"; import stripGeometry from "./scenarios/strip-geometry.ts"; import credentialFlow from "./scenarios/credential-flow.ts"; import transportRefusal from "./scenarios/transport-refusal.ts"; @@ -130,6 +131,13 @@ const SCENARIOS: Scenario[] = [ harnessFaults, petnameCeremony, settingsIdentity, + // THE EVENT RECORD (#132), beside the other two visor-sheet scenarios: + // it drives the same drawer and the same settings sheet. It proves the + // badge LIGHTS and clears; the strip's geometry under a lit badge is + // `stripGeometry`'s own business — it seeds an unseen record and takes + // every measurement with the dot rendered, so the two are independent + // rather than one relying on the other's leftovers. + visorEvents, stripGeometry, // The credential beats come before the refusal beat: one needs the // store up, the next needs it down, and a scenario that has to bring diff --git a/demo/e2e/scenarios/store-outage-recovery.ts b/demo/e2e/scenarios/store-outage-recovery.ts index f6b344b0..94bfacad 100644 --- a/demo/e2e/scenarios/store-outage-recovery.ts +++ b/demo/e2e/scenarios/store-outage-recovery.ts @@ -48,6 +48,15 @@ // before any earlier cycle succeeded necessarily carried it. That // is the honest reading, stated rather than invented as a // separate assertion. +// 5. THE FAILURE LEFT AN EVENT RECORD AND THE RECOVERY DID NOT (#132). +// After the heal the identity circle's badge is STILL lit: the +// standing sync CONDITION cleared with the recovery, but the +// consequential failure announcement's RECORD is unseen. The event +// list holds that sentence, with no "ongoing" block beside it and +// no trace of the ambient recovery line. This is the one place in +// the suite where the badge's two terms — unseen records, standing +// conditions — can be told apart, which is why the beat lives here +// rather than in scenarios/visor-events.ts. // // THE "Sync now" BYPASS BEAT IS DELIBERATELY OMITTED. Driving it here // would mean opening the storage sheet purely to reach one button while @@ -74,7 +83,15 @@ import type { Page } from "npm:playwright@1.57.0"; import type { Ctx, Scenario } from "../run.ts"; -import { act, assert, assertEquals, recordSurfaceLine, SOLO_KEYS, stripText } from "../util.ts"; +import { + act, + assert, + assertEquals, + assertIncludes, + recordSurfaceLine, + SOLO_KEYS, + stripText, +} from "../util.ts"; import { addTodo, appFrame, createAccount, solo, until, WAITS } from "../solo-util.ts"; const BUCKET = "pm-outage-recovery"; @@ -151,6 +168,18 @@ async function closeStorageSheet(page: Page): Promise { await page.waitForSelector("#storage-sheet", { state: "detached", timeout: 15_000 }); } +/** Is the dot on the identity circle? The event badge (#132) is a + * text-less child of `#visor-settings`, so its PRESENCE is the whole + * signal — a pure DOM read that opens nothing and waits on nothing, + * which is what lets it sit inside this scenario's timing-sensitive + * beats. Duplicated here rather than imported from another scenario, + * per this suite's scenario-local-helper convention. */ +function badgeLit(page: Page): Promise { + return page.evaluate(() => + document.querySelector("#visor-settings .visor-badge") !== null + ); +} + /** `syncStatus`'s shape, named locally so the `until` callbacks below * read as claims rather than `any`-shaped guesses. */ interface SyncStatusLike { @@ -175,8 +204,11 @@ const scenario: Scenario = { }, // Beats 2 and 3 alone budget past 100s of real backoff waiting; the // suite-wide deadline was never sized for that (Scenario.deadlineMs's - // own doc comment, run.ts). - deadlineMs: 420_000, + // own doc comment, run.ts). The trailing event-record beat adds a + // drawer round trip (two sheet swaps at SWAP_MS plus two selector + // waits) on top, so the budget grows with it rather than the new beat + // silently eating the old margin. + deadlineMs: 480_000, async run(page: Page, ctx: Ctx) { assert(ctx.minioDataDir !== null, "the harness did not expose MinIO's data directory"); @@ -315,6 +347,24 @@ const scenario: Scenario = { announced.includes("this device has stopped syncing with your storage"), `the strip should carry the visor's own announcement: ${JSON.stringify(announced)}`, ); + + // THE STANDING CONDITION LIT THE BADGE (#132). The failing sync + // is not a moment, it is a STATE, and host/solo.ts's + // `watchSyncFailures` now raises it as a keyed condition on the + // visor — the edge return is what gates the announcement above, + // and the same call lights the dot on the identity circle for as + // long as the fault stands. + // + // A PURE DOM READ, and deliberately nothing more: opening the + // drawer here would put a sheet over a scenario whose next beat + // is a multi-minute backoff wait, and the sheet's own ceremony + // is exactly the kind of interposed real time this scenario's + // timing story warns about. The list is inspected at the very + // END instead, where nothing is waiting on it. + assert( + await badgeLit(page), + "the standing sync condition must light the identity circle's badge", + ); }, ); @@ -425,6 +475,54 @@ const scenario: Scenario = { .waitFor({ state: "visible", timeout: WAITS.converge }); }, ); + + await act( + "THE OUTAGE IS STILL ON THE RECORD: the badge stays lit for the unseen failure, and the condition is gone", + async () => { + // THE TWO HALVES OF THE BADGE COME APART HERE, which is the + // whole reason this beat sits at the END of this scenario rather + // than in visor-events.ts: the CONDITION cleared when sync + // recovered, and the dot is STILL lit — because the failure + // announcement was CONSEQUENTIAL and therefore left a record + // that nobody has looked at yet (#132's mechanical rule). The + // recovery announcement is ambient and left nothing. So "lit = + // unseen records ∪ standing conditions" is being read here with + // the first term true and the second false, which no other + // scenario can produce. + assert( + await badgeLit(page), + "the unseen failure record must keep the badge lit after the condition cleared", + ); + + await page.evaluate(() => { + (document.getElementById("visor-settings") as HTMLButtonElement | null)?.click(); + }); + await page.waitForSelector("#visor-settings-events", { timeout: 15_000 }); + await page.click("#visor-settings-events"); + await page.waitForSelector("#visor-drawer-inner .events-sheet", { timeout: 15_000 }); + const text = await page.evaluate(() => + document.getElementById("visor-drawer-inner")?.textContent ?? "" + ); + assertIncludes( + text, + "this device has stopped syncing with your storage", + "the event list must hold the failure the strip announced", + ); + // NO STANDING BLOCK: the condition was cleared on recovery, and + // conditions are session-live — nothing may still be claiming + // the sync is down once it demonstrably is not. + assert( + !text.includes("ongoing:"), + `the sync condition cleared on recovery, so nothing stands: ${JSON.stringify(text)}`, + ); + // And the recovery sentence left NO record — it is ambient, and + // ambient lines never enter the list (#132's entry gating). + assert( + !text.includes("this device is syncing with your storage again"), + `an ambient recovery line must not enter the record: ${JSON.stringify(text)}`, + ); + }, + ); }, }; diff --git a/demo/e2e/scenarios/strip-geometry.ts b/demo/e2e/scenarios/strip-geometry.ts index 4cf1d291..65527257 100644 --- a/demo/e2e/scenarios/strip-geometry.ts +++ b/demo/e2e/scenarios/strip-geometry.ts @@ -10,6 +10,13 @@ // These are the claims a hand-drive checks by squinting. Here they are // numbers: cluster widths as a fraction of the bar, a real tap-target // floor, and zero horizontal overflow on the DOCUMENT. +// +// AND ALL OF IT IS MEASURED WITH THE EVENT BADGE LIT (#132). The dot on +// the identity circle promises zero layout shift, and that promise is +// pure CSS — absolute positioning, out of flow — so this scenario seeds +// an unseen record (see `SEEDED_EVENT`) and takes every number above +// with the dot rendered. A badge that ever started costing layout would +// move these numbers rather than passing unnoticed. import type { Scenario } from "../run.ts"; import { act, assert, hook, KEYS, waitForSheet } from "../util.ts"; @@ -31,6 +38,7 @@ interface Metrics { lines: { h: number; scrollW: number; clientW: number }[]; docOverflow: number; idLines: { scrollW: number; clientW: number }[]; + badge: boolean; } function measure(page: Page): Promise { @@ -76,18 +84,52 @@ function measure(page: Page): Promise { idLines: Array.from(document.querySelectorAll("#visor-identity .id-lines .who")).map( (e) => ({ scrollW: (e as HTMLElement).scrollWidth, clientW: (e as HTMLElement).clientWidth }), ), + // THE EVENT BADGE (#132), read alongside the geometry rather than + // in a beat of its own: every number above is measured WITH the + // dot rendered, and this is what says so. + badge: btn.querySelector(".visor-badge") !== null, }; }); } +/** The demo page's event-record key (visor/ui/visor.ts's `eventsKey`, as + * host/demo.ts sets it). Mirrored here rather than imported for the same + * reason `KEYS` is — a rename there should fail this scenario loudly + * rather than quietly stop lighting the badge. */ +const EVENTS_KEY = "pm-demo-visor-events"; + +/** ONE UNSEEN RECORD, seeded so the identity circle's badge (#132) is + * LIT for every measurement below. + * + * WHY IT BELONGS IN THIS SCENARIO. The badge's zero-layout-shift claim + * is made in CSS — an absolutely positioned, out-of-flow span — and a + * claim that rests only on a stylesheet is a claim nobody is checking. + * Seeding it here means the strip's whole measured geometry (two lines, + * the 44px tap floor, the 45/45/10 split, zero document overflow, the + * identity lines' ellipsis behaviour at 390) is taken with the dot on + * the button. If the badge ever starts costing layout, THESE numbers + * move, which is the only place in the suite where that would be + * caught. + * + * `seenAt: 0` is what makes the record unseen; the timestamp is a fixed + * point in the past (2024-01-01T00:00:00Z) rather than a computed one, + * so the seed is a literal and the sheet's coarse age never enters this + * scenario's business. */ +const SEEDED_EVENT = JSON.stringify({ + seenAt: 0, + events: [{ at: 1_704_067_200_000, text: "seeded for geometry" }], +}); + /** Seeded state shared by both widths: pathological words in every - * variable slot the strip has. */ + * variable slot the strip has, plus the unseen event that lights the + * badge (see `SEEDED_EVENT`). */ const hostileStorage = { [KEYS.identity]: JSON.stringify({ name: "Ada Lovelace-Byron the Countess", device: "the study PC under the stairs", icon: "⚑", }), + [EVENTS_KEY]: SEEDED_EVENT, }; const scenario: Scenario = { @@ -125,6 +167,19 @@ const scenario: Scenario = { }px + padding — something wrapped`, ); + // THE BADGE IS ACTUALLY LIT while all of this is being measured + // (see `SEEDED_EVENT`). Asserted rather than assumed: if the seed + // ever stopped taking — a renamed key, a changed record shape, a + // boot that marks seen on its own — every geometry number above + // would quietly go back to measuring the unlit strip, and the + // zero-layout-shift claim would rest on the stylesheet again with + // nothing to notice. + assert( + m.badge, + `${label}: the seeded unseen event did not light the badge — the geometry above is being ` + + `measured on an UNLIT strip (check ${EVENTS_KEY} and the record shape)`, + ); + // A real tap target. 44×44 is the floor a thumb needs, and this // button is the way into the visor's own settings. assert( diff --git a/demo/e2e/scenarios/visor-events.ts b/demo/e2e/scenarios/visor-events.ts new file mode 100644 index 00000000..e0d7a46c --- /dev/null +++ b/demo/e2e/scenarios/visor-events.ts @@ -0,0 +1,226 @@ +// THE EVENT RECORD (#132): announced-never-silent stops being hollow. +// +// The strip's announcement is twelve seconds on a bar the user may not +// be watching, and on a multi-device account consequential things happen +// while you are away BY CONSTRUCTION. The split #132 rules makes the +// announcement the ARRIVAL and adds a memory behind it: every +// CONSEQUENTIAL announcement leaves a record, the identity circle wears +// a dot while something is unseen, and the list is a drawer sheet +// reached from the settings sheet. This scenario walks that whole path +// once, in order, because every beat of it is a claim no other scenario +// makes: +// +// 1. a genuinely remote-caused, genuinely CONSEQUENTIAL event lands — +// not a synthetic call into the visor, so the mechanical rule +// itself is under test and not just the storage; +// 2. the badge lights on `#visor-settings` (a dot, no text, and the +// button's label gains the standing sentence); +// 3. the settings sheet's own row counts what is waiting, in the +// visor's words; +// 4. the list holds the sentence the strip said, and OPENING MARKS IT +// SEEN — the dot goes out with the sheet still up; +// 5. closing RESUMES settings (the erase entry's suspend/resume +// motion, which is the whole reason the row suspends rather than +// closes); +// 6. a reload finds the record still there and the badge still dark — +// the records persist at `eventsKey`, and so does the seen mark. +// +// WHERE THE EVENT COMES FROM, and why this one. `?pairing=mock` boots +// the demo against the in-page `PairingDriver` (device-pairing-mock.ts's +// backend), and host/demo.ts's boot calls `reconcileFromDriver` — the +// step that compares the localStorage BOOT CACHE against what the +// account actually says and ANNOUNCES the difference (visor/ui/pairing.ts: +// "a silently-changed hue/name is exactly the 'anchor that quietly +// changes' lesson"). So this scenario seeds a boot cache holding a name +// the account does not have, and boot reconciliation announces the +// correction as consequential. That is the cheapest honest source of a +// real one: engine-authored, remote in kind, and travelling the exact +// sink every other consequential line travels. (The pairing join flow +// would work too, at ten times the ceremony for the same one record.) +// +// THE ACCOUNT'S NAME IS THE IDENTITY RECORD'S. host/demo.ts creates the +// user-system group from `visor.identity().name` when no profile exists +// yet, so seeding the identity with a name and the boot NAME CACHE with +// a different one produces exactly one disagreement, worded by +// `reconcileFromDriver` and by nothing this file wrote. + +import type { Page } from "npm:playwright@1.57.0"; +import type { Scenario } from "../run.ts"; +import { + act, + assert, + assertEquals, + assertIncludes, + hook, + KEYS, + sheetText, + UI_TIMEOUT, + waitForBoot, + waitForSheet, +} from "../util.ts"; + +/** The user's own name, as their account will hold it. */ +const ACCOUNT_NAME = "Ada"; +/** What the stale boot cache claims instead — the disagreement + * reconciliation announces. Obviously synthetic, and never anything the + * account itself was told. */ +const STALE_CACHED_NAME = "Bea"; + +/** The demo's user-system boot-cache keys (visor/ui/pairing.ts's + * `usCacheKeys("pm-demo")`, whose prefix host/demo.ts passes). Mirrored + * rather than imported for the same reason `KEYS` is — a rename there + * should fail this scenario loudly. */ +const US_NAME_CACHE = "pm-demo-us-name-cache"; + +/** The sentence `reconcileFromDriver` announces for a name that changed + * underneath the boot cache, quoted here exactly as that function words + * it (visor/ui/pairing.ts) so a reworded announcement fails this + * scenario rather than silently drifting from it. */ +const RECORDED_LINE = `your name is now "${ACCOUNT_NAME}" (synced from your account)`; + +/** Is the dot on the identity circle? A pure DOM read of the visor's own + * pixels — the badge is a child of `#visor-settings` with no text at + * all, so its PRESENCE is the whole signal. */ +function badgeLit(page: Page): Promise { + return page.evaluate(() => + document.querySelector("#visor-settings .visor-badge") !== null + ); +} + +/** The settings button's standing sentence — the badge's only non-visual + * channel (the dot is `aria-hidden`, deliberately). */ +function settingsLabel(page: Page): Promise { + return page.evaluate(() => + document.getElementById("visor-settings")?.getAttribute("aria-label") ?? "" + ); +} + +/** Open the event list the way a user does: the settings sheet's own + * row, clicked. Never a handler call — the row's whole job is the + * suspend/resume motion around the click. */ +async function openEventsSheet(page: Page): Promise { + await page.click("#visor-settings-events"); + await page.waitForFunction( + () => document.querySelector("#visor-drawer-inner .events-sheet") !== null, + undefined, + { timeout: UI_TIMEOUT }, + ); +} + +const scenario: Scenario = { + name: "visor-events", + why: + "a consequential announcement leaves a record: the identity circle's badge lights, the settings row counts it, the list holds the sentence, opening marks it seen, and both the record and the seen-mark survive a reload", + page: { + // The in-page pairing driver: no wasm, no relay, no convergence wait + // — the same choice device-pairing-mock.ts makes, and for the same + // reason (everything above the driver seam is the code under test). + query: { pairing: "mock" }, + storage: { + [KEYS.identity]: JSON.stringify({ name: ACCOUNT_NAME }), + // THE STALE CACHE that makes boot reconciliation have something to + // say. Written before any page script runs, which is the only + // moment it can be a BOOT cache. + [US_NAME_CACHE]: STALE_CACHED_NAME, + }, + }, + + async run(page: Page) { + await act("boot reconciliation announces the account's name, and the badge lights", async () => { + // THE APPEARANCE IS THE ASSERTION, not the absence: the drain and + // the reconcile both run inside boot, so a "no dot yet" read taken + // after `waitForBoot` would be racing the very event this scenario + // is about. The clean absence assertion is the LAST beat, after + // the list has been seen and the page reloaded — there the state + // is settled and a dark badge is a claim rather than a coin toss. + await page.waitForFunction( + () => document.querySelector("#visor-settings .visor-badge") !== null, + undefined, + { timeout: UI_TIMEOUT }, + ); + assert(await badgeLit(page), "the badge should be lit by the reconciliation event"); + assertIncludes( + await settingsLabel(page), + "recent events waiting", + "the settings button's label while the badge is lit", + ); + }); + + await act("the settings sheet's own row says how many are unseen, in the visor's words", async () => { + await hook(page, "settings.openSheet"); + await waitForSheet(page, "settings", true); + const text = await sheetText(page); + // SINGULAR, and the count is inline on the row rather than on the + // badge (the badge is a dot, never a number — #132). + assertIncludes(text, "recent events — 1 unseen", "the settings sheet's event row"); + }); + + await act("the list holds the sentence the strip said, and opening MARKS IT SEEN", async () => { + await openEventsSheet(page); + const text = await sheetText(page); + assertIncludes(text, "Recent events", "the event sheet's heading"); + assertIncludes(text, RECORDED_LINE, "the record the announcement left"); + // The age is coarse and rendered once, at open. + assertIncludes(text, "just now", "the record's relative age"); + // No condition stands on the demo page, so the standing block must + // not render at all — an empty "ongoing:" rule would be the visor + // implying a live fault that does not exist. + assert( + !text.includes("ongoing:"), + `no condition stands, so no standing block should render: ${JSON.stringify(text)}`, + ); + // OPENING IS THE ACKNOWLEDGMENT: the dot is out while the sheet is + // still on screen. + assertEquals(await badgeLit(page), false, "the badge after opening the list"); + assertEquals( + await settingsLabel(page), + "your visor: name, device, colour", + "the settings button's label once nothing is waiting", + ); + }); + + await act("closing the list RESUMES the settings sheet underneath it", async () => { + // The erase entry's motion (visor/ui/sheets.ts): the row SUSPENDED + // settings rather than closing it, so the host slides settings + // back in on this close — there is nothing here that re-opens it. + await page.click(".events-sheet .cred-row button"); + await waitForSheet(page, "settings", true); + const text = await sheetText(page); + assertIncludes(text, "your visor", "the resumed settings sheet"); + // And the row it was opened from is back, now with nothing unseen + // to count. + assertIncludes(text, "recent events", "the resumed sheet's event row"); + assert( + !text.includes("unseen"), + `nothing is unseen after the list was opened: ${JSON.stringify(text)}`, + ); + await hook(page, "settings.cancel"); + await waitForSheet(page, "settings", false); + }); + + await act("the record and the seen-mark both survive a reload", async () => { + // Same browser context: localStorage is the point of this beat. + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForBoot(page); + + // THE CLEAN ABSENCE ASSERTION (see beat 1). The boot cache now + // agrees with the account — `reconcileFromDriver` refreshed it — + // so this boot announces nothing, nothing is unseen, and no + // condition stands. A lit badge here would mean either the + // seen-mark did not persist or the reconciliation re-announced a + // difference that no longer exists. + assertEquals(await badgeLit(page), false, "the badge after a reload with nothing waiting"); + + await hook(page, "settings.openSheet"); + await waitForSheet(page, "settings", true); + await openEventsSheet(page); + assertIncludes( + await sheetText(page), + RECORDED_LINE, + "the record after a reload — the list persists at the demo's eventsKey", + ); + }); + }, +}; + +export default scenario; diff --git a/demo/host/demo.ts b/demo/host/demo.ts index cea3d00f..8a47be2b 100644 --- a/demo/host/demo.ts +++ b/demo/host/demo.ts @@ -273,6 +273,13 @@ const LEGACY_CHROME_KEY = "pm-demo-chrome-hue"; // and must not sound alike (visor/ui/words.ts). const WORD_KEY = "pm-demo-visor-word"; const IDENTITY_KEY = "pm-demo-identity"; +/** WHERE THE EVENT RECORD LIVES (#132) — the list behind the identity + * circle's badge, plus its seen watermark. Nothing on this page has to + * write to it: the us-events drain and `reconcileFromDriver` both speak + * through `usAnnounce`, which is a `visorAnnounceSink`, and that sink + * records every consequential line it announces. This key is only where + * that record survives a reload. */ +const EVENTS_KEY = "pm-demo-visor-events"; // The trust table: the surface marks, the first-sight timestamps and the // user's petnames. The TABLE ITSELF — the assignment rule, the local @@ -976,6 +983,7 @@ async function boot() { legacyHueKey: LEGACY_CHROME_KEY, wordKey: WORD_KEY, identityKey: IDENTITY_KEY, + eventsKey: EVENTS_KEY, appSurface: () => appSurface, contextOverride: () => activePanel?.surface ?? null, }); diff --git a/demo/host/solo.ts b/demo/host/solo.ts index ec53a1ec..07cc56aa 100644 --- a/demo/host/solo.ts +++ b/demo/host/solo.ts @@ -189,6 +189,15 @@ const VISOR_KEY = "pm-solo-visor-hue"; // and must not sound alike (visor/ui/words.ts). const WORD_KEY = "pm-solo-visor-word"; const IDENTITY_KEY = "pm-solo-identity"; +/** WHERE THE EVENT RECORD LIVES (#132) — the list behind the identity + * circle's badge, plus its seen watermark. Its own `pm-solo-` key like + * every other visor key on this page: the demo page is a different + * device on the same origin, and one device's news is not the other's. + * Standing CONDITIONS are not here and never will be — they are + * session-live by design (visor/ui/visor.ts's `setCondition`), because a + * persisted condition could outlive the poller that would have cleared + * it. */ +const EVENTS_KEY = "pm-solo-visor-events"; const MARKS_KEY = "pm-solo-surface-marks"; const US_CACHE_KEYS = usCacheKeys("pm-solo"); @@ -458,6 +467,7 @@ async function boot() { hueKey: VISOR_KEY, wordKey: WORD_KEY, identityKey: IDENTITY_KEY, + eventsKey: EVENTS_KEY, deferClaim: true, // ONE app surface and no nested places: the strip's context falls // back to the app's row and there is nothing to override it with. @@ -1477,6 +1487,17 @@ async function startApp( if (fresh) { visor.announce("new visor colour set for this device — remember it", 15000); } + // THE ONE ANNOUNCE PATH THIS PAGE HAS, and #132's recording rule rides + // inside it: `visorAnnounceSink` writes an event record for every + // CONSEQUENTIAL line before it announces (visor/ui/pairing.ts). There + // is deliberately no second wrapper around this one — every + // consequential sentence the solo page says, including everything + // `drainAndAdopt` drains out of the user system and everything + // `reconcileFromDriver` announces, goes through this single closure, + // so a drained event is recorded EXACTLY ONCE. A local wrapper that + // also called `visor.addEvent` would double every remote change in the + // list; a local wrapper that bypassed the sink would silently drop + // them from it. const announce: AnnounceSink = visorAnnounceSink(visor); note("visor:painted"); @@ -2325,13 +2346,22 @@ async function startApp( * below never repaints a detached node. */ let syncLine: HTMLElement | null = null; - /** True while an announcement about a failing schedule is standing. - * ONE ANNOUNCEMENT PER CROSSING, not one per poll: the poll runs every - * second and a sync that is down stays down, so announcing on the - * fact rather than on the edge would bury every other sentence the - * visor has to say under a metronome. Cleared when the counts come - * back under the threshold, which re-arms it for the NEXT outage. */ - let syncFailureAnnounced = false; + /** THE CONDITION KEY for a schedule that has stopped syncing (#132). + * + * THE EDGE USED TO LIVE IN A LOCAL BOOLEAN HERE (`syncFailureAnnounced`) + * and now lives in the visor's condition table, which is the same + * once-per-crossing discipline moved to the place that can also SHOW + * it. The boolean could only gate an announcement; a condition gates + * the announcement AND lights the badge for as long as the fault + * stands, which is what a state deserves — "sync is down" was never a + * moment, and a transient line was always the wrong medium for it. + * `setCondition` returns true only on the raising edge and + * `clearCondition` only on the lowering one, so the announce-once + * semantics are unchanged: the poll runs every second, a sync that is + * down stays down, and announcing on the fact rather than on the edge + * would bury every other sentence the visor has to say under a + * metronome. */ + const SYNC_CONDITION = "sync"; /** THE THRESHOLD, and it is the worker's own (rpc.ts's `SyncStatus`): * three consecutive failures is where a background failure stops @@ -2409,7 +2439,18 @@ async function startApp( * * A RECOVERY IS ANNOUNCED TOO, and only when a failure was announced * — the visor should not congratulate itself for a sync nobody was - * told had stopped. + * told had stopped. That "only when" is `clearCondition`'s return + * value now (see `SYNC_CONDITION`), not a boolean this file keeps. + * + * TWO DIFFERENT TRACES, ON PURPOSE (#132's own open question, answered + * here by the mechanical rule and nothing else): the failure announce + * is CONSEQUENTIAL, so the sink records it and it stands in the event + * list until the user opens it; the recovery announce is AMBIENT, so + * it says its piece and leaves nothing behind. The asymmetry is right + * — "your work stopped going where you think it does" is news worth + * finding later, "it is fine again" is not — and the badge's other + * half covers the live case: while the fault stands, the CONDITION + * lights the dot even if every record has been seen. */ const watchSyncFailures = async (): Promise => { let st: DeviceStatus; @@ -2422,16 +2463,30 @@ async function startApp( if (!sync) return; const failing = sync.flushFailures >= SYNC_VISIBLE_AFTER || sync.pullFailures >= SYNC_VISIBLE_AFTER; - if (failing && !syncFailureAnnounced) { - syncFailureAnnounced = true; - announce( - `this device has stopped syncing with your storage${ - sync.lastError === null ? "" : ` — ${sync.lastError}` - }`, - true, + if (failing) { + // RAISED ON EVERY FAILING POLL, ANNOUNCED ONLY ON THE CROSSING. + // The repeat calls are not waste: they REFRESH the condition's + // wording, so the sentence the event sheet shows carries the + // seam's latest error rather than whatever it happened to be + // saying when the fault started. The condition's own text is + // shorter than the announcement's, because it is a standing state + // read in a list ("not syncing with your storage") rather than an + // arrival read once on the bar. + const first = visor.setCondition( + SYNC_CONDITION, + `not syncing with your storage${sync.lastError === null ? "" : ` — ${sync.lastError}`}`, ); - } else if (!failing && syncFailureAnnounced) { - syncFailureAnnounced = false; + if (first) { + announce( + `this device has stopped syncing with your storage${ + sync.lastError === null ? "" : ` — ${sync.lastError}` + }`, + true, + ); + } + } else if (visor.clearCondition(SYNC_CONDITION)) { + // The clear IS the test: it returns true only if the condition was + // standing, which is exactly "a failure was announced". announce("this device is syncing with your storage again"); } await paintSyncLine(); diff --git a/visor/README.md b/visor/README.md index 79fe60f0..f219029b 100644 --- a/visor/README.md +++ b/visor/README.md @@ -37,6 +37,33 @@ Three layers, one trust story: (`#visor-strip` and friends) are fixed — position is a trust anchor. `visor.css` carries the visor-owned styles both pages link. + THE EVENT RECORD lives here too (#132), because the strip's + announcement was doing three jobs on one transient line. Moment cues + stay push; **standing conditions** become keyed state the visor holds + (`setCondition`/`clearCondition`, whose return value is the EDGE, so a + poller announces once per crossing instead of once per tick) and are + session-live, never persisted — a stored condition could outlive the + poller that would have cleared it; **event records** become a + persistent, acknowledgeable list under the consumer's `eventsKey`, + wiped with everything else by `erase()`. The mechanical rule is one + line per host: EVERY CONSEQUENTIAL ANNOUNCEMENT LEAVES A RECORD, and + ambient lines never do — it sits inside `visorAnnounceSink`, which + already carried the flag, so a new event source is recorded the day it + is written and ambient telemetry cannot silt the list up. What + advertises it is a DOT on the identity circle (`#visor-settings`): + absolutely positioned so the strip's measured geometry cannot move, + carrying no text at all so it is framework voice by construction, and + lit exactly when there are unseen records OR a condition standing. + Behind it is the "recent events" sheet, reached from a visor-owned row + on the settings sheet through the erase entry's suspend/resume motion, + and OPENING IT MARKS EVERYTHING SEEN (the badge then goes out unless a + condition still stands). Entries are flat strings under `announce()`'s + voice policy exactly — framework voice, user-voice words inline, app + voice never — and under one further rule: THE AUTHOR IS ALWAYS THE + VISOR OR THE ENGINE. The subject of a record may be an app; letting an + app light the user's own identity circle would be handing components + "look at me!" as a primitive. + Device pairing lives here too (`pairing.ts`, PAIRING.md §5): the join flow's QR and 79-char code, the SAS screens, the add flow's heavy ceremony (statement of consequence, the drawer host's own `ARM_MS` diff --git a/visor/ui/pairing.ts b/visor/ui/pairing.ts index bbd733a3..eee419d5 100644 --- a/visor/ui/pairing.ts +++ b/visor/ui/pairing.ts @@ -223,7 +223,36 @@ export function visorAnnounceSink(visor: Visor, key = "visor-strip"): AnnounceSi return (line, consequential = false) => { const now = performance.now(); if (!consequential && (stickyUntil.get(key) ?? 0) > now) return; - if (consequential) stickyUntil.set(key, now + STICKY_MS); + if (consequential) { + stickyUntil.set(key, now + STICKY_MS); + // THE MECHANICAL RULE (#132): EVERY CONSEQUENTIAL ANNOUNCEMENT + // LEAVES A RECORD, and ambient lines never do. + // + // It lives HERE, in the one place a consumer's consequential + // traffic already funnels through, because that is what makes the + // wiring a rule rather than a habit: a host does not have to + // remember to record anything, and a new consequential event + // source is recorded the day it is written. The gate is the flag + // the sink already carried — ambient telemetry stays out, or the + // list becomes junk mail and the one alarm that matters drowns in + // it. + // + // BEFORE the announce, so that if `announce` ever throws (a + // detached strip, a consumer mid-teardown) the memory has already + // been written — the record is the half that has to survive. + // + // THE AUTHOR RULE HOLDS BY CONSTRUCTION on this path: everything + // that reaches a sink is visor- or engine-authored (see + // `AnnounceSink`'s "where the boundary sits" — a `PairingDriver` + // is host code on the visor's side of the app seam, not a + // sandboxed component), so nothing an app influenced can light the + // user's own identity circle. + // + // NOT IN `statusWriter`: that sink writes a caller-owned status + // ELEMENT on the standalone pairing page, which has no visor and + // therefore no badge and no list to record into. + visor.addEvent(line); + } visor.announce(line, consequential ? STICKY_MS : undefined); }; } diff --git a/visor/ui/sheets.ts b/visor/ui/sheets.ts index fa38b08a..113a0f18 100644 --- a/visor/ui/sheets.ts +++ b/visor/ui/sheets.ts @@ -524,12 +524,46 @@ export interface VisorSheets { * sheet's danger entry; exposed here for a consumer's driving hooks and * for the e2e suite. */ requestReset(): void; + /** Open the visor's own event list (#132). Reachable in the UI ONLY + * from the settings sheet's "recent events" row — the badge that + * advertises it is a dot on the settings button, and the way in is + * therefore through settings, exactly as the erase ceremony's is. + * Exposed here for a consumer's driving hooks and for the e2e suite. */ + requestEvents(): void; closeNaming(opts?: { context?: boolean }): void; closeSettings(opts?: { context?: boolean; commit?: boolean }): void; closeReset(opts?: { context?: boolean }): void; + closeEvents(opts?: { context?: boolean }): void; namingOpen(): boolean; settingsOpen(): boolean; resetOpen(): boolean; + eventsOpen(): boolean; +} + +/** A COARSE AGE, in the visor's own words — "how long ago" at the + * resolution a person actually reads a notification list at. + * + * DELIBERATELY COARSE, and deliberately not a clock. An exact timestamp + * invites the user to reason about ordering and causation from a + * device-local wall clock that may have jumped, been set by hand, or + * disagreed with the device the event came FROM; "3 hours ago" makes the + * weaker claim the visor can actually support. Rendered once, when the + * sheet opens (there is no ticking timer behind it): a list that + * silently rewrote itself while being read would be motion in the one + * place that is supposed to hold still. + * + * A future timestamp — a record written by a device whose clock ran + * ahead — reads as "just now" rather than as a negative age, which is + * the smallest honest thing to say about it. */ +function agoWords(at: number, now: number): string { + const secs = Math.max(0, Math.round((now - at) / 1000)); + if (secs < 60) return "just now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; } /** Register the visor's naming and settings ceremonies on a visor. @@ -623,9 +657,39 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi afterCollapse: thawPlace, }); + /** THE EVENT LIST'S SESSION (#132), registered BETWEEN settings and + * reset because registration order IS precedence here: the list is a + * lightweight sheet exactly like settings — nothing is typed on it, + * nothing is destroyed by it, and the worst a mis-tap costs is a sheet + * the user closes — so it must not outrank anything settings does not, + * and the erase ceremony must keep sitting behind both. + * + * NOT SUSPENDABLE, and that asymmetry is the point of the pair. The + * SETTINGS sheet suspends under this one (see the entry row's handler + * in `buildSettingsSheet`), because the user stepped one level further + * into an errand they will come back from. This sheet suspends under + * nothing: there is no deeper step to take from a list, and anything + * that displaces it is a separate errand started from elsewhere. + * + * It carries no session state at all — the list is rendered from the + * visor at open, and the ages are wall-clock at that instant (see + * `buildEventsSheet`: no ticking timers). */ + const eventsTenant = visor.drawer.tenant>({ + name: "events", + // The same three words the entry button, the strip's bottom line and + // the sheet's heading use. One place, one name. + spoken: "recent events", + context: () => ({ kind: "events" }), + dim: overNestedPlace, + beforeShow: freezePlace, + afterCollapse: thawPlace, + }); + /** THE ERASE SESSION, registered AFTER settings so the two lightweight - * tenants keep the precedence they had and this one sits behind them - * in `restoreContext`'s order. It carries no state: there is nothing to + * tenants keep the precedence they had — and after the event list, + * which joined them as a third of the same weight class (see its + * registration above) — so this one sits behind all of them in + * `restoreContext`'s order. It carries no state: there is nothing to * preview, nothing to revert, and the only thing the user can put into * it is the typed confirmation, which must never outlive the sheet. * @@ -655,6 +719,7 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi const closeSettings = (opts: { context?: boolean; commit?: boolean } = {}) => settingsTenant.close(opts); const closeReset = (opts: { context?: boolean } = {}) => resetTenant.close(opts); + const closeEvents = (opts: { context?: boolean } = {}) => eventsTenant.close(opts); /** Build the visor's App settings sheet — the naming ceremony GROWN into * the one place the visor says everything it knows about a component. @@ -1139,6 +1204,50 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi // CONSUMER ACTIONS (see `extraActions`). Nothing is rendered at all // when there are none, so a consumer that passes no actions gets the // sheet exactly as it was before this hook existed. + // THE VISOR'S OWN ROW, and it sits BEFORE the consumer's actions + // block deliberately: what it opens is the visor's own record of + // what the visor said, so it belongs with the framework's rows + // rather than below whatever a consumer contributed. It is framework + // policy for the same reason the erase entry is — a consumer that + // had to contribute this button could decline to, leaving a badge on + // the strip with no way in. + const eventsBlock = document.createElement("div"); + eventsBlock.className = "cred-field"; + const eventsBtn = document.createElement("button"); + eventsBtn.type = "button"; + eventsBtn.id = "visor-settings-events"; + eventsBtn.className = "settings-extra-action"; + // THE COUNT IS INLINE, IN THE VISOR'S OWN WORDS, and it is here + // rather than on the badge on purpose (#132): the badge is a DOT, + // never a number — it says "something is waiting" on a 12px circle + // where a numeral would be unreadable and a growing number would + // read as a demand. This row is where the user has already chosen to + // look, so it can afford the arithmetic. + const unseen = visor.unseenEventCount(); + eventsBtn.textContent = unseen > 0 + ? `recent events — ${unseen} unseen` + : "recent events"; + eventsBtn.onclick = () => { + // THE ERASE ENTRY'S MOTION, EXACTLY (see the reset button below + // and its long comment, which is the authority for every line + // here): revert the live hue preview by hand, because suspension + // deliberately bypasses the tenant's `beforeCollapse` and an + // uncommitted colour would otherwise ride into the list's frame + // and then disagree with the swatch the rebuilt sheet re-selects; + // then SUSPEND rather than close, so this sheet slides back in + // when the list closes and the host speaks "«word»: visor settings + // back". try/finally so a throw on the way in cannot leave every + // later displacer suspending settings instead of evicting it. + visor.applyHue(hueAtOpen); + settingsSuspends = true; + try { + requestEvents(); + } finally { + settingsSuspends = false; + } + }; + eventsBlock.append(eventsBtn); + const actions = config.extraActions ?? []; const actionsBlock = document.createElement("div"); // NOT `cred-row`: that class is the Save/Cancel pair, and both this @@ -1271,6 +1380,7 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi wordRow, note, ); + root.append(eventsBlock); if (actions.length > 0) root.append(actionsBlock); root.append(row); return { @@ -1550,6 +1660,149 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi }); }; + /** THE EVENT LIST (#132): what the visor has said, kept after the line + * that said it expired. + * + * WHY THE SHEET EXISTS AT ALL. An announcement is twelve seconds on a + * strip the user may not be watching, and on a multi-device account + * consequential things happen while you are away BY CONSTRUCTION. The + * strip's line is still the arrival — this is the memory, so that + * "announced, never silent" becomes "spoken at arrival, standing until + * seen". + * + * TWO SECTIONS, IN THIS ORDER, and the order is the argument: + * CONDITIONS first because they are true RIGHT NOW (a sync that is + * down is a thing to act on, not a thing that happened), then the + * records newest-first because the newest news is the news. + * + * EVERY LINE HERE IS FRAMEWORK VOICE and is rendered undressed — + * `textContent` into a plain element, no plate, no quoting. That is + * only safe because of the rule at the door (`Visor.addEvent`): these + * strings are the visor's and the engine's own sentences, under + * exactly `announce`'s policy, and an app-influenced string is not + * admissible into the record in the first place. If that ever changes, + * it changes here too — the growth path is typed slots, not a plate + * bolted onto a flat string. + * + * AGES ARE RENDERED ONCE, at open (see `agoWords`). No interval, no + * re-render: nothing here is urgent enough to justify a list that + * moves while it is being read, and a timer left behind by a closed + * sheet is a leak waiting to be found. */ + const buildEventsSheet = () => { + const root = document.createElement("div"); + root.className = "cred-sheet events-sheet armed"; + root.style.maxWidth = "72rem"; // rem: aligns with the page's --content-max column + root.style.marginLeft = "auto"; + root.style.marginRight = "auto"; + + const h = document.createElement("h2"); + h.textContent = "Recent events"; + root.append(h); + + const conditions = visor.conditions(); + const events = visor.events(); + + // THE STANDING CONDITIONS. Visually a block of their own, dressed as + // neither a plate nor a button: the plate is APP VOICE (these + // sentences are the visor's), and a button dress on a line the user + // cannot press is a control that does nothing. What marks them is + // the lead word — "ongoing:" — plus a quiet rule down the left, in + // the visor's own explanatory register. + if (conditions.size > 0) { + const standing = document.createElement("div"); + standing.className = "events-standing"; + for (const text of conditions.values()) { + const line = document.createElement("div"); + line.className = "events-standing-line"; + const lead = document.createElement("span"); + lead.className = "said"; + lead.textContent = "ongoing:"; + const body = document.createElement("span"); + body.textContent = text; + line.append(lead, body); + standing.append(line); + } + root.append(standing); + } + + if (events.length > 0) { + const now = Date.now(); + const list = document.createElement("div"); + list.className = "events-list"; + for (const e of events) { + const row = document.createElement("div"); + row.className = "events-row"; + const when = document.createElement("span"); + // `.said` is the visor's commentary register (visor/README.md's + // three voices) — the age is the visor talking ABOUT the entry, + // and the entry itself is the sentence. + when.className = "said events-when"; + when.textContent = agoWords(e.at, now); + const what = document.createElement("span"); + what.textContent = e.text; + row.append(when, what); + list.append(row); + } + root.append(list); + } + + // THE EMPTY STATE. Said plainly and said at all: a sheet that opened + // onto a heading and nothing else would read as a failure to load + // rather than as good news. + if (conditions.size === 0 && events.length === 0) { + const empty = document.createElement("div"); + empty.className = "cred-note"; + empty.textContent = "nothing to report"; + root.append(empty); + } + + const note = document.createElement("div"); + note.className = "cred-note"; + note.textContent = + "this list is the visor's own — every line here was said by the visor or the system behind it, never by an app"; + root.append(note); + + // The same row dress the settings sheet's Save/Cancel pair wears, so + // the way out of a visor sheet is one learnable shape. One button: + // there is nothing to commit or to cancel on a list. + const row = document.createElement("div"); + row.className = "cred-row"; + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.textContent = "Close"; + row.append(closeBtn); + root.append(row); + + return { root, closeBtn }; + }; + + const openEventsDrawer = () => { + const session: Record = {}; + eventsTenant.open(session, () => { + // OPENING IS THE ACKNOWLEDGMENT (#132's v1 model), and it happens + // BEFORE the sheet is built for a reason that is not merely + // ordering: the rows carry no seen/unseen marking of their own, so + // a list built first and marked seen afterwards would be + // indistinguishable — and marking first means the badge is already + // correct by the time the sheet finishes sliding in. What survives + // the mark is any STANDING CONDITION, which is exactly right: a + // condition is not news to be acknowledged, it is a state that is + // still true, and the dot stays lit until it stops being. + visor.markEventsSeen(); + const built = buildEventsSheet(); + built.closeBtn.onclick = () => { + if (!eventsTenant.owns(session)) return; + // A plain close. The settings sheet that opened this one is + // SUSPENDED, not closed (see the entry row), so the drawer host + // resumes it here — rebuilt, sliding back in, and the host + // speaks "«word»: visor settings back". Re-opening it from here + // would race the host into a second settings session. + closeEvents(); + }; + return { root: built.root }; + }); + }; + // The visor's naming ceremony, reachable ONLY from the strip's own // pixels — and the consumer's preconditions, in that order: the refusal // first (so a click while an exclusive sheet is up is a pure no-op), @@ -1583,6 +1836,12 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi // `open` outright, and nothing happens. const requestReset = () => openResetDrawer(); + // THE EVENT LIST, and it does NOT re-run the consumer's preconditions + // either, for exactly `requestReset`'s reason: it is reached only from + // the settings sheet, which paid `canOpen`/`beforeOpen` a moment ago. + // The refusal that still applies is the drawer host's own. + const requestEvents = () => openEventsDrawer(); + // THE STRIP'S LATE-BOUND CONTROLS. The strip is built by `initVisor`, // long before the drawer's tenants exist, so the "name it" affordance, // the context cluster and the settings button call through the visor's @@ -1594,11 +1853,14 @@ export function registerVisorSheets(visor: Visor, config: VisorSheetsConfig): Vi requestNaming, requestSettings, requestReset, + requestEvents, closeNaming, closeSettings, closeReset, + closeEvents, namingOpen: () => namingTenant.isOpen(), settingsOpen: () => settingsTenant.isOpen(), resetOpen: () => resetTenant.isOpen(), + eventsOpen: () => eventsTenant.isOpen(), }; } diff --git a/visor/ui/visor.css b/visor/ui/visor.css index 255f5a2b..16a95111 100644 --- a/visor/ui/visor.css +++ b/visor/ui/visor.css @@ -429,7 +429,45 @@ justify-content: center; width: 2.4em; height: 2.4em; min-width: 44px; min-height: 44px; - font-size: 1.5em; line-height: 1; } + font-size: 1.5em; line-height: 1; + /* The badge's containing block (see .visor-badge). + Positioning context only — it changes no geometry + by itself. */ + position: relative; } + +/* THE EVENT BADGE (#132): a dot on the identity circle, meaning + "something is waiting" — unseen event records, or a condition that is + standing right now. + + A DOT AND NEVER A COUNT, and the element is EMPTY. That makes it + FRAMEWORK VOICE BY CONSTRUCTION, the same trick #visor-ctx's pulse + uses: there is no string here, so there is nothing that could carry + another voice's marking onto the anchor. `aria-hidden` in the markup + keeps it out of the accessibility tree; the button's own label gains + "— recent events waiting" instead, which is a standing state read on + demand rather than an interruption. + + ABSOLUTE, AND THAT IS THE POINT: out of flow inside the button, so + the strip's MEASURED GEOMETRY (the `strip-geometry` e2e scenario) is + identical lit and unlit. A badge that reflowed the identity cluster + would move the one region whose position is a trust anchor. + + IT DOES NOT WEAR THE PLATE'S DRESS AND IT DOES NOT WEAR THE BUTTON'S. + No border (a bordered rectangle is this visor's control dress) and no + inset shadow (that is the app-voice plate's "recessed" marking, and + this is the visor talking about the visor). A solid, near-white disc + with a dark ring around it instead: the ring is a SEPARATOR from the + glyph and the button's own edge, not a border in the control sense, + and it is what makes the dot read at 12px type on all ten anchor hues + at 38% lightness — light-on-any-hue plus a dark halo works where a + single alpha value does not, because the badge must stay visible + against both the button's own lightened fill and the glyph beneath + it. */ +.visor-badge { position: absolute; top: 0; right: 0; + width: .34em; height: .34em; border-radius: 50%; + background: rgba(255,255,255,.96); + box-shadow: 0 0 0 1.5px rgba(0,0,0,.45); + pointer-events: none; } /* --- the drawer host's own chrome (no sheet CONTENT here) ----------------- Credential/naming/settings entry — every consumer's version of it — @@ -918,6 +956,39 @@ .settings-sheet .settings-reset .hint { font-size: 10px; opacity: .75; text-align: right; } +/* --- the event list (#132) ------------------------------------------- + The sheet behind the badge: what the visor said, kept after the line + that said it expired. It wears the shared `.cred-sheet` shape and adds + only these two blocks. + + NEITHER BLOCK WEARS THE PLATE'S DRESS OR THE BUTTON'S. The plate (an + alpha fill plus an inset shadow) is APP VOICE, and every line here is + the visor's own sentence; a bordered rectangle is this visor's CONTROL + dress, and none of these lines can be pressed. What separates them is + spacing, a quiet lead word and — for the standing block — a rule down + the left edge, which is the visor's explanatory register rather than + another voice. + + STANDING CONDITIONS read at full strength: they are true right now, + and dimming a live fault to make a list look calm would be the visor + choosing its own composure over the user's information. The AGE on a + record is `.said` (the visor's commentary register, .85), because it + is the visor talking about the entry rather than the entry itself. */ +.events-sheet .events-standing { display: flex; flex-direction: column; + gap: .3em; margin: .7em 0 .2em; + padding-left: .7em; + border-left: 2px solid rgba(255,255,255,.35); } +.events-sheet .events-standing-line { font-size: 13px; line-height: 1.4; + display: flex; gap: .4em; } +.events-sheet .events-list { display: flex; flex-direction: column; + gap: .35em; margin: .8em 0 .2em; } +.events-sheet .events-row { font-size: 13px; line-height: 1.4; + display: flex; gap: .5em; align-items: baseline; } +/* The age column is narrow and does not wrap: the sentences beside it + should start on one vertical line, so the list reads as a list. */ +.events-sheet .events-row .events-when { flex: none; min-width: 7.5em; + white-space: nowrap; } + /* --- the reset sheet ------------------------------------------------- THE HEAVY TENANT of the same drawer, and the only one of the three framework sheets that is: armed and dimmed, because what it does is diff --git a/visor/ui/visor.ts b/visor/ui/visor.ts index 44a7909e..864ba418 100644 --- a/visor/ui/visor.ts +++ b/visor/ui/visor.ts @@ -396,6 +396,67 @@ export function saveIdentity(identityKey: string, rec: VisorIdentity): void { } catch { /* nothing durable to write to */ } } +// --- the event record (#132) -------------------------------------------------- + +/** ONE THING THAT HAPPENED, kept after the line that said it expired. + * + * `text` is exactly what an announcement said — a FLAT STRING under + * `announce`'s three-voices policy (framework voice, user-voice words + * admissible inline, an app-influenced string never), which is what lets + * the list render it with no dressing at all. `at` is wall-clock + * milliseconds, used only to sort and to word a coarse age; nothing here + * is a clock the user is asked to trust. */ +export interface VisorEvent { + at: number; + text: string; +} + +/** How many records the visor keeps. The list is a RECENT-EVENTS list, + * not a log: past a screenful or two nobody scrolls, and an unbounded + * array in localStorage is a slow leak on the one storage the visor owns + * on this device. Oldest drops first — the newest news is the news. */ +export const EVENTS_MAX = 50; + +/** The persisted shape at `VisorConfig.eventsKey`: the records, plus the + * watermark that says which of them have been seen. Both halves in ONE + * key, because they are one fact — a seen-mark without its records is + * meaningless, and records without their mark would re-light the badge on + * every boot. */ +interface EventRecord { + seenAt: number; + events: VisorEvent[]; +} + +/** Read the record, TOLERANTLY. Missing, unparseable or hand-mangled all + * answer the same way — an empty record — for the same reason + * `loadIdentity` does: this is hand-editable storage, and a visor that + * threw on boot because a string in localStorage was not JSON would be + * bricked by a devtools typo. A corrupt record loses history; it must + * never lose the visor. */ +export function loadEvents(eventsKey: string): EventRecord { + try { + const raw = JSON.parse(localStorage.getItem(eventsKey) ?? "{}"); + if (!raw || typeof raw !== "object") return { seenAt: 0, events: [] }; + const rec = raw as Record; + const list = Array.isArray(rec.events) ? rec.events : []; + const events: VisorEvent[] = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const e = item as Record; + // Both fields or neither: a record with no text is a badge with + // nothing behind it, which is the one failure the badge cannot + // afford (see the events sheet's empty state). + if (typeof e.text !== "string" || e.text === "") continue; + if (typeof e.at !== "number" || !Number.isFinite(e.at)) continue; + events.push({ at: e.at, text: e.text }); + } + const seenAt = typeof rec.seenAt === "number" && Number.isFinite(rec.seenAt) ? rec.seenAt : 0; + return { seenAt, events: events.slice(-EVENTS_MAX) }; + } catch { + return { seenAt: 0, events: [] }; + } +} + /** The glyph the visor's own button wears. Unknown/absent → the default * shield (see VISOR_ICONS). */ export function identityIcon(rec: VisorIdentity): string { @@ -475,6 +536,11 @@ export type VisorContext = // and unlike "settings" it is a destructive act, which changes the // tenant's weight class (armed, dimmed) but not what the context means. | { kind: "reset" } + // THE EVENT LIST (#132, sheets.ts's "recent events" tenant). A bare + // kind for the same reason "settings" is: it is the visor telling the + // user what has happened to their own account and devices, and no + // component stands behind any of it. + | { kind: "events" } // THE ENTRY CEREMONIES (entry.ts): the device picker and the first-run // fork. Bare kinds for the same reason "settings" and "reset" are — // there is no component behind either. The picker is the only context @@ -779,6 +845,18 @@ export interface VisorConfig { wordKey: string; /** Where the identity record lives. */ identityKey: string; + /** Where the EVENT RECORD lives (#132) — the list behind the badge, + * plus its seen-watermark, under one key. + * + * OPTIONAL, and what its absence means is "this session only", not + * "off". The records still accumulate, the badge still lights, the + * sheet still lists them; nothing survives the reload. That is the + * honest degradation for an embedder that has not thought about where + * this belongs (the todomvc spike), and it keeps the feature from + * being a second thing a consumer can forget to turn on. A visor that + * silently dropped the whole mechanism because a key was missing would + * make announced-never-silent depend on configuration. */ + eventsKey?: string; /** THE APP'S OWN ROW IN THE TRUST TABLE — what the strip's top line * falls back to when no secondary surface is on screen. */ appSurface?: () => SurfaceIdentity | null; @@ -939,6 +1017,93 @@ export interface Visor { setBack(action: BackAction | null): void; identity(): VisorIdentity; saveIdentity(rec: VisorIdentity): void; + /** RECORD SOMETHING THAT HAPPENED, so it stands until the user has + * seen it (#132). Appends the line to the event record, drops the + * oldest past `EVENTS_MAX`, persists best-effort at + * `VisorConfig.eventsKey`, and lights the badge. + * + * IT NEVER SPEAKS. Not `announce`, not `speak`, not a pulse — this is + * the MEMORY half of an announcement, and the arrival push stays the + * caller's. That separation is the whole shape of the split: a + * consequential arrival still owes the user the spoken sentence and + * the strip line at the moment it happens; the record is what makes + * announced-never-silent survive a user who was not looking. Calling + * this INSTEAD of announcing would trade a loud twelve seconds for a + * silent dot, which is the opposite of the trade. + * + * VOICE — EXACTLY `announce`'s POLICY, and for exactly its reason. + * `text` is a flat string and therefore cannot carry class marking, so + * it is spoken in FRAMEWORK VOICE and may embed USER-voice words + * inline (a petname, the user's word for a device). An APP-INFLUENCED + * string must NEVER be passed here: the sheet renders these lines + * undressed, so a component's own words would sit in the visor's list + * wearing the visor's authority. Describe the fact in the visor's + * vocabulary instead. + * + * THE AUTHOR RULE (#132): only the VISOR and the ENGINE author events. + * The SUBJECT of a record may well be an app — an update landed, a + * component was revoked — but the AUTHOR never is. An app that could + * light the user's own identity circle would have been handed "look at + * me!" as a primitive, which is the one attention channel the visor + * exists to keep unforgeable. Nothing here can check provenance, so + * the rule is stated at the door and enforced by who is on the visor's + * side of the app seam. + * + * SILENT BEFORE THE CLAIM, never a refusal. The identity cluster + * renders nothing while unclaimed, so there is no button to badge and + * the dot simply does not exist yet — the record is still kept, and + * the badge appears with the cluster at `claim()`. */ + addEvent(text: string): void; + /** The event record, NEWEST FIRST — the order the sheet reads them in, + * so the list is not something every caller re-sorts. */ + events(): readonly VisorEvent[]; + /** ACKNOWLEDGE EVERYTHING CURRENTLY RECORDED: move the seen-watermark + * to now (or to the newest record, whichever is later — a record whose + * clock ran ahead must not stay permanently unseen), persist, and + * re-sync the badge. + * + * v1's whole acknowledgment model is "opening the list marks it seen". + * Per-entry dismissal waits until entries carry actions, because a + * dismiss control on an entry that does nothing else is a control + * whose only function is to make the user do the visor's filing. + * + * SILENT, like every method here: the badge un-lights, and nothing is + * said about it. */ + markEventsSeen(): void; + /** How many records are newer than the seen-watermark — the number the + * settings row says out loud, and half of what lights the badge. */ + unseenEventCount(): number; + /** RAISE A STANDING CONDITION under `key`: a state, not a moment + * ("sync is failing"), which stays lit for as long as it stands. + * + * THE RETURN VALUE IS THE EDGE, and it is the whole reason this + * returns anything: true ONLY when the key was not already standing. + * A poller can therefore call this on every failing tick and announce + * only on the crossing, which is the hand-rolled `syncFailureAnnounced` + * boolean (#132) moved into the one place that can hold it honestly. + * Re-setting a standing key UPDATES its text — a condition's wording + * gets better as the seam learns more — and returns false. + * + * SESSION-LIVE, NEVER PERSISTED, and that is deliberate: a persisted + * condition could outlive the thing that caused it with nothing left + * running to clear it, leaving a badge lit forever over a fault that + * ended while the tab was closed. Whoever raises a condition is a + * poller, and a poller re-asserts it a tick after the next boot. + * + * VOICE and AUTHOR: `text` is under `addEvent`'s policy, word for + * word. Silent, like everything here — the crossing's announcement is + * the caller's to make, guarded by this return. */ + setCondition(key: string, text: string): boolean; + /** LOWER A STANDING CONDITION. True only if it WAS standing — the + * other edge, so a recovery sentence is said once and only when a + * failure sentence was said (the visor does not congratulate itself + * for fixing something nobody was told was broken). Silent. */ + clearCondition(key: string): boolean; + /** The conditions currently standing, in the order they were raised — + * insertion order, because the sheet lists them in the order they + * arrived and re-sorting standing facts by anything else would make a + * stable list jump. */ + conditions(): ReadonlyMap; /** The hue currently COMMITTED as the user's anchor colour — as opposed * to a live preview a settings sheet is painting. `applyHue` paints; * this moves only where the choice is persisted, so a Cancel has @@ -1002,8 +1167,16 @@ export interface Visor { rerollWord(): void; /** FORGET EVERYTHING THIS VISOR HOLDS ON THIS DEVICE — the storage half * 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 committed anchor WORD, the EVENT RECORD and (when the consumer + * configured one) the legacy hue key are removed; nothing else is + * touched. + * + * The event record goes with the rest because it is the visor's memory + * of what happened TO THIS USER on this device — a list of sentences + * naming their devices and their account, which is exactly the class + * of thing the ceremony promised to forget. Leaving it would produce + * the sharpest possible contradiction: a freshly erased visor whose + * first badge points at a list of the previous owner's news. * * 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 @@ -1183,6 +1356,88 @@ export function initVisor(config: VisorConfig): Visor { // narrowness, and dropping them was dropping half of what an // impersonating rectangle cannot reproduce, at the width where the // strip is most crowded. + // --- the event record and the standing conditions (#132) ------------- + // + // TWO KINDS OF NEWS, one badge. RECORDS are moments that already + // happened and are kept until acknowledged; CONDITIONS are states that + // are true right now and clear themselves when they stop being true. + // They are held separately because they answer to different clocks — + // a record's clock is the user's attention (seen or not seen), a + // condition's is the world's (standing or not standing) — and the one + // thing they share is the dot. + // + // WHY THE STATE LIVES HERE rather than in a consumer: the badge hangs + // on the identity circle, which is the visor's own pixels, and the + // author rule (`addEvent`) is only meaningful if the list is not + // something an app-facing surface can reach at all. + const eventStore = config.eventsKey === undefined + // NO KEY, STILL A FEATURE: the in-memory record. See + // `VisorConfig.eventsKey` — the degradation is durability, not + // function. + ? { seenAt: 0, events: [] as VisorEvent[] } + : loadEvents(config.eventsKey); + /** Standing conditions, keyed, INSERTION-ORDERED by the Map itself — + * and deliberately not persisted (see `setCondition`). */ + const conditions = new Map(); + /** The button the badge hangs on, or null while unclaimed. Held rather + * than looked up each time because `renderIdentity` RECREATES the + * button, and a stale node would silently swallow every later badge + * update. */ + let settingsBtn: HTMLButtonElement | null = null; + + const persistEvents = () => { + if (config.eventsKey === undefined) return; + try { + localStorage.setItem(config.eventsKey, JSON.stringify(eventStore)); + } catch { /* nothing durable to write to — the session copy still stands */ } + }; + + const unseenEventCount = () => eventStore.events.filter((e) => e.at > eventStore.seenAt).length; + + /** The button's standing sentence, extended while the dot is lit. The + * dot itself carries no text (see `syncBadge`), so this is the ONLY + * channel that tells a non-sighted user the badge is on — and it is a + * `title`/`aria-label` rather than an announcement because the badge + * is a standing state, not an arrival. */ + const SETTINGS_LABEL = "your visor: name, device, colour"; + const SETTINGS_LABEL_LIT = `${SETTINGS_LABEL} — recent events waiting`; + + /** Put the dot on (or take it off) the CURRENT settings button. + * + * LIT = UNSEEN RECORDS ∪ STANDING CONDITIONS (#132). One predicate, so + * there is never a state where something is waiting and nothing shows. + * + * IT IS A DOT AND NEVER A COUNT, and the span is EMPTY: framework + * voice by construction, exactly as `pulseContext` is — there is no + * string here, so there is no way for any voice to leak onto the + * anchor. `aria-hidden` keeps it out of the accessibility tree + * entirely; the label above is the non-visual channel, because a bare + * decorative node announced as content would be noise. + * + * ZERO LAYOUT SHIFT: the span is absolutely positioned inside the + * button (visor.css), so it is out of flow and the strip's measured + * geometry — a property the `strip-geometry` scenario pins — cannot + * move whether the dot is there or not. Called by every mutator AND by + * `renderIdentity`, which is what keeps the two in step across a + * re-render. */ + const syncBadge = () => { + const btn = settingsBtn; + if (!btn) return; + const lit = unseenEventCount() > 0 || conditions.size > 0; + const existing = btn.querySelector(".visor-badge"); + if (lit && !existing) { + const dot = document.createElement("span"); + dot.className = "visor-badge"; + dot.setAttribute("aria-hidden", "true"); + btn.append(dot); + } else if (!lit && existing) { + existing.remove(); + } + const label = lit ? SETTINGS_LABEL_LIT : SETTINGS_LABEL; + btn.title = label; + btn.setAttribute("aria-label", label); + }; + const renderIdentity = () => { // NOTHING PERSONAL BEFORE THE CLAIM, enforced HERE rather than at // the call sites. The cluster is the one place the user's own name, @@ -1222,10 +1477,17 @@ export function initVisor(config: VisorConfig): Visor { // The face is a glyph from the visor's fixed vocabulary — never a // string out of the record (see VISOR_ICONS). btn.textContent = identityIcon(rec); - btn.title = "your visor: name, device, colour"; - btn.setAttribute("aria-label", "your visor: name, device, colour"); + btn.title = SETTINGS_LABEL; + btn.setAttribute("aria-label", SETTINGS_LABEL); btn.onclick = () => requestSettings(); identityBox.append(btn); + // THE BADGE IS RE-HUNG HERE, not carried over: the button above is a + // NEW node, and the dot is a child of it. `syncBadge` also owns the + // label (lit or not), which is why the two assignments above are the + // unlit default rather than the final word — this call is. It runs + // AFTER `textContent`, which replaces every child the button has. + settingsBtn = btn; + syncBadge(); }; renderIdentity(); @@ -1253,7 +1515,9 @@ export function initVisor(config: VisorConfig): Visor { * place to stop naming what is drawn underneath. */ const topSurface = (ctx: VisorContext): SurfaceIdentity | null => { if (ctx === null) return appSurface(); - if (ctx.kind === "settings" || ctx.kind === "reset") return appSurface(); + if (ctx.kind === "settings" || ctx.kind === "reset" || ctx.kind === "events") { + return appSurface(); + } // THE ENTRY CEREMONIES, same answer for the same reason: they are // the visor talking about the device and the account, not about a // component, so the cluster keeps naming whatever is installed. In @@ -1283,7 +1547,7 @@ export function initVisor(config: VisorConfig): Visor { const kind = ctx === null ? "app" : (ctx.kind ?? "panel"); const sheet = kind === "credentials" || kind === "naming" || kind === "settings" || kind === "storage" || kind === "reset" || - kind === "device-picker" || kind === "first-run"; + kind === "events" || kind === "device-picker" || kind === "first-run"; // --- the TOP line: THE USER'S RECOGNITION PAIR --------------------- // The mark the user picked and the word the user chose, side by side, @@ -1418,6 +1682,12 @@ export function initVisor(config: VisorConfig): Visor { // with it. : kind === "reset" ? "erase this visor" + // THE EVENT LIST, named on the strip with the same words its + // entry row and its spoken name use — the anchor, the button + // that opened the sheet and the sentence a screen reader hears + // must not each have their own name for one place. + : kind === "events" + ? "recent events" // THE ENTRY CEREMONIES. Both lines are the visor naming its own // sheet, in the plainest words it has: at picker time the strip // is the ONLY thing on screen that is not the sheet, so this @@ -2347,6 +2617,48 @@ export function initVisor(config: VisorConfig): Visor { setBack, identity: () => loadIdentity(config.identityKey), saveIdentity: (rec) => saveIdentity(config.identityKey, rec), + addEvent: (text) => { + eventStore.events.push({ at: Date.now(), text }); + // OLDEST OUT, and only ever by one per call, so the cap is a + // sliding window rather than a periodic purge. + if (eventStore.events.length > EVENTS_MAX) { + eventStore.events.splice(0, eventStore.events.length - EVENTS_MAX); + } + persistEvents(); + syncBadge(); + }, + // A COPY, REVERSED: the caller gets newest-first without the stored + // array — the one the cap and the watermark are computed against — + // being handed out to be mutated from outside. + events: () => [...eventStore.events].reverse(), + markEventsSeen: () => { + const newest = eventStore.events.length === 0 + ? 0 + : eventStore.events[eventStore.events.length - 1].at; + // MAX, not `Date.now()`: a record written by a machine whose clock + // is ahead (or restored from storage across a clock change) would + // otherwise sit permanently above the watermark, and the badge + // would re-light the instant it was cleared. + eventStore.seenAt = Math.max(Date.now(), newest); + persistEvents(); + syncBadge(); + }, + unseenEventCount, + setCondition: (key, text) => { + const fresh = !conditions.has(key); + // `set` on an existing key keeps its INSERTION position, which is + // what makes a re-assert a text refresh rather than a re-ordering + // of a list the user is reading. + conditions.set(key, text); + syncBadge(); + return fresh; + }, + clearCondition: (key) => { + const had = conditions.delete(key); + syncBadge(); + return had; + }, + conditions: () => new Map(conditions), committedHue: () => { // A LOUD REFUSAL, not a plausible number. Pre-claim there is no // committed hue; anything returned here would be painted or @@ -2403,7 +2715,15 @@ export function initVisor(config: VisorConfig): Visor { // 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, config.wordKey]) { + for ( + const key of [ + config.identityKey, + config.hueKey, + config.legacyHueKey, + config.wordKey, + config.eventsKey, + ] + ) { if (key === undefined) continue; try { localStorage.removeItem(key);