|
1 | 1 | # Visual explainer spec |
2 | 2 |
|
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. |
6 | 8 |
|
7 | 9 | ## Goals |
8 | 10 |
|
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. |
10 | 13 | - **No contributor burden.** Example markdown stays as it is. Figures are |
11 | 14 | 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. |
17 | 21 | - **Grammar reuse.** Figures are composed from the locked vocabulary in |
18 | 22 | `src/marginalia_grammar.py`. No bespoke SVG. |
19 | 23 |
|
20 | 24 | ## Layout strategy |
21 | 25 |
|
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: |
27 | 29 |
|
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; } |
37 | 32 | ``` |
38 | 33 |
|
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. |
41 | 37 |
|
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 | +``` |
43 | 53 |
|
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">`: |
47 | 56 |
|
48 | 57 | ```html |
49 | | -<div class="lp-cell"> |
| 58 | +<section class="lesson-step lp-cell has-figure"> |
50 | 59 | <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> |
54 | 63 | </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> |
71 | 66 | ``` |
72 | 67 |
|
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. |
77 | 71 |
|
78 | | -### Breakpoint |
| 72 | +## Anchors and attachments |
79 | 73 |
|
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: |
86 | 75 |
|
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 | + # … |
92 | 84 | } |
93 | | -@media (min-width: 1600px) { .margin-anchor { width: 240px; } } |
94 | | -@media (min-width: 1800px) { .margin-anchor { width: 280px; } } |
95 | 85 | ``` |
96 | 86 |
|
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: |
107 | 88 |
|
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 | |
118 | 92 |
|
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`. |
121 | 96 |
|
122 | 97 | ## Authoring model |
123 | 98 |
|
124 | 99 | ### What contributors do |
125 | 100 |
|
126 | | -Nothing new. Example markdown remains: |
| 101 | +Nothing new. Example markdown stays: |
127 | 102 |
|
128 | 103 | ``` |
129 | 104 | :::cell |
130 | 105 | prose… |
131 | 106 | ```python |
132 | 107 | … |
133 | 108 | ``` |
134 | | - |
135 | 109 | ```output |
136 | 110 | … |
137 | 111 | ``` |
138 | 112 | ::: |
139 | 113 | ``` |
140 | 114 |
|
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. |
144 | 118 |
|
145 | 119 | ### What the project owner does |
146 | 120 |
|
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. |
196 | 124 |
|
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 |
202 | 126 |
|
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. |
206 | 137 |
|
207 | 138 | ## Edge cases |
208 | 139 |
|
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. |
222 | 148 |
|
223 | 149 | ## Non-goals |
224 | 150 |
|
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