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.

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.