Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/example-figure-rubric.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/visual-explainer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 7 additions & 11 deletions scripts/check_browser_layout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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'
: '/usr/bin/google-chrome');
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, [
Expand Down Expand Up @@ -454,25 +456,20 @@ 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(`({
shikiColor: getComputedStyle(document.querySelector('.shiki-block span')).color,
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' }],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})`);
Expand Down Expand Up @@ -904,6 +898,8 @@ try {
searchFailure,
lightTheme,
darkTheme,
lightFigures,
darkFigures,
offlineRunner,
fragmentBound,
turnstileRetry,
Expand Down
109 changes: 109 additions & 0 deletions scripts/figure_theme_contract.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 3 additions & 9 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<pre><code class="language-python">', page)
Expand Down
27 changes: 7 additions & 20 deletions tests/test_marginalia_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down