Skip to content

Commit 265268a

Browse files
committed
Roll back margin overlay; inline-between is the production layout
User feedback: the implicit-outer-margin approach was unnecessary, and its "extra columns" displaced the code on viewports where the figure forced a multi-row grid. The inline-between layout demonstrated by the prototype works at every width without breaking anything. Production switch: - src/app.py: new _render_walkthrough_cell helper. When a figure is attached, the cell gets the has-figure class and the figure renders between .lp-prose and .cell-code-stack - public/site.css: replace the margin-anchor / margin-collected / @media (min-width: 1440px) rules with two simple ones — .lp-cell.has-figure { grid-template-columns: 1fr; } and .cell-figure styling. Cells without figures keep today's prose|code grid bit-for-bit - src/marginalia.py: render_for_anchor emits .cell-figure with figcaption visible (no aria-hidden); render_collected removed - src/templates/example.html: drop __MARGIN_COLLECTED__ slot - docs/visual-explainer-spec.md: rewritten for the inline approach; no viewport-conditional behaviour, no JS, no overlay layer Grammar tightening (the gestalt had become too vivid): - src/marginalia_grammar.py: closed_arrow now defaults to emphasis=False so figures must opt in to accent strokes for the single live element. SOFT_FILL changes from rgba(255,72,1,0.08) (orange-tinted, made every object box look highlighted) to rgba(82,16,0,0.05) (neutral warm tint that reads as a quiet container). Both changes restore the "emphasis is scarce" rule against the brighter --accent. Five new figures, designed to land where they teach: - closure-cell — outer scope holds a cell; inner function references it - slice-ruler — indices between cells; [:3] and [3:] partition at 3 - branch-fork — value flows through predicate to one of several branches - loop-repetition — walk the sequence, run the body, loop back - iter-protocol — iterable → iter() → next() … values Prototype refresh: - Drop layout-margin, layout-inline-above, layout-inline-between, layout-third-column, layout-summary-bottom, journey-spread (all superseded or rolled-back layouts) - Add four real example renderings, one per slug, each demonstrating the canonical inline-between layout on representative content: /prototyping/example-mutability.html (aliasing-mutation, cell 0) /prototyping/example-closures.html (closure-cell, cell 0) /prototyping/example-for-loops.html (iterator-unroll, cell 1) /prototyping/example-slices.html (slice-ruler, cell 0) - Replace journey-spread with journey-streams using three figures that actually map to the section concepts: branch-fork for "Make decisions explicitly", loop-repetition for "Choose the right loop shape", iter-protocol for "Recognize iteration as a protocol" https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
1 parent e27bebf commit 265268a

22 files changed

Lines changed: 756 additions & 1042 deletions

docs/visual-explainer-spec.md

Lines changed: 100 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -1,241 +1,160 @@
11
# Visual explainer spec
22

3-
This spec describes how Tufte-style marginalia attach to existing Python By
4-
Example pages without changing what contributors author and without altering
5-
today's centered layout.
3+
This spec describes how figures attach to existing Python By Example pages
4+
without changing what contributors author. The earlier draft of this spec
5+
relied on absolute-positioned figures escaping into the page's implicit
6+
outer margin; that approach was rolled back in favour of inline placement
7+
between prose and code, which works at every viewport.
68

79
## Goals
810

9-
- **Additive layout.** A reader who sees no marginalia sees today's page exactly.
11+
- **Universal, not viewport-conditional.** A reader at any width sees the
12+
same figure in the same place. No `@media` breakpoints; no overlay layer.
1013
- **No contributor burden.** Example markdown stays as it is. Figures are
1114
curated separately by the project owner.
12-
- **Use the implicit gutter.** Wide viewports already have empty space to the
13-
left and right of the centered `body`. Figures live in that space, not in
14-
a new column inside the content.
15-
- **Mobile-friendly fallback.** On viewports too narrow for a side gutter, all
16-
figures collect into a single section beneath the runnable example.
15+
- **Inline, between prose and code.** A figure sits in the same flow as the
16+
cell prose and code; the cell stops being two columns and stacks
17+
vertically so prose → figure → code reads top to bottom.
18+
- **Quiet by default.** Cells without figures keep the existing
19+
`prose | code` grid unchanged. Today's pages render bit-for-bit identical
20+
to before until a figure is attached.
1721
- **Grammar reuse.** Figures are composed from the locked vocabulary in
1822
`src/marginalia_grammar.py`. No bespoke SVG.
1923

2024
## Layout strategy
2125

22-
Today's `body` is `max-width: 1040px; margin: 0 auto;`. On any viewport wider
23-
than 1040px there is implicit empty space on either side. The marginalia
24-
system anchors each figure to a block inside `body` (a cell, the intro, the
25-
notes section, the playground) and uses absolute positioning to escape the
26-
centered column into that empty space.
26+
Each `.lp-cell` is normally `grid-template-columns: minmax(17rem, .85fr)
27+
minmax(0, 1fr)` — prose left, code right. When the cell has an attached
28+
figure, the renderer adds the `has-figure` class:
2729

28-
```
29-
┌── viewport ──────────────────────────────────────────────────────┐
30-
│ ┌─── body, max-width: 1040px ──────┐ │
31-
│ │ prose paragraph │ ┌─ figure ─┐ │
32-
│ │ … │ │ (svg) │ │
33-
│ │ code block │ └──────────┘ │
34-
│ └───────────────────────────────────┘ │
35-
│ ↑ centered, untouched ↑ position: absolute │
36-
└──────────────────────────────────────────────────────────────────┘
30+
```css
31+
.lp-cell.has-figure { grid-template-columns: 1fr; }
3732
```
3833

39-
Because the figure is `position: absolute`, it consumes no flow space and
40-
nothing inside the cell shifts. Today's grid is preserved bit-for-bit.
34+
Within that single column the children stack in document order: prose
35+
paragraph, figure (with optional caption), code-stack. Cells without a
36+
figure are not touched.
4137

42-
### Anchoring
38+
```
39+
┌── lp-cell (no figure, default) ──────────────────────┐
40+
│ prose paragraph │ source / output │
41+
└──────────────────────────────────────────────────────┘
42+
43+
┌── lp-cell.has-figure (single column) ───────────────┐
44+
│ prose paragraph │
45+
│ ┌──── cell-figure ────┐ │
46+
│ │ svg │ │
47+
│ └─────────────────────┘ │
48+
│ caption (italic, muted) │
49+
│ source │
50+
│ output │
51+
└──────────────────────────────────────────────────────┘
52+
```
4353

44-
Any block can host a figure. Required: `position: relative`. We add this to
45-
`.example-intro`, `.lp-cell`, `.notes-section`, and `.playground`. The figure
46-
element is rendered inside its anchor block:
54+
The figure renders inside the cell, between `<div class="lp-prose">` and
55+
`<div class="cell-code-stack">`:
4756

4857
```html
49-
<div class="lp-cell">
58+
<section class="lesson-step lp-cell has-figure">
5059
<div class="lp-prose">…</div>
51-
<div class="cell-code-stack">…</div>
52-
<figure class="margin-anchor" aria-hidden="true">
53-
<svg viewBox="0 0 240 160">…</svg>
60+
<figure class="cell-figure">
61+
<svg>…</svg>
62+
<figcaption>…</figcaption>
5463
</figure>
55-
</div>
56-
```
57-
58-
Positioning:
59-
60-
```css
61-
.margin-anchor {
62-
position: absolute;
63-
top: 0;
64-
left: calc(100% + 32px);
65-
width: 240px;
66-
}
67-
.margin-anchor[data-side="left"] {
68-
left: auto;
69-
right: calc(100% + 32px);
70-
}
64+
<div class="cell-code-stack">…</div>
65+
</section>
7166
```
7267

73-
Vertical alignment comes for free: `top: 0` of the figure aligns with the top
74-
of its anchor. If the figure is taller than the cell it overflows downward
75-
into still-empty margin. If the cell is taller, the figure sits high in a
76-
tall empty margin — this is the desired Tufte effect.
68+
`.cell-figure` keeps the SVG `width: 100%; max-width: 360px;` so figures
69+
display at a comfortable reading size on prose-column-wide cells without
70+
ballooning when the column is wider.
7771

78-
### Breakpoint
72+
## Anchors and attachments
7973

80-
Width math (accounting for body's 24px padding): a marginalia of width *w*
81-
fits when `viewport ≥ 1024 + 2w`. For the minimum useful figure width of
82-
200 px, that means **viewport ≥ 1424 px**. We round to **1440 px** as the
83-
cutoff. Above this the floating figures appear; below it they disappear and
84-
the collected section appears instead. Above 1600 px the marginalia widens
85-
to 240 px; above 1800 px to 280 px, with a wider gutter.
74+
`src/marginalia.py` declares which figures attach where:
8675

87-
```css
88-
.margin-anchor { display: none; } /* default */
89-
@media (min-width: 1440px) {
90-
.margin-anchor { display: block; … } /* float into gutter */
91-
.margin-collected { display: none; }
76+
```python
77+
ATTACHMENTS = {
78+
"mutability": [
79+
("cell-0", "aliasing-mutation",
80+
"Two names share one mutable list — appending through one name "
81+
"changes the object visible through both."),
82+
],
83+
#
9284
}
93-
@media (min-width: 1600px) { .margin-anchor { width: 240px; } }
94-
@media (min-width: 1800px) { .margin-anchor { width: 280px; } }
9585
```
9686

97-
14" full-screen MacBooks at native resolution (≥1440 px) get the gutter;
98-
smaller windows fall back to the collected section gracefully.
99-
100-
### Mobile placement
101-
102-
Below 1440px every figure renders in a single `.margin-collected` section the
103-
server emits **after the runnable playground**. The same SVG appears in two
104-
places in the markup: the floating copy inside its anchor, and the collected
105-
copy after the playground. SVG is small; the duplication cost is negligible
106-
and lets each viewport state stay declarative (no JS reflows).
87+
Anchor identifiers:
10788

108-
```html
109-
<section class="playground">…</section>
110-
<section class="margin-collected" aria-label="Diagrams">
111-
<h2>Diagrams</h2>
112-
<figure data-anchor="cell-1">
113-
<svg>…</svg>
114-
<figcaption>…</figcaption>
115-
</figure>
116-
</section>
117-
```
89+
| anchor | targets |
90+
|-----------------|--------------------------------------|
91+
| `cell-0`, `cell-1`, … | each literate-program cell, zero-indexed |
11892

119-
The desktop copy carries `aria-hidden="true"` so screen readers announce only
120-
the collected copy, which carries the canonical `<figcaption>`.
93+
Other anchors (`intro`, `notes`, `playground`) are reserved for future use
94+
but only `cell-N` is wired today. Most slugs will start with no entry.
95+
Adding a figure is a one-line edit in `src/marginalia.py`.
12196

12297
## Authoring model
12398

12499
### What contributors do
125100

126-
Nothing new. Example markdown remains:
101+
Nothing new. Example markdown stays:
127102

128103
```
129104
:::cell
130105
prose…
131106
```python
132107
133108
```
134-
135109
```output
136110
137111
```
138112
:::
139113
```
140114
141-
There is no `:::figure` block, no frontmatter key for marginalia, no caption
142-
alongside the prose. Contributors merge cell content; the figure layer is
143-
composed independently.
115+
There is no `:::figure` block, no frontmatter key, no caption alongside
116+
the prose. Contributors merge cell content; the figure layer is composed
117+
independently.
144118
145119
### What the project owner does
146120
147-
A single Python module declares the figure registry and the attachment map:
148-
149-
```python
150-
# src/marginalia.py
151-
from marginalia_grammar import Canvas
152-
153-
# Named figures, each a function that paints onto a Canvas.
154-
def aliasing_mutation(c: Canvas) -> None:
155-
156-
157-
FIGURES = {
158-
"aliasing-mutation": aliasing_mutation,
159-
160-
}
161-
162-
# slug → list of (anchor, figure_name, optional caption)
163-
ATTACHMENTS = {
164-
"mutability": [("cell-0", "aliasing-mutation", "two names share one list")],
165-
"for-loops": [("cell-1", "iterator-unroll", None)],
166-
167-
}
168-
```
169-
170-
Anchor identifiers:
171-
172-
| anchor | targets |
173-
|-----------------|--------------------------------------|
174-
| `intro` | the `<section class="example-intro">` |
175-
| `cell-0`, `cell-1`, … | each literate-program cell, zero-indexed |
176-
| `notes` | the `<h2>Notes</h2>` + `<ul>` block |
177-
| `playground` | the runnable example block |
178-
179-
A slug with no attachments has no figures. Most slugs will start empty.
180-
181-
### Where figures live
182-
183-
`src/marginalia_grammar.py` — the grammar (palette, tokens, words, phrases,
184-
metrics).
185-
`src/marginalia.py` — the registry (`FIGURES`, `ATTACHMENTS`, render helpers).
186-
The Cloudflare Worker imports both at request time and renders SVGs inline.
187-
188-
`scripts/build_marginalia.py` — the gestalt review page; builds `public/
189-
marginalia-gestalt.html` from the same grammar so design review and
190-
production share the visual language.
191-
192-
## Renderer integration
193-
194-
`render_example_page` in `src/app.py` consults `marginalia.ATTACHMENTS` for
195-
the current slug and:
121+
Edit `ATTACHMENTS` in `src/marginalia.py`. Add a paint function (composed
122+
from grammar primitives) and register it in `FIGURES`. Append a tuple of
123+
`(anchor, figure_name, caption)` to `ATTACHMENTS[slug]`. Done.
196124
197-
1. When emitting each cell, looks up `(slug, "cell-<i>")` and, if a figure
198-
exists, appends `<figure class="margin-anchor" aria-hidden="true">`
199-
inside the cell's `<section>`.
200-
2. After emitting the playground, emits `<section class="margin-collected">`
201-
containing the same figures with their captions and accessible markup.
125+
## Files
202126
203-
Both emissions are pure-function: same slug → same HTML. The HTML cache
204-
version digests `src/marginalia.py` so adding or moving a figure invalidates
205-
caches automatically.
127+
- `src/marginalia_grammar.py` — palette, tokens, words, phrases, metrics.
128+
Aligned with `public/site.css` design tokens; cards use the four palette
129+
constants and never pick colours directly.
130+
- `src/marginalia.py` — figure registry (`FIGURES`) and attachment map
131+
(`ATTACHMENTS`). Exports `render_for_anchor(slug, anchor)` returning the
132+
HTML the renderer injects into each cell.
133+
- `src/app.py` — `_render_walkthrough_cell` adds the `has-figure` class
134+
and inserts the figure between prose and code-stack when an attachment
135+
exists.
136+
- `public/site.css` — `.lp-cell.has-figure` and `.cell-figure` rules.
206137
207138
## Edge cases
208139
209-
- **Two figures on one anchor.** Stack via `top: var(--y-offset, 0)` set
210-
inline on the second figure. Three or more is a signal that the anchor's
211-
prose is doing too much.
212-
- **Figure taller than its cell.** Allowed. It overflows down into still-empty
213-
margin, which is the desired effect.
214-
- **Print.** `@media print { .margin-anchor { display: none; } .margin-
215-
collected { display: block; } }`. Print falls back to the linear placement.
216-
- **Right-to-left or two-sided plates.** `data-side="left"` mirrors the same
217-
technique on the opposite side. Reserve for very wide viewports.
218-
- **Very narrow viewports (≤320px).** SVG scales down via `max-width: 100%`.
219-
Text inside the SVG follows our locked sizes; if a figure becomes
220-
illegible at 280px, that figure is too dense and should be redrawn from
221-
fewer primitives.
140+
- **Two figures on one cell.** Allowed; both render in document order
141+
inside the `has-figure` cell. Use sparingly — three or more is a signal
142+
the cell's prose is doing too much.
143+
- **No figure attached.** The cell keeps today's `prose | code` 2-column
144+
grid; no DOM, CSS, or layout difference from before.
145+
- **Print.** The single-column stacking is print-friendly by default.
146+
- **Very narrow viewports (≤340px).** SVGs scale via `max-width: 100%`.
147+
The grammar's intrinsic text sizes stay readable down to ~280px.
222148
223149
## Non-goals
224150
225-
- **No JavaScript-driven anchoring.** No scroll computation, no resize
226-
observer, no popup affordance. Pure CSS + server-side rendering.
227-
- **No contributor surface.** Contributors do not author figures, do not
228-
preview marginalia placement, and do not need to think about the spec.
229-
- **No in-prose interactivity.** Figures are static SVG; they do not animate,
230-
toggle, or open lightboxes.
231-
232-
## Rollout
233-
234-
1. Add `position: relative` to anchor blocks in `public/site.css`.
235-
2. Add `.margin-anchor` and `.margin-collected` rules with the 1440px
236-
breakpoint.
237-
3. Move `marginalia_grammar.py` to `src/` so the Worker imports it.
238-
4. Create `src/marginalia.py` with empty `ATTACHMENTS` and registry helpers.
239-
5. Wire `render_example_page` to consult `ATTACHMENTS` and emit both copies.
240-
6. Ship one figure end-to-end (`mutability` cell-0) as a smoke test.
241-
7. Populate `ATTACHMENTS` per slug at the project owner's pace.
151+
- **No JavaScript-driven layout.** No scroll listener, no resize observer,
152+
no popup affordance. Pure CSS + server-side rendering.
153+
- **No viewport-conditional layout.** The earlier margin-overlay approach
154+
required a 1440px+ viewport to work; that complexity is gone.
155+
- **No contributor surface.** Contributors do not author figures or
156+
preview placement.
157+
- **No chromatic decoration.** Figures use only the locked palette
158+
(`--text`, `--muted`, `--accent`, `--accent-soft`-equivalent neutral).
159+
Emphasis is scarce: at most one accent mark per figure, used only for
160+
the single element the prose names.

0 commit comments

Comments
 (0)