diff --git a/.github/workflows/scheduled-quality.yml b/.github/workflows/scheduled-quality.yml
new file mode 100644
index 0000000..7d079d1
--- /dev/null
+++ b/.github/workflows/scheduled-quality.yml
@@ -0,0 +1,31 @@
+name: Scheduled quality
+
+# Runs the editorial quality gates weekly on the default branch, with no code
+# change needed. `make check-waiver-expiry` fails while a quality waiver has 30
+# days or fewer left, so an expiry is noticed here (GitHub emails the failure)
+# a month before `make verify` would start failing every pull request.
+on:
+ schedule:
+ - cron: '23 6 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
+ with:
+ enable-cache: false
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.13'
+ - name: Install locked dependencies
+ run: uv sync --locked --all-groups
+ - name: Quality gates
+ run: make quality-checks
+ - name: Fail 30 days before a quality waiver expires
+ run: make check-waiver-expiry
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bf06099..749eb33 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -97,7 +97,7 @@ The single source of truth for the registries is `docs/quality-registries.toml`.
## Secrets and deploy configuration
-Production deployment is manual through an authenticated Wrangler session with `make deploy`; public `workers.dev` and version preview URLs are disabled. Runtime Worker secrets (`TURNSTILE_SECRET_KEY`, `TURNSTILE_CLEARANCE_SECRET`, `PBE_SMOKE_BYPASS_SECRET`) are managed with `wrangler secret put`; see `docs/turnstile-runner-protection-spec.md`.
+Production deployment is manual through an authenticated Wrangler session with `make deploy`, which smoke-tests the deployed origin afterwards and fails if the smoke fails; public `workers.dev` and version preview URLs are disabled. Runtime Worker secrets (`TURNSTILE_SECRET_KEY`, `TURNSTILE_CLEARANCE_SECRET`, `PBE_SMOKE_BYPASS_SECRET`) are managed with `wrangler secret put`; see `docs/turnstile-runner-protection-spec.md`.
Generated output is prevented from drifting before merge: install the local hooks with `scripts/install-git-hooks.sh`, and keep `main` protected so pull requests require the `verify` status check to pass against the current base. CI enforces the same `make check-generated` contract for contributors without local hooks.
diff --git a/Makefile b/Makefile
index 5e592ef..4762686 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
PY := uv run --python 3.13
NODE_DEPS_STAMP := node_modules/.package-lock.json
-.PHONY: check-node-version node-deps test embed-examples embed-editorial-registry build-search-index build check-generated fingerprint prototypes browser-layout-test search-ranking-test social-cards check-social-cards seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported check-program-covers-cells check-prose-duplication check-inline-links score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes audit-example-graph quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy upgrade-runtime-deps lint
+.PHONY: check-node-version node-deps test embed-examples embed-editorial-registry build-search-index build check-generated fingerprint prototypes browser-layout-test search-ranking-test social-cards check-social-cards seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported check-program-covers-cells check-prose-duplication check-inline-links score-example-criteria check-quality-scores check-waiver-expiry check-no-figure-rationales check-journey-outcomes audit-example-graph quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment post-deploy-smoke dev deploy upgrade-runtime-deps lint
check-node-version:
@major="$$(node -p 'process.versions.node.split(".")[0]')"; \
@@ -92,6 +92,11 @@ score-example-criteria:
check-quality-scores:
$(PY) scripts/check_quality_scores.py
+# Scheduled (weekly) gate: fail while a quality waiver has 30 days or fewer
+# left, so the expiry surfaces before it turns every pull request red.
+check-waiver-expiry:
+ $(PY) scripts/check_quality_scores.py --fail-within-days 30
+
check-no-figure-rationales:
$(PY) scripts/check_no_figure_rationales.py
@@ -121,12 +126,24 @@ dev: node-deps
uv run --group workers pywrangler dev --port 9696
smoke-deployment:
- $(PY) scripts/smoke_deployment.py $(URL)
+ $(PY) scripts/smoke_deployment.py $(URL) $(SMOKE_ARGS)
+
+# Origin that `make deploy` smoke-tests once Wrangler has deployed. With
+# Turnstile challenges enabled, export PBE_SMOKE_BYPASS_SECRET so the POST
+# checks can run; SMOKE_ARGS passes extra flags (e.g. --skip-post) explicitly.
+DEPLOY_URL ?= https://www.pythonbyexample.dev
+
+post-deploy-smoke:
+ @$(MAKE) --no-print-directory smoke-deployment URL=$(DEPLOY_URL) || { \
+ echo "Deployment smoke FAILED for $(DEPLOY_URL). The new version is already live: investigate now, or roll back with 'uv run --group workers pywrangler rollback'." >&2; \
+ exit 1; \
+ }
deploy: node-deps check-generated
uv run --group workers pywrangler sync --force
git diff --exit-code -- pylock.toml
uv run --group workers pywrangler deploy
+ @$(MAKE) --no-print-directory post-deploy-smoke
# Production vendors pylock.toml; tests run against uv.lock. Refresh both together.
upgrade-runtime-deps: node-deps
diff --git a/README.md b/README.md
index c05521e..ffc3da9 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 and checks the rendered Shiki code-block layout so generated line markup does not create visual blank rows.
+`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.
## Asset fingerprinting and cache busting
@@ -174,7 +174,7 @@ scripts/format_examples.py --check
make deploy
```
-`make deploy` first runs `make check-generated`, which rebuilds and rejects any generated output not committed to the branch. It then syncs the ignored Python Workers dependency bundle from the committed `pylock.toml`, refusing to deploy if the sync would change that lock, before Wrangler deploys.
+`make deploy` first runs `make check-generated`, which rebuilds and rejects any generated output not committed to the branch. It then syncs the ignored Python Workers dependency bundle from the committed `pylock.toml`, refusing to deploy if the sync would change that lock, before Wrangler deploys. Finally it runs `scripts/smoke_deployment.py` against `DEPLOY_URL` (default `https://www.pythonbyexample.dev`) and exits non-zero if the deployed Worker fails any GET or POST check. Export `PBE_SMOKE_BYPASS_SECRET` when Turnstile challenges are enabled so the POST checks can run; `SMOKE_ARGS` passes extra flags such as `--skip-post` explicitly.
## Updating dependencies
diff --git a/docs/lessons-learned.md b/docs/lessons-learned.md
index 6e9a944..25c1773 100644
--- a/docs/lessons-learned.md
+++ b/docs/lessons-learned.md
@@ -40,7 +40,7 @@ This document records project lessons that should guide future changes to Python
- Keep the complete editable program visible because it is the thing that actually runs.
- When storing examples as Markdown, keep the full editable program in `:::program` and teaching fragments in separate `:::cell` blocks. Do not concatenate cells to recreate the editor source.
- Fine-grained cells can restate definitions to stay executable. This is better than collapsing class, property, recursion, match, or type-hint examples into one large cell.
-- Preserve a frozen golden catalog while migrating source formats. Full stdout parity is not enough; rendered teaching-cell structure must also match. (After the migration, the fixture's job changed: it is now a reviewed structural snapshot, refreshed explicitly by `scripts/refresh_golden_fixture.py`, that catches loader/parser regressions rather than guarding a migration.)
+- Preserve a frozen golden catalog while migrating source formats. Full stdout parity is not enough; rendered teaching-cell structure must also match. (After the migration the frozen fixture and its refresh script were retired in 0929c25, 2026-06-13; loader and parser regressions are now caught by the frontmatter property tests in `tests/test_parser_properties.py` and the byte-exact output check in `scripts/verify_examples.py`.)
- Beware broad-surface titles that quietly teach only one narrow slice. Pages named `Testing`, `Packages`, `Regular Expressions`, `Type Hints`, `Async Await`, or `Special Methods` must either cover the forms a reader reasonably expects or explicitly frame themselves as a first pass and link to focused neighbors.
- Do not let an important syntax form live only in a separate page if another page title strongly implies it. An umbrella `Operators` page should at least point to and lightly show `:=`, even though `Assignment Expressions` remains the focused lesson.
- Journey order should follow prerequisite thinking, not catalog order. Put booleans before truthiness and conditionals, scope before closures, bytes before networking, and environment boundaries before subprocess/thread boundaries.
@@ -64,6 +64,7 @@ This document records project lessons that should guide future changes to Python
- Avoid layout shifts after execution. Reserve space for metadata such as execution time before a run occurs.
- When two columns use the orange rail, source and output need identical rail spacing. Differences in border/padding make the page feel broken even if the content is correct.
- Use browser screenshot tests for visual bugs. Static HTML/CSS assertions are useful but can miss actual rendered layout behavior.
+- Assert what the browser computes, not what `site.css` says. Unit tests that matched literal CSS or JS text broke on harmless refactors and passed on real regressions, so `scripts/check_browser_layout.mjs` now measures the same intentions: computed transforms under a forced `:active`, touch-target heights, contrast in both themes, the reader's font size, emulated `prefers-reduced-transparency`/`prefers-contrast`, and runner behaviour driven through the page. To add a visual rule, add a measurement there that fails when the rule is removed.
## Testing and verification
@@ -108,7 +109,7 @@ git diff --check
- **Score what's shipping, not what was designed.** A scoring dict on the gestalt is design-time review. Production figures live in `src/marginalia.py` `FIGURES` and may have been redesigned during promotion. Scoring should track the production version with the gestalt as separate history.
- **Semantic diagram review has four objects: page, cell, paint function, caption.** Geometry contracts can prove that a figure renders, but not that it still teaches the adjacent cell. On every diagram pass, read the current example cell, the attached paint function, the caption, and the score rationale together. The same audit caught `container-protocols` still showing `iter()/next()` after the page had shifted to `__setitem__`/`__contains__`/`__getitem__`, `structured-data-shapes` over-focusing on `TypedDict` while anchored to a dataclass cell, and `object-lifecycle` mentioning `__del__` after the lesson had been reframed around references.
- **Some examples should never have figures.** Constraint-shaped, infrastructure-shaped, and aggregator-shaped slugs lack a single mechanism to depict. Force-fitting figures on them scores below the gate. Leave them figure-less and document why rather than ship weak figures.
-- **Audits without contracts rot; bug classes need automated gates.** When you find a bug class — clipping, collision, off-palette colour, drifting twin coordinates, duplicate caption — write a unit test that asserts the invariant across every figure. The geometry contracts in `tests/test_marginalia_geometry.py` started as ad-hoc scripts and were promoted to CI gates after the same bug class recurred. 54 tests today cover 9 contract families; new figures pass them automatically because each test iterates `FIGURES`.
+- **Audits without contracts rot; bug classes need automated gates.** When you find a bug class — clipping, collision, off-palette colour, drifting twin coordinates, duplicate caption — write a unit test that asserts the invariant across every figure. The geometry contracts in `tests/test_marginalia_geometry.py` started as ad-hoc scripts and were promoted to CI gates after the same bug class recurred. Each contract family is a test class there; new figures pass them automatically because each test iterates `FIGURES`. (Count the classes rather than quoting a number here: an earlier "54 tests, 9 families" claim drifted from the file.)
- **Clipping ≠ collision; padding the viewBox fixes one but not the other.** The `value-types` bug had two components: the first `INT` tag was clipped above the viewBox (geometry escapes its frame), and the `STR`/`LIST`/`DICT` tags overlapped the boxes above them (geometry collides internally). Padding the viewBox solved (1) and disguised (2). Element-element collision needs its own audit that walks every text-rect, text-text, and (for label-on-edge cases) text-line bounding-box pair.
- **Heuristic audits over-flag; trust the design, not the regex.** Probes for "prose duplication" (SVG text matching caption substring), "text crossing a line" (label bbox bisected by a hairline), and "text overlapping a circle" each surfaced ~3-10 hits — all false positives. Diagrammatic labels naturally appear in captions (`__getattr__`, `yield from inner`); dashed strikes through `.append` are deliberate; text inside node circles is the design. Don't promote heuristic findings to contracts without confirming each is a real bug.
- **Structural twins must share coordinates exactly.** When two figures depict parallel concepts — `kw-only-separator` and `positional-only-separator`, `class-triangle` and `metaclass-triangle` — they read as a pair. A single-pixel drift in one breaks the visual rhyme. Treat a coordinate change in one as a forced change in both, in the same commit. The audit caught `kw-only-separator` at the old `x=82` after `positional-only-separator` had moved to the corrected `x=75`.
diff --git a/scripts/check_browser_layout.mjs b/scripts/check_browser_layout.mjs
index 48bf30f..b16000a 100755
--- a/scripts/check_browser_layout.mjs
+++ b/scripts/check_browser_layout.mjs
@@ -226,6 +226,22 @@ try {
busy: form.hasAttribute('aria-busy'),
};
})()`);
+ const networkFailure = await evaluateValue(`(async () => {
+ const form = document.querySelector('form.runner-editor');
+ const originalFetch = window.fetch;
+ window.fetch = () => Promise.reject(new TypeError('network down'));
+ window.pythonByExampleEditor.setValue('print("offline")');
+ form.requestSubmit();
+ for (let i = 0; i < 100 && form.hasAttribute('aria-busy'); i++) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ window.fetch = originalFetch;
+ return {
+ output: document.querySelector('.output-panel code')?.textContent,
+ busy: form.hasAttribute('aria-busy'),
+ runDisabled: form.querySelector('button[type="submit"]').disabled,
+ };
+ })()`);
const share = await evaluateValue(`(async () => {
const writes = [];
window.__pbeClipboardWrites = writes;
@@ -242,7 +258,14 @@ try {
await new Promise(resolve => requestAnimationFrame(resolve));
document.querySelector('.share-button').click();
await new Promise(resolve => setTimeout(resolve, 50));
- return { code, url: writes.at(-1) || '', editorHeight: document.querySelector('.cm-editor').getBoundingClientRect().height };
+ const url = writes.at(-1) || '';
+ const editorHeight = document.querySelector('.cm-editor').getBoundingClientRect().height;
+ const textarea = document.getElementById('code-editor');
+ window.pythonByExampleEditor.setValue(textarea.dataset.originalCode ?? textarea.defaultValue);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ document.querySelector('.share-button').click();
+ await new Promise(resolve => setTimeout(resolve, 50));
+ return { code, url, editorHeight, uneditedUrl: writes.at(-1) || '', pageUrl: location.href.split('#')[0] };
})()`);
const sharedReloadUrl = new URL(share.url);
@@ -266,20 +289,66 @@ try {
const source = document.querySelector('.cell-source');
const expected = source.querySelector('pre').textContent;
const button = source.querySelector('.copy-button');
+ const icon = button.querySelector('.copy-icon');
+ const statusNode = button.querySelector('.copy-status');
+ const mask = () => getComputedStyle(icon).webkitMaskImage || getComputedStyle(icon).maskImage;
+ const idleMask = mask();
+ const sourceRect = source.getBoundingClientRect();
+ const buttonRect = button.getBoundingClientRect();
button.click();
await new Promise(resolve => setTimeout(resolve, 50));
- return { expected, copied: writes.at(-1) || '', state: button.className, status: button.querySelector('.copy-status')?.textContent };
+ const copiedState = {
+ copied: writes.at(-1) || '',
+ state: button.className,
+ status: statusNode?.textContent,
+ statusRight: statusNode?.getBoundingClientRect().right,
+ liveRegion: button.getAttribute('aria-live'),
+ copiedMask: mask(),
+ };
+ // Without the async Clipboard API the button must fall back to a selected
+ // off-screen textarea and execCommand('copy').
+ const fallbackWrites = [];
+ try {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
+ } catch (_) {}
+ document.execCommand = command => {
+ if (command !== 'copy') return false;
+ fallbackWrites.push([...document.querySelectorAll('textarea[readonly]')].at(-1)?.value ?? '');
+ return true;
+ };
+ button.click();
+ await new Promise(resolve => setTimeout(resolve, 50));
+ return {
+ expected,
+ ...copiedState,
+ idleMask,
+ iconPaint: [getComputedStyle(icon).backgroundColor, getComputedStyle(icon).color],
+ // Anchored to its own cell's top-right corner, not to a distant ancestor.
+ buttonAnchored: Math.abs(buttonRect.right - sourceRect.right) <= 1 && Math.abs(buttonRect.top - sourceRect.top) <= 16,
+ fallbackCopied: fallbackWrites.at(-1) ?? '',
+ fallbackState: button.className,
+ };
})()`);
const arrow = await evaluateValue(`(async () => {
const textarea = document.getElementById('code-editor');
- const modifiedPath = location.pathname;
+ // Count navigation attempts with the Navigation API and cancel them, so a
+ // guard that fails is seen even though the page has not unloaded yet.
+ let attempts = 0;
+ const cancel = event => { attempts += 1; event.preventDefault(); };
+ navigation.addEventListener('navigate', cancel);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await new Promise(resolve => setTimeout(resolve, 50));
- const blockedModified = location.pathname === modifiedPath;
+ const blockedModified = attempts === 0;
+ const attemptsWhileEdited = attempts;
window.pythonByExampleEditor.setValue(textarea.dataset.originalCode || textarea.defaultValue);
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', metaKey: true, bubbles: true }));
+ document.querySelector('button[type="submit"]').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+ await new Promise(resolve => setTimeout(resolve, 50));
+ navigation.removeEventListener('navigate', cancel);
+ const blockedModifierAndButton = attempts === attemptsWhileEdited;
const next = document.querySelector('.example-nav a[rel="next"]')?.href || '';
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
- return { blockedModified, next };
+ return { blockedModified, blockedModifierAndButton, next };
})()`);
await waitFor(`location.href === ${JSON.stringify(arrow.next)} && !!window.pythonByExampleEditor && !!document.querySelector('.cm-content')`, 'clean arrow navigation');
const editableBlocked = await evaluateValue(`(async () => {
@@ -288,6 +357,23 @@ try {
await new Promise(resolve => setTimeout(resolve, 50));
return location.pathname === path;
})()`);
+ // ArrowLeft walks back: next -> the target page -> the first example, where
+ // there is no previous link and the key must do nothing (and throw nothing).
+ const arrowBack = [];
+ for (let step = 0; step < 2; step++) {
+ const expected = await evaluateValue(`document.querySelector('.example-nav a[rel="prev"]')?.href || ''`);
+ await evaluateValue(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))`);
+ await waitFor(`location.href === ${JSON.stringify(expected)} && document.readyState === 'complete' && !!document.querySelector('.example-nav')`, `ArrowLeft navigation ${step + 1}`);
+ arrowBack.push(expected);
+ }
+ const catalogEdge = await evaluateValue(`(async () => {
+ const errors = [];
+ window.addEventListener('error', event => errors.push(event.message));
+ const path = location.pathname;
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return { hasPrev: !!document.querySelector('.example-nav a[rel="prev"]'), stayed: location.pathname === path, errors };
+ })()`);
async function searchWidthAt(label, width, height, mobile) {
await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile });
@@ -321,6 +407,26 @@ try {
window.fetch = originalFetch;
return state;
})()`);
+ await client.send('Page.navigate', { url: `${new URL(target).origin}/?browser_search_ok=${Date.now()}` });
+ await waitFor("document.readyState === 'complete' && !!document.getElementById('site-search-input')", 'search success page');
+ const searchSuccess = await evaluateValue(`(async () => {
+ const input = document.getElementById('site-search-input');
+ input.focus();
+ input.value = 'decorator';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ for (let i = 0; i < 100 && input.getAttribute('aria-expanded') !== 'true'; i++) {
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+ const listbox = document.getElementById(input.getAttribute('aria-controls'));
+ const options = [...(listbox?.querySelectorAll('[role="option"]') || [])];
+ return {
+ expanded: input.getAttribute('aria-expanded'),
+ listboxRole: listbox?.getAttribute('role'),
+ listboxHidden: listbox?.hidden,
+ options: options.length,
+ firstOption: options[0]?.textContent?.trim().slice(0, 60),
+ };
+ })()`);
await client.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 1800, deviceScaleFactor: 2, mobile: true });
// Theme changes are a live OS preference, not a reload-only setting.
@@ -330,9 +436,39 @@ try {
});
await client.send('Page.navigate', { url: `${target}?browser_theme=light` });
await waitFor("document.readyState === 'complete' && !!document.querySelector('.shiki-block') && !!document.querySelector('.cm-editor')", 'light theme page');
+ // Colour helpers shared by the light and dark measurements.
+ await evaluateValue(`window.__pbeColors = () => {
+ const parse = color => color.match(/[\\d.]+/g).slice(0, 3).map(Number);
+ const luminance = color => {
+ const channels = parse(color).map(value => {
+ const normalized = value / 255;
+ return normalized <= .04045 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4;
+ });
+ return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2];
+ };
+ const contrast = (fg, bg) => {
+ const a = luminance(fg);
+ const b = luminance(bg);
+ return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
+ };
+ 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');
+ 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),
+ figurePaperLuminance: figure ? luminance(getComputedStyle(figure).backgroundColor) : null,
+ figurePaperAlpha: figure ? getComputedStyle(figure).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(),
})`);
await client.send('Emulation.setEmulatedMedia', {
media: 'screen',
@@ -359,6 +495,7 @@ try {
editorColors: [...new Set([...document.querySelectorAll('.cm-line span')].map(node => getComputedStyle(node).color))].sort(),
runContrast: (Math.max(a, b) + .05) / (Math.min(a, b) + .05),
editorOutlineWidth: editorStyle.outlineWidth,
+ ...window.__pbeColors(),
};
})()`);
@@ -372,10 +509,17 @@ try {
const offlineUrl = `${target}#code=${Buffer.from(offlineCode, 'utf8').toString('base64')}`;
await client.send('Page.navigate', { url: offlineUrl });
await waitFor(`!!document.querySelector('.share-button') && !!document.querySelector('.copy-button') && document.getElementById('code-editor')?.value === ${JSON.stringify(offlineCode)}`, 'runner controls while CDN is pending');
- const offlineRunner = await evaluateValue(`({
- editorLoaded: !!window.pythonByExampleEditor,
- fallbackHeight: document.getElementById('code-editor').getBoundingClientRect().height,
- })`);
+ const offlineRunner = await evaluateValue(`(() => {
+ const textarea = document.getElementById('code-editor');
+ const panel = textarea.parentElement;
+ const panelStyle = getComputedStyle(panel);
+ const contentRight = panel.getBoundingClientRect().right - Number.parseFloat(panelStyle.paddingRight) - Number.parseFloat(panelStyle.borderRightWidth);
+ return {
+ editorLoaded: !!window.pythonByExampleEditor,
+ fallbackHeight: textarea.getBoundingClientRect().height,
+ fallbackOverflow: textarea.getBoundingClientRect().right - contentRight,
+ };
+ })()`);
removePauseListener();
await client.send('Fetch.disable');
@@ -412,6 +556,7 @@ try {
let scriptAttempts = 0;
let renderAction = '';
let widgetOptions = null;
+ let removes = 0;
document.head.appendChild = node => {
if (node.tagName === 'SCRIPT' && node.src.includes('challenges.cloudflare.com/turnstile')) {
scriptAttempts += 1;
@@ -423,7 +568,7 @@ try {
window.turnstile = {
render: (_box, options) => { widgetOptions = options; renderAction = options.action; return 7; },
execute: () => queueMicrotask(() => widgetOptions.callback('retry-token')),
- remove: () => {},
+ remove: () => { removes += 1; },
reset: () => {},
};
node.onload?.(new Event('load'));
@@ -460,7 +605,13 @@ try {
const finalOutput = output();
document.head.appendChild = nativeAppend;
window.fetch = nativeFetch;
- return { scriptAttempts, fetchCount, renderAction, submittedToken, firstFailure, finalOutput };
+ return {
+ scriptAttempts, fetchCount, renderAction, submittedToken, firstFailure, finalOutput,
+ renderExecution: widgetOptions?.execution,
+ renderHasSize: !!widgetOptions && 'size' in widgetOptions,
+ removes,
+ challengeHidden: document.querySelector('[data-turnstile-sitekey]').hidden,
+ };
})()`);
// A server that keeps rejecting tokens (wrong secret, hostname, or action)
@@ -514,6 +665,135 @@ try {
return { firstRun, secondRun };
})()`);
+ // Rendered-style contracts. Unit tests used to assert literal site.css text;
+ // these measure what the browser computes for the same intentions instead.
+ const origin = new URL(target).origin;
+ await client.send('Emulation.setEmulatedMedia', { media: 'screen', features: [{ name: 'prefers-color-scheme', value: 'light' }] });
+ await client.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 1800, deviceScaleFactor: 2, mobile: true });
+ await client.send('Page.navigate', { url: `${target}?browser_styles=${Date.now()}` });
+ await waitFor("document.readyState === 'complete' && !!window.pythonByExampleEditor && !!document.querySelector('.copy-button') && !!document.querySelector('.share-button')", 'rendered-style page');
+ // Headless pages are not focused by default, so :focus never matches.
+ await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
+ const rendered = await evaluateValue(`(async () => {
+ const rect = element => element.getBoundingClientRect();
+ const toolbar = rect(document.querySelector('.playground-toolbar'));
+ const run = rect(document.querySelector('button[type="submit"]'));
+ const reset = rect(document.querySelector('[data-reset]'));
+ const share = rect(document.querySelector('.share-button'));
+ const skip = document.querySelector('.skip-link');
+ const skipBefore = rect(skip).right;
+ skip.focus();
+ const skipFocused = rect(skip);
+ skip.blur();
+ const allTransitions = [...document.querySelectorAll('*')].filter(element => {
+ const style = getComputedStyle(element);
+ return style.transitionProperty.split(',').some(value => value.trim() === 'all')
+ && style.transitionDuration.split(',').some(value => Number.parseFloat(value) > 0);
+ }).map(element => element.tagName.toLowerCase() + (element.className ? '.' + String(element.className).split(' ')[0] : ''));
+ const output = document.querySelector('.output-panel code');
+ const pre = document.querySelector('.output-panel pre');
+ const panel = document.querySelector('.output-panel');
+ const executionTime = document.querySelector('.execution-time');
+ const panelRight = rect(panel).right;
+ output.textContent = 'x'.repeat(600);
+ const text = document.createRange();
+ text.selectNodeContents(output);
+ const wide = {
+ textPastPanel: text.getBoundingClientRect().right - panelRight,
+ pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ };
+ const oneLinePanelHeight = rect(panel).height;
+ output.textContent = Array.from({ length: 60 }, (_, index) => 'line ' + index).join('\\n');
+ const tall = { panelHeight: rect(panel).height, preClipped: pre.scrollHeight - pre.clientHeight };
+ return {
+ fontSmoothing: getComputedStyle(document.body).webkitFontSmoothing,
+ headingWrap: getComputedStyle(document.querySelector('h1')).textWrapStyle || getComputedStyle(document.querySelector('h1')).textWrap,
+ executionNumerals: executionTime ? getComputedStyle(executionTime).fontVariantNumeric : null,
+ navUnderline: getComputedStyle(document.querySelector('nav a')).textDecorationLine,
+ navTargetHeights: [...document.querySelectorAll('.nav-links a')].map(link => rect(link).height),
+ buttonHeights: [run.height, reset.height, share.height],
+ shareGap: share.left - reset.right,
+ shareToToolbarEnd: Math.abs(toolbar.right - share.right),
+ skipBefore,
+ skipFocused: { left: skipFocused.left, top: skipFocused.top, width: skipFocused.width },
+ allTransitions,
+ wide,
+ oneLinePanelHeight,
+ tall,
+ };
+ })()`);
+
+ // Pressed states: force :active through DevTools rather than trusting CSS text.
+ await client.send('DOM.enable');
+ await client.send('CSS.enable');
+ async function forcedActiveTransform(selector) {
+ const { root } = await client.send('DOM.getDocument', { depth: 0 });
+ const { nodeId } = await client.send('DOM.querySelector', { nodeId: root.nodeId, selector });
+ if (!nodeId) return `missing ${selector}`;
+ await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['active'] });
+ await sleep(400); // let the 150ms transform transition finish
+ const transform = await evaluateValue(`getComputedStyle(document.querySelector(${JSON.stringify(selector)})).transform`);
+ await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
+ return transform;
+ }
+ const scaleOf = transform => (/^matrix\(([-\d.]+)/.exec(transform || '') || [])[1];
+ const pressed = {};
+ for (const selector of ['button[type="submit"]', '.share-button', '.copy-button']) {
+ pressed[selector] = await forcedActiveTransform(selector);
+ }
+
+ // Body text follows the reader's browser font size instead of a fixed 16px.
+ await client.send('Page.setFontSizes', { fontSizes: { standard: 20, fixed: 16 } });
+ await client.send('Page.navigate', { url: `${target}?browser_font_size=${Date.now()}` });
+ await waitFor("document.readyState === 'complete'", 'font-size page');
+ const userFontSize = await evaluateValue(`getComputedStyle(document.body).fontSize`);
+ await client.send('Page.setFontSizes', { fontSizes: { standard: 16, fixed: 13 } });
+
+ // Home page: the nav is visible on landing, cards press down, and the
+ // translucent header has solid fallbacks for two non-motion preferences.
+ await client.send('Page.navigate', { url: `${origin}/?browser_home_styles=${Date.now()}` });
+ await waitFor("document.readyState === 'complete' && !!document.querySelector('.card')", 'home style page');
+ const home = await evaluateValue(`(() => {
+ const header = document.querySelector('header');
+ const nav = document.querySelector('.nav-links');
+ const navRect = nav.getBoundingClientRect();
+ return {
+ headerOpacity: getComputedStyle(header).opacity,
+ navVisible: getComputedStyle(nav).visibility === 'visible' && getComputedStyle(nav).opacity === '1' && navRect.height > 0 && navRect.top >= 0,
+ headerBackdrop: getComputedStyle(header).backdropFilter,
+ };
+ })()`);
+ pressed['.card'] = await forcedActiveTransform('.card');
+ const headerUnder = async (name, value) => {
+ await client.send('Emulation.setEmulatedMedia', { media: 'screen', features: [{ name: 'prefers-color-scheme', value: 'light' }, { name, value }] });
+ await sleep(400); // nav colours transition over 160ms
+ return evaluateValue(`(() => {
+ const header = getComputedStyle(document.querySelector('header'));
+ return {
+ matches: matchMedia(${JSON.stringify(`(${name}: ${value})`)}).matches,
+ backdrop: header.backdropFilter,
+ background: header.backgroundColor,
+ navColor: getComputedStyle(document.querySelector('.nav-links a')).color,
+ textColor: getComputedStyle(document.body).color,
+ };
+ })()`);
+ };
+ const reducedTransparency = await headerUnder('prefers-reduced-transparency', 'reduce');
+ const moreContrast = await headerUnder('prefers-contrast', 'more');
+ await client.send('Emulation.setEmulatedMedia', { media: 'screen', features: [{ name: 'prefers-color-scheme', value: 'light' }] });
+
+ // The About page renders the design tokens live; every token it uses must exist.
+ await client.send('Page.navigate', { url: `${origin}/about?browser_tokens=${Date.now()}` });
+ await waitFor("document.readyState === 'complete' && !!document.querySelector('.token-grid')", 'about tokens page');
+ const aboutTokens = await evaluateValue(`(() => {
+ const html = document.documentElement.outerHTML;
+ const used = [...new Set([...html.matchAll(/var\\((--[a-z0-9-]+)\\)/g)].map(match => match[1]))];
+ const definedInline = new Set([...html.matchAll(/(--[a-z0-9-]+)\\s*:/g)].map(match => match[1]));
+ const root = getComputedStyle(document.documentElement);
+ const unresolved = used.filter(token => !definedInline.has(token) && !root.getPropertyValue(token).trim());
+ return { used: used.length, unresolved };
+ })()`);
+
const failures = [];
if (interaction.result.value?.ariaLabel !== 'Editable Python example code') {
failures.push('CodeMirror editor is missing its accessible name');
@@ -555,6 +835,50 @@ try {
if (!fragmentBound.unchanged || !fragmentBound.notice?.includes('invalid or too large')) failures.push('Oversized shared-code fragment was decoded or not announced');
if (turnstileRetry.scriptAttempts !== 2 || !turnstileRetry.firstFailure.includes('press Run to retry') || turnstileRetry.finalOutput !== 'retry succeeded') failures.push('Transient Turnstile CDN failure was cached instead of retried');
if (turnstileRetry.renderAction !== 'run-example' || turnstileRetry.submittedToken !== 'retry-token') failures.push('Turnstile browser action/token contract failed');
+ if (turnstileRetry.renderExecution !== 'execute' || turnstileRetry.renderHasSize) failures.push(`Turnstile widget is not rendered in Invisible execute mode (execution=${turnstileRetry.renderExecution}, size set=${turnstileRetry.renderHasSize})`);
+ if (turnstileRetry.removes < 1 || !turnstileRetry.challengeHidden) failures.push('Turnstile widget was not removed and hidden after the challenge');
+ if (networkFailure.output !== 'Run failed: network down' || networkFailure.busy || networkFailure.runDisabled) failures.push(`Network failure did not end the run with a message (${JSON.stringify(networkFailure)})`);
+ if (!share.uneditedUrl || share.uneditedUrl.includes('#code=') || share.uneditedUrl !== share.pageUrl) failures.push(`Copy link for unedited code should be the plain page URL (${share.uneditedUrl})`);
+ if (sourceCopy.fallbackCopied !== sourceCopy.expected || !sourceCopy.fallbackState.includes('copied')) failures.push('Source copy did not fall back to execCommand when the Clipboard API is unavailable');
+ if (!sourceCopy.buttonAnchored) failures.push('Copy button is not anchored to its source cell');
+ if (!(sourceCopy.statusRight <= 0) || sourceCopy.liveRegion !== 'polite') failures.push('Copy status is not an off-screen polite live announcement');
+ if (sourceCopy.idleMask === 'none' || sourceCopy.copiedMask === sourceCopy.idleMask) failures.push('Copy icon is not a mask glyph that changes when copied');
+ if (sourceCopy.iconPaint[0] !== sourceCopy.iconPaint[1]) failures.push('Copy icon glyph is not painted in currentColor');
+ if (!arrow.blockedModifierAndButton) failures.push('Arrow navigation fired with a modifier key or from a focused button');
+ if (arrowBack.length !== 2 || !arrowBack.every(Boolean)) failures.push('ArrowLeft did not walk back through previous examples');
+ if (catalogEdge.hasPrev || !catalogEdge.stayed || catalogEdge.errors.length) failures.push(`ArrowLeft at the first example was not a no-op (${JSON.stringify(catalogEdge)})`);
+ if (searchSuccess.expanded !== 'true' || searchSuccess.listboxRole !== 'listbox' || searchSuccess.listboxHidden || searchSuccess.options < 1) failures.push(`Search results do not expose combobox/listbox/option semantics (${JSON.stringify(searchSuccess)})`);
+ if (Math.abs(darkTheme.pageLuminance - lightTheme.pageLuminance) < 0.5) failures.push('Page background did not switch between light and dark palettes');
+ for (const [label, theme] of [['light', lightTheme], ['dark', darkTheme]]) {
+ if (theme.textContrast < 4.5) failures.push(`${label} body text contrast is ${theme.textContrast.toFixed(2)}:1`);
+ if (theme.terminalContrast < 7) failures.push(`${label} output terminal contrast is ${theme.terminalContrast.toFixed(2)}:1`);
+ }
+ if (lightTheme.runContrast < 4.5) failures.push(`Light Run-button contrast is ${lightTheme.runContrast.toFixed(2)}:1`);
+ if (!(darkTheme.figurePaperLuminance > 0.7)) failures.push(`Dark mode does not keep marginalia figures on light paper (${darkTheme.figurePaperAlpha})`);
+ 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})`);
+ if (rendered.executionNumerals !== 'tabular-nums') failures.push('Execution time does not use tabular numerals');
+ if (!rendered.navUnderline.includes('underline')) failures.push('Nav links are not underlined');
+ if (!rendered.navTargetHeights.length || rendered.navTargetHeights.some(height => height < 40)) failures.push(`Nav link touch targets are under 40px (${rendered.navTargetHeights})`);
+ if (rendered.buttonHeights.some(height => height < 40)) failures.push(`Runner buttons are under 40px tall (${rendered.buttonHeights})`);
+ if (rendered.shareGap < 16 || rendered.shareToToolbarEnd > 1) failures.push(`Share button does not sit apart at the end of the toolbar (gap ${rendered.shareGap}px, ${rendered.shareToToolbarEnd}px from the end)`);
+ if (rendered.skipBefore > 0) failures.push('Skip link is visible before it receives focus');
+ if (rendered.skipFocused.left < 0 || rendered.skipFocused.top < 0 || rendered.skipFocused.width <= 0) failures.push('Skip link does not appear on screen when focused');
+ if (rendered.allTransitions.length) failures.push(`Elements animate "transition: all" (${rendered.allTransitions.slice(0, 5)})`);
+ if (rendered.wide.textPastPanel > 1 || rendered.wide.pageOverflow > 1) failures.push(`Long output overflows the output panel instead of wrapping (${JSON.stringify(rendered.wide)})`);
+ if (rendered.tall.panelHeight <= rendered.oneLinePanelHeight || rendered.tall.preClipped > 1) failures.push(`Tall output is clipped instead of growing the panel (${JSON.stringify(rendered.tall)})`);
+ for (const [selector, expected] of [['button[type="submit"]', '0.96'], ['.share-button', '0.96'], ['.copy-button', '0.96'], ['.card', '0.99']]) {
+ if (scaleOf(pressed[selector]) !== expected) failures.push(`${selector} does not press down to scale(${expected}) when active (${pressed[selector]})`);
+ }
+ if (userFontSize !== '20px') failures.push(`Body text ignores the reader's browser font size (${userFontSize} at a 20px default)`);
+ if (home.headerOpacity !== '1' || !home.navVisible) failures.push('Home header hides the nav on landing');
+ for (const [label, state] of [['prefers-reduced-transparency', reducedTransparency], ['prefers-contrast: more', moreContrast]]) {
+ if (!state.matches) failures.push(`${label} could not be emulated`);
+ if (state.backdrop !== 'none' || !/^rgb\(/.test(state.background)) failures.push(`${label}: header stays translucent (${state.backdrop}, ${state.background})`);
+ }
+ if (moreContrast.navColor !== moreContrast.textColor) failures.push('prefers-contrast: more does not raise nav links to full text colour');
+ if (aboutTokens.used < 21 || aboutTokens.unresolved.length) failures.push(`About page uses undefined design tokens (${aboutTokens.unresolved}; ${aboutTokens.used} used)`);
const { firstRun: rejectedRun, secondRun: rejectedRetry } = turnstileRejected;
if (rejectedRun.fetchCount !== 2 || rejectedRun.solves !== 1) failures.push(`Rejected Turnstile token re-challenged in a loop (${rejectedRun.fetchCount} POSTs, ${rejectedRun.solves} solves for one Run)`);
if (!rejectedRun.output.includes('verification failed') || rejectedRun.busy) failures.push('Rejected Turnstile token did not end the run with the server message');
@@ -576,6 +900,17 @@ try {
turnstileRetry,
turnstileRejected,
heldCdnRequests,
+ networkFailure,
+ arrowBack,
+ catalogEdge,
+ searchSuccess,
+ rendered,
+ pressed,
+ userFontSize,
+ home,
+ reducedTransparency,
+ moreContrast,
+ aboutTokens,
}, null, 2));
client.close();
if (failures.length) {
diff --git a/scripts/check_quality_scores.py b/scripts/check_quality_scores.py
index d7be7b1..8d4fc08 100755
--- a/scripts/check_quality_scores.py
+++ b/scripts/check_quality_scores.py
@@ -9,9 +9,14 @@
Waivers are time-boxed: `expires` must be an ISO date in the future,
and a waiver whose example has recovered to target is flagged as stale
so the registry only ever describes live editorial debt.
+
+A waiver that expires within WAIVER_WARNING_DAYS prints a warning. The
+weekly scheduled workflow runs `--fail-within-days 30`, so an approaching
+expiry turns that run red a month before it would fail every pull request.
"""
from __future__ import annotations
+import argparse
import datetime
import sys
@@ -23,6 +28,9 @@ def _entry_has_text(entry: dict, *keys: str) -> bool:
return all(isinstance(entry.get(key), str) and bool(entry[key].strip()) for key in keys)
+WAIVER_WARNING_DAYS = 30
+
+
def check_expiry_date(value, *, today: datetime.date | None = None) -> str | None:
"""Return an error string when `value` is not a future ISO date."""
today = today or datetime.datetime.now(datetime.UTC).date()
@@ -37,7 +45,55 @@ def check_expiry_date(value, *, today: datetime.date | None = None) -> str | Non
return None
-def main() -> int:
+def expiry_warning(
+ value, *, today: datetime.date | None = None, within_days: int = WAIVER_WARNING_DAYS
+) -> str | None:
+ """Return a warning when a still-valid waiver expires within `within_days`."""
+ today = today or datetime.datetime.now(datetime.UTC).date()
+ if check_expiry_date(value, today=today) is not None:
+ return None # invalid or already expired: check_expiry_date reports it
+ days_left = (datetime.date.fromisoformat(value) - today).days
+ if days_left > within_days:
+ return None
+ return f"expires on {value} ({days_left} days left); re-review and extend or fix the example"
+
+
+def waiver_expiry_findings(
+ waivers: dict, *, today: datetime.date, fail_within_days: int | None = None
+) -> tuple[list[str], list[str]]:
+ """Return (errors, warnings) for waiver expiry dates.
+
+ Expired or malformed dates are errors. Dates inside the warning window
+ are warnings, or errors when `fail_within_days` is set.
+ """
+ errors: list[str] = []
+ warnings: list[str] = []
+ window = WAIVER_WARNING_DAYS if fail_within_days is None else fail_within_days
+ for slug in sorted(waivers):
+ expires = waivers[slug].get("expires")
+ if expiry_error := check_expiry_date(expires, today=today):
+ errors.append(f"quality waiver {slug}: {expiry_error}")
+ elif warning := expiry_warning(expires, today=today, within_days=window):
+ (warnings if fail_within_days is None else errors).append(f"quality waiver {slug}: {warning}")
+ return errors, warnings
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
+ parser.add_argument(
+ "--fail-within-days",
+ type=int,
+ default=None,
+ help="treat waivers expiring within N days as errors (the scheduled run uses 30)",
+ )
+ parser.add_argument("--as-of", help="evaluate expiry as of this ISO date instead of today (UTC)")
+ args = parser.parse_args(argv)
+ today = (
+ datetime.date.fromisoformat(args.as_of)
+ if args.as_of
+ else datetime.datetime.now(datetime.UTC).date()
+ )
+
registry = load_registry()
gates = registry.get("quality_gates", {})
target = float(gates.get("example_target", 9.0))
@@ -58,6 +114,12 @@ def main() -> int:
backlog = registry.get("quality_improvement_backlog", {})
section_backlog = registry.get("journey_section_improvement_backlog", {})
errors: list[str] = []
+ expiry_errors, warnings = waiver_expiry_findings(
+ {slug: entry for slug, entry in waivers.items() if slug in slugs},
+ today=today,
+ fail_within_days=args.fail_within_days,
+ )
+ errors.extend(expiry_errors)
missing_scores = slugs - set(EXAMPLE_QUALITY_SCORES)
ghost_scores = set(EXAMPLE_QUALITY_SCORES) - slugs
@@ -87,9 +149,6 @@ def main() -> int:
entry = waivers[slug]
if not _entry_has_text(entry, "reason") or not isinstance(entry.get("accepted_min"), (int, float)):
errors.append(f"quality waiver {slug} must include accepted_min, reason, and expires")
- expiry_error = check_expiry_date(entry.get("expires"))
- if expiry_error:
- errors.append(f"quality waiver {slug}: {expiry_error}")
waived_score = EXAMPLE_QUALITY_SCORES.get(slug, (0.0, ""))[0]
if waived_score >= target:
errors.append(f"quality waiver {slug} is stale because score is now {waived_score:.1f}")
@@ -148,6 +207,8 @@ def main() -> int:
f"journey_average_min {float(journey_average_min):.1f}"
)
+ for warning in warnings:
+ print(f"WARNING: {warning}", file=sys.stderr)
if errors:
for error in errors:
print(error, file=sys.stderr)
diff --git a/tests/test_app.py b/tests/test_app.py
index 83762dc..0e6fca0 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -281,14 +281,12 @@ def test_example_page_contains_code_docs_and_run_form(self):
html = render_example_page(get_example("hello-world"), output="hello world\n")
self.assertIn("Hello World", html)
self.assertIn("https://docs.python.org/", html)
- css = (ROOT / "public" / "site.css").read_text()
self.assertIn('rel="icon" href="/favicon.svg"', html)
self.assertRegex(html, r'rel="stylesheet" href="/site\.[0-9a-f]{12}\.css"')
self.assertRegex(html, r'type="module" src="/syntax-highlight\.[0-9a-f]{12}\.js"')
self.assertRegex(html, r'type="module" src="/editor\.[0-9a-f]{12}\.js"')
self.assertNotIn('href="/site.css"', html)
self.assertNotIn('src="/syntax-highlight.js"', html)
- self.assertIn('textarea { box-sizing: border-box; width: 100%; height: auto;', css)
self.assertIn('class="language-python"', html)
self.assertIn('print("hello world")', html)
self.assertNotIn('class="tok-', html)
@@ -311,24 +309,11 @@ def test_example_page_contains_code_docs_and_run_form(self):
self.assertIn('class="runner-panel output-panel"', html)
self.assertIn('Run the complete example', html)
- def test_ui_polish_principles_are_applied(self):
+ def test_home_cards_are_links_and_examples_use_the_shell(self):
+ # Rendered styling (press states, touch targets, contrast, balanced
+ # headings, antialiasing, transitions) is measured in a real browser by
+ # scripts/check_browser_layout.mjs, not by matching site.css text.
html = render_example_page(get_example("hello-world"), output="hello world\n")
- css = (ROOT / "public" / "site.css").read_text()
- self.assertIn("-webkit-font-smoothing: antialiased", css)
- self.assertIn("text-wrap: balance", css)
- self.assertIn("font-variant-numeric: tabular-nums", css)
- self.assertIn("transform: scale(0.96)", css)
- self.assertIn("nav a { color: inherit; text-decoration: underline", css)
- self.assertNotIn("nav a { min-height: 40px; display: inline-flex; align-items: center; border-radius", css)
- self.assertNotIn("transition: all", css)
- self.assertIn("min-height: 40px", css)
- self.assertIn("--accent-action: #C83800", css)
- self.assertIn("background: var(--accent-action)", css)
- self.assertIn("box-shadow:", css)
- self.assertIn("background: transparent", css)
- self.assertIn("border-left: 2px solid var(--accent)", css)
- self.assertIn("--space-6", css)
- self.assertIn("runner-grid", css)
home = render_home()
self.assertIn('class="hero"', home)
self.assertIn('', home)
@@ -341,15 +326,12 @@ def test_turnstile_widget_is_conditional_and_temporary(self):
self.assertNotIn('")\n'}
@@ -412,25 +382,9 @@ def test_cf_workers_design_system_and_playground_lessons(self):
self.assertNotIn("')
- def test_share_button_sits_apart_from_the_run_reset_pair(self):
- js = (ROOT / "public" / "runner.js").read_text()
- self.assertIn("'tool-button share-button'", js)
- css = (ROOT / "public" / "site.css").read_text()
- self.assertIn(".share-button { margin-left: auto; }", css)
-
def test_arrow_navigation_guards_missing_neighbors_at_catalog_edges(self):
examples = list_examples()
first_page = render_example_page(get_example(examples[0]["slug"]))
@@ -1095,7 +912,6 @@ def test_arrow_navigation_guards_missing_neighbors_at_catalog_edges(self):
self.assertIn('