fix(debug 04-06): decouple welcome-hero mark stroke via --mks-mark-stroke

Operator-empirical Task 4 checkpoint flagged the dark-mode mark stroke
as muddy ink-on-madder. Root cause: .welcome-hero__mark used
`color: var(--mks-fg-inverse)`, which is a SEMANTIC text-foreground-on-
inverse-surface token that flips to ink-900 in the dark theme
(tokens.css line 244). The mark sits on a theme-independent madder-600
circle, so the stroke must be theme-independent too.

Fix: introduce a dedicated BRAND-COMPONENT token --mks-mark-stroke =
var(--mks-linen-50) in the universal :root block. CRUCIALLY NOT
overridden in the .dark/[data-theme="dark"] block — stays linen-50 on
every surface. Rewire .welcome-hero__mark to point at the new token.

SVG (mokosh-mark.svg) unchanged — `stroke="currentColor"` cascade
plumbing identical; only the wrapper's color source changed.

A35 strengthened: extracted live-DOM probe into a helper, now probes
BOTH light + dark themes (data-theme="dark" toggle on documentElement),
and added A35.5 — the decouple proof that light.computedStroke ===
dark.computedStroke === "rgb(250, 247, 241)" (linen-50). No new
__MOKOSH_UAT__ symbol; FORBIDDEN_HOOK_STRINGS stays at 12.

Scope expansion note: src/welcome/welcome.css was not in Plan 04-06
re-plan iter-2 files_modified. The edit is authorized by the operator's
TWEAK verdict on Task 4 checkpoint.

Verification:
- /tmp/04-06-welcome-hero-{light,dark}.png re-shot — both show identical
  crisp linen-on-madder grid icon.
- A35.5 LIVE-DOM probe (UAT): light="rgb(250, 247, 241)", dark=same.
- UAT 36/36 GREEN; vitest 187 + 1 tolerated webm-remux flake.
- 6/6 pre-checkpoint bundle gates PASS; FORBIDDEN_HOOK_STRINGS = 12.

Debug session: .planning/debug/04-06-dark-mode-mark-decouple.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:54:54 +02:00
parent d66cbf6900
commit a8bcc17822
7 changed files with 310 additions and 82 deletions

View File

@@ -544,15 +544,19 @@ async function main(): Promise<number> {
// RUN — not env-gated; the 5-min wait is A33's, not A34's).
{ name: 'A34', drive: driveA34Wrapped },
// Plan 04-06 A35: UI-SPEC dark-logo `currentColor` strategy LIVE-DOM
// proof. Opens welcome.html as a real Puppeteer tab so populateMark()
// actually runs; reads getComputedStyle().stroke on the injected
// <svg> to verify the currentColor cascade resolves through
// .welcome-hero__mark color: var(--mks-fg-inverse) (UI-SPEC Option A).
// Appended LAST in the drivers array so the new welcome tab cannot
// pollute any later driver (and welcomePage.close() in finally
// guarantees no tab leak regardless). Host-side driver — mirrors
// the driveA32/A33/A34 host-side pattern (NOT a page.evaluate
// (window.__mokoshHarness) wrapper).
// proof + Plan 04-06 Task 4 decouple verification. Opens welcome.html
// as a real Puppeteer tab so populateMark() actually runs; reads
// getComputedStyle().stroke on the injected <svg> in BOTH light and
// dark themes to verify (a) the currentColor cascade resolves
// through .welcome-hero__mark color: var(--mks-mark-stroke), and
// (b) the theme-INDEPENDENT --mks-mark-stroke token is NOT
// overridden by the .dark/[data-theme="dark"] override block — both
// probes resolve to the same linen-50 RGB. Appended LAST in the
// drivers array so the new welcome tab cannot pollute any later
// driver (and welcomePage.close() in finally guarantees no tab leak
// regardless). Host-side driver — mirrors the driveA32/A33/A34
// host-side pattern (NOT a page.evaluate(window.__mokoshHarness)
// wrapper).
{ name: 'A35', drive: driveA35Wrapped },
];

View File

@@ -2961,20 +2961,29 @@ export async function driveA34(
// chrome.runtime.getURL('src/welcome/welcome.html') resolves to — so
// welcome.ts init() runs populateMark() at DOMContentLoaded and the
// inline <svg> actually lands in the page DOM. We then read
// getComputedStyle().stroke on the injected <svg> to prove the
// `currentColor` cascade resolves through the .welcome-hero__mark
// wrapper's `color: var(--mks-fg-inverse)` rule.
// getComputedStyle().stroke on the injected <svg> in BOTH the LIGHT
// default theme AND the DARK theme (the latter set via
// document.documentElement.setAttribute('data-theme','dark'), which
// triggers the .dark, [data-theme="dark"] { ... } override block in
// src/shared/tokens.css). The cascade chain is:
// mokosh-mark.svg `stroke="currentColor"`
// ← .welcome-hero__mark { color: var(--mks-mark-stroke) }
// ← :root { --mks-mark-stroke: var(--mks-linen-50) }
// (NOT overridden by the .dark/[data-theme="dark"] block — Plan
// 04-06 operator-empirical Task 4 decouple resolution)
// So the computed stroke MUST be the same in both themes and MUST
// resolve to linen-50 (rgb(250, 247, 241), the hex #faf7f1).
//
// This is the iter-2 BLOCKER 1 resolution: the prior iter-1 re-plan
// claimed live-DOM injection was delegated to A17.8, but A17.8 is
// 100% string-grep on the welcome JS chunk and the harness does NOT
// open welcome.html as a live tab (it only fetches the HTML text via
// chrome.runtime.getURL + DOMParser.parseFromString — a DETACHED
// parse, not a live document). driveA35 is the canonical automated
// proof of the runtime injection + cascade. A35 is appended LAST in
// the drivers array; nothing reads its return value, so the new tab
// cannot pollute later drivers (welcomePage.close() in finally also
// guarantees no tab leak).
// This is the iter-2 BLOCKER 1 resolution + Plan 04-06 Task 4 decouple
// extension: the prior iter-1 re-plan claimed live-DOM injection was
// delegated to A17.8, but A17.8 is 100% string-grep on the welcome JS
// chunk and the harness does NOT open welcome.html as a live tab (it
// only fetches the HTML text via chrome.runtime.getURL +
// DOMParser.parseFromString — a DETACHED parse, not a live document).
// driveA35 is the canonical automated proof of the runtime injection +
// cascade + decouple. A35 is appended LAST in the drivers array;
// nothing reads its return value, so the new tab cannot pollute later
// drivers (welcomePage.close() in finally also guarantees no tab leak).
//
// Pattern: HOST-SIDE driver (mirrors driveA32/driveA33/driveA34 — NOT
// a page.evaluate(window.__mokoshHarness) wrapper). Builds a
@@ -2989,6 +2998,8 @@ export async function driveA34(
// - tests/welcome/inline-svg.test.ts (the source-text counterpart)
// - tests/uat/extension-page-harness.ts A17.8 (the source-bundling
// counterpart — narrowed in Plan 04-06 to a raw-source grep only)
// - .planning/debug/resolved/04-06-dark-mode-mark-decouple.md
// (operator-empirical Task 4 decouple debug session)
/** Live-DOM navigation + populateMark()-settle ceiling for the
* driveA35 welcome-page open. ~5 seconds is generous against the
@@ -2999,25 +3010,83 @@ export async function driveA34(
const A35_WELCOME_PAGE_TIMEOUT_MS = 5_000;
/**
* driveA35 — UI-SPEC dark-logo `currentColor` strategy LIVE-DOM proof.
* Canonical linen-50 hex (matches src/shared/tokens.css :root
* `--mks-linen-50: #faf7f1`) and the Chrome-serialized RGB form
* (canonical Chromium getComputedStyle().stroke output for #faf7f1).
* Used by A35.5 to assert the decoupled stroke matches the brand token.
*/
const A35_LINEN_50_HEX = '#faf7f1';
const A35_LINEN_50_RGB = 'rgb(250, 247, 241)';
/**
* Probe the `.welcome-hero__mark` live-DOM cascade signals for a given
* theme. Pulled into a helper so the LIGHT (default) probe and the DARK
* ([data-theme="dark"] override) probe share the exact same evaluate
* payload — the decoupling assertion (A35.5) only proves equality when
* the two reads are byte-for-byte the same probe shape.
*
* For the dark probe we set `data-theme="dark"` on <html> before reading;
* the tokens.css cascade fires on that attribute (src/shared/tokens.css
* line 234 selector `.dark, [data-theme="dark"]`).
*/
async function a35Probe(
welcomePage: Page,
dark: boolean,
): Promise<{
svgPresent: boolean;
strokeAttr: string | null;
computedStroke: string;
imgPresent: boolean;
}> {
if (dark) {
await welcomePage.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
} else {
await welcomePage.evaluate(() => {
document.documentElement.removeAttribute('data-theme');
});
}
return welcomePage.evaluate(() => {
const svgEl = document.querySelector('.welcome-hero__mark svg');
const imgEl = document.querySelector('.welcome-hero__mark img');
const svgPresent = svgEl !== null;
const strokeAttr = svgEl !== null ? svgEl.getAttribute('stroke') : null;
const computedStroke =
svgEl !== null ? getComputedStyle(svgEl).stroke : '';
const imgPresent = imgEl !== null;
return { svgPresent, strokeAttr, computedStroke, imgPresent };
});
}
/**
* driveA35 — UI-SPEC dark-logo `currentColor` strategy LIVE-DOM proof
* + Plan 04-06 Task 4 decouple verification.
*
* Opens a fresh welcome.html tab via `browser.newPage()`, navigates to
* `chrome-extension://<id>/src/welcome/welcome.html` (the canonical
* web-accessible welcome page path; the same path A17 already fetches),
* waits for `populateMark()` to inject the inline <svg> at
* DOMContentLoaded, then runs a single `welcomePage.evaluate(...)` that
* reads the LIVE DOM and returns the four signals A35 asserts on:
* - svgPresent — `.welcome-hero__mark svg` exists (inline <svg>
* injected).
* - strokeAttr — that <svg>'s `stroke` attribute === 'currentColor'
* (the canonical mark recolour landed correctly).
* - computedStroke`getComputedStyle(svgEl).stroke` is a resolved
* non-default colour value (the `currentColor`
* cascade resolved through
* `.welcome-hero__mark { color: var(--mks-fg-inverse) }`).
* Empty / 'none' would mean the cascade is broken.
* - imgPresent — `.welcome-hero__mark img` is NULL (the legacy
* <img> injection path is gone; we're inlining now).
* DOMContentLoaded, then probes the LIVE DOM in BOTH the LIGHT default
* theme AND the DARK theme (toggled by setting data-theme="dark" on the
* documentElement, which fires the `.dark, [data-theme="dark"]` block
* in tokens.css). Five sub-checks:
* A35.1 — `.welcome-hero__mark svg` injected (populateMark ran).
* A35.2 — that <svg> carries stroke="currentColor" (Option A recolor).
* A35.3 — getComputedStyle(<svg>).stroke resolves to a non-default
* colour in LIGHT (cascade through
* `.welcome-hero__mark { color: var(--mks-mark-stroke) }`
* actually works).
* A35.4 — no legacy <img> in the slot (pre-04-06 path is gone).
* A35.5 — Plan 04-06 Task 4 DECOUPLE PROOF: the computed stroke in
* LIGHT and DARK themes are EQUAL and BOTH resolve to the
* canonical linen-50 RGB (rgb(250, 247, 241) — Chromium's
* serialization of #faf7f1). This is the live-DOM evidence
* that --mks-mark-stroke is theme-independent (NOT overridden
* by the .dark/[data-theme="dark"] block) and that the
* wrapper points at it. The operator-empirical dark-mode
* "muddy ink-on-madder" defect is fixed when this check
* passes.
*
* Always closes the new tab in a `finally` block so no extra Puppeteer
* page leaks across the harness run. Mirrors the driveA33/driveA34
@@ -3050,52 +3119,60 @@ export async function driveA35(
});
diagnostics.push('A35 welcome.html DOMContentLoaded + inline <svg> selector resolved');
const probe = await welcomePage.evaluate(() => {
const svgEl = document.querySelector('.welcome-hero__mark svg');
const imgEl = document.querySelector('.welcome-hero__mark img');
const svgPresent = svgEl !== null;
const strokeAttr = svgEl !== null ? svgEl.getAttribute('stroke') : null;
const computedStroke =
svgEl !== null ? getComputedStyle(svgEl).stroke : '';
const imgPresent = imgEl !== null;
return { svgPresent, strokeAttr, computedStroke, imgPresent };
});
// LIGHT probe (default theme; remove any prior data-theme).
const lightProbe = await a35Probe(welcomePage, false);
diagnostics.push(
`A35 live-DOM probe: svgPresent=${probe.svgPresent} strokeAttr=${probe.strokeAttr ?? '<null>'} computedStroke="${probe.computedStroke}" imgPresent=${probe.imgPresent}`,
`A35 LIGHT probe: svgPresent=${lightProbe.svgPresent} strokeAttr=${lightProbe.strokeAttr ?? '<null>'} computedStroke="${lightProbe.computedStroke}" imgPresent=${lightProbe.imgPresent}`,
);
const computedStrokeResolved =
probe.computedStroke.length > 0 && probe.computedStroke !== 'none';
// DARK probe (set data-theme="dark" on <html>; same selector / same
// evaluate payload).
const darkProbe = await a35Probe(welcomePage, true);
diagnostics.push(
`A35 DARK probe: svgPresent=${darkProbe.svgPresent} strokeAttr=${darkProbe.strokeAttr ?? '<null>'} computedStroke="${darkProbe.computedStroke}" imgPresent=${darkProbe.imgPresent}`,
);
const lightStrokeResolved =
lightProbe.computedStroke.length > 0 && lightProbe.computedStroke !== 'none';
const decoupled =
lightProbe.computedStroke === darkProbe.computedStroke
&& lightProbe.computedStroke === A35_LINEN_50_RGB;
checks.push({
name: 'A35.1: inline <svg> injected into `.welcome-hero__mark` slot (populateMark ran)',
expected: 'non-null `.welcome-hero__mark svg`',
actual: probe.svgPresent ? 'present' : 'missing',
passed: probe.svgPresent,
actual: lightProbe.svgPresent ? 'present' : 'missing',
passed: lightProbe.svgPresent,
});
checks.push({
name: 'A35.2: injected <svg> carries stroke="currentColor" (UI-SPEC Option A recolor)',
expected: 'currentColor',
actual: probe.strokeAttr,
passed: probe.strokeAttr === 'currentColor',
actual: lightProbe.strokeAttr,
passed: lightProbe.strokeAttr === 'currentColor',
});
checks.push({
name: 'A35.3: getComputedStyle(<svg>).stroke resolves to a non-default colour (the currentColor cascade through `.welcome-hero__mark { color: var(--mks-fg-inverse) }` actually worked)',
name: 'A35.3: getComputedStyle(<svg>).stroke resolves to a non-default colour in LIGHT (the currentColor cascade through `.welcome-hero__mark { color: var(--mks-mark-stroke) }` actually worked)',
expected: 'non-empty, non-"none" resolved colour',
actual: probe.computedStroke,
passed: computedStrokeResolved,
actual: lightProbe.computedStroke,
passed: lightStrokeResolved,
});
checks.push({
name: 'A35.4: no legacy <img> in `.welcome-hero__mark` slot (the pre-04-06 <img> injection path is gone)',
expected: 'null',
actual: probe.imgPresent ? 'present (UNEXPECTED — legacy <img> still injected)' : 'null',
passed: !probe.imgPresent,
actual: lightProbe.imgPresent ? 'present (UNEXPECTED — legacy <img> still injected)' : 'null',
passed: !lightProbe.imgPresent,
});
checks.push({
name: `A35.5: Plan 04-06 Task 4 DECOUPLE — LIGHT and DARK computed strokes are EQUAL and BOTH resolve to linen-50 (${A35_LINEN_50_HEX} = ${A35_LINEN_50_RGB}). Proves --mks-mark-stroke is theme-independent (NOT overridden by .dark/[data-theme="dark"]) and the wrapper points at it.`,
expected: `light === dark === "${A35_LINEN_50_RGB}"`,
actual: `light="${lightProbe.computedStroke}", dark="${darkProbe.computedStroke}"`,
passed: decoupled,
});
const passed = checks.every((c) => c.passed);
return {
passed,
name: 'A35 — welcome-page inline-SVG injected at populateMark() runtime; currentColor stroke resolves via parent CSS color cascade (UI-SPEC dark-logo strategy live-DOM proof; iter-2 BLOCKER 1 resolution)',
name: 'A35 — welcome-page inline-SVG injected at populateMark() runtime; currentColor stroke resolves via parent CSS color cascade AND is theme-decoupled via --mks-mark-stroke (UI-SPEC dark-logo strategy live-DOM proof; iter-2 BLOCKER 1 resolution + Plan 04-06 Task 4 decouple)',
checks,
diagnostics,
};
@@ -3104,7 +3181,7 @@ export async function driveA35(
diagnostics.push(`A35 THREW: ${errMsg}`);
return {
passed: false,
name: 'A35 — welcome-page inline-SVG injected at populateMark() runtime; currentColor stroke resolves via parent CSS color cascade (UI-SPEC dark-logo strategy live-DOM proof; iter-2 BLOCKER 1 resolution)',
name: 'A35 — welcome-page inline-SVG injected at populateMark() runtime; currentColor stroke resolves via parent CSS color cascade AND is theme-decoupled via --mks-mark-stroke (UI-SPEC dark-logo strategy live-DOM proof; iter-2 BLOCKER 1 resolution + Plan 04-06 Task 4 decouple)',
checks,
diagnostics,
error: errMsg,