2026-07-26 OpenRouter Interceptor Palette, Rows, and the Fixture That Tested Itself

What I set out to do

Merge design/sidebar-layout and clear the three design findings left open at the end of 2026-07-25 OpenRouter Interceptor Architecture Refactor: the palette duplicated four times per file across two files, the empty grey avatar square, and the redundant - JanitorAI title suffix. Adam added a fourth mid-session: retire test/playwright/sidebar-test.html, which that note had already named as the next test job.

What I actually did

Two commits on design/palette-title-avatar, merged --no-ff as 51aa153.

One palette (63a6d78). Every colour now lives once in src/styles/palette.css, imported by sidebar.css and options.css and inlined by Vite at build time. Each token is declared as light-dark(light, dark); [data-theme] switches color-scheme and the colours follow. That removed 95 lines from sidebar.css and 64 from options.css and, more to the point, made adding a token a one-line edit instead of an eight-block one.

Probed before committing to it. light-dark() inside a custom property, crossing a shadow boundary, is exactly the shape that “parses, ships, and silently never matches” — the failure mode :host([data-theme]) had one session earlier. Verified all twelve combinations (explicit light / explicit dark / system × light OS / dark OS × document / shadow root) in both Firefox and Chromium before writing a line of the real thing.

The new contrast case found an old bug. The theme spec had two cases, both dark. Adding “explicit light on a dark OS” — the path where an explicit light choice has to restore every token the dark branch changed — surfaced --highlight-char-text at 4.24:1, under AA. Checked it predated my change by stashing only the two stylesheets and rerunning, and it did. Green is the one hue where the 700 step is not dark enough to carry body text on its own tinted background; moved to green-800.

Rows that identify their character. One identity slot at one fixed 28px size whatever fills it, because a 28px avatar on scraped rows and a 16px favicon on the rest meant the title started at a different x on every row. The empty case gets a person glyph rather than a blank square: a blank grey box is what a broken image looks like, so it invites a bug report instead of reading as “no avatar”. Considered a monogram tile and rejected it (a capture carries no character name), considered dropping the favicon and rejected it (that contradicts a recorded decision in docs/superpowers/specs/2026-07-04-query-model-reload-avatar-design.md).

captureDisplayTitle (src/utils/capture-title.ts, TDD, 10 tests written red first) strips a site’s own name off the end of a page title, matching against both the domain label and the full domain because sites write it both ways. Query cards use the same helper, so a query and the capture it came from stay recognisably the same thing.

The fixture (9652716). sidebar-test.html was 698 lines of hand-copied sidebar markup driving six specs, and every way it had rotted was silent. It kept a #playgroundToggle for years after the playground became a tab, and its specs went on clicking it. It declared six template prompts under keys (ooc_direct, json_extraction) that appear nowhere in src/. Its “Generate Character Card” handler was a twenty-line fake emitting {generated: true}, and a spec named character card conversion functions work correctly asserted against that fake. Two specs had no assertions at all.

The six now run against the sidebar lab. Two were rewritten rather than migrated because what they asserted did not exist: capture-transformations went 639 lines → ~170, and playground was rebuilt against the six real templates in src/sidebar/playground/templates.ts. extension-core-fixed.spec.ts (491 lines) is replaced by sidebar-shell.spec.ts, which drives the real shell — empty state, every header button named for a screen reader, the theme toggle actually cycling light → dark → system, the preference reaching browser.storage.local.

Two supporting changes fell out. test/playwright/sidebar-lab.ts exists because migrating six specs would otherwise have duplicated openSidebar seven ways; it owns the lab URL, the capture() factory and the shadow-root locators. src/sidebar/ui/theme-toggle.ts was extracted from sidebar.ts so the toggle is reachable from the lab rather than only from a real extension page.

What was striking

  • Deleting a test file made the suite stronger, which should be the normal case more often than it is. Net −1717 lines across the merge, and the specs that remain drive shipped modules. The fixture’s problem was never that it was stale; it is that a hand-written page tests itself, so drift has no way to announce itself.
  • Moving two assertions off toasts found two real things. A toast removes itself after a few seconds, so a loaded machine finishes the click and misses it. Rewriting them to assert durable state exposed that the “Please select a template first” guard is unreachable — #copyTemplate lives inside the preview it guards — and that a failed avatar scan reports “No data found on current page”, not the deeper-branch message I had guessed at. Both had been passing for the wrong reason.
  • The worst hour of the session was my own malformed poll. I wrote expect.poll(...).toBeTruthy().then(...), which is not a thing. One test ran for 1.4 hours and starved the run; the eight resulting failures all said browserType.launch: Timeout and looked like eight bugs. Isolated, the same test passes in 3.2s. A chained assertion that returns a promise nobody awaits fails as a resource problem, not as an assertion problem, which is the expensive part.
  • My first attempt to date the contrast bug proved nothing. git stash also stashed the new test case, so “4 passed” meant “the test I was asking about is not present”. Had to stash the two stylesheets by path. Bisecting with a stash is only sound if you are precise about what the stash took with it.
  • Load average hit 196 and none of it was interesting. 48 test browsers, one ffmpeg-mac per test (video: "on"), on top of Docker’s com.docker.krun holding ~116% CPU on its own. Two full-suite attempts abandoned past 35 minutes. Rather than assume the four remaining failures were environmental I reran them serially: 22 passed in 42.4s. Later, on an idle machine, the whole suite ran in 38.3s.
  • Screenshots lied twice, both times for a reason worth keeping. My injected * { animation: none } cannot reach into the captures-list shadow root, so shots came out mid-transition; and all four sample captures shared a chat URL, which is the grouping key, so four captures collapsed into one row. The second one bit again later in an avatar test. It is now written into sidebar-lab.ts’s capture() doc: a spec that wants N rows must give each capture its own chat URL.

Verification

Playwright 273 passed / 1 skipped in 38.3s on an idle machine (load 4.4), which is the run I am counting; the earlier 140/4 run was taken under load average 130-196 and its four failures were confirmed environmental by a serial rerun. 659 unit tests (48 files). tsc --noEmit clean across all three projects, ESLint and Stylelint clean.

Second session: clearing the four findings, and gating one

Adam asked for the four reported-but-not-acted-on items fixed on main, and for advice on lint rules that would stop the same decay recurring. Shipped as dd75ac7.

Dead CSS, 445 lines out of sidebar.css. The capture-row block plus nine classes that match no markup anywhere in the repo. Gated by a new scripts/check-css.ts in npm run lint.

The two dead playground branches were only half what I had claimed. readCharacterInput cast JSON.parse’s result to CharacterInput, which is a lie: null, 42, "a bio" and [] all parse. So the inner catch was genuinely unreachable, but the caller’s null check was reachable through exactly those values, where it answered a paste of prose with a complaint about invalid JSON. Replaced with an exported parseCharacterInput that accepts only a JSON object; non-nullable return with a name always set, so no failure case is left for a guard to grow around.

The unreachable template guard was covering real drift. The toast could not fire, but the <option> values lived in sidebar.html as a second list of template keys nobody compared against TEMPLATE_PROMPTS — the retired fixture had drifted exactly that way. Options are now built from the map.

Model:anthropic/...: label and value in a space-between flex item with no gap, so once the pair filled its column the space went to zero. Measured 0 in a test before the fix.

What was striking (second session)

  • The rule I wrote caught the bug I introduced ten minutes later. Turning on stylelint’s no-unknown-animations immediately failed on slide-inUp and slideOutDown, neither of which exists: sidebar toasts have never had an entrance or exit animation, and toast.ts has been waiting 300ms for one. Separately, deleting the dead capture-row block took @keyframes slide-in with it, which .query-card still used — and the same new rule is what said so.
  • as casts are what block the type-aware ESLint rules, and the mechanism is a circularity. no-unnecessary-condition (75 findings) flagged the ?? in APPEARANCE[theme as Theme] ?? APPEARANCE.system. It is right about the types and wrong about the program: the cast makes the lookup look total, so the rule points at the guard rather than at the lie. Applying it mechanically would delete safety across 20 files. Rewrote that one site with a value is Theme predicate as the worked example.
  • noUncheckedIndexedAccess was already on and could not help. It adds | undefined to index-signature access; a key-union lookup is not one. The cast moved the expression out of the category the safety option covers. Strictness options constrain inference, and as is precisely the escape hatch from inference.
  • The other rule failure was ESLint’s, not the code’s. no-unnecessary-type-assertion called three querySelector assertions in queries.ts redundant; its autofix removed them and tsc then failed on all three. Isolated it with a two-line probe: querySelector is generic (<E extends Element = Element>), so the assertion is itself the inference site for E. With the assertion present the expression already is the asserted type, so the rule sees them match. The assertion is what makes the assertion look unnecessary. Not flagged on a non-generic function with the same shape.
  • A conservative checker is the right default for a lint gate. check-css.ts searches for class names rather than parsing class uses, so it can only produce false negatives. A use-extracting version would catch more (it misses .capture-item, named in a shadowRoot querySelector) but could tell you to delete a live rule, and that failure direction is much worse in a pre-commit hook.

Verification (second session)

Playwright 279 passed / 1 skipped in 39.6s, 682 unit tests (49 files), lint clean including the new checks.

Findings reported, not acted on

  • sidebar-chrome.spec.ts still carries its own openSidebar and capture() instead of test/playwright/sidebar-lab.ts, breaking a rule I wrote into test/CLAUDE.md the same day. Migrating means rewriting every call site.
  • no-floating-promises (12) and no-misused-promises (10) are a genuine bug class in a background page, where an unhandled rejection is silent. Each needs a judgment about void versus await; blanket-void would only silence them.
  • no-unsafe-type-assertion is 167 findings, almost all getElementById(...) as HTMLInputElement | null. Converting those to a checked helper is the prerequisite for trusting any of the type-aware rules.

Third session: hardening against the whole class

Adam asked what could be done so the decay is impossible at compile or lint time, and said he was fine with churn. Chose valibot (types inferred from schemas) for foreign data, and full coverage including src/experiments. Six commits, e136048 through d4d96a7. 134 non-const as assertions in src, down to 57.

The diagnosis. Every family of bug this week traced back to as. A cast is an unverified claim, and everything the compiler concludes downstream is only as sound as it. Worse, the type-aware lint rules read the same types, so a cast makes the rule confidently wrong too.

What was done, in order. allowUnreachableCode: false across all four tsconfigs (already clean, free). errors.ts with errorMessage/toError, replacing 7 unsafe (e as Error).message and 13 copies of the correct-but-duplicated instanceof Error ? … : String(…). dom-utils.ts rebuilt: getEl(id, HTMLInputElement) takes the class as a value so the claim becomes an instanceof, plus findEl, queryIn, isTextEntry. events.ts declaring the five sidebar custom events once, with a DocumentEventMap augmentation and a typed sidebarEvent dispatcher. valibot schemas for the OpenRouter completion and the JanitorAI payload. messaging.ts with one request→response table behind a typed request(). Finally no-floating-promises and no-misused-promises turned on, with fireAndForget(work, label) rather than bare void.

Bugs the types found on first compile.

  • getEl<HTMLInputElement>("save") on a button compiled and returned a button: .value read undefined, writes did nothing. Now throws and names what it found.
  • The custom-analysis modal’s captureId is string | null, and detail: any was letting the null reach analyzeCapture.
  • validateJanitorAIResponse checked that first_messages was a non-empty array but not what was in it, while the interface beside it promised string[]. Numbers passed the guard, reached validateGreeting(greeting: string), read undefined for .length so every bound check was false, and threw at .includes("\0"). Two of three new cases were red before the schema replaced it.
  • CapturesListElement was an optional-everything interface asserted onto whatever #capturesList happened to be, so “custom element not upgraded yet” and “ready” were indistinguishable and the assignments after would have written expandos onto a plain HTMLElement.
  • Two of the three contentEditable === "true" shortcut guards fired while the caret sat in a child of a rich-text region. isContentEditable is inherited and is what they meant.
  • request({ type: "GET_PLAYGROUND_DATA" }) typing the request surfaced a tabs[0]?.id that could have been sent as undefined.

What was striking (third session)

  • The rule caught the bug I introduced ten minutes later. stylelint’s no-unknown-animations immediately failed on slide-inUp/slideOutDown, neither of which exists: sidebar toasts have never had an entrance or exit animation and toast.ts has been waiting 300ms for one. Then deleting the dead capture-row CSS took @keyframes slide-in with it, which .query-card still used, and the same new rule said so.
  • no-unnecessary-type-assertion is wrong here, and the mechanism is a circularity worth remembering. It called three querySelector(...) as HTMLSelectElement | null redundant; its autofix removed them and tsc failed on all three. querySelector is generic (<E extends Element = Element>), so the assertion is itself the inference site for E: with it present the expression already is the asserted type. Isolated with a two-line probe — not flagged on a non-generic function of identical shape.
  • no-unnecessary-condition stayed at 74 findings even after the casts went, which killed my initial theory. Three separate causes, none fixable by the rule: document.body is typed non-null but is null before <body> parses; CommandMessage describes what we send while the router receives it; and TS does not track a let mutated inside a callback. Enabling it would delete all three kinds of live guard. Only the second is closable, by validating inbound messages.
  • noUncheckedIndexedAccess was already on and could not help. It adds | undefined to index-signature access; a key-union lookup is not one. The cast moved the expression out of the category the option covers. Strictness options constrain inference; as is the escape hatch from inference.
  • Parsing moved the error to where it happened. An existing test asserted “Failed to process analysis response” for an empty choices array — the message from choices[0].message five frames downstream. It now says “returned no choices” at the boundary, and the test says so.
  • tsc-files in the pre-commit hook has a blind spot. It compiles only staged files, so a global HTMLElementTagNameMap augmentation in an unstaged file is absent and the commit fails where the full typecheck passes. The fix was the better code anyway: an explicit instanceof with a value import, which also handles the not-yet-upgraded case.

Verification (third session)

Playwright 279 passed / 1 skipped in 39.5s after every stage, 729 unit tests (52 files), lint clean including the new CSS check and the two promise rules. Each of the six commits verified independently.

Findings reported, not acted on (third session)

  • 57 assertions remain: ~10 into Firefox internals (ChromeUtils.importESModule) where no types exist upstream, several inside type guards where the assertion is the implementation, globalThis as Record<string, unknown> for extension-private globals, and diagnostics.ts (11), which reshapes captures for the bundle.
  • Inbound message validation is the one remaining piece that would let no-unnecessary-condition be turned on.
  • sidebar-chrome.spec.ts still carries its own openSidebar/capture() instead of sidebar-lab.ts.

Fourth session: the message boundary, and what exists off the shelf

Two commits, 9f309dc and 9754d1b. 53 non-const assertions in src, from 134 at the start of the day.

Inbound validation. browser.runtime.onMessage delivers whatever any context sends, and the router’s request was typed CommandMessage regardless: an assertion written into a signature. The nine per-case guards compensating for it were the only real check in the path, and read as dead code to a type-aware linter. The router now parses with a valibot variant schema; the guards are deleted rather than left unreachable, and the value constraints they carried came with them (v.nonEmpty() for “non-empty string”, v.minValue(0) for tabId). VALIDATE_API_KEY’s model became required, which is what its guard already enforced while the type said optional.

Two details worth keeping: commands are looseObject, so a newer UI against an older background page does not lose the commands that still work over one unknown field; and settings needed an explicit plain-object intersection, because an array satisfies an all-optional v.object on its own ([] has none of the named keys and parses to {}). exactOptional rather than partial, so absent stays absent under exactOptionalPropertyTypes.

The ambient CommandMessage, NotificationMessage, ExtensionMessage and four message interfaces are gone from global.d.ts — the types are inferred from the schemas now. switch-exhaustiveness-check is on as the compile-time half: adding a command without a router case fails lint.

ChromeUtils.importESModule returns any, so src/experiments is the one place a shape claim genuinely cannot be compiler-checked — which is why it should be runtime-checked. Two as unknown as casts became module is X predicates verifying the one method each caller reaches for. These are unversioned Firefox internals that have already moved once (.jsm.sys.mjs, which is why the fallback exists), so a rename upstream now fails with a sentence naming the module rather than a TypeError inside getTopWindow.

Off-the-shelf schema packages: what actually exists

Researched because Adam asked whether either boundary has a published schema store.

  • @openrouter/sdk (v1.1.8, Apache-2.0, published 2026-07-25 — actively maintained, Speakeasy-generated from their OpenAPI spec). It genuinely exports runtime schemas: ChatResult$inboundSchema is a z.ZodType<ChatResult, unknown>. Not worth swapping in here, for a specific reason: ChatResult requires object: "chat.completion", created, id and model, all of which the extension never reads, so a generated schema is more likely to reject a real-but-minimal provider response — the exact failure it would be there to prevent. It also pulls zod v4 alongside valibot. Worth it if the extension ever wants streaming, tool calls, or typed requests.
  • Firefox internals: nothing on npm. @types/firefox is not internals (and the WebExtensions API is already covered by @types/firefox-webext-browser). gecko-types (quark-platform) does have types/custom/ChromeUtils.d.ts and Services.d.ts, but it is GitHub-only and its last commit is 2024-02-06, so it describes an early-2024 Firefox. The authoritative source is Mozilla’s own in-tree tools/@types/ in mozilla-central (lib.gecko.custom.d.ts, lib.gecko.tweaks.d.ts, generated/), unpublished and vendorable. Disproportionate for four call sites.

What was striking (fourth session)

  • The claim was in the signature, which is the hardest place to see it. request: CommandMessage looks like typing, not asserting. It is the same act as as CommandMessage with better manners, and it had nine guards downstream quietly doing the work the type claimed was done.
  • Valibot’s v.object accepts an array when every field is optional. [] has none of the named keys, so it parses to {}. Harmless where it landed, but I only found it because I wrote the test asserting rejection first and watched it pass.
  • Rejection detail belongs in the console, not the response. v.summarize names all sixteen command types, which is useless in a toast and exactly what the diagnostic capture wants.
  • A denied tool call was correct. I tried to npm install github:quark-platform/gecko-types to inspect it and was blocked for running install scripts from a repo Adam had not named. Read-only inspection through the GitHub API answered the question better anyway, and surfaced the stale-since-2024 commit date that the search snippets had not mentioned.

Verification (fourth session)

Playwright 279 passed / 1 skipped in 40.1s, 746 unit tests, lint clean.

Fifth session: down to two assertions

Adam asked for the remaining 53 done, the OpenRouter SDK added, and our own Firefox type stubs rather than a dependency. Four commits, a0e41d6 through 2f19c8f. 134 non-const assertions in src at the start of the day, 2 at the end.

The SDK finding, which changed the design. ChatResult$inboundSchema requires id, created, model, object and system_fingerprint. The extension reads none of them, and a probe with an otherwise fully-conforming response fails without system_fingerprint — a field many providers omit. As a hard gate it would reject ordinary traffic, i.e. exactly the failure the validation is for. The request side is worse: the extension deliberately handles system/prompt bodies and engine, none of which ChatRequest allows. So the SDK schemas report drift to the console and minimal valibot schemas remain the gate. That serves the stated reason (track upstream, free upgrades) without a new failure mode, and it is one line to flip.

Cost measured rather than assumed: the background script goes 76K → 272K from zod v4 and the model chain. Confined to the background page; content scripts and the sidebar bundle are byte-identical. moduleResolution had to move nodebundler (and module → ESNext) because the legacy setting cannot follow a dependency’s exports subpaths.

Firefox stubs, split by what is checkable. GeckoModules in our own firefox-internals.d.ts constrains the module path, so a typo in a resource:// URL is a compile error. The return deliberately stays unknown, because the exported shape is someone else’s unversioned code; the is* predicates verify it. Nothing published covers this: @types/firefox-webext-browser is the WebExtensions API, gecko-types is GitHub-only and stale since 2024-02-06, Mozilla’s in-tree tools/@types/ is unpublished.

Everything else. schemas/page.ts for executeScript and inspectedWindow.eval results (both APIs return any/object, and both helpers were taking a caller-chosen type parameter for six and one shapes). schemas/storage.ts, because the writer of storage is a previous version of this extension and the migrations run straight off the read. types/page-globals.d.ts for the three content-script globals. The rest were instanceof, in narrowing, and structural fixes.

What was striking (fifth session)

  • inspectedWindow.eval hides that it returns a pair. Its declared type is object, which is what forced as unknown as [T, EvalError]. The API erasing its own shape is a different failure from a lazy cast, and the fix is the same: parse it.
  • stripHeavyFields was four assertions spelling “everything except these”. A JSON round-trip asserted back to SidebarCapture, then Record<string, unknown> three times to delete fields the type says are required. Rewritten as a construction with a rest destructure, returning its own type — because a capture with no prompt and no messages is not a SidebarCapture, and admitting that is what let all four casts go.
  • Two valibot behaviours cost real time. v.object/v.looseObject accept an array when every field is optional (an array is a non-null object with none of the named keys, so [1,2] parses to {}); and v.intersect cannot combine a transforming schema with a plain one, so a schema needing v.transform cannot also carry the plain-object guard. Both now in AGENT.md.
  • A test failed for the right reason. Collapsing isEditableTarget into isTextEntry turned one red: it built { tagName: "DIV", isContentEditable: true } asserted to Element, which an instanceof guard rejects. Its neighbours in the same file already used real elements. It was also testing the host of a rich-text region rather than a caret inside one, which is the case that actually misbehaved.
  • The pre-commit hook’s blind spot bit twice. tsc-files compiles only staged files, so an ambient declaration in an unstaged one is absent from its program and the commit fails where npm run typecheck passes. Both times the fix was better code anyway (an explicit instanceof with a value import; a /// <reference> matching how global.d.ts is already reached), but the hook is weaker than it looks.
  • Where it stopped, and why. The two survivors are the experiment sandbox global (an experiment file is loaded as a script into a Gecko sandbox where this is the global; there is no declaration to point that at) and messaging.ts. The second is a claim about our own router, not foreign data, and closing it properly means typing the router’s replies against the same Responses table so both ends are checked — about 18 call sites in the hot path. Flagged rather than done at the end of a long session.

Verification (fifth session)

Playwright 279 passed / 1 skipped in 39.2s after every commit, 754 unit tests (from 659 this morning), lint clean including the CSS checks and the promise rules.

Sixth session: closing messaging.ts, and naming the principle

Adam said to fix messaging.ts too. One commit, c133ce2. One assertion site left in src, the Gecko sandbox global.

The honest framing is that the assertion was the symptom. request() claimed a row of the Responses table because sendMessage resolves unknown, but nothing checked the router: it reached sendResponse, typed unknown, directly. Removing the cast without checking the other end would have moved the lie, not retired it.

So the table is now read from both ends. route() returns its answer instead of sending it — createMessageRouter is the only thing that touches sendResponse, so a case cannot bypass the check — and every reply is built by answer(request, data), whose request parameter exists only to infer the command from the narrowed request.type and check data against that row. request() parses the reply against the schema for the same row in schemas/responses.ts, each annotated PayloadSchema<K> so the compiler holds the schema to its row too.

Both halves earn their place, and they catch different things. Compile-time catches the router and its callers drifting inside one build. Runtime catches two builds meeting — which here is the ordinary development loop, since reloading the background page with the sidebar open leaves an old document talking to a new router. It also covers sendMessage resolving undefined when nothing answered, which the old CommandResponse<T> said could not happen while five call sites defended against it with ?..

What turning the check on found, none of it visible before: SidebarCapture.requestData promised { messages?: … }, which BackgroundCapture (unknown) does not have and which no reader used — both places that read it already parse with InterceptedRequest. GET_STREAMING_STATUS shipped a count nobody reads. VALIDATE_API_KEY answered a rejected key with { success: false } and no message, so the options page showed a placeholder. Six response aliases in global.d.ts were dead and two had drifted. Guards in DOWNLOAD_FILE and REANALYZE_QUERY re-checked what the command schema already guarantees.

What was striking (sixth session)

  • Adam named the principle: parse, don’t validate. Turn the untrusted value into a value of a type that carries the guarantee, rather than checking it and then asserting what it is. That is the through-line of the whole week: a check followed by as leaves the guarantee where the compiler cannot see it, which is precisely how the guard becomes the part that looks removable. Now in AGENT.md and in project memory.
  • The correlated-union problem is real and has one clean way out. TypeScript cannot call a union of handlers with a union of arguments, so a handler-map dispatch needs an assertion — the exact thing being removed. Threading the narrowed request into a helper (answer(request, data)) sidesteps it: inside case "GET_CAPTURES" the literal type fixes K, and the payload is checked against that row with no dispatch table at all.
  • The mapped-type annotation matters more than it looks. satisfies on the schema table left ResponsePayloads[type] an opaque indexed access, so the parse result would not resolve to Responses[K] and wanted a cast. Annotating with { [K in keyof Responses]: PayloadSchema<K> } makes the indexed access resolve and the cast disappear. Same code, one keyword, and the difference is whether the compiler can follow the correlation.
  • Four Playwright tests found a wire-format assumption I had not thought about. Structured cloning preserves result: undefined as a present key, and exactOptional — which exactOptionalPropertyTypes forces — rejects exactly that. { ...query, result: undefined } written anywhere upstream would have arrived as a malformed reply. Undefined-valued keys are now pruned before the parse, in one place, with the reason written down.
  • The strongest new test is the round trip. Every command’s real router reply is fed through the schemas the caller parses with. The compiler already checks both ends inside one build; this checks that they agree at runtime. Verified it can fail by adding a required field to one schema and watching only that case go red.

Verification (sixth session)

Playwright 279 passed / 1 skipped in 43.1s, 784 unit tests (from 659 this morning), tsc --noEmit clean across all three projects, ESLint and Stylelint clean, build clean.

Seventh session: the sandbox global, and a claim of mine that was wrong

Adam asked for the last one. c4b77e6. Zero type assertions in src/.

I had written this one up twice — in a commit message and in the file’s own comment — as the assertion with no checkable alternative, on the grounds that TypeScript types top-level this as any in a non-module file and there is nothing to point it at. The first half was simply wrong, and I only found out by trying it: TS types top-level this in a script as typeof globalThis, and the error it gives is TS7017: Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. That is not “no type available”, it is “you have not declared the property”. The whole fix was one declare var beside the other Gecko stubs.

And it checks something real, which is the part that makes it worth doing rather than a formality: Firefox reads the API class back off the sandbox global by the name in experiment_apis in manifest.json, so the declaration ties that key to the assignment, and the slot’s type means only an ExtensionAPI subclass fits. var rather than let/const because a global slot is a property of the global object, which is the one thing var declares and the other two do not — with the eslint disable explained in place rather than silently.

Verified the emitted JS by diffing the build against the previous commit’s: unchanged apart from line wrapping.

  • The lesson is about the write-up, not the code. I had asserted a mechanism (“TS types it as any”) twice in durable places without probing it, and it read as authoritative because it was specific. The probe took one command. Anywhere a comment explains why something cannot be done, that explanation is load-bearing and deserves the same evidence as a fix.
  • What is left is honest to name. scripts/ has 31 and the test fixtures have the rest. Fixture assertions are not harmless: a { tagName: "DIV", isContentEditable: true } object literal asserted to Element is exactly what broke when isTextEntry started using instanceof, and it had been passing for years by testing nothing.

Verification (seventh session)

Playwright 279 passed / 1 skipped in 39.9s, 784 unit tests, lint and build clean.

Eighth session: the test fixtures, and turning the rules on

Adam asked for the test fixtures too, then “see if we can turn on the rules”. Two commits, e3eab16 and e801391. Zero assertions in src/ and test/; the rules that enforce that are now on.

91 sites across 25 files. The premise is that an as in a fixture is not harmless for being in a test: a fixture that lies about the shape it built cannot notice that shape changing, which is exactly how the pagination tests passed for years against a { tagName: "DIV" } object literal.

Each family got replaced with something that checks rather than claims:

  • Page-expression results parse with the schemas the sidebar parses the real ones with. evalCapture takes a schema instead of a caller-chosen type and holds every result to PageCaptureResult first, so a builder drifting from what the reader expects fails in the unit test rather than in a console at runtime.
  • globalThis as Record<string, unknown> reads the declared globals in types/page-globals.d.ts. The half a typed read cannot cover — the injected expression building the name into a string — became three explicit tests pinning each constant to its declared name. That is strictly more than the cast was checking.
  • DOM lookups go through a new src/test-support/dom.ts whose need/needNth throw with the selector in the message.
  • window as unknown as { __lab: ... } is declared in test/lab/lab-globals.d.ts beside the generator that installs it — which immediately caught the inline shape declaring two of state()’s five fields.
  • fetch stubs return real Request/Response objects. The old literals set ok by hand rather than deriving it from the status, so a stub could be ok: true with a 500 and nothing would say so.

Three production changes fell out, each because the type was wrong rather than because a test wanted it: extractSection took string while guarding typeof prompt !== "string" (the guard is load-bearing — prompts come from captured bodies and a nullable stored systemPrompt — so the parameter is unknown now); ElementClass is exported from dom-utils for the helper to name; and one fixture stopped claiming requestId/method/tabId, which SidebarCapture does not have.

Then the rules

Measured fifteen type-aware rules against the cleaned tree rather than guessing. no-unsafe-type-assertion went 167 → 0 in src and test, so the rule that was the whole point of the exercise is now free, and turning it on is what makes this a ratchet instead of a snapshot of one afternoon. Verified by adding an assertion back and watching lint reject it.

no-unnecessary-type-assertion is also 0, and its old objection cannot fire while nothing writes querySelector(...) as X. I left the mechanism recorded next to the rule anyway: it is the sort of thing someone meets again with --fix in hand.

Four more went on because measuring made them nearly free — await-thenable (5, all await context.pages() where pages() is synchronous), restrict-template-expressions (2, ${error} on a caught unknown rendering “[object Object]”), prefer-readonly (6), and no-explicit-any promoted warn → error with nothing left to warn about.

Five stay off, and they are now documented with their counts rather than as a vague “too noisy”: strict-boolean-expressions 207, no-unnecessary-condition 94, the no-unsafe-* family ~173 (mostly scripts/), no-confusing-void-expression 36, require-await 19.

What was striking (eighth session)

  • Both assertion rules are off for scripts/, deliberately. It still has 28. A lint that fails on every commit until someone finds a weekend does not teach discipline; it teaches people to skip the hook. Scoping the rule to the trees that are actually clean is what makes it enforceable.
  • The tests got better, not just quieter. Parsing the page-expression results with the production schemas means the unit tests now answer a question they never did: does this builder still produce what the reader expects? Before, the expression builder and the sidebar’s schema could drift and only a live capture would notice.
  • Counting is the whole argument. “Too noisy” is not a reason anyone can act on later; “94 findings, 66 in src, and here are the two lib.dom limits that cause most of them” is. The one worth revisiting is no-unnecessary-condition: its message-boundary cause closed when the router started parsing, and what remains is document.body typed non-null when it is null before <body> parses, plus TS not tracking a let mutated inside a callback.
  • A correlated-union problem showed up in a test, and the fix was to stop being generic. nextEvent<K extends keyof SidebarEvents> cannot read e.detail as SidebarEvents[K] — TypeScript will not correlate the listener’s key with the map’s. Naming the three row events as a union instead works, because every member answers { id: string } anyway. Same shape as the answer(request, data) trick from the morning: give the compiler a concrete key and the correlation is free.

Verification (eighth session)

787 unit tests (from 659 this morning), Playwright 279 passed / 1 skipped in 40.1s, tsc --noEmit clean across all three projects, ESLint and Stylelint clean with the new rules, build clean.

Ninth session: no partial rules

Adam: “lets just fix the scripts too so we don’t have to do this partial rule. lets turn on no-unecessary conditions.” One commit, 6a1da89. Zero assertions anywhere in the repo; no-unnecessary-condition on.

His instinct was right and I had rationalised the split. A rule scoped to two trees out of three is a rule with an asterisk, and the asterisk is where things rot.

scripts/, 32 → 0. The pattern was one JSON.parse asserted into a hand-written mirror of the card shape, and then the whole file undoing the claim: card?.name as string, data.extensions as CardExtensions, raw as unknown as Card. scripts/lib/card-schema.ts is now where a PNG’s tEXt chunk stops being JSON and becomes a card. CharacterCardRaw is gone — an all-optional mirror that nothing held either side to.

That surfaced a real precedence bug: fields were read as data.data?.x ?? data.x, so a V2 block with the key present and empty shadowed the V1 value that was actually there.

The tsconfig was the hidden part of the job. scripts/tsconfig.json was three ways divergent from the root: moduleResolution: "node" (so it could not resolve the OpenRouter SDK, and had been failing since that commit), and missing noUncheckedIndexedAccess, noPropertyAccessFromIndexSignature, exactOptionalPropertyTypes. 83 errors from aligning it. Aligning was a prerequisite, not tidying: without noUncheckedIndexedAccess, no-unnecessary-condition would have told me to delete arr[i] ?? x guards that are load-bearing. Also: npm run typecheck did not include that project at all, which is how a broken config sat there unnoticed. It does now.

no-unnecessary-condition, 94 → 0, and the three kinds of answer are the whole lesson:

  • Most were stale ?. and ?? "" left behind by a type that had since been tightened. Deleted.
  • Some were the type lying. browser.privilegedTabs was declared as a namespace, so “did the experiment load” was always true; the Gecko stub claimed gBrowser always exists. Both | undefined now, and their guards are live. This is the category that makes the rule worth having: it finds declarations that over-claim.
  • Two were TypeScript’s own limits. Document.body is declared non-null and is null before <body> parses, which is exactly where a document_start content script runs — hence bodyOrNull(), which reads the same element through querySelector and gets the type the runtime actually offers, with no assertion. And TS does not track a let assigned inside a callback, so capture-store’s “did anything change” flag read as never set; derived from the result now.

What was striking (ninth session)

  • The test suite caught me getting one wrong, and it was the most interesting finding of the day. Removing globalThis.location?.origin ?? "" turned two model-cache tests red: location is declared non-optional and is genuinely absent under Vitest, and the throw was being swallowed into the fallback model list. Same class as document.body. Had the tests not covered it, I would have shipped a silent degradation on the authority of a lint rule — which is precisely the failure mode I had spent the previous session warning about in a config comment.
  • The right move when a rule says a guard is dead is to ask which of you is wrong. Three times it was the code, twice it was a declaration I wrote, twice it was lib.dom. Deleting on the rule’s say-so would have been wrong in four of nine cases.
  • querySelector("body") is the fix for document.body. Same element, correctly nullable via HTMLElementTagNameMap, no assertion. My first attempt was document.body as HTMLElement | null — which the assertion rules I had just enabled promptly rejected. The rules disciplining each other is the point of having more than one.
  • A const with an explicit | undefined annotation does not defeat narrowing. const x: T | undefined = someT still narrows to T through the initializer, so annotating your way out of a false “always truthy” does not work. The fix has to be a real runtime check (typeof) or a differently-typed read.

Verification (ninth session)

787 unit tests, Playwright 279 passed / 1 skipped in 40.5s, tsc --noEmit clean across all four projects (scripts included for the first time), ESLint and Stylelint clean, build clean.

Open follow-ups

Carried forward from yesterday and still true: no CI (the repo has no remote, so the pre-commit hook is the only gate), background.ts still 1569 lines, src/types/global.d.ts still 335 lines of ambient globals, and nothing has been exercised in the real PWA profile since Playwright never loads the Firefox extension for real.