From 50ed727ec7d4cd48181aa4a7db1f8431260a15a0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 10 May 2026 12:38:21 +0000
Subject: [PATCH 01/20] Rebase onto main; reconcile journey figures with new
journey structure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The branch landing covers the entire visual-explainer thread of work.
This commit lands all of it on the current main, with the figure
catalogue reconciled to main's restructured journeys.
Net additions:
- docs/visual-explainer-spec.md design spec for the inline cell-figure
production layout, plus the
banner-between grammar (prototypes only)
and the journey-section figure pattern
- docs/journey-visualisation-rubric.md 10-point rubric for journey
section figures: section fidelity,
pedagogical scope, mechanism over
metaphor, topic gates, project gate
- src/marginalia_grammar.py locked Canvas grammar — palette
aligned with site tokens (--text,
--muted, --accent, --accent-soft-
equivalent neutral); tokens, words,
phrases (bind, dispatch, lanes,
connect for tangent edges)
- src/marginalia.py 27 figures: 18 journey-section,
plus 9 lesson + library figures
- src/app.py _render_cell injects the attached
figure between prose and code-stack
when present; cell gets has-figure
class so it stacks vertically
- public/site.css .lp-cell.has-figure single-column;
.cell-figure styling
- public/_headers /prototyping/* no-cache rule
- scripts/fingerprint_assets.py digests src/marginalia.py and
src/marginalia_grammar.py so figure
edits invalidate HTML cache
- scripts/build_marginalia.py 76-card gestalt review page
- scripts/build_prototypes.py 15 prototypes: index, design-review
pair, three banner-grammar demos,
eight journey demos, one journey-
figures gestalt
Reconciled with main's restructure of journeys (Streams was split into
Control Flow + Iteration; Workers added; some titles renamed):
Old key → New key
Streams · Make decisions explicitly → Control Flow · Choose between paths
Streams · Recognize iter as protocol → Iteration · See the protocol behind `for`
Streams · Choose the right loop → Iteration · Choose the right loop shape
(unchanged)
Three new figures designed to close coverage gaps surfaced by audit:
naming-decisions Control flow · Name and shape decisions
early-exit Control flow · Stop as soon as the answer is known
lazy-stream Iteration · Compose lazy value streams
Coverage: 21 of 24 journey sections have figures. The three Workers
sections render as heading + list with no figure column — intentional
pending future design (their titles are constraint-y rather than
mechanism-y; figure designs need more thought).
Audit results clean:
- production rendering: has-figure class + cell-figure svg present;
no margin-anchor or margin-collected leaks
- palette: only the seven site-token values appear across all
prototype output
- PROTOTYPES list and on-disk files agree
- every journey figure has a non-empty caption
- 39 unit tests pass
https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
---
docs/journey-visualisation-rubric.md | 121 +++
docs/visual-explainer-spec.md | 253 +++++
public/_headers | 3 +
public/prototyping/index.html | 41 +
public/prototyping/journey-control-flow.html | 44 +
.../prototyping/journey-figures-gestalt.html | 55 ++
public/prototyping/journey-interfaces.html | 44 +
public/prototyping/journey-iteration.html | 44 +
public/prototyping/journey-reliability.html | 44 +
public/prototyping/journey-runtime.html | 44 +
public/prototyping/journey-shapes.html | 44 +
public/prototyping/journey-types.html | 44 +
public/prototyping/journey-workers.html | 44 +
public/prototyping/layout-banner-pair.html | 109 ++
public/prototyping/layout-banner-single.html | 109 ++
public/prototyping/layout-banner-trio.html | 109 ++
public/prototyping/marginalia-gestalt.html | 460 +++++++++
.../operators-polish-comparison.html | 133 +++
public/site.css | 8 +
...5f6f7da7c305.css => site.d666d8585635.css} | 8 +
scripts/build_marginalia.py | 929 ++++++++++++++++++
scripts/build_prototypes.py | 581 +++++++++++
scripts/fingerprint_assets.py | 6 +-
src/app.py | 10 +-
src/asset_manifest.py | 4 +-
src/marginalia.py | 448 +++++++++
src/marginalia_grammar.py | 350 +++++++
27 files changed, 4083 insertions(+), 6 deletions(-)
create mode 100644 docs/journey-visualisation-rubric.md
create mode 100644 docs/visual-explainer-spec.md
create mode 100644 public/prototyping/index.html
create mode 100644 public/prototyping/journey-control-flow.html
create mode 100644 public/prototyping/journey-figures-gestalt.html
create mode 100644 public/prototyping/journey-interfaces.html
create mode 100644 public/prototyping/journey-iteration.html
create mode 100644 public/prototyping/journey-reliability.html
create mode 100644 public/prototyping/journey-runtime.html
create mode 100644 public/prototyping/journey-shapes.html
create mode 100644 public/prototyping/journey-types.html
create mode 100644 public/prototyping/journey-workers.html
create mode 100644 public/prototyping/layout-banner-pair.html
create mode 100644 public/prototyping/layout-banner-single.html
create mode 100644 public/prototyping/layout-banner-trio.html
create mode 100644 public/prototyping/marginalia-gestalt.html
create mode 100644 public/prototyping/operators-polish-comparison.html
rename public/{site.5f6f7da7c305.css => site.d666d8585635.css} (95%)
create mode 100644 scripts/build_marginalia.py
create mode 100644 scripts/build_prototypes.py
create mode 100644 src/marginalia.py
create mode 100644 src/marginalia_grammar.py
diff --git a/docs/journey-visualisation-rubric.md b/docs/journey-visualisation-rubric.md
new file mode 100644
index 0000000..4faba10
--- /dev/null
+++ b/docs/journey-visualisation-rubric.md
@@ -0,0 +1,121 @@
+# Journey visualisation rubric
+
+This rubric scores the figure beside each journey section heading.
+The example rubric (docs/example-quality-rubric.md) covers individual
+lesson pages; this one covers the conceptual figures that introduce
+each journey section.
+
+A journey section sits *above* individual lessons. It groups three to
+five examples under a shared conceptual shift, e.g. "Recognise iteration
+as a protocol" or "Bundle behavior with state". The figure beside that
+heading should depict the shift the section asks the reader to make.
+It is not a recycled lesson figure.
+
+Score each section figure on a 10-point scale.
+
+## Content (5.5)
+
+1. **Section fidelity (0-1.5)** — the figure depicts the conceptual
+ shift the section title and summary describe. It does not depict
+ one of the section's examples. A figure for "Make decisions
+ explicitly" must show *deciding*, not the body of any particular
+ `match` statement; a figure for "Bundle behavior with state" must
+ show *bundling*, not one specific class.
+2. **Pedagogical scope (0-1.0)** — the figure captures the general
+ pattern that unifies the section's items. If the figure could be
+ replaced with the diagram from any single lesson it is too specific.
+3. **One conceptual move (0-1.0)** — exactly one shift, before-state
+ to after-state, or the depiction of a single mechanism. Two ideas
+ compete for the reader's eye and both lose. Squint test: the
+ primary structure is identifiable within two seconds.
+4. **Mechanism over metaphor (0-1.0)** — the figure shows the actual
+ machinery the section names — the iterator object, the cell, the
+ dispatch arrow — not a cartoon of it. Knuth's rule.
+5. **Caption alignment (0-1.0)** — the `figcaption` names the
+ conceptual shift in plain language and matches the section
+ summary's voice. The caption is part of the figure, not optional.
+
+## Craft (3.0)
+
+6. **Grammar conformance (0-1.0)** — composed exclusively from
+ `Canvas` primitives in `src/marginalia_grammar.py`. No bespoke
+ SVG, no new colours, no stroke weights outside the locked set.
+7. **Emphasis scarcity (0-1.0)** — at most one accent mark per
+ figure. The accent goes on the single element the section names
+ (the live yield, the dispatch arrow, the captured cell). If three
+ things are orange the figure has no emphasis at all.
+8. **Restraint (0-1.0)** — no decoration that does not carry
+ information. No drop shadows, gradients, ornamental rules,
+ non-orthogonal tilts, or marks placed for "balance".
+
+## Context (1.5)
+
+9. **Independence from lesson figures (0-1.0)** — distinct framing
+ from any single lesson's diagram. If the section figure is
+ identical to a `cell-figure` in one of the section's lessons,
+ one of them is wrong. Usually the section figure should be the
+ *more abstract* one.
+10. **Layout fit (0-0.5)** — renders comfortably at the journey
+ page's ~280-320px section-figure column. Text inside the SVG
+ stays readable at that scale; the figure does not overflow.
+
+## Topic gates
+
+- **Decision sections** — depict the fork explicitly: a value flowing
+ through a predicate to one of several branches. A single linear
+ arrow does not satisfy this gate.
+- **Loop sections** — show the back-edge that makes a loop a loop.
+ A linear sequence of cells without a return path is not a loop
+ picture, it is just a sequence.
+- **Iteration sections** — show the `iter()` / `next()` protocol
+ explicitly: an iterable, an iterator, and one or more values
+ pulled out by `next()`. The figure must distinguish iterable
+ from iterator.
+- **Type sections** — show annotations as ghost overlays on runtime
+ values, or show type relationships (union, generic, structural
+ matching) as containment / flow. Do not let a type figure devolve
+ into "a function with parameter names".
+- **Resource and boundary sections** — show enter and exit as paired
+ events bracketing a body, with the failure path also routed
+ through exit. A one-way arrow is not a context manager.
+- **Concurrency sections** — show two parallel lanes with handoffs
+ between them. A single timeline is not a concurrency picture.
+
+## Release gates outside the score
+
+- **Exactly one figure per section.** Section figures are not stacked.
+ If the section needs two figures the section is doing two things.
+- **Caption present.** A figure without a `figcaption` is not allowed.
+- **Section summary aligns with caption.** The summary in
+ `src/app.py`'s `JOURNEYS` list agrees with what the figure caption
+ asserts. Disagreement means one or the other is wrong.
+- **Renders within `.journey-section`'s 2-column grid.** The figure
+ obeys the column the layout gives it (~280-320px); design at a
+ viewBox sized for that column, not at lesson-figure dimensions.
+- **Uses only the four palette constants.** `INK`, `INK_SOFT`,
+ `EMPHASIS`, `SOFT_FILL`. Anything else is grounds for redesign.
+
+## Quality bands
+
+- **9.0-10.0** — captures the conceptual shift in two seconds; the
+ caption could only describe this figure; pleasant to look at on
+ return visits.
+- **8.0-8.9** — depicts the right idea but shares too much framing
+ with a lesson figure, or the caption hedges instead of asserting,
+ or one secondary mark steals attention from the primary one.
+- **7.0-7.9** — depicts the section but loses something in scope:
+ uses a specific predicate / iterable / type instead of the
+ general pattern; or topic gate not satisfied.
+- **below 7.0** — recycled lesson figure, missing topic gate,
+ multiple primary ideas competing, or accent marks scattered
+ rather than scarce. Redesign before publishing.
+
+## Project gate
+
+Every section figure on a published journey page should score at
+least **8.5**. The journey average across its three sections should
+exceed **8.8** so the journey reads as a unified set rather than
+three independently designed cards.
+
+The score is a guide, not a substitute for reading the page beside
+its surrounding lessons.
diff --git a/docs/visual-explainer-spec.md b/docs/visual-explainer-spec.md
new file mode 100644
index 0000000..5e773e7
--- /dev/null
+++ b/docs/visual-explainer-spec.md
@@ -0,0 +1,253 @@
+# Visual explainer spec
+
+This spec describes how figures attach to existing Python By Example pages
+without changing what contributors author. The earlier draft of this spec
+relied on absolute-positioned figures escaping into the page's implicit
+outer margin; that approach was rolled back in favour of inline placement
+between prose and code, which works at every viewport.
+
+## Goals
+
+- **One column model per page type, fixed.** Example pages keep cells in
+ the prose|code 2-col grid; journey pages keep section heading + figure
+ in a 2-col grid. Figures never reflow the surrounding columns.
+- **Universal, not viewport-conditional.** A reader at any width sees the
+ same figure in the same place. No `@media` breakpoints for figure
+ positioning; no overlay layer.
+- **Multiple figures supported.** Banners hold one, two, or three
+ figures via an auto-fit grid; small multiples are first-class.
+- **No contributor burden.** Example markdown stays as it is. Figures are
+ curated separately by the project owner.
+- **Quiet by default.** A page with no figures attached renders
+ bit-for-bit identical to today.
+- **Grammar reuse.** Figures are composed from the locked vocabulary in
+ `src/marginalia_grammar.py`. No bespoke SVG.
+
+## Layout strategy
+
+Two patterns, one for each page type. Both keep their underlying column
+model fixed; figures slot into defined positions without disrupting it.
+
+### Example pages — banners between cells
+
+Each `.lp-cell` stays `grid-template-columns: minmax(17rem, .85fr)
+minmax(0, 1fr)` — prose left, code right — **always**. Figures live in
+banner rows that sit between cells, not inside them. The banner spans
+the full content width and uses an auto-fit grid so it can hold one,
+two, or three figures as small multiples.
+
+```
+[ cell 0 · prose | code ]
+─────── banner row ───────
+[ figure ] optional caption
+─────────────────────────
+[ cell 1 · prose | code ]
+```
+
+This matches the union of three influences:
+- *Knuth* — cells preserve the literate-program rhythm of prose and code
+ side by side, uninterrupted.
+- *Tufte* — the banner slot accepts a small-multiple of related figures
+ so contrasts and progressions read as one composition.
+- *Algebrica* — each banner figure carries a quiet italic caption beneath,
+ in the muted text colour, with generous whitespace above and below.
+
+Banner positions:
+
+| key | renders |
+|--------------------|--------------------------------------|
+| `before` | once, before the first cell |
+| `after-cell-N` | once, after cell N (zero-indexed) |
+| `after-walkthrough`| once, after the last cell |
+
+Each position holds **one or more** figures via `cell-banner` markup.
+Captions are per-figure.
+
+```html
+
+ prose | code
+
+
+
+ Mutable: change visible through any alias.
+
+
+
+ Immutable: aliases share a frozen value.
+
+
+ prose | code
+
+```
+
+```css
+.cell-banner {
+ margin: var(--space-5) 0;
+ padding: var(--space-4) 0;
+ border-block: 1px dashed var(--hairline-soft);
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--space-4);
+ justify-items: center;
+}
+.cell-banner figure { margin: 0; padding: 0; max-width: 360px; }
+.cell-banner svg { width: 100%; height: auto; display: block; }
+.cell-banner figcaption {
+ margin-top: var(--space-2);
+ color: var(--muted);
+ font-size: .92rem;
+ font-style: italic;
+ max-width: 44ch;
+}
+.cell-banner--1 figure { max-width: 440px; }
+```
+
+The cell never reflows: cells without banners around them and cells
+between banners look identical to today's layout.
+
+### Journey pages — figure beside section heading
+
+Journey pages are not literate code; they are linear lists of items
+grouped under section headings. Here the figure lives **beside** the
+section heading in a 2-column row (heading-and-list on the left, figure
+on the right). The column model is fixed for the whole journey page:
+each section is a 2-col grid, every section the same shape.
+
+```css
+.journey-section {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-4);
+}
+@media (min-width: 900px) {
+ .journey-section {
+ grid-template-columns: minmax(0, 1.4fr) minmax(220px, 320px);
+ align-items: start;
+ }
+}
+```
+
+One figure per section, faithful to the section's conceptual shift,
+scored against `docs/journey-visualisation-rubric.md`. The same template
+is reused for every journey; figures are mapped via
+`JOURNEY_SECTION_FIGURES` in `scripts/build_prototypes.py`. Sections
+without a figure render as a heading + list with no figure column,
+which is the intended state for journeys still pending design (today,
+all three sections of the Workers journey).
+
+### Why these two, not five
+
+The earlier prototypes that mixed inline-above, inline-between,
+after-output, and prose-aside all switched the cell's column model
+when a figure was present. That created the perils of randomly
+oscillating between 1, 2, and 3 columns within the same page. The
+banner-between grammar fixes the column model and lets figure count
+vary instead. Adding a second or third figure changes the banner's
+internal grid (auto-fit handles 1/2/3+), but the cells around it
+remain unchanged — no reflow, no cognitive context-switch.
+
+## Anchors and attachments
+
+`src/marginalia.py` declares which figures attach where. The data shape
+will move from per-cell injection toward per-position banners:
+
+```python
+# proposed shape — banners keyed by position, each holding 1+ figures
+BANNERS = {
+ "mutability": {
+ "after-cell-0": [
+ ("aliasing-mutation",
+ "Two names share one mutable list — appending through one "
+ "name changes the object visible through both."),
+ ("tuple-no-mutation",
+ "By contrast, a tuple is frozen — aliases share a value "
+ "no method can change in place."),
+ ],
+ },
+}
+```
+
+Banner positions:
+
+| position | renders |
+|--------------------|--------------------------------------|
+| `before` | once, before the first cell |
+| `after-cell-0`, … | once, after cell N (zero-indexed) |
+| `after-walkthrough`| once, after the last cell |
+
+Each position is a list, not a single figure: the same banner may hold
+multiple figures as a small multiple. Most slugs will start empty.
+Adding a banner is a one-line edit in `src/marginalia.py`.
+
+## Authoring model
+
+### What contributors do
+
+Nothing new. Example markdown stays:
+
+```
+:::cell
+prose…
+```python
+…
+```
+```output
+…
+```
+:::
+```
+
+There is no `:::figure` block, no frontmatter key, no caption alongside
+the prose. Contributors merge cell content; the figure layer is composed
+independently.
+
+### What the project owner does
+
+Edit `ATTACHMENTS` in `src/marginalia.py`. Add a paint function (composed
+from grammar primitives) and register it in `FIGURES`. Append a tuple of
+`(anchor, figure_name, caption)` to `ATTACHMENTS[slug]`. Done.
+
+## Files
+
+- `src/marginalia_grammar.py` — palette, tokens, words, phrases, metrics.
+ Aligned with `public/site.css` design tokens; figures use the four
+ palette constants and never pick colours directly.
+- `src/marginalia.py` — figure registry (`FIGURES`) and attachment map.
+ Exports `render_for_anchor(slug, anchor)` for the current cell-inline
+ layout; banner-rendering helpers will land alongside.
+- `src/app.py` — `_render_walkthrough_cell` is the current rendering
+ helper; the banner-between rollout will rename or replace it with a
+ walkthrough-level renderer that interleaves cells and banners.
+- `public/site.css` — currently `.lp-cell.has-figure` and `.cell-figure`
+ rules; will gain `.cell-banner` rules when the banner grammar ships
+ in production.
+- `scripts/build_prototypes.py` — already implements the banner grammar
+ and journey-section grammar so prototypes can validate it before
+ production migration.
+
+## Edge cases
+
+- **Many figures in one banner.** Auto-fit grid handles 1 (centered,
+ larger), 2 (small-multiple pair), 3+ (wraps as content allows). More
+ than 3 in one banner is usually a signal that two adjacent banners
+ are clearer.
+- **No figures attached.** The page renders bit-for-bit identical to
+ today.
+- **Print.** Banners and cells both flow naturally in single-column
+ print contexts.
+- **Very narrow viewports (≤340px).** Banner figures stack via the
+ auto-fit grid; SVGs scale via `max-width: 100%`. Cells keep their
+ existing 980px breakpoint for collapsing the 2-col grid.
+
+## Non-goals
+
+- **No JavaScript-driven layout.** No scroll listener, no resize observer,
+ no popup affordance. Pure CSS + server-side rendering.
+- **No viewport-conditional layout.** The earlier margin-overlay approach
+ required a 1440px+ viewport to work; that complexity is gone.
+- **No contributor surface.** Contributors do not author figures or
+ preview placement.
+- **No chromatic decoration.** Figures use only the locked palette
+ (`--text`, `--muted`, `--accent`, `--accent-soft`-equivalent neutral).
+ Emphasis is scarce: at most one accent mark per figure, used only for
+ the single element the prose names.
diff --git a/public/_headers b/public/_headers
index 29969bb..d75a1d8 100644
--- a/public/_headers
+++ b/public/_headers
@@ -9,3 +9,6 @@
/favicon.svg
Cache-Control: public, max-age=31536000, immutable
+
+/prototyping/*
+ Cache-Control: no-cache, must-revalidate
diff --git a/public/prototyping/index.html b/public/prototyping/index.html
new file mode 100644
index 0000000..61800e0
--- /dev/null
+++ b/public/prototyping/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+Visual explainer prototypes · Prototype
+
+
+
+
+
Real example pages with their attached figures, plus the design-review pages. The example pages all use the production layout: a cell with an attached figure stacks prose, figure, and code vertically; cells without figures keep today's prose|code grid.
The grammar: cells stay 2-column always; figures live in banner rows BETWEEN cells. Holds one figure here. The intended union of Tufte/Knuth/algebrica.
Same grammar with two figures in the banner — a Tufte small-multiple. The mutable list and the immutable tuple side by side, captioned, between the same pair of cells.
The grammar at scale: a single-figure banner before the walkthrough, a pair-banner between two cells, a single-figure summary after the last cell. Multiple diagrams; cells never displaced.
Prototype · Control Flow journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
+
+
Choose between paths.
Start with ordinary branching and boolean predicates before reaching for more compact forms.
leave the current path when ordinary return values are not enough
The loop exits at the first match — break short-circuits the rest of the sequence.
+
+
+
+
diff --git a/public/prototyping/journey-figures-gestalt.html b/public/prototyping/journey-figures-gestalt.html
new file mode 100644
index 0000000..cfe395b
--- /dev/null
+++ b/public/prototyping/journey-figures-gestalt.html
@@ -0,0 +1,55 @@
+
+
+
+
+Journey-figures gestalt · Prototype
+
+
+
+
+
Prototype · All 18 journey section figures grouped by journey, for uniform rubric review. · all prototypes
+
+
+
+
Journeys · 18 section figures
+
Journey-figures gestalt
+
Every journey section's figure on one page so the set can be reasoned about as a whole. Score against the rubric; redesign anything below the 8.5 gate before shipping to /journeys/<slug>.
+
+
Runtime
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
Start with executable evidence.
Every page is a runnable program. The smallest mental model: source produces visible output.
Separate value, identity, and absence.
Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).
Read expressions as object operations.
Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
Control Flow
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
Choose between paths.
A value flows through a predicate to one of several branches.
Name and shape decisions.
The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.
Stop as soon as the answer is known.
The loop exits at the first match — break short-circuits the rest of the sequence.
Iteration
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
Choose the right loop shape.
Walk the sequence, run the body, return; the shape behind for and while.
See the protocol behind `for`.
iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.
Compose lazy value streams.
Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
Shapes
This journey teaches the core Python habit of choosing a data shape, transforming it directly, and making the result visible.
Pick the container that matches the question.
Each container answers a different question: ordered, fixed, lookup, unique.
Move between shapes deliberately.
Most everyday code reshapes data: one input, one transform, one new value.
Cross text and data boundaries.
Programs receive text and produce structured data; parsing makes the boundary explicit.
Interfaces
This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.
Start with functions as named behavior.
A function is the first abstraction boundary: arguments in, body, return value out.
Use functions as values.
Functions are first-class values. A second name binds to the same function object.
Bundle behavior with state.
Classes group fields and methods so data and behavior move together behind one interface.
Types
This journey maps Python's runtime object model to optional static annotations so learners know what types can and cannot promise.
Keep runtime and static analysis separate.
Annotations describe expected types for tools; the runtime accepts any object regardless.
Describe realistic data shapes.
A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.
Scale annotations for reusable libraries.
A generic type variable preserves shape across a call: the same T flows in and out.
Reliability
This journey follows the boundaries where programs fail, clean up, split into modules, communicate with the outside world, and run concurrent work.
Make failure explicit.
try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.
Control resource and module boundaries.
A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.
Handle operations that outlive one expression.
On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
Workers
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
Prototype · Interfaces journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
customize class creation when ordinary class tools are not enough
Classes group fields and methods so data and behavior move together behind one interface.
+
+
+
+
diff --git a/public/prototyping/journey-iteration.html b/public/prototyping/journey-iteration.html
new file mode 100644
index 0000000..37c7197
--- /dev/null
+++ b/public/prototyping/journey-iteration.html
@@ -0,0 +1,44 @@
+
+
+
+
+Journey · Iteration · Prototype
+
+
+
+
+
Prototype · Iteration journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
+
+
Choose the right loop shape.
Loops differ by what they consume, when they stop, and whether completion itself carries meaning.
Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
+
+
+
+
diff --git a/public/prototyping/journey-reliability.html b/public/prototyping/journey-reliability.html
new file mode 100644
index 0000000..8d58290
--- /dev/null
+++ b/public/prototyping/journey-reliability.html
@@ -0,0 +1,44 @@
+
+
+
+
+Journey · Reliability · Prototype
+
+
+
+
+
Prototype · Reliability journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
+
+
+
+
diff --git a/public/prototyping/journey-runtime.html b/public/prototyping/journey-runtime.html
new file mode 100644
index 0000000..a2dd135
--- /dev/null
+++ b/public/prototyping/journey-runtime.html
@@ -0,0 +1,44 @@
+
+
+
+
+Journey · Runtime · Prototype
+
+
+
+
+
Prototype · Runtime journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
+
+
Start with executable evidence.
Learners first need to see that every page is a runnable program with visible output.
contrast text with binary data and explicit decoding
Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
+
+
+
+
diff --git a/public/prototyping/journey-shapes.html b/public/prototyping/journey-shapes.html
new file mode 100644
index 0000000..ddf6307
--- /dev/null
+++ b/public/prototyping/journey-shapes.html
@@ -0,0 +1,44 @@
+
+
+
+
+Journey · Shapes · Prototype
+
+
+
+
+
Prototype · Shapes journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
Programs receive text and produce structured data; parsing makes the boundary explicit.
+
+
+
+
diff --git a/public/prototyping/journey-types.html b/public/prototyping/journey-types.html
new file mode 100644
index 0000000..2312e81
--- /dev/null
+++ b/public/prototyping/journey-types.html
@@ -0,0 +1,44 @@
+
+
+
+
+Journey · Types · Prototype
+
+
+
+
+
Prototype · Types journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
create distinct static identities for runtime-compatible values
A generic type variable preserves shape across a call: the same T flows in and out.
+
+
+
+
diff --git a/public/prototyping/journey-workers.html b/public/prototyping/journey-workers.html
new file mode 100644
index 0000000..679fe17
--- /dev/null
+++ b/public/prototyping/journey-workers.html
@@ -0,0 +1,44 @@
+
+
+
+
+Journey · Workers · Prototype
+
+
+
+
+
Prototype · Workers journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
+
+
Replace unavailable process boundaries with portable evidence.
Dynamic Workers run Python in a constrained runtime, so examples cannot assume child processes, shell commands, or project-local virtual environments are available.
Keep network lessons local to the protocol boundary.
Workers should not open arbitrary low-level sockets, so the networking example teaches addresses, protocol constants, and bytes without making an outbound connection.
Some objects change in place, while others return new values.
+
+
Mutable objects can change in place. first and second point to the same list, so appending through one name changes the object seen through both names.
Source
first = ["python"]
+second = first
+second.append("workers")
+print(first)
+print(second)
Output
['python', 'workers']
+['python', 'workers']
Two names share one mutable list — appending through one name changes the object visible through both.By contrast, a tuple is frozen — its contents cannot change in place, so aliasing carries no mutation hazard.
Immutable objects do not change in place. String methods such as upper() return a new string, leaving the original string unchanged.
Source
text = "python"
+upper_text = text.upper()
+print(text)
+print(upper_text)
Output
python
+PYTHON
Some APIs make the boundary explicit. sorted() returns a new list, while methods such as append() and list.sort() mutate an existing list.
Some objects change in place, while others return new values.
+
+
Mutable objects can change in place. first and second point to the same list, so appending through one name changes the object seen through both names.
Source
first = ["python"]
+second = first
+second.append("workers")
+print(first)
+print(second)
Output
['python', 'workers']
+['python', 'workers']
Two names share one mutable list — appending through one name changes the object visible through both.
Immutable objects do not change in place. String methods such as upper() return a new string, leaving the original string unchanged.
Source
text = "python"
+upper_text = text.upper()
+print(text)
+print(upper_text)
Output
python
+PYTHON
Some APIs make the boundary explicit. sorted() returns a new list, while methods such as append() and list.sort() mutate an existing list.
Prototype · Demonstrates that the grammar accepts multiple banners at any position: lead-in figure, mid-walkthrough small-multiple pair, summary figure. Cells never reflow. · all prototypes
Some objects change in place, while others return new values.
+
+
Two names share one mutable list — appending through one name changes the object visible through both.
Mutable objects can change in place. first and second point to the same list, so appending through one name changes the object seen through both names.
Source
first = ["python"]
+second = first
+second.append("workers")
+print(first)
+print(second)
Output
['python', 'workers']
+['python', 'workers']
Immutable objects do not change in place. String methods such as upper() return a new string, leaving the original string unchanged.
Source
text = "python"
+upper_text = text.upper()
+print(text)
+print(upper_text)
Output
python
+PYTHON
Mutable: change visible through any alias.Immutable: aliases share a frozen value.
Some APIs make the boundary explicit. sorted() returns a new list, while methods such as append() and list.sort() mutate an existing list.
Same node positions in both panels. Only the four edges differ.
+
The before panel shows the original hand-coded line endpoints. Hollow rings mark where each line actually terminated; the gap between ring and node is the alignment error. Note that * → 4 ended at the centre of 4, not at its circle.
+
+
+
+
+
+
Before · hand-coded coordinates
+
Lines miss the circle tangents
+
+
+
+
+
After · c.connect()
+
Endpoints computed from the line of centres
+
+
+
+
+
+
+
The before coordinates were chosen by eye. (168, 36) → (220, 62) in particular drew the line straight into 4's centre — the radius-10 circle gets bisected by its own incoming edge.
+
The after endpoints come from c.connect(ax, ay, ar, bx, by, br) in src/marginalia_grammar.py: a unit vector along the line of centres, scaled by each radius, gives endpoints that are tangent by construction. No card can pick wrong coordinates because it never picks coordinates at all.
+{body}
+
+
+"""
+
+
+def render_cell(step: dict) -> str:
+ """Render one literate cell — always prose | code in a 2-column grid.
+
+ Figures live in banner rows between cells, never inside them.
+ """
+ prose_html = "".join(f"
{render_inline(p)}
" for p in step["prose"])
+ code = html.escape(step["code"])
+ output = html.escape(step["output"])
+ code_stack = (
+ '
'
+ f'
Source
{code}
'
+ f'
Output
{output}
'
+ "
"
+ )
+ return f'
{prose_html}
{code_stack}'
+
+
+def render_article(example: dict, *, banners: dict[str, str] | None = None) -> str:
+ """Render a full example article with optional banner rows between cells.
+
+ banners keys: "before" | "after-cell-N" | "after-walkthrough".
+ Each value is the HTML of one banner (use the banner() helper).
+ """
+ cells = _walkthrough_cells(example)
+ banners = banners or {}
+ parts: list[str] = []
+ if "before" in banners:
+ parts.append(banners["before"])
+ for i, step in enumerate(cells):
+ parts.append(render_cell(step))
+ key = f"after-cell-{i}"
+ if key in banners:
+ parts.append(banners[key])
+ if "after-walkthrough" in banners:
+ parts.append(banners["after-walkthrough"])
+ walkthrough = "".join(parts)
+ notes = "".join(f"
{render_inline(n)}
" for n in example.get("notes", []))
+ code = html.escape(example["code"])
+ output = html.escape(example.get("expected_output", ""))
+ return f"""
+
+
+
+
+"""
+
+
+def banner(*items: tuple[str, str | None]) -> str:
+ """A banner row holding 1+ figures spanning the full cell width.
+
+ items: tuples of (figure_name, caption_or_None). The banner uses an
+ auto-fit grid so a single figure centers, two pair as small multiples,
+ three or more wrap as the cell allows.
+ """
+ figures_html = "".join(
+ f'{_render_svg(name)}'
+ f'{("" + html.escape(cap) + "") if cap else ""}'
+ ""
+ for name, cap in items
+ )
+ count_class = f" cell-banner--{len(items)}"
+ return f'
{figures_html}
'
+
+
+# ─── Index ─────────────────────────────────────────────────────────────
+
+
+PROTOTYPES = [
+ ("marginalia-gestalt.html", "Marginalia gestalt",
+ "Every journey and example as a card, drawn from the shared grammar. Pure design review."),
+ ("journey-figures-gestalt.html", "Journey-figures gestalt",
+ "All 18 journey section figures on one page, grouped by journey, for uniform rubric review."),
+ ("operators-polish-comparison.html", "Operators alignment polish",
+ "Side-by-side before/after for the tree-edge alignment fix; demonstrates Canvas.connect()."),
+ ("layout-banner-single.html", "Layout · banner between cells",
+ "The grammar: cells stay 2-column always; figures live in banner rows BETWEEN cells. Holds one figure here. The intended union of Tufte/Knuth/algebrica."),
+ ("layout-banner-pair.html", "Layout · banner with small-multiples pair",
+ "Same grammar with two figures in the banner — a Tufte small-multiple. The mutable list and the immutable tuple side by side, captioned, between the same pair of cells."),
+ ("layout-banner-trio.html", "Layout · multiple banners across the walkthrough",
+ "The grammar at scale: a single-figure banner before the walkthrough, a pair-banner between two cells, a single-figure summary after the last cell. Multiple diagrams; cells never displaced."),
+ ("journey-runtime.html", "Journey · Runtime",
+ "Programs run statements, names refer to objects, expressions become method calls."),
+ ("journey-control-flow.html", "Journey · Control Flow",
+ "Branches choose paths; the figure depicts a value flowing through a predicate to one of several branches."),
+ ("journey-iteration.html", "Journey · Iteration",
+ "Loops repeat; the protocol behind for is iter() then next() until exhausted."),
+ ("journey-shapes.html", "Journey · Shapes",
+ "Containers answer different questions; reshaping is the everyday move; text becomes structured data."),
+ ("journey-interfaces.html", "Journey · Interfaces",
+ "Functions are named behavior; functions are values; classes bundle state with behavior."),
+ ("journey-types.html", "Journey · Types",
+ "Annotations describe but don't enforce; unions cover alternatives; generics preserve shape across calls."),
+ ("journey-reliability.html", "Journey · Reliability",
+ "Failure is explicit; resources have boundaries; concurrency outlives single expressions."),
+ ("journey-workers.html", "Journey · Workers",
+ "Workers-specific journey added on main; section figures pending design."),
+]
+
+
+def build_index() -> None:
+ items = "".join(
+ f'
'
+ for (slug, title, desc) in PROTOTYPES
+ )
+ body = f"""
+
+
+
Prototypes · cache: no-cache, must-revalidate
+
Visual explainer prototypes
+
Real example pages with their attached figures, plus the design-review pages. The example pages all use the production layout: a cell with an attached figure stacks prose, figure, and code vertically; cells without figures keep today's prose|code grid.
+
+
{items}
+
+"""
+ style = """
+ .prototype-list { list-style: none; padding: 0; margin: var(--space-4) 0 0; }
+ .prototype-list li { padding: var(--space-3) 0; border-bottom: 1px dashed var(--hairline-soft); }
+ .prototype-list li:first-child { border-top: 1px dashed var(--hairline-soft); }
+ .prototype-list strong { font-weight: 600; font-size: 1.05rem; }
+ .prototype-list .meta { margin: .25rem 0 0; max-width: 60ch; }
+"""
+ (OUT_DIR / "index.html").write_text(
+ page("Visual explainer prototypes", "All prototypes", style, body)
+ )
+
+
+# ─── Banner CSS (lives between cells, never inside) ───────────────────
+
+
+BANNER_CSS = """
+ /* Banner rows live BETWEEN cells, never inside them. The cell keeps
+ its prose|code 2-column grid intact; the banner spans the full
+ content width and holds 1+ figures via an auto-fit grid. Generous
+ vertical rhythm marks each banner as a teaching pause between
+ cells (Tufte's small-multiples, Knuth's interleaved literate
+ prose, algebrica's quiet figure+caption pairing). */
+ .cell-banner {
+ margin: var(--space-5) 0;
+ padding: var(--space-4) 0;
+ border-block: 1px dashed var(--hairline-soft);
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--space-4);
+ justify-items: center;
+ }
+ .cell-banner figure { margin: 0; padding: 0; max-width: 360px; }
+ .cell-banner svg { width: 100%; height: auto; display: block; }
+ .cell-banner figcaption {
+ margin-top: var(--space-2);
+ color: var(--muted);
+ font-size: .92rem;
+ font-style: italic;
+ max-width: 44ch;
+ }
+ /* Single figure: centered, generous breathing room. */
+ .cell-banner--1 figure { max-width: 440px; }
+"""
+
+
+# ─── Journey prototype ─────────────────────────────────────────────────
+
+
+JOURNEY_STYLE = """
+ .journey-section { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
+ @media (min-width: 900px) {
+ .journey-section { grid-template-columns: minmax(0, 1.4fr) minmax(220px, 320px); align-items: start; }
+ }
+ .journey-figure { margin: 0; padding: 0; }
+ .journey-figure svg { width: 100%; height: auto; display: block; }
+ .journey-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .85rem; font-style: italic; }
+"""
+
+
+# Each journey section maps to ONE figure that captures the section's
+# conceptual shift. Keys are section titles exactly as they appear in
+# JOURNEYS in app.py. Each value is (figure_name, caption).
+JOURNEY_SECTION_FIGURES: dict[str, tuple[str, str]] = {
+ # Runtime
+ "Start with executable evidence.": (
+ "program-output",
+ "Every page is a runnable program. The smallest mental model: source produces visible output.",
+ ),
+ "Separate value, identity, and absence.": (
+ "identity-and-equality",
+ "Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).",
+ ),
+ "Read expressions as object operations.": (
+ "operator-dispatch",
+ "Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.",
+ ),
+ # Control Flow
+ "Choose between paths.": (
+ "branch-fork",
+ "A value flows through a predicate to one of several branches.",
+ ),
+ "Name and shape decisions.": (
+ "naming-decisions",
+ "The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.",
+ ),
+ "Stop as soon as the answer is known.": (
+ "early-exit",
+ "The loop exits at the first match — break short-circuits the rest of the sequence.",
+ ),
+ # Iteration
+ "Choose the right loop shape.": (
+ "loop-repetition",
+ "Walk the sequence, run the body, return; the shape behind for and while.",
+ ),
+ "See the protocol behind `for`.": (
+ "iter-protocol",
+ "iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.",
+ ),
+ "Compose lazy value streams.": (
+ "lazy-stream",
+ "Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.",
+ ),
+ # Shapes
+ "Pick the container that matches the question.": (
+ "container-questions",
+ "Each container answers a different question: ordered, fixed, lookup, unique.",
+ ),
+ "Move between shapes deliberately.": (
+ "reshape-pipeline",
+ "Most everyday code reshapes data: one input, one transform, one new value.",
+ ),
+ "Cross text and data boundaries.": (
+ "text-data-boundary",
+ "Programs receive text and produce structured data; parsing makes the boundary explicit.",
+ ),
+ # Interfaces
+ "Start with functions as named behavior.": (
+ "function-signature",
+ "A function is the first abstraction boundary: arguments in, body, return value out.",
+ ),
+ "Use functions as values.": (
+ "function-as-value",
+ "Functions are first-class values. A second name binds to the same function object.",
+ ),
+ "Bundle behavior with state.": (
+ "class-with-state",
+ "Classes group fields and methods so data and behavior move together behind one interface.",
+ ),
+ # Types
+ "Keep runtime and static analysis separate.": (
+ "annotation-ghost",
+ "Annotations describe expected types for tools; the runtime accepts any object regardless.",
+ ),
+ "Describe realistic data shapes.": (
+ "union-types",
+ "A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.",
+ ),
+ "Scale annotations for reusable libraries.": (
+ "generic-preservation",
+ "A generic type variable preserves shape across a call: the same T flows in and out.",
+ ),
+ # Reliability
+ "Make failure explicit.": (
+ "exception-lanes",
+ "try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.",
+ ),
+ "Control resource and module boundaries.": (
+ "context-bowtie",
+ "A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.",
+ ),
+ "Handle operations that outlive one expression.": (
+ "async-swimlane",
+ "On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.",
+ ),
+}
+
+
+def build_journey(slug: str) -> None:
+ journey = JOURNEYS_BY_SLUG[slug]
+ sections_html = []
+ for section in journey["sections"]:
+ items = []
+ for item in section["items"]:
+ if item[0] == "example":
+ _, ex_slug, sentence = item
+ ex = get_example(ex_slug)
+ items.append(
+ f'
Every journey section's figure on one page so the set can be reasoned about as a whole. Score against the rubric; redesign anything below the 8.5 gate before shipping to /journeys/<slug>.
+
+ {''.join(blocks)}
+
+"""
+ (OUT_DIR / "journey-figures-gestalt.html").write_text(
+ page(
+ "Journey-figures gestalt",
+ "All 18 journey section figures grouped by journey, for uniform rubric review.",
+ JOURNEY_FIGURES_GESTALT_STYLE,
+ body,
+ )
+ )
+
+
+def main() -> None:
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+ build_index()
+ # ─── Banner-between grammar ─────────────────────────────────────
+ # Cells stay 2-column; banners between cells hold 1+ figures.
+ aliasing_caption = (
+ "Two names share one mutable list — appending through one name "
+ "changes the object visible through both."
+ )
+ tuple_caption = (
+ "By contrast, a tuple is frozen — its contents cannot change in "
+ "place, so aliasing carries no mutation hazard."
+ )
+ ex_mut = get_example("mutability")
+
+ # 1) one banner, one figure between cells 0 and 1
+ body = render_article(
+ ex_mut,
+ banners={"after-cell-0": banner(("aliasing-mutation", aliasing_caption))},
+ )
+ (OUT_DIR / "layout-banner-single.html").write_text(
+ page(
+ "Mutability · banner between cells (single figure)",
+ "Cells keep their prose|code grid; one figure sits in a banner row between cell 0 and cell 1.",
+ BANNER_CSS,
+ body,
+ )
+ )
+
+ # 2) one banner, two figures (small multiples) between cells 0 and 1
+ body = render_article(
+ ex_mut,
+ banners={
+ "after-cell-0": banner(
+ ("aliasing-mutation", aliasing_caption),
+ ("tuple-no-mutation", tuple_caption),
+ )
+ },
+ )
+ (OUT_DIR / "layout-banner-pair.html").write_text(
+ page(
+ "Mutability · banner with paired small-multiples",
+ "One banner with two figures — list mutates, tuple does not. Same grammar, just more figures in the slot.",
+ BANNER_CSS,
+ body,
+ )
+ )
+
+ # 3) multiple banners across the walkthrough
+ body = render_article(
+ ex_mut,
+ banners={
+ "before": banner(
+ ("aliasing-mutation", aliasing_caption),
+ ),
+ "after-cell-1": banner(
+ ("aliasing-mutation", "Mutable: change visible through any alias."),
+ ("tuple-no-mutation", "Immutable: aliases share a frozen value."),
+ ),
+ "after-walkthrough": banner(
+ ("tuple-no-mutation", "When in doubt, reach for the immutable shape."),
+ ),
+ },
+ )
+ (OUT_DIR / "layout-banner-trio.html").write_text(
+ page(
+ "Mutability · banners across the whole walkthrough",
+ "Demonstrates that the grammar accepts multiple banners at any position: lead-in figure, mid-walkthrough small-multiple pair, summary figure. Cells never reflow.",
+ BANNER_CSS,
+ body,
+ )
+ )
+
+ for journey_slug in (
+ "runtime",
+ "control-flow",
+ "iteration",
+ "shapes",
+ "interfaces",
+ "types",
+ "reliability",
+ "workers",
+ ):
+ build_journey(journey_slug)
+ build_journey_figures_gestalt()
+ written = sorted(p.name for p in OUT_DIR.iterdir() if p.is_file() and p.suffix == ".html")
+ print(f"wrote {len(written)} files to {OUT_DIR.relative_to(ROOT)}/:")
+ for name in written:
+ print(f" {name}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/fingerprint_assets.py b/scripts/fingerprint_assets.py
index b5d1099..6beb53e 100755
--- a/scripts/fingerprint_assets.py
+++ b/scripts/fingerprint_assets.py
@@ -41,6 +41,8 @@ def html_version(paths: dict[str, str]) -> str:
ROOT / "src" / "examples.py",
ROOT / "src" / "example_loader.py",
ROOT / "src" / "example_sources_data.py",
+ ROOT / "src" / "marginalia.py",
+ ROOT / "src" / "marginalia_grammar.py",
ROOT / "src" / "example_sources" / "manifest.toml",
*sorted((ROOT / "src" / "example_sources").glob("*.md")),
*sorted((ROOT / "src" / "templates").glob("*.html")),
@@ -67,7 +69,9 @@ def main() -> None:
"/editor.*.js\n"
" Cache-Control: public, max-age=31536000, immutable\n\n"
"/favicon.svg\n"
- " Cache-Control: public, max-age=31536000, immutable\n"
+ " Cache-Control: public, max-age=31536000, immutable\n\n"
+ "/prototyping/*\n"
+ " Cache-Control: no-cache, must-revalidate\n"
)
for name, path in paths.items():
print(f"{name}={path}")
diff --git a/src/app.py b/src/app.py
index e0c1d00..9045845 100644
--- a/src/app.py
+++ b/src/app.py
@@ -10,9 +10,11 @@
try:
from .asset_manifest import ASSET_PATHS
from .examples import EXAMPLES, EXAMPLES_BY_SLUG, PYTHON_VERSION, REFERENCE_URL
+ from .marginalia import render_for_anchor
except ImportError: # Cloudflare Python Workers import sibling modules from main's directory.
from asset_manifest import ASSET_PATHS
from examples import EXAMPLES, EXAMPLES_BY_SLUG, PYTHON_VERSION, REFERENCE_URL
+ from marginalia import render_for_anchor
class AppResponse:
@@ -721,12 +723,14 @@ def render_cell_output_flow_option(example):
return _layout(f'{example["title"]} literate cells option', content, description=f'Prototype layout for the {example["title"]} Python example.', path='/layout-options/cell-output-flow', include_editor=True)
-def _render_cell(step):
+def _render_cell(step, *, slug=None, index=None):
prose_html = "".join(f"
{render_inline(prose)}
" for prose in step["prose"])
source = html.escape(step["code"])
if step.get("kind") == "unsupported":
return f'
{prose_html}
Standard Python
{source}
'
- return f'
{prose_html}
Source
{source}
Output
{html.escape(step["output"])}
'
+ figure_html = render_for_anchor(slug, f"cell-{index}") if slug is not None and index is not None else ""
+ css_class = "lesson-step lp-cell" + (" has-figure" if figure_html else "")
+ return f'
{prose_html}
{figure_html}
Source
{source}
Output
{html.escape(step["output"])}
'
def render_example_page(example, output=None, code=None, execution_time_ms=None):
@@ -747,7 +751,7 @@ def render_example_page(example, output=None, code=None, execution_time_ms=None)
if next_example
else ""
)
- walkthrough_html = "".join(_render_cell(step) for step in walkthrough)
+ walkthrough_html = "".join(_render_cell(step, slug=example["slug"], index=i) for i, step in enumerate(walkthrough))
notes_html = "".join(f"
{note}
" for note in notes)
see_also_examples = [get_example(slug) for slug in example.get("see_also", [])]
see_also_links = "".join(
diff --git a/src/asset_manifest.py b/src/asset_manifest.py
index 7764327..d4b5c76 100644
--- a/src/asset_manifest.py
+++ b/src/asset_manifest.py
@@ -1,3 +1,3 @@
# Generated by scripts/fingerprint_assets.py. Do not edit by hand.
-ASSET_PATHS = {'SITE_CSS': '/site.5f6f7da7c305.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'}
-HTML_CACHE_VERSION = '781554af9745'
+ASSET_PATHS = {'SITE_CSS': '/site.d666d8585635.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'}
+HTML_CACHE_VERSION = '4e7ccbe1f128'
diff --git a/src/marginalia.py b/src/marginalia.py
new file mode 100644
index 0000000..5323004
--- /dev/null
+++ b/src/marginalia.py
@@ -0,0 +1,448 @@
+"""Marginalia attachments and figure registry.
+
+Authors of example markdown never touch this file. The project owner curates
+named figures here and attaches them to cells via slug + anchor.
+
+Anchors today:
+ "cell-0", "cell-1", … each literate-program cell, zero-indexed.
+
+(Reserved for future positions: `intro`, `notes`, `playground`. Banner
+positions `before` / `after-cell-N` / `after-walkthrough` are documented
+in the spec but currently exercised only by scripts/build_prototypes.py.)
+
+The renderer in app.py emits each attached figure inline inside its cell
+as ``. The cell switches to single-column
+stacking (prose, figure, code) via the `has-figure` class so the figure
+sits between prose and code-stack without disturbing cells without
+figures, which keep today's prose|code 2-column grid.
+
+See docs/visual-explainer-spec.md for the full design and
+docs/journey-visualisation-rubric.md for the figure-quality rubric.
+"""
+
+from __future__ import annotations
+
+import html
+from typing import Callable
+
+try:
+ from .marginalia_grammar import Canvas
+except ImportError: # Cloudflare Workers import siblings without the package prefix.
+ from marginalia_grammar import Canvas
+
+
+# ─── Named figures ─────────────────────────────────────────────────────
+
+
+def aliasing_mutation(c: Canvas) -> None:
+ """Two names binding to one mutable list, before and after a mutation."""
+ c.tag(0, 12, "before")
+ c.name_box(0, 18, "first")
+ c.name_box(0, 48, "second")
+ c.closed_arrow(60, 30, 86, 46, emphasis=False)
+ c.closed_arrow(60, 60, 86, 46, emphasis=False)
+ c.object_box(88, 32, "", '["python"]', w=88, h=28)
+
+ c.tag(0, 100, "after append")
+ c.name_box(0, 108, "first")
+ c.name_box(0, 138, "second")
+ c.closed_arrow(60, 120, 86, 136, emphasis=False)
+ c.closed_arrow(60, 150, 86, 136, emphasis=False)
+ c.object_box(88, 122, "", '["python","workers"]', w=130, h=28)
+
+
+def tuple_no_mutation(c: Canvas) -> None:
+ """The contrast: two names binding to one immutable tuple — no mutation possible."""
+ c.tag(0, 12, "tuple — frozen")
+ c.name_box(0, 18, "first")
+ c.name_box(0, 48, "second")
+ c.closed_arrow(60, 30, 86, 46, emphasis=False)
+ c.closed_arrow(60, 60, 86, 46, emphasis=False)
+ c.object_box(88, 32, "", '("python",)', w=110, h=28)
+
+ c.tag(0, 100, "no .append")
+ c.name_box(0, 108, "first")
+ c.name_box(0, 138, "second")
+ c.closed_arrow(60, 120, 86, 136, emphasis=False)
+ c.closed_arrow(60, 150, 86, 136, emphasis=False)
+ c.object_box(88, 122, "", '("python",)', w=110, h=28)
+ c.label(150, 170, "tuples raise AttributeError", anchor="middle")
+
+
+def iterator_unroll(c: Canvas) -> None:
+ """Four passes of next() over a sequence, with a caret advancing each row."""
+ items = list("abcd")
+ for i in range(4):
+ y = 8 + i * 30
+ c.cells(20, y, items)
+ c.caret(20 + i * 24 + 12, y)
+ suffix = " — last" if i == 3 else ""
+ c.label(124, y + 16, f"next(){suffix}")
+
+
+def scope_rings(c: Canvas) -> None:
+ """LEGB lookup: nested rings, lookup path traced from innermost outward."""
+ c.frame(8, 6, 200, 100, label="B · built-in")
+ c.frame(28, 22, 160, 76, label="G · global")
+ c.frame(48, 38, 120, 52, label="E · enclosing")
+ c.frame(68, 54, 80, 28, label="L · local")
+ c.dot(108, 68, emphasis=True)
+ c.dashed(108, 78, 108, 100)
+
+
+def closure_cell(c: Canvas) -> None:
+ """Inner function references a cell created by the outer call.
+
+ Outer scope holds the `cell` (the captured `factor`); the inner function
+ keeps a reference into it, so the call survives the outer return.
+ """
+ c.frame(0, 16, 240, 96, label="make_multiplier")
+ c.tag(16, 32, "cell")
+ c.cell(16, 38, "factor=2", w=84, h=22)
+ c.frame(112, 38, 122, 60, label="multiply")
+ c.label(173, 76, "uses cell", anchor="middle")
+ c.closed_arrow(128, 76, 102, 56, emphasis=True)
+
+
+def slice_ruler(c: Canvas) -> None:
+ """Adjacent slices [:3] and [3:] split a sequence at index 3."""
+ items = list("abcdef")
+ for i, v in enumerate(items):
+ c.cell(20 + i * 32, 30, v, w=32, h=24)
+ c.hairline(20, 64, 20 + 6 * 32, 64)
+ for i in range(7):
+ c.tick(20 + i * 32, 64)
+ c.label(20 + i * 32, 76, str(i), anchor="middle")
+ # bracket above for [:3]
+ c.dashed(20, 22, 20, 28)
+ c.dashed(20 + 3 * 32, 22, 20 + 3 * 32, 28)
+ c.dashed(20, 22, 20 + 3 * 32, 22)
+ c.label(20 + 1.5 * 32, 18, "[:3]", anchor="middle")
+ # bracket below for [3:]
+ c.dashed(20 + 3 * 32, 92, 20 + 3 * 32, 86)
+ c.dashed(20 + 6 * 32, 92, 20 + 6 * 32, 86)
+ c.dashed(20 + 3 * 32, 92, 20 + 6 * 32, 92)
+ c.label(20 + 4.5 * 32, 104, "[3:]", anchor="middle")
+
+
+def branch_fork(c: Canvas) -> None:
+ """Make decisions explicitly: a value flows through a predicate to one branch."""
+ c.cell(0, 36, "value", w=52, h=22)
+ c.closed_arrow(52, 47, 80, 47, emphasis=False)
+ c.node(98, 47, "?", r=14)
+ c.closed_arrow(110, 38, 158, 14, emphasis=False)
+ c.closed_arrow(110, 56, 158, 80, emphasis=False)
+ c.cell(160, 4, "case A", w=68, h=22)
+ c.cell(160, 70, "case B", w=68, h=22)
+
+
+def loop_repetition(c: Canvas) -> None:
+ """The shape of a loop: walk the sequence, run the body, return."""
+ c.cells(0, 28, ["a", "b", "c", "d"], w=28)
+ c.caret(0 + 14, 28)
+ c.closed_arrow(116, 40, 142, 40, emphasis=False)
+ c.cell(144, 28, "body", w=56, h=24)
+ c.dashed(172, 54, 172, 76)
+ c.dashed(172, 76, 14, 76)
+ c.dashed(14, 76, 14, 54)
+ c.closed_arrow(14, 56, 14, 40, emphasis=True)
+
+
+def iter_protocol(c: Canvas) -> None:
+ """The hidden machinery behind for: iterable → iter() → next() … values."""
+ c.object_box(0, 30, "iterable", "[a,b,c]", w=82, h=28)
+ c.dashed(84, 44, 118, 44)
+ c.label(101, 38, "iter()", anchor="middle")
+ c.object_box(120, 30, "iterator", "", w=80, h=28)
+ c.closed_arrow(200, 44, 232, 44, emphasis=True)
+ c.label(216, 38, "next()", anchor="middle")
+ c.cells(234, 32, ["a", "b", "c"], w=22)
+
+
+# ─── Runtime journey ──────────────────────────────────────────────────
+
+
+def program_output(c: Canvas) -> None:
+ """Runtime · Start with executable evidence: a program produces visible output."""
+ c.cell(0, 28, 'print("…")', w=92, h=28)
+ c.closed_arrow(92, 42, 124, 42, emphasis=False)
+ c.label(108, 36, "stdout", anchor="middle")
+ c.cell(126, 28, "hello world", w=110, h=28, soft=True)
+
+
+def identity_and_equality(c: Canvas) -> None:
+ """Runtime · Separate value, identity, and absence: same object vs equal objects."""
+ c.tag(0, 14, "is + ==")
+ c.name_box(0, 22, "a")
+ c.name_box(0, 50, "b")
+ c.closed_arrow(60, 34, 100, 50, emphasis=False)
+ c.closed_arrow(60, 62, 100, 50, emphasis=False)
+ c.cell(102, 38, "[1,2]", w=46, h=24, soft=True)
+ c.tag(170, 14, "== only")
+ c.name_box(170, 22, "a")
+ c.name_box(170, 50, "b")
+ c.closed_arrow(230, 34, 252, 34, emphasis=False)
+ c.closed_arrow(230, 62, 252, 62, emphasis=False)
+ c.cell(254, 22, "[1,2]", w=44, h=22, soft=True)
+ c.cell(254, 52, "[1,2]", w=44, h=22, soft=True)
+
+
+def operator_dispatch(c: Canvas) -> None:
+ """Runtime · Read expressions as object operations: syntax becomes a method call."""
+ c.cell(0, 30, "a + b", w=70, h=28)
+ c.closed_arrow(70, 44, 116, 44, emphasis=True)
+ c.label(93, 36, "dispatches", anchor="middle")
+ c.cell(118, 30, "a.__add__(b)", w=140, h=28, soft=True)
+
+
+# ─── Shapes journey ───────────────────────────────────────────────────
+
+
+def container_questions(c: Canvas) -> None:
+ """Shapes · Pick the container that matches the question — each answers a different one."""
+ pairs = [
+ ("list", "[a,b]", "ordered"),
+ ("tuple", "(a,b)", "fixed"),
+ ("dict", "{k:v}", "lookup"),
+ ("set", "{a,b}", "unique"),
+ ]
+ for i, (tag, val, q) in enumerate(pairs):
+ x = i * 70
+ c.object_box(x, 26, tag, val, w=64, h=28)
+ c.label(x + 32, 70, q, anchor="middle")
+
+
+def reshape_pipeline(c: Canvas) -> None:
+ """Shapes · Move between shapes deliberately: one input, one transform, one result."""
+ c.cell(0, 30, "[3,1,4]", w=80, h=28)
+ c.closed_arrow(80, 44, 120, 44, emphasis=True)
+ c.label(100, 36, "sorted", anchor="middle")
+ c.cell(122, 30, "[1,3,4]", w=80, h=28, soft=True)
+
+
+def text_data_boundary(c: Canvas) -> None:
+ """Shapes · Cross text and data boundaries: text in, structured value out."""
+ c.cell(0, 30, '"42"', w=70, h=28)
+ c.tag(0, 24, "text")
+ c.closed_arrow(70, 44, 110, 44, emphasis=True)
+ c.label(90, 36, "parse", anchor="middle")
+ c.object_box(112, 30, "int", "42", w=58, h=28)
+
+
+# ─── Interfaces journey ───────────────────────────────────────────────
+
+
+def function_signature(c: Canvas) -> None:
+ """Interfaces · Functions as named behavior: input → body → output."""
+ c.closed_arrow(0, 44, 32, 44, emphasis=False)
+ c.label(16, 36, "args", anchor="middle")
+ c.frame(34, 26, 110, 36, label="def f(...)")
+ c.closed_arrow(144, 44, 176, 44, emphasis=False)
+ c.label(160, 36, "return", anchor="middle")
+
+
+def function_as_value(c: Canvas) -> None:
+ """Interfaces · Functions as values: name binds to a function object."""
+ c.frame(0, 22, 80, 36, label="fn")
+ c.mono(40, 44, "def f")
+ c.closed_arrow(80, 40, 116, 40, emphasis=True)
+ c.cell(118, 26, "g = fn", w=80, h=28, soft=True)
+ c.label(105, 76, "second name binds to the same function", anchor="middle")
+
+
+def class_with_state(c: Canvas) -> None:
+ """Interfaces · Bundle behavior with state: a class groups fields and methods."""
+ c.frame(0, 8, 150, 92, label="class Box")
+ c.tag(12, 26, "state")
+ c.cell(12, 32, "x · y", w=126, h=22)
+ c.tag(12, 64, "methods")
+ c.cell(12, 70, "move(...)", w=126, h=22)
+
+
+# ─── Types journey ────────────────────────────────────────────────────
+
+
+def annotation_ghost(c: Canvas) -> None:
+ """Types · Keep runtime and static separate: annotations describe; runtime accepts any object."""
+ c.mono(0, 50, "def f(x: int, y: str) -> bool: …", anchor="start")
+ c.dashed(54, 42, 76, 42)
+ c.dashed(102, 42, 124, 42)
+ c.dashed(150, 42, 192, 42)
+ c.label(96, 80, "annotations describe; runtime accepts any object", anchor="middle")
+
+
+def union_types(c: Canvas) -> None:
+ """Types · Describe realistic shapes: a slot accepting one of several types."""
+ c.tag(0, 14, "x: int | str | None")
+ c.cell(0, 22, "x", w=44, h=28)
+ c.closed_arrow(44, 36, 80, 14, emphasis=False)
+ c.closed_arrow(44, 36, 80, 36, emphasis=False)
+ c.closed_arrow(44, 36, 80, 58, emphasis=False)
+ c.cell(82, 4, "int", w=70, h=22, soft=True)
+ c.cell(82, 26, "str", w=70, h=22, soft=True)
+ c.cell(82, 48, "None", w=70, h=22, soft=True)
+
+
+def generic_preservation(c: Canvas) -> None:
+ """Types · Scale annotations: a generic preserves the input type through the call."""
+ c.cell(0, 30, "T", w=36, h=28, soft=True)
+ c.closed_arrow(36, 44, 72, 44, emphasis=True)
+ c.frame(74, 26, 100, 36, label="fn[T]")
+ c.closed_arrow(174, 44, 210, 44, emphasis=True)
+ c.cell(212, 30, "T", w=36, h=28, soft=True)
+ c.label(124, 78, "the same T flows in and out", anchor="middle")
+
+
+# ─── Reliability journey ──────────────────────────────────────────────
+
+
+def exception_lanes(c: Canvas) -> None:
+ """Reliability · Make failure explicit: try/except/else/finally as parallel lanes."""
+ ys = [(20, "try"), (40, "except"), (60, "else"), (80, "finally")]
+ path = [(50, 20), (110, 20), (130, 60), (200, 60), (220, 80), (290, 80)]
+ c.lanes(ys, x0=40, x1=300, path=path)
+
+
+def context_bowtie(c: Canvas) -> None:
+ """Reliability · Control resource boundaries: enter → body → exit, with raise routed through exit."""
+ c.node(20, 48, "in", r=14)
+ c.closed_arrow(34, 48, 76, 48, emphasis=False)
+ c.cell(78, 36, "body", w=86, h=24)
+ c.closed_arrow(164, 48, 206, 48, emphasis=False)
+ c.node(220, 48, "out", r=14)
+ c.dashed(122, 60, 210, 48)
+ c.label(170, 84, "raise still routes through __exit__", anchor="middle")
+
+
+def async_swimlane(c: Canvas) -> None:
+ """Reliability · Operations that outlive one expression: loop and coroutine swap on await."""
+ c.lane(28, x0=20, x1=270, label="loop")
+ c.lane(64, x0=20, x1=270, label="coro")
+ c.cell(40, 58, "", w=34, h=12)
+ c.dashed(76, 64, 110, 28)
+ c.cell(112, 22, "", w=58, h=12)
+ c.dashed(172, 28, 206, 64)
+ c.cell(208, 58, "", w=34, h=12)
+ c.label(95, 16, "await", anchor="middle")
+ c.label(190, 16, "resume", anchor="middle")
+
+
+# ─── Control flow journey ─────────────────────────────────────────────
+
+
+def naming_decisions(c: Canvas) -> None:
+ """Control flow · Name and shape decisions: the walrus binds while comparing."""
+ c.cell(0, 30, "len(xs)", w=80, h=28)
+ c.closed_arrow(80, 44, 110, 44, emphasis=True)
+ c.label(95, 36, ":=", anchor="middle")
+ c.cell(112, 4, "n", w=40, h=22, soft=True)
+ c.tag(112, 0, "name")
+ c.cell(112, 50, "value", w=78, h=22, soft=True)
+ c.dashed(152, 16, 196, 16)
+ c.cell(198, 4, "n > 10", w=72, h=22)
+ c.label(135, 90, "one expression: bind a name and use the value", anchor="middle")
+
+
+def early_exit(c: Canvas) -> None:
+ """Control flow · Stop as soon as the answer is known: the loop exits on first match."""
+ c.cells(0, 28, ["a", "b", "c", "d", "e"], w=28)
+ c.dot(70, 40, emphasis=True)
+ c.closed_arrow(70, 56, 70, 78, emphasis=True)
+ c.cell(40, 80, "found · break", w=80, h=24, soft=True)
+ c.label(70, 14, "first match", anchor="middle")
+
+
+# ─── Iteration journey ────────────────────────────────────────────────
+
+
+def lazy_stream(c: Canvas) -> None:
+ """Iteration · Compose lazy value streams: filter and map flow values without materialising."""
+ c.object_box(0, 26, "source", "[a,b,c]", w=78, h=24)
+ c.dashed(78, 38, 102, 38)
+ c.object_box(104, 26, "filter", "x>0", w=68, h=24)
+ c.dashed(172, 38, 196, 38)
+ c.object_box(198, 26, "map", "x*2", w=64, h=24)
+ c.closed_arrow(262, 38, 294, 38, emphasis=True)
+ c.label(278, 30, "next()", anchor="middle")
+ c.label(150, 70, "values flow lazily — nothing materialised", anchor="middle")
+
+
+# Registry: figure_name -> (paint_fn, viewbox_w, viewbox_h)
+FIGURES: dict[str, tuple[Callable[[Canvas], None], int, int]] = {
+ "aliasing-mutation": (aliasing_mutation, 220, 175),
+ "tuple-no-mutation": (tuple_no_mutation, 220, 185),
+ "iterator-unroll": (iterator_unroll, 220, 130),
+ "scope-rings": (scope_rings, 216, 116),
+ "closure-cell": (closure_cell, 240, 120),
+ "slice-ruler": (slice_ruler, 232, 120),
+ "branch-fork": (branch_fork, 232, 100),
+ "loop-repetition": (loop_repetition, 204, 90),
+ "iter-protocol": (iter_protocol, 304, 70),
+ # Runtime
+ "program-output": (program_output, 240, 80),
+ "identity-and-equality": (identity_and_equality, 304, 96),
+ "operator-dispatch": (operator_dispatch, 260, 70),
+ # Shapes
+ "container-questions": (container_questions, 280, 88),
+ "reshape-pipeline": (reshape_pipeline, 204, 80),
+ "text-data-boundary": (text_data_boundary, 172, 70),
+ # Interfaces
+ "function-signature": (function_signature, 188, 80),
+ "function-as-value": (function_as_value, 200, 92),
+ "class-with-state": (class_with_state, 152, 108),
+ # Types
+ "annotation-ghost": (annotation_ghost, 220, 96),
+ "union-types": (union_types, 156, 80),
+ "generic-preservation": (generic_preservation, 250, 92),
+ # Reliability
+ "exception-lanes": (exception_lanes, 320, 100),
+ "context-bowtie": (context_bowtie, 244, 96),
+ "async-swimlane": (async_swimlane, 280, 84),
+ # Control flow + Iteration coverage gap (see audit)
+ "naming-decisions": (naming_decisions, 274, 110),
+ "early-exit": (early_exit, 144, 116),
+ "lazy-stream": (lazy_stream, 300, 84),
+}
+
+
+# ─── Attachments ───────────────────────────────────────────────────────
+
+# slug -> [(anchor, figure_name, caption_or_None), …]
+ATTACHMENTS: dict[str, list[tuple[str, str, str | None]]] = {
+ "mutability": [
+ (
+ "cell-0",
+ "aliasing-mutation",
+ "Two names share one mutable list — appending through one name changes the object visible through both.",
+ ),
+ ],
+}
+
+
+# ─── Render helpers ────────────────────────────────────────────────────
+
+
+def _render_svg(figure_name: str) -> str:
+ paint, w, h = FIGURES[figure_name]
+ canvas = Canvas(w=w, h=h)
+ paint(canvas)
+ return canvas.to_svg()
+
+
+def render_for_anchor(slug: str, anchor: str) -> str:
+ """HTML for inline figures inside an anchor block. Empty if none.
+
+ Returns one or more `` elements. The cell
+ that hosts the figure switches to single-column layout via the
+ has-figure class added by the renderer, so prose, figure, and code
+ stack vertically.
+ """
+ attachments = ATTACHMENTS.get(slug, [])
+ matched = [(name, caption) for (a, name, caption) in attachments if a == anchor]
+ if not matched:
+ return ""
+ parts = []
+ for name, caption in matched:
+ cap = f"{html.escape(caption)}" if caption else ""
+ parts.append(f'{_render_svg(name)}{cap}')
+ return "".join(parts)
diff --git a/src/marginalia_grammar.py b/src/marginalia_grammar.py
new file mode 100644
index 0000000..4b5bef3
--- /dev/null
+++ b/src/marginalia_grammar.py
@@ -0,0 +1,350 @@
+"""Marginalia grammar — primitives for the Python By Example diagram set.
+
+The grammar enforces a single visual language. Cards compose figures from
+WORDS and PHRASES; metrics, palette, stroke weights, and typography are
+locked at module level. There is no escape hatch for raw SVG.
+
+Hierarchy:
+ TOKENS — atomic marks. Never called directly by cards.
+ WORDS — composable shapes (name_box, object_box, cell, register, …).
+ PHRASES — recurring multi-word constructions (bind, dispatch, lanes, …).
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+from typing import Callable
+
+# ─── Palette ───────────────────────────────────────────────────────────
+# Aligned with public/site.css design tokens. These are the only
+# colours figures may use; cards never pick a colour directly.
+# INK site --text (warm dark brown)
+# INK_SOFT site --muted (--text at 70%)
+# EMPHASIS site --accent (the brand orange)
+# SOFT_FILL site --accent-soft (--accent at ~8%)
+INK = "#521000"
+INK_SOFT = "rgba(82, 16, 0, 0.7)"
+EMPHASIS = "#FF4801"
+# A neutral warm tint built from --text at 5%. Object boxes need to read as
+# quiet containers; tinting them with --accent-soft made every box look
+# highlighted, which broke the "emphasis is scarce" rule.
+SOFT_FILL = "rgba(82, 16, 0, 0.05)"
+
+# ─── Stroke weights ────────────────────────────────────────────────────
+W_HAIRLINE = 0.6
+W_STROKE = 1.0
+W_EMPHASIS = 1.4
+W_GHOST = 0.5
+GHOST_OPACITY = 0.4
+DASH = "2 2"
+
+# ─── Locked geometry — never override per-card ─────────────────────────
+GAP_S = 8
+GAP_L = 16
+DOT_R = 2.5
+TICK_LEN = 6
+NODE_R = 14
+ARROW_OPEN = 5
+ARROW_CLOSED = 7
+
+NAME_W = 60
+NAME_H = 24
+OBJECT_W = 80
+OBJECT_H = 32
+CELL = 24
+WORD_W = 44
+
+# ─── Typography ────────────────────────────────────────────────────────
+FONT_SERIF = "'Iowan Old Style', Charter, Georgia, serif"
+FONT_MONO = "'JetBrains Mono', 'IBM Plex Mono', Menlo, monospace"
+FONT_SANS = "-apple-system, 'Source Sans Pro', sans-serif"
+SIZE_BODY = 11
+SIZE_MONO = 10
+SIZE_SMALL = 9
+SIZE_TAG = 8
+BASELINE = 4 # add to box-center y to render text vertically centered
+
+
+@dataclass
+class Canvas:
+ w: int = 320
+ h: int = 110
+ parts: list[str] = field(default_factory=list)
+
+ # ── tokens (private; cards should not reach for these) ────────────
+ def _add(self, s: str) -> None:
+ self.parts.append(s)
+
+ def _line(self, x1, y1, x2, y2, *, color=INK, weight=W_STROKE, dash=None, opacity=1.0):
+ attrs = [f'x1="{x1}"', f'y1="{y1}"', f'x2="{x2}"', f'y2="{y2}"',
+ f'stroke="{color}"', f'stroke-width="{weight}"']
+ if dash:
+ attrs.append(f'stroke-dasharray="{dash}"')
+ if opacity < 1.0:
+ attrs.append(f'opacity="{opacity}"')
+ self._add(f"")
+
+ def hairline(self, x1, y1, x2, y2):
+ self._line(x1, y1, x2, y2, weight=W_HAIRLINE)
+
+ def stroke(self, x1, y1, x2, y2):
+ self._line(x1, y1, x2, y2)
+
+ def ghost(self, x1, y1, x2, y2):
+ self._line(x1, y1, x2, y2, weight=W_GHOST, opacity=GHOST_OPACITY)
+
+ def dashed(self, x1, y1, x2, y2):
+ self._line(x1, y1, x2, y2, weight=W_HAIRLINE, dash=DASH)
+
+ def dot(self, x, y, *, emphasis=False):
+ self._add(f'')
+
+ def tick(self, x, y, *, length=TICK_LEN):
+ self.hairline(x, y - length / 2, x, y + length / 2)
+
+ def open_arrow(self, x1, y1, x2, y2):
+ """Axis-style arrow: hairline ending in tiny open V."""
+ self.hairline(x1, y1, x2, y2)
+ dx, dy = x2 - x1, y2 - y1
+ L = math.hypot(dx, dy) or 1
+ ux, uy = dx / L, dy / L
+ bx, by = x2 - ARROW_OPEN * ux, y2 - ARROW_OPEN * uy
+ px, py = -uy * (ARROW_OPEN / 2), ux * (ARROW_OPEN / 2)
+ self._add(
+ f''
+ )
+
+ def closed_arrow(self, x1, y1, x2, y2, *, emphasis=False):
+ """Becomes-this / dispatches-to: line + filled wedge.
+
+ Defaults to ink. Pass emphasis=True only for THE single live arrow
+ per figure — the one mark the surrounding prose explicitly names.
+ Saturated --accent strokes everywhere break visual scarcity.
+ """
+ color = EMPHASIS if emphasis else INK
+ weight = W_EMPHASIS if emphasis else W_STROKE
+ dx, dy = x2 - x1, y2 - y1
+ L = math.hypot(dx, dy) or 1
+ ux, uy = dx / L, dy / L
+ end_x, end_y = x2 - ARROW_CLOSED * ux, y2 - ARROW_CLOSED * uy
+ self._line(x1, y1, end_x, end_y, color=color, weight=weight)
+ bx, by = x2 - ARROW_CLOSED * ux, y2 - ARROW_CLOSED * uy
+ px, py = -uy * (ARROW_CLOSED / 2.5), ux * (ARROW_CLOSED / 2.5)
+ self._add(f'')
+
+ # ── text ──────────────────────────────────────────────────────────
+ def _text(self, x, y, s, *, family, size, anchor, color, italic=False, tracking=None):
+ attrs = [f'x="{x}"', f'y="{y}"', f'font-family="{family}"', f'font-size="{size}"',
+ f'fill="{color}"', f'text-anchor="{anchor}"']
+ if italic:
+ attrs.append('font-style="italic"')
+ if tracking:
+ attrs.append(f'letter-spacing="{tracking}"')
+ self._add(f"{s}")
+
+ def mono(self, x, y, s, *, anchor="middle", size=SIZE_MONO, color=INK):
+ self._text(x, y, s, family=FONT_MONO, size=size, anchor=anchor, color=color)
+
+ def ident(self, x, y, s, *, anchor="middle", color=INK):
+ self._text(x, y, s, family=FONT_SERIF, size=SIZE_BODY, anchor=anchor, color=color, italic=True)
+
+ def label(self, x, y, s, *, anchor="start"):
+ self._text(x, y, s, family=FONT_SANS, size=SIZE_SMALL, anchor=anchor, color=INK_SOFT)
+
+ def tag(self, x, y, s, *, anchor="start"):
+ self._text(x, y, s.upper(), family=FONT_SANS, size=SIZE_TAG,
+ anchor=anchor, color=INK_SOFT, tracking="0.5")
+
+ # ── words ─────────────────────────────────────────────────────────
+ def name_box(self, x, y, name):
+ """Open rect with italic identifier. Returns right-edge midpoint."""
+ self._add(
+ f''
+ )
+ self.ident(x + NAME_W / 2, y + NAME_H / 2 + BASELINE, name)
+ return (x + NAME_W, y + NAME_H / 2)
+
+ def object_box(self, x, y, type_tag, value, *, w=OBJECT_W, h=OBJECT_H, soft=True):
+ """Filled rect with type tag upper-left and value centered. Returns left-edge midpoint."""
+ fill = SOFT_FILL if soft else "none"
+ self._add(
+ f''
+ )
+ if type_tag:
+ self.tag(x + 4, y - 3, type_tag)
+ if value:
+ self.mono(x + w / 2, y + h / 2 + BASELINE, value)
+ return (x, y + h / 2)
+
+ def cell(self, x, y, content="", *, w=CELL, h=CELL, ghost=False, soft=False):
+ weight = W_GHOST if ghost else W_STROKE
+ opacity = f' opacity="{GHOST_OPACITY}"' if ghost else ""
+ fill = SOFT_FILL if soft else "none"
+ self._add(
+ f''
+ )
+ if content:
+ self.mono(x + w / 2, y + h / 2 + BASELINE, content)
+
+ def cells(self, x, y, items, *, w=CELL, h=CELL):
+ """Row of cells. items is a list of strings (use '' for empty)."""
+ for i, c in enumerate(items):
+ self.cell(x + i * w, y, c, w=w, h=h)
+ return (x, y, x + len(items) * w, y + h)
+
+ def caret(self, x, y_top):
+ """Triangular caret pointing down into the cell whose top is at y_top."""
+ self._add(f'')
+
+ def register(self, x, y, w, *, divisions=None, between=False):
+ """Hairline with regular ticks."""
+ self.hairline(x, y, x + w, y)
+ if divisions is None:
+ return
+ step = w / divisions
+ for i in range(divisions + 1):
+ self.tick(x + i * step, y)
+ if between:
+ for i in range(divisions):
+ self.hairline(x + i * step + step / 2, y - TICK_LEN / 3,
+ x + i * step + step / 2, y + TICK_LEN / 3)
+
+ def node(self, x, y, label, *, r=NODE_R, ghost=False):
+ weight = W_GHOST if ghost else W_STROKE
+ opacity = f' opacity="{GHOST_OPACITY}"' if ghost else ""
+ self._add(
+ f''
+ )
+ self.mono(x, y + BASELINE, label, size=SIZE_SMALL)
+
+ def frame(self, x, y, w, h, *, label=None, ghost=False):
+ weight = W_GHOST if ghost else W_STROKE
+ opacity = f' opacity="{GHOST_OPACITY}"' if ghost else ""
+ self._add(
+ f''
+ )
+ if label:
+ self.tag(x + 6, y - 3, label)
+
+ def gate(self, x, y_top, y_bot):
+ """Vertical EMPHASIS line crossing a ribbon."""
+ self._line(x, y_top, x, y_bot, color=EMPHASIS, weight=W_EMPHASIS)
+
+ def ribbon(self, x, y, w, *, h=30, gates=(), soft_segments=()):
+ """Horizontal track with optional gates and soft fills."""
+ for x0, x1 in soft_segments:
+ self._add(
+ f''
+ )
+ self._add(
+ f''
+ )
+ for gx in gates:
+ self.gate(gx, y, y + h)
+
+ def lane(self, y, *, x0=20, x1=300, label=None):
+ """Horizontal hairline used for parallel dispatch lanes."""
+ self.hairline(x0, y, x1, y)
+ if label:
+ self.tag(x0 - 6, y + 3, label, anchor="end")
+
+ # ── phrases ───────────────────────────────────────────────────────
+ def bind(self, x, y, name, type_tag, value, *, object_w=OBJECT_W, gap=40):
+ """name → object. The foundational picture."""
+ nx, ny = self.name_box(x, y + (OBJECT_H - NAME_H) / 2, name)
+ ox, oy = self.object_box(nx + gap, y, type_tag, value, w=object_w)
+ self.closed_arrow(nx + 2, ny, ox - 2, oy)
+ return (x, y, nx + gap + object_w, y + OBJECT_H)
+
+ def dispatch(self, x, y, src, dst, *, src_w=70, dst_w=120):
+ """Source form → method form."""
+ self.object_box(x, y, "", src, w=src_w, soft=False)
+ self.closed_arrow(x + src_w + 4, y + OBJECT_H / 2, x + src_w + 36, y + OBJECT_H / 2)
+ self.object_box(x + src_w + 40, y, "", dst, w=dst_w, soft=True)
+
+ def connect(self, ax, ay, ar, bx, by, br, *, kind="stroke", offset=0):
+ """Edge between two circles, terminating tangentially at each boundary.
+
+ Endpoints are computed from the line of centers so the edge meets each
+ circle exactly — never short of it, never inside it.
+
+ kind: "stroke" | "ghost" | "dashed" | "arrow" | "emphasis"
+ offset: extra gap past each circle, in viewBox units
+ (0 for tree edges, 2 for state-machine arrows)
+ """
+ dx, dy = bx - ax, by - ay
+ L = math.hypot(dx, dy) or 1
+ ux, uy = dx / L, dy / L
+ sx = ax + (ar + offset) * ux
+ sy = ay + (ar + offset) * uy
+ ex = bx - (br + offset) * ux
+ ey = by - (br + offset) * uy
+ if kind == "stroke":
+ self.stroke(sx, sy, ex, ey)
+ elif kind == "ghost":
+ self.ghost(sx, sy, ex, ey)
+ elif kind == "dashed":
+ self.dashed(sx, sy, ex, ey)
+ elif kind == "arrow":
+ self.closed_arrow(sx, sy, ex, ey, emphasis=False)
+ elif kind == "emphasis":
+ self.closed_arrow(sx, sy, ex, ey, emphasis=True)
+ else:
+ raise ValueError(f"unknown connect kind: {kind!r}")
+
+ def lanes(self, ys_labels, *, x0=40, x1=300, path=None):
+ """Stack of parallel lanes; optional traced emphasis path through them."""
+ for y, lab in ys_labels:
+ self.lane(y, x0=x0, x1=x1, label=lab)
+ if path:
+ d = " ".join(("M" if i == 0 else "L") + f"{px},{py}" for i, (px, py) in enumerate(path))
+ self._add(f'')
+ self.dot(path[-1][0], path[-1][1], emphasis=True)
+
+ # ── render ────────────────────────────────────────────────────────
+ def to_svg(self) -> str:
+ return (
+ f'"
+ )
+
+
+@dataclass
+class Card:
+ slug: str
+ title: str
+ section: str
+ order: int | str
+ figure: Callable[[Canvas], None]
+ note: str = ""
+ width: int = 320
+ height: int = 110
+ is_journey: bool = False
+
+ def render_html(self) -> str:
+ c = Canvas(w=self.width, h=self.height)
+ self.figure(c)
+ kind = " journey" if self.is_journey else ""
+ if isinstance(self.order, int):
+ eyebrow = f"{self.section} · {self.order:02d}"
+ else:
+ eyebrow = f"Journey · {self.order}"
+ note_html = f'
{self.note}
\n' if self.note else ""
+ return (
+ f'
\n'
+ f'
{eyebrow}
\n'
+ f'
{self.title}
\n'
+ f" {c.to_svg()}\n"
+ f"{note_html}"
+ f"
"
+ )
From 8c88d447364503636392dbc82ae0a28e308da0cd Mon Sep 17 00:00:00 2001
From: Ade Oshineye
Date: Sun, 10 May 2026 14:00:50 +0100
Subject: [PATCH 02/20] Restore viz preview workflow
---
.github/workflows/preview-viz.yml | 57 +++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 .github/workflows/preview-viz.yml
diff --git a/.github/workflows/preview-viz.yml b/.github/workflows/preview-viz.yml
new file mode 100644
index 0000000..50697b5
--- /dev/null
+++ b/.github/workflows/preview-viz.yml
@@ -0,0 +1,57 @@
+name: Preview viz
+
+on:
+ push:
+ branches:
+ - claude/tuftean-marginalia-viz-TB0fw
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: preview-viz
+ cancel-in-progress: true
+
+jobs:
+ upload-preview:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.13'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Install dependencies
+ run: uv sync --all-groups
+ - name: Build generated assets
+ run: make build
+ - name: Verify Cloudflare auth
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: npx --yes wrangler whoami
+ - name: Sync Python Workers vendor
+ run: uv run pywrangler sync
+ - name: Upload Cloudflare Preview
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ set -x
+ uv run pywrangler preview \
+ --name viz \
+ --message "${{ github.sha }}" \
+ --json
+ - name: Dump wrangler logs on failure
+ if: failure()
+ run: |
+ find ~ /tmp /root -name "*.log" -path "*wrangler*" 2>/dev/null | while read f; do
+ echo "=== $f ==="
+ tail -300 "$f" || true
+ done
From 43b50562ab28db9ea895acdc5b5b28447ec0cb4c Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 10 May 2026 13:06:43 +0000
Subject: [PATCH 03/20] Fix figure scaling and strip duplicate prose from
inside SVGs
The journey-types screenshot showed two related defects on every page
that uses small-viewBox figures:
1) text inside small-viewBox figures was rendered too large because the
SVG had only viewBox set; CSS width: 100% then stretched a 156-wide
viewBox to ~320px in the journey-section figure column, doubling all
text sizes inside the SVG.
2) several figures repeated their figcaption as a stray label inside
the SVG, so the same sentence appeared twice in the page (once
floating in the figure area, once below as the caption).
Fixes:
- Canvas.to_svg() now emits width="W" height="H" matching the viewBox.
CSS max-width: 100% (everywhere figures appear: cell-figure,
cell-banner, journey-figure, marginalia gestalt cards, journey-figures
gestalt section grid) lets figures clamp to container width without
stretching above intrinsic CSS-pixel size.
- Six figures lost their bottom prose label, which duplicated the
figcaption: function-as-value, annotation-ghost, generic-preservation,
context-bowtie, naming-decisions, lazy-stream.
- ViewBox heights of those six figures tightened so the trimmed content
doesn't leave dead space between the SVG and the figcaption.
Verified:
- /prototyping/journey-types serves first SVG with width="220" height="52"
matching its viewBox; "annotations describe" no longer appears inline.
- 39 unit tests pass.
- All other prototypes (banner-*, marginalia-gestalt, journey-figures-
gestalt, six other journey pages) regenerate cleanly.
https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
---
public/prototyping/journey-control-flow.html | 4 +-
.../prototyping/journey-figures-gestalt.html | 4 +-
public/prototyping/journey-interfaces.html | 4 +-
public/prototyping/journey-iteration.html | 4 +-
public/prototyping/journey-reliability.html | 4 +-
public/prototyping/journey-runtime.html | 4 +-
public/prototyping/journey-shapes.html | 4 +-
public/prototyping/journey-types.html | 4 +-
public/prototyping/journey-workers.html | 2 +-
public/prototyping/layout-banner-pair.html | 4 +-
public/prototyping/layout-banner-single.html | 4 +-
public/prototyping/layout-banner-trio.html | 8 +-
public/prototyping/marginalia-gestalt.html | 154 +++++++++---------
...d666d8585635.css => site.489bc3f7eb6d.css} | 2 +-
public/site.css | 2 +-
scripts/build_marginalia.py | 2 +-
scripts/build_prototypes.py | 6 +-
src/asset_manifest.py | 4 +-
src/marginalia.py | 26 ++-
src/marginalia_grammar.py | 8 +-
20 files changed, 127 insertions(+), 127 deletions(-)
rename public/{site.d666d8585635.css => site.489bc3f7eb6d.css} (99%)
diff --git a/public/prototyping/journey-control-flow.html b/public/prototyping/journey-control-flow.html
index 3c92528..8680d8f 100644
--- a/public/prototyping/journey-control-flow.html
+++ b/public/prototyping/journey-control-flow.html
@@ -22,7 +22,7 @@
.journey-section { grid-template-columns: minmax(0, 1.4fr) minmax(220px, 320px); align-items: start; }
}
.journey-figure { margin: 0; padding: 0; }
- .journey-figure svg { width: 100%; height: auto; display: block; }
+ .journey-figure svg { max-width: 100%; height: auto; display: block; }
.journey-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .85rem; font-style: italic; }
@@ -37,7 +37,7 @@
Control Flow
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
-
Choose between paths.
Start with ordinary branching and boolean predicates before reaching for more compact forms.
leave the current path when ordinary return values are not enough
The loop exits at the first match — break short-circuits the rest of the sequence.
@@ -97,363 +104,433 @@
Examples
Basics · 01
Hello World
+
9.0 · program → output, smallest mechanism
Basics · 02
Values
+
8.0 · three typed boxes; static enumeration
every value is an object with a type
Basics · 03
Numbers
+
9.0 · int register + float thinning
Basics · 04
Booleans
+
8.5 · 2×2 truth table
Basics · 05
Operators and Literals
+
9.0 · expression tree mechanism
Basics · 06
None
+
9.0 · three names converging on one None
one shared singleton
Basics · 07
Variables
+
9.5 · the canonical Python picture
names bind to objects
Basics · 08
Constants
+
8.0 · name → value, convention only
UPPER_CASE — convention, not enforcement
Basics · 09
Truthiness
+
7.0 · row of falsy values; static
Basics · 10
Equality and Identity
+
9.0 · shared vs separate, side-by-side
same object · or two equal objects
Basics · 11
Mutability
+
9.5 · three states; in production
Basics · 12
Strings
+
9.0 · codepoints + bytes registers
Basics · 13
String Formatting
+
8.5 · format-spec railroad
Control Flow · 14
Conditionals
+
8.5 · branch fork
Control Flow · 15
Assignment Expressions
+
8.0 · walrus binding; abstract
Control Flow · 16
For Loops
+
9.0 · 4-row caret advance
Control Flow · 17
Break and Continue
+
7.5 · two moves competing
Control Flow · 18
Loop Else
+
8.5 · fell-through gate
Control Flow · 19
Iterating over Iterables
+
8.5 · iter ribbon
Control Flow · 20
Iterators
+
8.5 · three-state machine
Control Flow · 21
Match Statements
+
8.5 · dispatch ladder
Control Flow · 22
Advanced Match Patterns
+
7.5 · dense; four variants
Control Flow · 23
While Loops
+
8.5 · cond + body + back-edge
Collections · 24
Lists
+
9.0 · cells with append
Collections · 25
Tuples
+
8.5 · closed shape
Collections · 26
Unpacking
+
9.0 · binding-line mechanism
Collections · 27
Dictionaries
+
9.0 · hash buckets with collision
Collections · 28
Sets
+
8.5 · bucket + x-in-s
Collections · 29
Slices
+
9.0 · ruler with bracket overlay
Collections · 30
Comprehensions
+
9.0 · equivalence stacked
Collections · 31
Comprehension Patterns
+
8.0 · pipeline
nested clauses compose left to right
Collections · 32
Sorting
+
9.0 · stability ribbons
equal keys keep original order
Functions · 33
Functions
+
8.0 · input → body → return
named behavior with a stable interface
Functions · 34
Keyword-only Arguments
+
9.0 · signature with * separator
Functions · 35
Positional-only Parameters
+
9.0 · signature with /
Functions · 36
Args and Kwargs
+
8.5 · extra-positionals/keywords regions
Functions · 37
Multiple Return Values
+
8.5 · tuple unpack
Functions · 38
Closures
+
9.0 · captured cell reference
Functions · 39
Global and Nonlocal
+
9.0 · LEGB rings
Functions · 40
Recursion
+
9.0 · stack with same name
Functions · 41
Lambdas
+
8.0 · lambda as expression
Iteration · 42
Generators
+
9.0 · ribbon with yield gates
function body as a timeline
Iteration · 43
Yield From
+
8.5 · stitched ribbons
Iteration · 44
Generator Expressions
+
8.5 · lazy pipeline
Iteration · 45
Itertools
+
7.5 · three mini icons
Functions · 46
Decorators
+
9.0 · before/after rebinding
Classes · 47
Classes
+
9.0 · instance/class/type triangle
Classes · 48
Inheritance and Super
+
9.0 · MRO chain with diamond ghost
Classes · 49
Dataclasses
+
9.0 · fields → generated init
Classes · 50
Properties
+
8.5 · Y-fork: fget vs __dict__
Data Model · 51
Special Methods
+
9.0 · syntax → method dispatch
Classes · 52
Metaclasses
+
8.5 · extended triangle
Data Model · 53
Context Managers
+
9.0 · enter / body / exit bowtie
Data Model · 54
Delete Statements
+
8.5 · name erased; object survives
Errors · 55
Exceptions
+
9.0 · lanes with traced path
no raise → try → else → finally
Errors · 56
Assertions
+
7.5 · pass/raise
Errors · 57
Exception Chaining
+
8.5 · cause vs context
Errors · 58
Exception Groups
+
8.5 · before/after peel
matched leaves removed; survivors regrouped
Modules · 59
Modules
+
8.5 · sys.path resolution
Modules · 60
Import Aliases
+
8.0 · alias → module binding
Types · 61
Type Hints
+
9.0 · ghost annotations
Types · 62
Protocols
+
8.5 · structural duck check
duck — required methods present
Types · 63
Enums
+
8.0 · closed set
Text · 64
Regular Expressions
+
8.5 · pattern ruler with anchors
Standard Library · 65
Number Parsing
+
7.5 · state machine fragment
Errors · 66
Custom Exceptions
+
8.5 · subclass chain
Standard Library · 67
JSON
+
8.5 · two-column mapping
Standard Library · 68
Dates and Times
+
8.0 · timezone strip
Async · 69
Async Await
+
9.0 · two-lane swimlane
Async · 70
Async Iteration and Context
+
8.5 · ribbon with await yields
+
Prototype · All 41 figures currently registered in src/marginalia.py FIGURES; each card names where it renders. · all prototypes
+
+
+
+
Production figure registry · 41 figures
+
Production figures gestalt
+
Every figure currently registered in src/marginalia.pyFIGURES. Each card names the figure, where it renders today (an example attachment, a journey section, or "not yet attached"), and the intrinsic viewBox dimensions. Use this page beside the example-figure rubric to triage which figures are shipping, which are journey-only, and which are sitting in the registry waiting for an example attachment.
+
+
aliasing-mutation
attached to /examples/mutability · viewBox 220×175
tuple-no-mutation
registered, not yet attached · viewBox 220×185
iterator-unroll
registered, not yet attached · viewBox 220×130
scope-rings
registered, not yet attached · viewBox 216×116
closure-cell
registered, not yet attached · viewBox 240×120
slice-ruler
registered, not yet attached · viewBox 232×120
branch-fork
attached to a journey section · viewBox 232×100
loop-repetition
attached to a journey section · viewBox 204×90
iter-protocol
attached to a journey section · viewBox 304×70
program-output
attached to a journey section · viewBox 240×80
identity-and-equality
attached to a journey section · viewBox 304×96
operator-dispatch
attached to /examples/special-methods · attached to a journey section · viewBox 260×70
container-questions
attached to a journey section · viewBox 280×88
reshape-pipeline
attached to a journey section · viewBox 204×80
text-data-boundary
attached to a journey section · viewBox 172×70
function-signature
attached to a journey section · viewBox 188×80
function-as-value
attached to a journey section · viewBox 200×66
class-with-state
attached to a journey section · viewBox 152×108
annotation-ghost
attached to a journey section · viewBox 220×52
union-types
attached to a journey section · viewBox 156×80
generic-preservation
attached to a journey section · viewBox 250×70
exception-lanes
attached to a journey section · viewBox 320×100
context-bowtie
attached to a journey section · viewBox 244×76
async-swimlane
attached to a journey section · viewBox 280×84
naming-decisions
attached to a journey section · viewBox 274×80
early-exit
attached to a journey section · viewBox 144×116
lazy-stream
attached to a journey section · viewBox 300×56
variables-bind
attached to /examples/variables · viewBox 180×44
call-stack
attached to /examples/recursion · viewBox 200×100
decorator-rebind
attached to /examples/decorators · viewBox 232×110
mro-chain
attached to /examples/inheritance-and-super · viewBox 200×152
dataclass-fields
attached to /examples/dataclasses · viewBox 280×76
class-triangle
attached to /examples/classes · viewBox 274×60
exception-cause-context
attached to /examples/exception-chaining · viewBox 282×70
unpacking-bind
attached to /examples/unpacking · viewBox 152×80
comprehension-equivalence
attached to /examples/comprehensions · viewBox 280×76
Every journey section's figure on one page so the set can be reasoned about as a whole. Score against the rubric; redesign anything below the 8.5 gate before shipping to /journeys/<slug>.
-
Runtime
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
Start with executable evidence.
Every page is a runnable program. The smallest mental model: source produces visible output.
Separate value, identity, and absence.
Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).
Read expressions as object operations.
Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
Control Flow
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
Choose between paths.
A value flows through a predicate to one of several branches.
Name and shape decisions.
The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.
Stop as soon as the answer is known.
The loop exits at the first match — break short-circuits the rest of the sequence.
Iteration
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
Choose the right loop shape.
Walk the sequence, run the body, return; the shape behind for and while.
See the protocol behind `for`.
iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.
Compose lazy value streams.
Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
Shapes
This journey teaches the core Python habit of choosing a data shape, transforming it directly, and making the result visible.
Pick the container that matches the question.
Each container answers a different question: ordered, fixed, lookup, unique.
Move between shapes deliberately.
Most everyday code reshapes data: one input, one transform, one new value.
Cross text and data boundaries.
Programs receive text and produce structured data; parsing makes the boundary explicit.
Interfaces
This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.
Start with functions as named behavior.
A function is the first abstraction boundary: arguments in, body, return value out.
Use functions as values.
Functions are first-class values. A second name binds to the same function object.
Bundle behavior with state.
Classes group fields and methods so data and behavior move together behind one interface.
Types
This journey maps Python's runtime object model to optional static annotations so learners know what types can and cannot promise.
Keep runtime and static analysis separate.
Annotations describe expected types for tools; the runtime accepts any object regardless.
Describe realistic data shapes.
A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.
Scale annotations for reusable libraries.
A generic type variable preserves shape across a call: the same T flows in and out.
Reliability
This journey follows the boundaries where programs fail, clean up, split into modules, communicate with the outside world, and run concurrent work.
Make failure explicit.
try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.
Control resource and module boundaries.
A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.
Handle operations that outlive one expression.
On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
Workers
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
+
Runtime
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
Start with executable evidence.
Every page is a runnable program. The smallest mental model: source produces visible output.
Separate value, identity, and absence.
Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).
Read expressions as object operations.
Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
Control Flow
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
Choose between paths.
A value flows through a predicate to one of several branches.
Name and shape decisions.
The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.
Stop as soon as the answer is known.
The loop exits at the first match — break short-circuits the rest of the sequence.
Iteration
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
Choose the right loop shape.
Walk the sequence, run the body, return; the shape behind for and while.
See the protocol behind `for`.
iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.
Compose lazy value streams.
Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
Shapes
This journey teaches the core Python habit of choosing a data shape, transforming it directly, and making the result visible.
Pick the container that matches the question.
Each container answers a different question: ordered, fixed, lookup, unique.
Move between shapes deliberately.
Most everyday code reshapes data: one input, one transform, one new value.
Cross text and data boundaries.
Programs receive text and produce structured data; parsing makes the boundary explicit.
Interfaces
This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.
Start with functions as named behavior.
A function is the first abstraction boundary: arguments in, body, return value out.
Use functions as values.
Functions are first-class values. A second name binds to the same function object.
Bundle behavior with state.
Classes group fields and methods so data and behavior move together behind one interface.
Types
This journey maps Python's runtime object model to optional static annotations so learners know what types can and cannot promise.
Keep runtime and static analysis separate.
Annotations describe expected types for tools; the runtime accepts any object regardless.
Describe realistic data shapes.
A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.
Scale annotations for reusable libraries.
A generic type variable preserves shape across a call: the same T flows in and out.
Reliability
This journey follows the boundaries where programs fail, clean up, split into modules, communicate with the outside world, and run concurrent work.
Make failure explicit.
try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.
Control resource and module boundaries.
A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.
Handle operations that outlive one expression.
On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
Workers
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
-
Choose the right loop shape.
Loops differ by what they consume, when they stop, and whether completion itself carries meaning.
On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
diff --git a/public/prototyping/journey-runtime.html b/public/prototyping/journey-runtime.html
index a2dd135..9803a41 100644
--- a/public/prototyping/journey-runtime.html
+++ b/public/prototyping/journey-runtime.html
@@ -22,7 +22,7 @@
.journey-section { grid-template-columns: minmax(0, 1.4fr) minmax(220px, 320px); align-items: start; }
}
.journey-figure { margin: 0; padding: 0; }
- .journey-figure svg { width: 100%; height: auto; display: block; }
+ .journey-figure svg { max-width: 100%; height: auto; display: block; }
.journey-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .85rem; font-style: italic; }
@@ -37,7 +37,7 @@
Runtime
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
-
Start with executable evidence.
Learners first need to see that every page is a runnable program with visible output.
Two names share one mutable list — appending through one name changes the object visible through both.By contrast, a tuple is frozen — its contents cannot change in place, so aliasing carries no mutation hazard.
Immutable objects do not change in place. String methods such as upper() return a new string, leaving the original string unchanged.
Source
text = "python"
+['python', 'workers']
Two names share one mutable list — appending through one name changes the object visible through both.By contrast, a tuple is frozen — its contents cannot change in place, so aliasing carries no mutation hazard.
Immutable objects do not change in place. String methods such as upper() return a new string, leaving the original string unchanged.
Source
text = "python"
upper_text = text.upper()
print(text)
print(upper_text)
Some objects change in place, while others return new values.
-
Two names share one mutable list — appending through one name changes the object visible through both.
Mutable objects can change in place. first and second point to the same list, so appending through one name changes the object seen through both names.
Source
first = ["python"]
+
Two names share one mutable list — appending through one name changes the object visible through both.
Mutable objects can change in place. first and second point to the same list, so appending through one name changes the object seen through both names.
Source
first = ["python"]
second = first
second.append("workers")
print(first)
@@ -65,11 +65,11 @@
The grammar: cells stay 2-column always; figures live in banner rows BETWEEN cells. Holds one figure here. The intended union of Tufte/Knuth/algebrica.
Same grammar with two figures in the banner — a Tufte small-multiple. The mutable list and the immutable tuple side by side, captioned, between the same pair of cells.
The grammar at scale: a single-figure banner before the walkthrough, a pair-banner between two cells, a single-figure summary after the last cell. Multiple diagrams; cells never displaced.
Every figure currently registered in src/marginalia.py FIGURES, with a tag showing where it renders (example attachment, journey section, or unattached).
The grammar: cells stay 2-column always; figures live in banner rows BETWEEN cells. Holds one figure here. The intended union of Tufte/Knuth/algebrica.
Same grammar with two figures in the banner — a Tufte small-multiple. The mutable list and the immutable tuple side by side, captioned, between the same pair of cells.
The grammar at scale: a single-figure banner before the walkthrough, a pair-banner between two cells, a single-figure summary after the last cell. Multiple diagrams; cells never displaced.
Every journey section's figure on one page so the set can be reasoned about as a whole. Score against the rubric; redesign anything below the 8.5 gate before shipping to /journeys/<slug>.
-
Runtime
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
Start with executable evidence.
print("…")stdouthello worldEvery page is a runnable program. The smallest mental model: source produces visible output.
Separate value, identity, and absence.
IS + ==ab[1,2]== ONLYab[1,2][1,2]Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).
Read expressions as object operations.
a + bdispatchesa.__add__(b)Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
Control Flow
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
Choose between paths.
value?case Acase BA value flows through a predicate to one of several branches.
Name and shape decisions.
len(xs):=nNAMEvaluen > 10The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.
Stop as soon as the answer is known.
abcdefound · breakfirst matchThe loop exits at the first match — break short-circuits the rest of the sequence.
Iteration
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
Choose the right loop shape.
abcdbodyWalk the sequence, run the body, return; the shape behind for and while.
See the protocol behind `for`.
ITERABLE[a,b,c]iter()ITERATORnext()abciter() exposes the iterator behind for; next() pulls one value at a time until exhausted.
Compose lazy value streams.
SOURCE[a,b,c]FILTERx>0MAPx*2next()Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
Shapes
This journey teaches the core Python habit of choosing a data shape, transforming it directly, and making the result visible.
Pick the container that matches the question.
LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}uniqueEach container answers a different question: ordered, fixed, lookup, unique.
Move between shapes deliberately.
[3,1,4]sorted[1,3,4]Most everyday code reshapes data: one input, one transform, one new value.
Cross text and data boundaries.
"42"TEXTparseINT42Programs receive text and produce structured data; parsing makes the boundary explicit.
Interfaces
This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.
Start with functions as named behavior.
argsDEF F(...)returnA function is the first abstraction boundary: arguments in, body, return value out.
Use functions as values.
FNdef fg = fnFunctions are first-class values. A second name binds to the same function object.
Bundle behavior with state.
CLASS BOXSTATEx · yMETHODSmove(...)Classes group fields and methods so data and behavior move together behind one interface.
Types
This journey maps Python's runtime object model to optional static annotations so learners know what types can and cannot promise.
Keep runtime and static analysis separate.
def f(x: int, y: str) -> bool: …Annotations describe expected types for tools; the runtime accepts any object regardless.
Describe realistic data shapes.
X: INT | STR | NONExintstrNoneA typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.
Scale annotations for reusable libraries.
TFN[T]TA generic type variable preserves shape across a call: the same T flows in and out.
Reliability
This journey follows the boundaries where programs fail, clean up, split into modules, communicate with the outside world, and run concurrent work.
Make failure explicit.
TRYEXCEPTELSEFINALLYtry, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.
Control resource and module boundaries.
inbodyoutA context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.
Handle operations that outlive one expression.
LOOPCOROawaitresumeOn await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
Workers
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
+
Runtime
This journey builds the smallest coherent model of Python at runtime: programs run statements, names refer to objects, objects have types, and operations ask those objects to do work.
Start with executable evidence.
print("…")stdouthello worldEvery page is a runnable program. The smallest mental model: source produces visible output.
Separate value, identity, and absence.
IS + ==ab[1,2]== ONLYab[1,2][1,2]Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).
Read expressions as object operations.
a + bdispatchesa.__add__(b)Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
Control Flow
This journey follows how a Python program chooses which path runs, names facts at decision points, and exits early when the remaining work no longer applies.
Choose between paths.
value?case Acase BA value flows through a predicate to one of several branches.
Name and shape decisions.
len(xs):=nNAMEvaluen > 10The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.
Stop as soon as the answer is known.
abcdefound · breakfirst matchThe loop exits at the first match — break short-circuits the rest of the sequence.
Iteration
This journey follows repeated work from ordinary loops to the iterator protocol: consume values, stop deliberately, and produce lazy streams only when they help.
Choose the right loop shape.
abcdbodyWalk the sequence, run the body, return; the shape behind for and while.
See the protocol behind `for`.
ITERABLE[a,b,c]iter()ITERATORnext()abciter() exposes the iterator behind for; next() pulls one value at a time until exhausted.
Compose lazy value streams.
SOURCE[a,b,c]FILTERx>0MAPx*2next()Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
Shapes
This journey teaches the core Python habit of choosing a data shape, transforming it directly, and making the result visible.
Pick the container that matches the question.
LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}uniqueEach container answers a different question: ordered, fixed, lookup, unique.
Move between shapes deliberately.
[3,1,4]sorted[1,3,4]Most everyday code reshapes data: one input, one transform, one new value.
Cross text and data boundaries.
"42"TEXTparseINT42Programs receive text and produce structured data; parsing makes the boundary explicit.
Interfaces
This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.
Start with functions as named behavior.
argsDEF F(...)returnA function is the first abstraction boundary: arguments in, body, return value out.
Use functions as values.
FNdef fg = fnFunctions are first-class values. A second name binds to the same function object.
Bundle behavior with state.
CLASS BOXSTATEx · yMETHODSmove(...)Classes group fields and methods so data and behavior move together behind one interface.
Types
This journey maps Python's runtime object model to optional static annotations so learners know what types can and cannot promise.
Keep runtime and static analysis separate.
def f(x: int, y: str) -> bool: …Annotations describe expected types for tools; the runtime accepts any object regardless.
Describe realistic data shapes.
X: INT | STR | NONExintstrNoneA typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.
Scale annotations for reusable libraries.
TFN[T]TA generic type variable preserves shape across a call: the same T flows in and out.
Reliability
This journey follows the boundaries where programs fail, clean up, split into modules, communicate with the outside world, and run concurrent work.
Make failure explicit.
TRYEXCEPTELSEFINALLYtry, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.
Control resource and module boundaries.
inbodyoutA context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.
Handle operations that outlive one expression.
LOOPCOROawaitresumeOn await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
Workers
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
Replace unavailable process boundaries with portable evidence.
PROCESS APROCESS BINSTEADvaluecapturedWorker isolation breaks the usual cross-process pathways; the lesson preserves a captured value as portable evidence instead.
Keep network lessons local to the protocol boundary.
REQUEST SHAPEGET /resourceRESPONSE SHAPE200 · { … }Demonstrate the protocol shape (request and response) rather than calling out over the network.
Preserve the lesson while respecting the runtime.
LESSONevidenceRUNTIMErespectedThe lesson's evidence survives across the boundary that the worker runtime enforces.
This journey explains the examples that were adapted so they can teach operating-system boundaries while still running inside Cloudflare Dynamic Workers.
-
Replace unavailable process boundaries with portable evidence.
Dynamic Workers run Python in a constrained runtime, so examples cannot assume child processes, shell commands, or project-local virtual environments are available.
Keep network lessons local to the protocol boundary.
Workers should not open arbitrary low-level sockets, so the networking example teaches addresses, protocol constants, and bytes without making an outbound connection.
show cleanup boundaries that still apply when resources are represented abstractly
+
Replace unavailable process boundaries with portable evidence.
Dynamic Workers run Python in a constrained runtime, so examples cannot assume child processes, shell commands, or project-local virtual environments are available.
PROCESS APROCESS BINSTEADvaluecapturedWorker isolation breaks the usual cross-process pathways; the lesson preserves a captured value as portable evidence instead.
Keep network lessons local to the protocol boundary.
Workers should not open arbitrary low-level sockets, so the networking example teaches addresses, protocol constants, and bytes without making an outbound connection.