Skip to content

Commit 72f383a

Browse files
committed
Fix audit findings for runner, docs, and CI
1 parent 1f002c6 commit 72f383a

18 files changed

Lines changed: 455 additions & 205 deletions

.github/workflows/regenerate-generated-files.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ jobs:
5454
exit 0
5555
fi
5656
echo "Push raced with another commit; retrying (attempt $attempt)."
57-
git reset --soft HEAD~1
58-
git restore --staged .
57+
# Drop the unpushed generated commit and its worktree changes;
58+
# the next iteration rebases a clean checkout, then rebuilds.
59+
git reset --hard HEAD~1
5960
done
6061
echo "Failed to push regenerated files after 3 attempts." >&2
6162
exit 1

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Source assets live at stable paths such as:
126126
public/site.css
127127
public/syntax-highlight.js
128128
public/editor.js
129+
public/runner.js
129130
```
130131

131132
Before tests/deploy, regenerate embedded example data, fingerprinted asset copies, and Python manifests:
@@ -140,6 +141,7 @@ This writes files such as:
140141
public/site.<hash>.css
141142
public/syntax-highlight.<hash>.js
142143
public/editor.<hash>.js
144+
public/runner.<hash>.js
143145
src/asset_manifest.py
144146
src/editorial_registry_data.py
145147
```

docs/rubric-saturation.md

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
# Rubric saturation analysis
1+
# Historical rubric saturation analysis (superseded)
22

3-
After six iteration passes, the figure system attaches a figure to
4-
every example (one banner per slug on `main`) from the 124 paint
5-
functions in `src/marginalia.py FIGURES`. Coverage is 100%.
6-
Distribution against `docs/example-figure-rubric.md`:
3+
> **Historical record, not release evidence.** This snapshot predates the
4+
> current figure rubric and its ≥8.5 production gate. It is retained to explain
5+
> the rubric changes below; use `docs/quality-registries.toml`,
6+
> `docs/example-figure-rubric.md`, and the quality checks for current scores
7+
> and release decisions.
8+
9+
At the time of this analysis, the figure system attached one banner per slug
10+
from 124 paint functions in `src/marginalia.py FIGURES`. Its then-current
11+
scoring distribution was:
712

813
| band | count | composition |
914
|---|---:|---|
@@ -12,17 +17,14 @@ Distribution against `docs/example-figure-rubric.md`:
1217
| 8.5 | ~55 | strong but honest reuse, or generic placeholders |
1318
| 8.0 | ~16 | binding pictures, abstract pictures, weak reuses |
1419

15-
Mean ≈ 8.7. **No figure scores below 8.0.** No figure exceeds 9.5.
16-
Pushing further requires changes to the rubric itself, because the
17-
remaining drag comes from criteria that are structurally over-strict
18-
for a library this size.
20+
The historical mean was approximately 8.7, with no score below 8.0. Those
21+
scores do **not** satisfy or describe today's production gate.
1922

20-
## Why every figure cannot reach 9.0 under the current rubric
23+
## Why every figure could not reach 9.0 under the v1 rubric
2124

22-
Two criteria in `docs/example-figure-rubric.md` cap most figures
23-
at 8.5 by design:
25+
Two v1 criteria capped most figures at 8.5 by design:
2426

25-
### Criterion 2 — "Match the running variables (0–1.0)"
27+
### v1 Criterion 2 — "Match the running variables (0–1.0)"
2628

2729
A figure loses up to 1.0 when its placeholders (`a`, `b`, `xs`) do
2830
not match the cell's specific names (`first`, `second`, `factor`,
@@ -57,7 +59,7 @@ journey-section figure already sits at `/journeys/<slug>`, and the
5759
lesson appears alone at `/examples/<slug>`; readers don't see both
5860
beside each other. The "independence" penalty fires regardless.
5961

60-
## What the rubric needs
62+
## Historical recommendations for the rubric
6163

6264
Four upgrades would let further iteration produce visible quality
6365
gains rather than just shuffling the same band.
@@ -81,7 +83,7 @@ For canonical figures: criteria 2 and 9 stay as written. Cap at
8183
Result: ~70 library figures (today reuse-shaped) all reach 9.0;
8284
~30 canonical figures reach 9.0–9.5 by being slug-specific.
8385

84-
### 2. Replace criterion 2 with **"the figure earns its place"**
86+
### 2. Replace criterion 2 with **"the figure earns its place"** (implemented)
8587

8688
Strict variable-matching loses information value at scale. The
8789
better question is "does swapping in this figure improve the cell
@@ -124,7 +126,7 @@ page-level rubric (0–1.0) would score:
124126
Today this is a manual judgement; codifying it would prevent the
125127
inevitable "too many figures" failure mode as coverage grows.
126128

127-
## What this turn changed
129+
## Changes recorded by this historical analysis
128130

129131
- Fixed the layout regression: cells stay 2-col always; figures live
130132
in banner rows BETWEEN cells. `hello-world` now matches production.

docs/visual-explainer-spec.md

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ between prose and code, which works at every viewport.
99
## Goals
1010

1111
- **One column model per page type, fixed.** Example pages keep cells in
12-
the prose|code 2-col grid; journey pages keep section heading + figure
13-
in a 2-col grid. Figures never reflow the surrounding columns.
12+
the prose|code 2-col grid; journey pages keep headings, figures, and lists
13+
in one centered flow. Figures never reflow the surrounding columns.
1414
- **Universal, not viewport-conditional.** A reader at any width sees the
1515
same figure in the same place. No `@media` breakpoints for figure
1616
positioning; no overlay layer.
@@ -121,11 +121,12 @@ single-column flow on every viewport:
121121
```
122122

123123
One figure per section, faithful to the section's conceptual shift,
124-
scored against `docs/journey-visualisation-rubric.md`. Figures are
125-
mapped by section title via `SECTION_FIGURES` in `src/marginalia.py`;
126-
`render_for_section` returns empty for unmapped titles, but the
127-
SectionFigureContract requires every production journey section to
128-
have one.
124+
scored against `docs/journey-visualisation-rubric.md`. Journey-section
125+
metadata lives in `docs/quality-registries.toml` and is loaded through
126+
`src/editorial_registry.py`; `src/marginalia.py` renders the resulting
127+
registry entry. `render_for_section` returns empty for an unmapped title,
128+
but the SectionFigureContract requires every production journey section
129+
to have one.
129130

130131
### Why these two, not five
131132

@@ -138,38 +139,22 @@ vary instead. Adding a second or third figure changes the banner's
138139
internal grid (auto-fit handles 1/2/3+), but the cells around it
139140
remain unchanged — no reflow, no cognitive context-switch.
140141

141-
## Anchors and attachments
142+
## Anchors, attachments, and journey figures
142143

143-
`src/marginalia.py` declares which figures attach where. The data shape
144-
will move from per-cell injection toward per-position banners:
144+
`docs/quality-registries.toml` is the source of truth for attachment and
145+
caption metadata. Each `[[figure_attachments]]` row names an example slug,
146+
cell anchor, paint-function name, and caption; each
147+
`[[journey_section_figures]]` row names a journey-section title, figure, and
148+
caption. `src/editorial_registry.py` loads those rows, while
149+
`src/marginalia.py` owns only executable paint functions and the
150+
`render_for_anchor` / `render_for_section` renderers.
145151

146-
```python
147-
# proposed shape — banners keyed by position, each holding 1+ figures
148-
BANNERS = {
149-
"mutability": {
150-
"after-cell-0": [
151-
("aliasing-mutation",
152-
"Two names share one mutable list — appending through one "
153-
"name changes the object visible through both."),
154-
("tuple-no-mutation",
155-
"By contrast, a tuple is frozen — aliases share a value "
156-
"no method can change in place."),
157-
],
158-
},
159-
}
160-
```
161-
162-
Banner positions:
163-
164-
| position | renders |
165-
|--------------------|--------------------------------------|
166-
| `before` | once, before the first cell |
167-
| `after-cell-0`, … | once, after cell N (zero-indexed) |
168-
| `after-walkthrough`| once, after the last cell |
169-
170-
Each position is a list, not a single figure: the same banner may hold
171-
multiple figures as a small multiple. Most slugs will start empty.
172-
Adding a banner is a one-line edit in `src/marginalia.py`.
152+
Example-page rendering interleaves each cell with
153+
`render_for_anchor(slug, "cell-N")`. A registry row may therefore attach one
154+
or more figures after a teaching cell. Journey pages call
155+
`render_for_section(section_title)` between a section heading and its list.
156+
Add or move metadata in the registry; add a `FIGURES` paint function only when
157+
the diagram itself is new.
173158

174159
## Authoring model
175160

@@ -235,12 +220,14 @@ explicitly. Re-introducing either is a defect.
235220
- `src/marginalia_grammar.py` — palette, tokens, words, phrases, metrics.
236221
Aligned with `public/site.css` design tokens; figures use the four
237222
palette constants and never pick colours directly.
238-
- `src/marginalia.py` — figure registry (`FIGURES`) and attachment map.
239-
Exports `render_for_anchor(slug, anchor)` for the current cell-inline
240-
layout; banner-rendering helpers will land alongside.
241-
- `src/app.py` — `_render_walkthrough_cell` is the current rendering
242-
helper; the banner-between rollout will rename or replace it with a
243-
walkthrough-level renderer that interleaves cells and banners.
223+
- `docs/quality-registries.toml` — source of truth for attachments,
224+
captions, journeys, and curated editorial scores.
225+
- `src/editorial_registry.py` — loader for the registry (or its generated
226+
Worker fallback).
227+
- `src/marginalia.py` — executable figure registry (`FIGURES`) plus
228+
`render_for_anchor(slug, anchor)` and `render_for_section(title)`.
229+
- `src/app.py` — `render_example_page` interleaves teaching cells and
230+
`render_for_anchor`; `render_journey_page` places section figures.
244231
- `public/site.css` — `.cell-banner` rules. Production uses the
245232
banner-between grammar; cells always render with the prose|code
246233
2-column grid and never receive a `has-figure` class.

public/_headers

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Referrer-Policy: strict-origin-when-cross-origin
44
X-Frame-Options: DENY
55
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
6-
Content-Security-Policy: default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-pbe-inline-v1' https://esm.sh https://challenges.cloudflare.com 'wasm-unsafe-eval'; script-src-attr 'none'; connect-src 'self' https://esm.sh https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; worker-src 'self'
6+
Content-Security-Policy: default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' https://esm.sh https://challenges.cloudflare.com 'wasm-unsafe-eval'; script-src-attr 'none'; connect-src 'self' https://esm.sh https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; worker-src 'self'
77

88
/site.*.css
99
Cache-Control: public, max-age=31536000, immutable
@@ -14,6 +14,9 @@
1414
/editor.*.js
1515
Cache-Control: public, max-age=31536000, immutable
1616

17+
/runner.*.js
18+
Cache-Control: public, max-age=31536000, immutable
19+
1720
/favicon.svg
1821
Cache-Control: public, max-age=31536000, immutable
1922

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ if (textarea && form) {
1717
syntaxHighlighting(defaultHighlightStyle),
1818
lineNumbers(),
1919
EditorView.lineWrapping,
20+
EditorView.contentAttributes.of({
21+
'aria-label': textarea.getAttribute('aria-label') || 'Editable Python example code',
22+
}),
2023
EditorView.updateListener.of((update) => {
2124
if (update.docChanged) textarea.value = update.state.doc.toString();
2225
}),
@@ -37,5 +40,5 @@ if (textarea && form) {
3740
}
3841

3942
form.addEventListener('submit', syncTextarea);
40-
window.pythonByExampleEditor = { setValue, syncTextarea };
43+
window.pythonByExampleEditor = { setValue, syncTextarea, focus: () => view.focus() };
4144
}

public/editor.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ if (textarea && form) {
1717
syntaxHighlighting(defaultHighlightStyle),
1818
lineNumbers(),
1919
EditorView.lineWrapping,
20+
EditorView.contentAttributes.of({
21+
'aria-label': textarea.getAttribute('aria-label') || 'Editable Python example code',
22+
}),
2023
EditorView.updateListener.of((update) => {
2124
if (update.docChanged) textarea.value = update.state.doc.toString();
2225
}),
@@ -37,5 +40,5 @@ if (textarea && form) {
3740
}
3841

3942
form.addEventListener('submit', syncTextarea);
40-
window.pythonByExampleEditor = { setValue, syncTextarea };
43+
window.pythonByExampleEditor = { setValue, syncTextarea, focus: () => view.focus() };
4144
}

public/runner.eddb185f7433.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
function initializeRunner() {
2+
const textarea = document.getElementById('code-editor');
3+
const form = document.querySelector('form.runner-editor');
4+
if (!textarea || !form) return;
5+
6+
const originalCode = textarea.dataset.originalCode ?? textarea.defaultValue;
7+
const setCode = (value) => {
8+
textarea.value = value;
9+
window.pythonByExampleEditor?.setValue(value);
10+
};
11+
12+
const resetButton = form.querySelector('[data-reset]');
13+
if (resetButton) {
14+
resetButton.addEventListener('click', () => {
15+
setCode(originalCode);
16+
window.pythonByExampleEditor?.focus();
17+
});
18+
}
19+
20+
const hash = new URL(window.location.href).hash;
21+
if (hash.startsWith('#code=')) {
22+
try {
23+
setCode(decodeURIComponent(escape(atob(hash.slice(6)))));
24+
} catch (_) {
25+
// Ignore malformed share fragments and leave the authored example intact.
26+
}
27+
}
28+
29+
const outputPanel = document.querySelector('.output-panel');
30+
if (!outputPanel) return;
31+
const challengeBox = document.querySelector('[data-turnstile-sitekey]');
32+
let turnstileWidgetId = null;
33+
let loadingTurnstile = null;
34+
35+
function loadTurnstile() {
36+
if (window.turnstile) return Promise.resolve();
37+
if (loadingTurnstile) return loadingTurnstile;
38+
loadingTurnstile = new Promise((resolve, reject) => {
39+
const script = document.createElement('script');
40+
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
41+
script.async = true;
42+
script.defer = true;
43+
script.onload = resolve;
44+
script.onerror = () => reject(new Error('Turnstile failed to load'));
45+
document.head.appendChild(script);
46+
});
47+
return loadingTurnstile;
48+
}
49+
50+
function removeTurnstile() {
51+
if (!challengeBox) return;
52+
if (window.turnstile && turnstileWidgetId !== null) {
53+
try {
54+
turnstile.remove(turnstileWidgetId);
55+
} catch (_) {
56+
try {
57+
turnstile.reset(turnstileWidgetId);
58+
} catch (_) {
59+
// The widget is already gone.
60+
}
61+
}
62+
}
63+
turnstileWidgetId = null;
64+
challengeBox.hidden = true;
65+
challengeBox.innerHTML = '';
66+
}
67+
68+
async function requestTurnstileToken() {
69+
if (!challengeBox) throw new Error('Turnstile challenge is not configured');
70+
await loadTurnstile();
71+
challengeBox.hidden = false;
72+
return new Promise((resolve, reject) => {
73+
challengeBox.innerHTML = '';
74+
turnstileWidgetId = turnstile.render(challengeBox, {
75+
sitekey: challengeBox.dataset.turnstileSitekey,
76+
execution: 'execute',
77+
callback: (token) => {
78+
removeTurnstile();
79+
resolve(token);
80+
},
81+
'error-callback': () => {
82+
removeTurnstile();
83+
reject(new Error('Turnstile challenge failed'));
84+
},
85+
'expired-callback': () => {
86+
removeTurnstile();
87+
reject(new Error('Turnstile challenge expired'));
88+
},
89+
});
90+
turnstile.execute(turnstileWidgetId);
91+
});
92+
}
93+
94+
function responseErrorMessage(response, body) {
95+
if (response.status === 413) {
96+
return 'Submitted code is too large (over 100 kB). Trim it and run again.';
97+
}
98+
const document = new DOMParser().parseFromString(body, 'text/html');
99+
return document.querySelector('.output-panel code')?.textContent?.trim()
100+
|| body.trim()
101+
|| `HTTP ${response.status}`;
102+
}
103+
104+
async function submitRun(turnstileToken = '') {
105+
outputPanel.removeAttribute('data-output-placeholder');
106+
window.pythonByExampleEditor?.syncTextarea();
107+
outputPanel.querySelector('code').textContent = 'Running in a Dynamic Python Worker…';
108+
const formData = new FormData(form);
109+
if (turnstileToken) formData.set('cf-turnstile-response', turnstileToken);
110+
const response = await fetch(form.action, {
111+
method: 'POST',
112+
body: new URLSearchParams(formData),
113+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
114+
});
115+
const html = await response.text();
116+
if (!response.ok) throw new Error(responseErrorMessage(response, html));
117+
const document = new DOMParser().parseFromString(html, 'text/html');
118+
const challengeRequired = document.querySelector('[data-turnstile-required]');
119+
if (challengeRequired) {
120+
outputPanel.querySelector('code').textContent = challengeRequired.textContent
121+
|| 'Verification required before running edited code…';
122+
return submitRun(await requestTurnstileToken());
123+
}
124+
const nextOutput = document.querySelector('.output-panel');
125+
if (nextOutput) outputPanel.innerHTML = nextOutput.innerHTML;
126+
}
127+
128+
form.addEventListener('submit', async (event) => {
129+
event.preventDefault();
130+
try {
131+
await submitRun();
132+
} catch (error) {
133+
removeTurnstile();
134+
outputPanel.querySelector('code').textContent = `Run failed: ${error.message}`;
135+
}
136+
});
137+
}
138+
139+
if (document.readyState === 'loading') {
140+
document.addEventListener('DOMContentLoaded', initializeRunner, { once: true });
141+
} else {
142+
initializeRunner();
143+
}

0 commit comments

Comments
 (0)