Skip to content

Commit 8d4a7cc

Browse files
committed
Banner-between grammar: union of Tufte, Knuth, Algebrica
Single rule: the cell stays a 2-column unit always; figures live BETWEEN cells in banner rows that span both columns. A banner row holds one OR many figures via an auto-fit grid. No more switching between 1, 2, and 3 column layouts within a page. This is the intended union the user asked for: - Knuth — cells preserve the literate prose|code rhythm uninterrupted - Tufte — banner accepts a small-multiple of related figures - Algebrica — quiet italic caption beneath each figure, generous whitespace above and below as a teaching pause Three new prototypes on the mutability lesson, all with the same cell content; only the banner configuration varies: /prototyping/layout-banner-single one banner with one figure between cells 0 and 1 /prototyping/layout-banner-pair one banner with two figures — list mutates, tuple does not — a Tufte small-multiple /prototyping/layout-banner-trio multiple banners across the walkthrough (lead-in, mid, summary) proving cells never reflow Implementation: - Added tuple-no-mutation figure to src/marginalia.py to pair with aliasing-mutation for the small-multiples demo - scripts/build_prototypes.py: new banner() helper for 1+ figure rows; render_article gains a banners= parameter keyed by position (before, after-cell-N, after-walkthrough); BANNER_CSS supplies the auto-fit grid + dashed top/bottom border for visual rhythm - docs/visual-explainer-spec.md rewritten: two-pattern grammar (banners between cells for example pages, figure beside heading for journey pages); the earlier inline-* / margin-overlay variants catalogued as the perils of switching column models Production rendering is untouched in this commit — banner-between lives in prototypes for review before migration. https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
1 parent a0dcad6 commit 8d4a7cc

7 files changed

Lines changed: 675 additions & 83 deletions

File tree

docs/visual-explainer-spec.md

Lines changed: 162 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -8,91 +8,172 @@ between prose and code, which works at every viewport.
88

99
## Goals
1010

11+
- **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.
1114
- **Universal, not viewport-conditional.** A reader at any width sees the
12-
same figure in the same place. No `@media` breakpoints; no overlay layer.
15+
same figure in the same place. No `@media` breakpoints for figure
16+
positioning; no overlay layer.
17+
- **Multiple figures supported.** Banners hold one, two, or three
18+
figures via an auto-fit grid; small multiples are first-class.
1319
- **No contributor burden.** Example markdown stays as it is. Figures are
1420
curated separately by the project owner.
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.
21+
- **Quiet by default.** A page with no figures attached renders
22+
bit-for-bit identical to today.
2123
- **Grammar reuse.** Figures are composed from the locked vocabulary in
2224
`src/marginalia_grammar.py`. No bespoke SVG.
2325

2426
## Layout strategy
2527

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:
28+
Two patterns, one for each page type. Both keep their underlying column
29+
model fixed; figures slot into defined positions without disrupting it.
2930

30-
```css
31-
.lp-cell.has-figure { grid-template-columns: 1fr; }
32-
```
31+
### Example pages — banners between cells
3332

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.
33+
Each `.lp-cell` stays `grid-template-columns: minmax(17rem, .85fr)
34+
minmax(0, 1fr)` — prose left, code right — **always**. Figures live in
35+
banner rows that sit between cells, not inside them. The banner spans
36+
the full content width and uses an auto-fit grid so it can hold one,
37+
two, or three figures as small multiples.
3738

3839
```
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-
└──────────────────────────────────────────────────────┘
40+
[ cell 0 · prose | code ]
41+
─────── banner row ───────
42+
[ figure ] optional caption
43+
─────────────────────────
44+
[ cell 1 · prose | code ]
5245
```
5346

54-
The figure renders inside the cell, between `<div class="lp-prose">` and
55-
`<div class="cell-code-stack">`:
47+
This matches the union of three influences:
48+
- *Knuth* — cells preserve the literate-program rhythm of prose and code
49+
side by side, uninterrupted.
50+
- *Tufte* — the banner slot accepts a small-multiple of related figures
51+
so contrasts and progressions read as one composition.
52+
- *Algebrica* — each banner figure carries a quiet italic caption beneath,
53+
in the muted text colour, with generous whitespace above and below.
54+
55+
Banner positions:
56+
57+
| key | renders |
58+
|--------------------|--------------------------------------|
59+
| `before` | once, before the first cell |
60+
| `after-cell-N` | once, after cell N (zero-indexed) |
61+
| `after-walkthrough`| once, after the last cell |
62+
63+
Each position holds **one or more** figures via `cell-banner` markup.
64+
Captions are per-figure.
5665

5766
```html
58-
<section class="lesson-step lp-cell has-figure">
59-
<div class="lp-prose">…</div>
60-
<figure class="cell-figure">
61-
<svg>…</svg>
62-
<figcaption>…</figcaption>
63-
</figure>
64-
<div class="cell-code-stack">…</div>
67+
<section class="literate-program">
68+
<section class="lp-cell">prose | code</section>
69+
<div class="cell-banner cell-banner--2">
70+
<figure>
71+
<svg>…</svg>
72+
<figcaption>Mutable: change visible through any alias.</figcaption>
73+
</figure>
74+
<figure>
75+
<svg>…</svg>
76+
<figcaption>Immutable: aliases share a frozen value.</figcaption>
77+
</figure>
78+
</div>
79+
<section class="lp-cell">prose | code</section>
6580
</section>
6681
```
6782

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.
83+
```css
84+
.cell-banner {
85+
margin: var(--space-5) 0;
86+
padding: var(--space-4) 0;
87+
border-block: 1px dashed var(--hairline-soft);
88+
display: grid;
89+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
90+
gap: var(--space-4);
91+
justify-items: center;
92+
}
93+
.cell-banner figure { margin: 0; padding: 0; max-width: 360px; }
94+
.cell-banner svg { width: 100%; height: auto; display: block; }
95+
.cell-banner figcaption {
96+
margin-top: var(--space-2);
97+
color: var(--muted);
98+
font-size: .92rem;
99+
font-style: italic;
100+
max-width: 44ch;
101+
}
102+
.cell-banner--1 figure { max-width: 440px; }
103+
```
104+
105+
The cell never reflows: cells without banners around them and cells
106+
between banners look identical to today's layout.
107+
108+
### Journey pages — figure beside section heading
109+
110+
Journey pages are not literate code; they are linear lists of items
111+
grouped under section headings. Here the figure lives **beside** the
112+
section heading in a 2-column row (heading-and-list on the left, figure
113+
on the right). The column model is fixed for the whole journey page:
114+
each section is a 2-col grid, every section the same shape.
115+
116+
```css
117+
.journey-section {
118+
display: grid;
119+
grid-template-columns: minmax(0, 1fr);
120+
gap: var(--space-4);
121+
}
122+
@media (min-width: 900px) {
123+
.journey-section {
124+
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 320px);
125+
align-items: start;
126+
}
127+
}
128+
```
129+
130+
One figure per section, faithful to the section's conceptual shift.
131+
This is the journey-streams pattern; reused unchanged for all six
132+
journeys (see `JOURNEY_SECTION_FIGURES` in `scripts/build_prototypes.py`).
133+
134+
### Why these two, not five
135+
136+
The earlier prototypes that mixed inline-above, inline-between,
137+
after-output, and prose-aside all switched the cell's column model
138+
when a figure was present. That created the perils of randomly
139+
oscillating between 1, 2, and 3 columns within the same page. The
140+
banner-between grammar fixes the column model and lets figure count
141+
vary instead. Adding a second or third figure changes the banner's
142+
internal grid (auto-fit handles 1/2/3+), but the cells around it
143+
remain unchanged — no reflow, no cognitive context-switch.
71144

72145
## Anchors and attachments
73146

74-
`src/marginalia.py` declares which figures attach where:
147+
`src/marginalia.py` declares which figures attach where. The data shape
148+
will move from per-cell injection toward per-position banners:
75149

76150
```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-
#
151+
# proposed shape — banners keyed by position, each holding 1+ figures
152+
BANNERS = {
153+
"mutability": {
154+
"after-cell-0": [
155+
("aliasing-mutation",
156+
"Two names share one mutable list — appending through one "
157+
"name changes the object visible through both."),
158+
("tuple-no-mutation",
159+
"By contrast, a tuple is frozen — aliases share a value "
160+
"no method can change in place."),
161+
],
162+
},
84163
}
85164
```
86165

87-
Anchor identifiers:
166+
Banner positions:
88167

89-
| anchor | targets |
90-
|-----------------|--------------------------------------|
91-
| `cell-0`, `cell-1`, … | each literate-program cell, zero-indexed |
168+
| position | renders |
169+
|--------------------|--------------------------------------|
170+
| `before` | once, before the first cell |
171+
| `after-cell-0`, … | once, after cell N (zero-indexed) |
172+
| `after-walkthrough`| once, after the last cell |
92173

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`.
174+
Each position is a list, not a single figure: the same banner may hold
175+
multiple figures as a small multiple. Most slugs will start empty.
176+
Adding a banner is a one-line edit in `src/marginalia.py`.
96177

97178
## Authoring model
98179

@@ -125,26 +206,34 @@ from grammar primitives) and register it in `FIGURES`. Append a tuple of
125206
## Files
126207
127208
- `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.
209+
Aligned with `public/site.css` design tokens; figures use the four
210+
palette constants and never pick colours directly.
211+
- `src/marginalia.py` — figure registry (`FIGURES`) and attachment map.
212+
Exports `render_for_anchor(slug, anchor)` for the current cell-inline
213+
layout; banner-rendering helpers will land alongside.
214+
- `src/app.py` — `_render_walkthrough_cell` is the current rendering
215+
helper; the banner-between rollout will rename or replace it with a
216+
walkthrough-level renderer that interleaves cells and banners.
217+
- `public/site.css` — currently `.lp-cell.has-figure` and `.cell-figure`
218+
rules; will gain `.cell-banner` rules when the banner grammar ships
219+
in production.
220+
- `scripts/build_prototypes.py` — already implements the banner grammar
221+
and journey-section grammar so prototypes can validate it before
222+
production migration.
137223
138224
## Edge cases
139225
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.
226+
- **Many figures in one banner.** Auto-fit grid handles 1 (centered,
227+
larger), 2 (small-multiple pair), 3+ (wraps as content allows). More
228+
than 3 in one banner is usually a signal that two adjacent banners
229+
are clearer.
230+
- **No figures attached.** The page renders bit-for-bit identical to
231+
today.
232+
- **Print.** Banners and cells both flow naturally in single-column
233+
print contexts.
234+
- **Very narrow viewports (≤340px).** Banner figures stack via the
235+
auto-fit grid; SVGs scale via `max-width: 100%`. Cells keep their
236+
existing 980px breakpoint for collapsing the 2-col grid.
148237
149238
## Non-goals
150239

0 commit comments

Comments
 (0)