From f897746551456a09d24e7c57b06e4e7f3633fa54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:59:56 +0000 Subject: [PATCH 1/2] Measure every figure token in both themes in the browser check The browser check covered #19's figure design for --fig-ink only: its contrast, the SVG having no painted chip, and the ink changing between themes. Two unit tests still guarded the rest by reading site.css as text. scripts/figure_theme_contract.mjs now measures, in light and in dark, on the rendered page: - every token in the grammar's FIGURE_TOKENS_LIGHT/DARK resolves to a colour on the figures and equals the colour the grammar documents (compared as computed colours, not as spelled); - every stroke and fill painted through a token paints that token's colour; - every such stroke and fill changes colour between light and dark; - no figure SVG has a painted background, and the ink keeps 4.5:1. The existing ink, chip and recolour checks move into the module, so the browser check and a stand-alone probe can share one implementation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012eisRQmQvg4TphwztxhQGJ --- scripts/check_browser_layout.mjs | 18 ++--- scripts/figure_theme_contract.mjs | 109 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 scripts/figure_theme_contract.mjs diff --git a/scripts/check_browser_layout.mjs b/scripts/check_browser_layout.mjs index a89cc8e..d1e4b6e 100755 --- a/scripts/check_browser_layout.mjs +++ b/scripts/check_browser_layout.mjs @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { documentedFigureTokens, figureThemeExpression, figureThemeFailures } from './figure_theme_contract.mjs'; const chromePath = process.env.CHROME_PATH || (process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' @@ -10,6 +11,7 @@ const chromePath = process.env.CHROME_PATH || (process.platform === 'darwin' const target = process.argv[2] || 'http://127.0.0.1:9696/examples/values'; const url = `${target}${target.includes('?') ? '&' : '?'}browser_layout_check=${Date.now()}`; const port = 9333 + Math.floor(Math.random() * 1000); +const figureTokens = documentedFigureTokens(); const profile = await mkdtemp(path.join(tmpdir(), 'pythonbyexample-chrome-')); const chrome = spawn(chromePath, [ @@ -454,18 +456,12 @@ try { const body = getComputedStyle(document.body); const run = getComputedStyle(document.querySelector('button[type="submit"]')); const terminal = getComputedStyle(document.querySelector('.output-panel pre')); - const figure = document.querySelector('.cell-banner figure svg'); - const figureInkNode = figure?.querySelector('[stroke="var(--fig-ink)"]'); - const figureInk = figureInkNode ? getComputedStyle(figureInkNode).stroke : null; return { pageBackground: body.backgroundColor, pageLuminance: luminance(body.backgroundColor), textContrast: contrast(body.color, body.backgroundColor), runContrast: contrast(run.color, run.backgroundColor), terminalContrast: contrast(terminal.color, terminal.backgroundColor), - figureChip: figure ? getComputedStyle(figure).backgroundColor : null, - figureInk, - figureInkContrast: figureInk?.startsWith('rgb') ? contrast(figureInk, body.backgroundColor) : null, }; }; true`); const lightTheme = await evaluateValue(`({ @@ -473,6 +469,7 @@ try { editorColors: [...new Set([...document.querySelectorAll('.cm-line span')].map(node => getComputedStyle(node).color))].sort(), ...window.__pbeColors(), })`); + const lightFigures = await evaluateValue(figureThemeExpression(figureTokens.light)); await client.send('Emulation.setEmulatedMedia', { media: 'screen', features: [{ name: 'prefers-color-scheme', value: 'dark' }], @@ -501,6 +498,7 @@ try { ...window.__pbeColors(), }; })()`); + const darkFigures = await evaluateValue(figureThemeExpression(figureTokens.dark)); // Keep esm.sh import requests pending. The runner has no imports and is // emitted after its markup, so its controls must attach before the editor's @@ -859,11 +857,7 @@ try { if (lightTheme.runContrast < 4.5) failures.push(`Light Run-button contrast is ${lightTheme.runContrast.toFixed(2)}:1`); // Figures paint with the --fig-* tokens, so dark mode recolours them with // the page instead of setting each SVG on a light paper chip. - for (const [label, theme] of [['light', lightTheme], ['dark', darkTheme]]) { - if (!(theme.figureInkContrast >= 4.5)) failures.push(`${label} marginalia figure ink contrast is ${theme.figureInkContrast?.toFixed(2)}:1 (ink ${theme.figureInk} on ${theme.pageBackground})`); - if (theme.figureChip !== 'rgba(0, 0, 0, 0)') failures.push(`${label} marginalia figures sit on a painted chip (${theme.figureChip})`); - } - if (lightTheme.figureInk === darkTheme.figureInk) failures.push(`Marginalia figure ink did not recolour with the dark palette (${darkTheme.figureInk})`); + failures.push(...figureThemeFailures({ light: lightFigures, dark: darkFigures })); if (offlineRunner.fallbackOverflow > 0.5) failures.push(`Fallback textarea overflows its panel by ${offlineRunner.fallbackOverflow.toFixed(1)}px`); if (rendered.fontSmoothing !== 'antialiased') failures.push('Body text is not antialiased'); if (!String(rendered.headingWrap).includes('balance')) failures.push(`Headings do not balance their lines (${rendered.headingWrap})`); @@ -904,6 +898,8 @@ try { searchFailure, lightTheme, darkTheme, + lightFigures, + darkFigures, offlineRunner, fragmentBound, turnstileRetry, diff --git a/scripts/figure_theme_contract.mjs b/scripts/figure_theme_contract.mjs new file mode 100644 index 0000000..a14318a --- /dev/null +++ b/scripts/figure_theme_contract.mjs @@ -0,0 +1,109 @@ +// Rendered contract for the marginalia figure tokens. +// +// Figures paint with var(--fig-ink) and friends instead of literal colours, +// so one paint function renders in both colour schemes. That holds only if +// the page defines every token in light and dark, with the colours +// src/marginalia_grammar.py documents (the social cards and gestalt pages +// embed the same values). check_browser_layout.mjs measures each scheme on +// the rendered page with figureThemeExpression() and reports +// figureThemeFailures(); nothing here reads CSS text. +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// The grammar's FIGURE_TOKENS_LIGHT and FIGURE_TOKENS_DARK, imported from Python. +export function documentedFigureTokens() { + const program = 'import json; from src.marginalia_grammar import FIGURE_TOKENS_DARK, FIGURE_TOKENS_LIGHT; ' + + 'print(json.dumps({"light": FIGURE_TOKENS_LIGHT, "dark": FIGURE_TOKENS_DARK}))'; + const output = execFileSync('uv', ['run', '--python', '3.13', 'python', '-c', program], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(output); +} + +// Runs in the page under the current colour scheme. `documented` maps each +// token to the colour the grammar gives it in that scheme. +function measureFigures(documented) { + // Compare colours as the browser resolves them, not as they are spelled. + const resolve = value => { + const probe = document.createElement('span'); + probe.style.color = value; + if (!probe.style.color) return null; + document.body.append(probe); + const colour = getComputedStyle(probe).color; + probe.remove(); + return colour; + }; + const luminance = colour => { + const [r, g, b] = colour.match(/[\d.]+/g).slice(0, 3).map(value => { + const channel = Number(value) / 255; + return channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4; + }); + return .2126 * r + .7152 * g + .0722 * b; + }; + const contrast = (a, b) => { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + .05) / (low + .05); + }; + const pageBackground = getComputedStyle(document.body).backgroundColor; + const svgs = [...document.querySelectorAll('.cell-banner figure svg')]; + const inherited = svgs[0] ? getComputedStyle(svgs[0]) : null; + const tokens = {}; + for (const [name, value] of Object.entries(documented)) { + tokens[name] = { value: inherited ? resolve(inherited.getPropertyValue(name).trim()) : null, documented: resolve(value) }; + } + // Every stroke and fill a figure paints through a token, in document order. + const paints = []; + for (const node of svgs.flatMap(svg => [...svg.querySelectorAll('*')])) { + for (const attr of ['stroke', 'fill']) { + const token = /^var\((--fig-[a-z-]+)\)$/.exec(node.getAttribute(attr) || '')?.[1]; + if (!token) continue; + const style = getComputedStyle(node); + paints.push({ token, attr, painted: style[attr], tokenColour: resolve(style.getPropertyValue(token).trim()) }); + } + } + const ink = paints.find(paint => paint.token === '--fig-ink' && paint.attr === 'stroke')?.painted ?? null; + return { + figures: svgs.length, + pageBackground, + chips: svgs.map(svg => getComputedStyle(svg).backgroundColor), + tokens, + paints, + ink, + inkContrast: ink?.startsWith('rgb') ? contrast(ink, pageBackground) : null, + }; +} + +export function figureThemeExpression(documented) { + return `(${measureFigures.toString()})(${JSON.stringify(documented)})`; +} + +export function figureThemeFailures({ light, dark }) { + const failures = []; + for (const [scheme, theme] of [['light', light], ['dark', dark]]) { + if (!theme.figures) { + failures.push(`${scheme}: no marginalia figure on the page to measure`); + continue; + } + if (!(theme.inkContrast >= 4.5)) failures.push(`${scheme} marginalia figure ink contrast is ${theme.inkContrast?.toFixed(2)}:1 (ink ${theme.ink} on ${theme.pageBackground})`); + // No light "paper chip" behind figures: the tokens do the work. + const chips = [...new Set(theme.chips)].filter(chip => chip !== 'rgba(0, 0, 0, 0)'); + if (chips.length) failures.push(`${scheme} marginalia figures sit on a painted chip (${chips.join(', ')})`); + for (const [name, token] of Object.entries(theme.tokens)) { + if (!token.value) failures.push(`${scheme} figure token ${name} does not resolve to a colour`); + else if (token.value !== token.documented) failures.push(`${scheme} figure token ${name} is ${token.value}, not the ${token.documented} src/marginalia_grammar.py documents`); + } + const offToken = theme.paints.filter(paint => !paint.tokenColour || paint.painted !== paint.tokenColour); + if (offToken.length) failures.push(`${scheme}: ${offToken.length} figure strokes/fills do not paint their token's colour (${offToken.slice(0, 3).map(paint => `${paint.attr}=var(${paint.token}) painted ${paint.painted}`).join('; ')})`); + } + // Dark mode recolours figures through the tokens, not with a second palette. + const kept = light.paints.filter((paint, index) => paint.painted === dark.paints[index]?.painted); + if (!light.paints.length || light.paints.length !== dark.paints.length || kept.length) { + failures.push(`Marginalia figures did not recolour with the dark palette (${kept.length} of ${light.paints.length} strokes/fills kept their light colour: ${[...new Set(kept.map(paint => paint.token))].join(', ')})`); + } + return failures; +} From 85c2da17d7a6f1f3322bcb90fb895c8dd1c2ac22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:03:56 +0000 Subject: [PATCH 2/2] Retire #19's two site.css text tests for the figure tokens The browser check now measures what both tests meant on the rendered page (scripts/figure_theme_contract.mjs). Planting each bug they targeted in site.css and its served copy fails the rendered check; two of those plants passed one of the text tests: - test_dark_mode_recolours_marginalia_figures_through_tokens asserted "--fig-ink" appears after the dark media query. "--fig-ink-soft" also matches, so deleting the dark --fig-ink passed it, while the page drew 1.26:1 ink on the dark background. - A chip under another selector (.cell-banner svg { background: ... }) passed both tests; the rendered chip check fails it. - test_site_css_defines_every_token_in_both_schemes failed when a token was respelled as the same colour (#521000 to rgb(82 16 0)); the rendered check compares computed colours and passes. Neither test held a non-text assertion. FigureThemeTokenContract keeps test_every_painted_colour_is_a_token_reference, and the docs that cited Contract 15 as a site.css check now point at the rendered check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012eisRQmQvg4TphwztxhQGJ --- README.md | 2 +- docs/example-figure-rubric.md | 3 ++- docs/visual-explainer-spec.md | 5 +++-- tests/test_app.py | 12 +++--------- tests/test_marginalia_geometry.py | 27 +++++++-------------------- 5 files changed, 16 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 18868a5..5eaf19b 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ git diff --check - Worker Cache API keys include the HTML version - prototype layout pages are not cached -`make browser-layout-test` launches headless Chrome against the local Worker. It checks the rendered Shiki code-block layout so generated line markup does not create visual blank rows, drives the runner, sharing, copy, search and keyboard navigation through the page, and measures computed styles (pressed states, touch targets, contrast in both themes, the reader's font size, reduced-transparency and more-contrast fallbacks) in place of unit tests that matched CSS text. +`make browser-layout-test` launches headless Chrome against the local Worker. It checks the rendered Shiki code-block layout so generated line markup does not create visual blank rows, drives the runner, sharing, copy, search and keyboard navigation through the page, and measures computed styles (pressed states, touch targets, contrast in both themes, marginalia figure colours against the grammar's `--fig-*` tokens, the reader's font size, reduced-transparency and more-contrast fallbacks) in place of unit tests that matched CSS text. ## Asset fingerprinting and cache busting diff --git a/docs/example-figure-rubric.md b/docs/example-figure-rubric.md index 736d218..be40050 100644 --- a/docs/example-figure-rubric.md +++ b/docs/example-figure-rubric.md @@ -145,7 +145,8 @@ the figure can merge. - **Both colour schemes, enforced.** Figures paint with the `var(--fig-*)` tokens; `public/site.css` defines all four in the light root and the dark media block with the grammar's values, so - no figure needs a light chip in dark mode. *Contract 15.* + no figure needs a light chip in dark mode. *Contract 15, measured on + the rendered page by `scripts/figure_theme_contract.mjs`.* - **figcaption present and declarative.** Captions in the form "Two names share one mutable list — appending through one name changes the object visible through both." Not "this shows X" or diff --git a/docs/visual-explainer-spec.md b/docs/visual-explainer-spec.md index 0695522..c0a42a2 100644 --- a/docs/visual-explainer-spec.md +++ b/docs/visual-explainer-spec.md @@ -232,8 +232,9 @@ explicitly. Re-introducing either is a defect. mode, dark mode, the gestalt pages and the social cards. There is no paper chip. Any HTML shell that embeds a figure outside `site.css` must define the tokens with - `figure_token_css(FIGURE_TOKENS_LIGHT)`; Contract 15 checks that - `site.css` carries both sets. + `figure_token_css(FIGURE_TOKENS_LIGHT)`; the browser check + (`scripts/figure_theme_contract.mjs`) measures that the rendered + page resolves both sets. 4. **Typography follows the page and mono runs are pinned.** The grammar's mono and sans stacks are the page's own (the site loads no diff --git a/tests/test_app.py b/tests/test_app.py index f15b05d..fbae14e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -531,15 +531,9 @@ def test_dynamic_worker_code_wraps_user_example(self): class DarkModeAndAccessibilityTests(unittest.TestCase): - def test_dark_mode_recolours_marginalia_figures_through_tokens(self): - css = (ROOT / "public" / "site.css").read_text() - dark_block = css.split("@media (prefers-color-scheme: dark)", 1)[1] - for token in ("--fig-ink", "--fig-ink-soft", "--fig-accent", "--fig-soft"): - self.assertIn(token, dark_block) - # No light "paper chip" behind figures: the tokens do the work. - self.assertNotIn("--figure-paper", css) - self.assertNotIn(".cell-banner figure svg", dark_block) - + # Figure colours in both themes (every --fig-* token defined, no paper + # chip) are measured on the rendered page by + # scripts/figure_theme_contract.mjs, run from check_browser_layout.mjs. def test_optional_highlighters_retain_readable_server_fallbacks(self): page = render_example_page(get_example("values")) self.assertIn('
', page)
diff --git a/tests/test_marginalia_geometry.py b/tests/test_marginalia_geometry.py
index 6f088e7..f77cdc1 100644
--- a/tests/test_marginalia_geometry.py
+++ b/tests/test_marginalia_geometry.py
@@ -847,30 +847,17 @@ def test_no_arrow_is_shorter_than_the_minimum(self):
 
 
 class FigureThemeTokenContract(unittest.TestCase):
-    """Contract 15: the page defines the figure tokens the grammar paints with.
+    """Contract 15: figures paint only through the --fig-* theme tokens.
 
     Figures emit var(--fig-ink) and friends instead of literal colours so
-    one paint function renders in both colour schemes. That only holds
-    if public/site.css defines every token in both the light :root and
-    the dark media block, with exactly the values the grammar documents;
-    a missing token renders as black in light mode and invisible ink in
-    dark mode, and neither is caught by the geometry contracts.
+    one paint function renders in both colour schemes. The other half of
+    the contract is measured on the rendered page, in light and dark, by
+    scripts/figure_theme_contract.mjs (run from check_browser_layout.mjs):
+    every token resolves to the colour FIGURE_TOKENS_LIGHT/DARK document,
+    and every stroke and fill paints it. A missing token leaves strokes
+    unpainted and fills black, which no geometry contract can see.
     """
 
-    def test_site_css_defines_every_token_in_both_schemes(self):
-        from pathlib import Path
-
-        from src.marginalia_grammar import FIGURE_TOKENS_DARK, FIGURE_TOKENS_LIGHT
-
-        css = (Path(__file__).resolve().parents[1] / "public" / "site.css").read_text()
-        light, dark = css.split("@media (prefers-color-scheme: dark)", 1)
-        failures: list[str] = []
-        for scheme, block, tokens in (("light", light, FIGURE_TOKENS_LIGHT), ("dark", dark, FIGURE_TOKENS_DARK)):
-            for name, value in tokens.items():
-                if f"{name}: {value};" not in block:
-                    failures.append(f"{scheme}: expected `{name}: {value};`")
-        self.assertEqual(failures, [], "\n  " + "\n  ".join(failures))
-
     def test_every_painted_colour_is_a_token_reference(self):
         from src.marginalia_grammar import EMPHASIS, INK, INK_SOFT, SOFT_FILL