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 + + + + +
Prototype · All prototypes · all prototypes
+ +
+
+

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.

+
+ +
+ + + diff --git a/public/prototyping/journey-control-flow.html b/public/prototyping/journey-control-flow.html new file mode 100644 index 0000000..3c92528 --- /dev/null +++ b/public/prototyping/journey-control-flow.html @@ -0,0 +1,44 @@ + + + + +Journey · Control Flow · Prototype + + + + +
Prototype · Control Flow journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
+ +
+ +
+

Journey

+

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.

  • Booleans

    combine facts into readable conditions

  • Truthiness

    use object truth values without hiding intent

  • Operators

    build comparisons and boolean expressions for conditions

  • Conditionals

    choose between branches with clear predicates

value?case Acase B
A value flows through a predicate to one of several branches.

Name and shape decisions.

Some branches become clearer when the code names an intermediate value or dispatches on data shape.

len(xs):=nNAMEvaluen > 10one expression: bind a name and use the value
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 exits make the successful path easier to read by moving exceptional or completed cases out of the way.

  • Guard Clauses

    show how early returns reduce nested conditional code

  • Assertions

    state assumptions that should fail loudly while developing

  • Exceptions

    leave the current path when ordinary return values are not enough

abcdefound · breakfirst match
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.

print("…")stdouthello world
Every 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 B
A value flows through a predicate to one of several branches.

Name and shape decisions.

len(xs):=nNAMEvaluen > 10one expression: bind a name and use the value
The 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 match
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.

abcdbody
Walk the sequence, run the body, return; the shape behind for and while.

See the protocol behind `for`.

ITERABLE[a,b,c]iter()ITERATORnext()abc
iter() 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()values flow lazily — nothing materialised
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}unique
Each 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"TEXTparseINT42
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.

argsDEF F(...)return
A function is the first abstraction boundary: arguments in, body, return value out.

Use functions as values.

FNdef fg = fnsecond name binds to the same function
Functions 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; runtime accepts any object
Annotations describe expected types for tools; the runtime accepts any object regardless.

Describe realistic data shapes.

X: INT | STR | NONExintstrNone
A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

Scale annotations for reusable libraries.

TFN[T]Tthe same T flows in and out
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.

TRYEXCEPTELSEFINALLY
try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

Control resource and module boundaries.

inbodyoutraise still routes through __exit__
A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

Handle operations that outlive one expression.

LOOPCOROawaitresume
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.

+
+ + + diff --git a/public/prototyping/journey-interfaces.html b/public/prototyping/journey-interfaces.html new file mode 100644 index 0000000..42ce9eb --- /dev/null +++ b/public/prototyping/journey-interfaces.html @@ -0,0 +1,44 @@ + + + + +Journey · Interfaces · Prototype + + + + +
Prototype · Interfaces journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces. · all prototypes
+ +
+ +
+

Journey

+

Interfaces

+

This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.

+
+

Start with functions as named behavior.

Functions are the first abstraction boundary because they name behavior and control how callers provide information.

argsDEF F(...)return
A function is the first abstraction boundary: arguments in, body, return value out.

Use functions as values.

Python functions can capture state, be passed around, and wrap other functions.

FNdef fg = fnsecond name binds to the same function
Functions are first-class values. A second name binds to the same function object.

Bundle behavior with state.

Classes become useful when data and behavior need to move together behind a stable interface.

CLASS BOXSTATEx · yMETHODSmove(...)
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
+ +
+ +
+

Journey

+

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.

Loops differ by what they consume, when they stop, and whether completion itself carries meaning.

abcdbody
Walk the sequence, run the body, return; the shape behind for and while.

See the protocol behind `for`.

The important mental shift is that loops consume producers through a protocol rather than special-casing lists.

ITERABLE[a,b,c]iter()ITERATORnext()abc
iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.

Compose lazy value streams.

Iterator pipelines are useful when code can transform values one at a time instead of materializing every intermediate result.

SOURCE[a,b,c]FILTERx>0MAPx*2next()values flow lazily — nothing materialised
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
+ +
+ +
+

Journey

+

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.

Robust Python code distinguishes expected absence, broken assumptions, recoverable errors, and domain-specific failures.

TRYEXCEPTELSEFINALLY
try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

Control resource and module boundaries.

Cleanup, deletion, imports, and modules define where responsibilities begin and end.

inbodyoutraise still routes through __exit__
A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

Handle operations that outlive one expression.

I/O, testing, logging, subprocesses, and concurrency require different control boundaries from ordinary expressions.

LOOPCOROawaitresume
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
+ +
+ +
+

Journey

+

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.

  • Hello World

    start with a complete program and its output

  • Values

    see that Python programs manipulate runtime objects

  • Literals

    write small values directly in source code

  • Variables

    understand that names bind to objects rather than storing values themselves

  • Constants

    learn the convention Python uses for values that should not change

print("…")stdouthello world
Every page is a runnable program. The smallest mental model: source produces visible output.

Separate value, identity, and absence.

This section prevents early confusion about equality, object identity, missing values, and truth tests.

  • None

    represent expected absence with a singleton object

  • Booleans

    combine facts with boolean operators

  • Truthiness

    predict how objects behave in boolean contexts

  • Equality and Identity

    distinguish value equality from object identity

  • Mutability

    predict when operations change an object in place

  • Object Lifecycle

    explain references, garbage collection, and why identity can outlive a single name

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.

This section connects operators, text, and formatting to Python's data model.

  • Numbers

    use numeric objects and arithmetic operators

  • Operators

    combine, compare, and test values with expression syntax

  • Strings

    treat text as Unicode rather than raw bytes

  • String Formatting

    turn objects into readable text at output boundaries

  • Bytes and Bytearray

    contrast text with binary data and explicit decoding

a + bdispatchesa.__add__(b)
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
+ +
+ +
+

Journey

+

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.

Lists, tuples, dictionaries, and sets answer different questions about order, position, lookup, and uniqueness.

  • Lists

    store ordered mutable data

  • Tuples

    group fixed-position values

  • Dictionaries

    look up values by key

  • Sets

    model uniqueness and membership

  • Collections Module

    show `deque`, `Counter`, `defaultdict`, and `namedtuple` as specialized shapes

LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}unique
Each container answers a different question: ordered, fixed, lookup, unique.

Move between shapes deliberately.

Most everyday Python code is data reshaping, so learners need the idioms for selecting, unpacking, and rebuilding values.

[3,1,4]sorted[1,3,4]
Most everyday code reshapes data: one input, one transform, one new value.

Cross text and data boundaries.

Programs often receive text and produce structured data, so parsing and serialization belong in the data journey.

"42"TEXTparseINT42
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
+ +
+ +
+

Journey

+

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.

The first lesson is that annotations describe expectations for tools while ordinary Python objects still run the program.

  • Type Hints

    document expected types and feed type checkers

  • Protocols

    describe required behavior by structural shape

  • Enums

    name a fixed set of symbolic values

  • Runtime Type Checks

    show `type()`, `isinstance()`, and `issubclass()` without turning Python into Java

def f(x: int, y: str) -> bool: …annotations describe; runtime accepts any object
Annotations describe expected types for tools; the runtime accepts any object regardless.

Describe realistic data shapes.

Typed Python becomes useful when annotations explain optional values, unions, callables, and JSON-like records.

X: INT | STR | NONExintstrNone
A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

Scale annotations for reusable libraries.

Advanced typing exists to preserve information across reusable functions, containers, and decorators.

  • Generics and TypeVar

    write reusable typed containers and functions

  • ParamSpec

    preserve callable signatures through decorators

  • Overloads

    describe APIs whose return type depends on the input shape

  • Casts and Any

    show escape hatches and their tradeoffs

  • NewType

    create distinct static identities for runtime-compatible values

TFN[T]Tthe same T flows in and out
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
+ +
+ +
+

Journey

+

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.

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.

  • Bytes and Bytearray

    show the text-to-bytes boundary that networking and subprocess APIs usually require

  • Networking

    make endpoint and byte-encoding boundaries visible without opening a socket

  • Async Await

    show the supported coroutine model for I/O-shaped work in this environment

Preserve the lesson while respecting the runtime.

The changed examples favor deterministic, editable evidence over fake demonstrations of unavailable operating-system features.

  • Logging

    show operational output through a configurable Python API rather than shell output

  • Testing

    capture test-runner output so the page remains deterministic

  • Context Managers

    show cleanup boundaries that still apply when resources are represented abstractly

+
+ + + diff --git a/public/prototyping/layout-banner-pair.html b/public/prototyping/layout-banner-pair.html new file mode 100644 index 0000000..2cdbb61 --- /dev/null +++ b/public/prototyping/layout-banner-pair.html @@ -0,0 +1,109 @@ + + + + +Mutability · banner with paired small-multiples · Prototype + + + + +
Prototype · One banner with two figures — list mutates, tuple does not. Same grammar, just more figures in the slot. · all prototypes
+ +
+ +
+

Data Model

+

Mutability

+

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']
BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
Two names share one mutable list — appending through one name changes the object visible through both.
TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
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.

Source

numbers = [3, 1, 2]
+ordered = sorted(numbers)
+print(ordered)
+print(numbers)

Output

[1, 2, 3]
+[3, 1, 2]
+

Notes

+ +
+

Run the complete example

+
+
+

Example code

+
first = ["python"]
+second = first
+second.append("workers")
+print(first)
+print(second)
+
+text = "python"
+upper_text = text.upper()
+print(text)
+print(upper_text)
+
+numbers = [3, 1, 2]
+ordered = sorted(numbers)
+print(ordered)
+print(numbers)
+
+
+

Expected output

['python', 'workers']
+['python', 'workers']
+python
+PYTHON
+[1, 2, 3]
+[3, 1, 2]
+
+
+
+
+ + + diff --git a/public/prototyping/layout-banner-single.html b/public/prototyping/layout-banner-single.html new file mode 100644 index 0000000..1100ea5 --- /dev/null +++ b/public/prototyping/layout-banner-single.html @@ -0,0 +1,109 @@ + + + + +Mutability · banner between cells (single figure) · Prototype + + + + +
Prototype · Cells keep their prose|code grid; one figure sits in a banner row between cell 0 and cell 1. · all prototypes
+ +
+ +
+

Data Model

+

Mutability

+

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']
BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["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.

Source

numbers = [3, 1, 2]
+ordered = sorted(numbers)
+print(ordered)
+print(numbers)

Output

[1, 2, 3]
+[3, 1, 2]
+

Notes

+ +
+

Run the complete example

+
+
+

Example code

+
first = ["python"]
+second = first
+second.append("workers")
+print(first)
+print(second)
+
+text = "python"
+upper_text = text.upper()
+print(text)
+print(upper_text)
+
+numbers = [3, 1, 2]
+ordered = sorted(numbers)
+print(ordered)
+print(numbers)
+
+
+

Expected output

['python', 'workers']
+['python', 'workers']
+python
+PYTHON
+[1, 2, 3]
+[3, 1, 2]
+
+
+
+
+ + + diff --git a/public/prototyping/layout-banner-trio.html b/public/prototyping/layout-banner-trio.html new file mode 100644 index 0000000..d918cf0 --- /dev/null +++ b/public/prototyping/layout-banner-trio.html @@ -0,0 +1,109 @@ + + + + +Mutability · banners across the whole walkthrough · Prototype + + + + +
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
+ +
+ +
+

Data Model

+

Mutability

+

Some objects change in place, while others return new values.

+
+
BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
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
BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
Mutable: change visible through any alias.
TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
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.

Source

numbers = [3, 1, 2]
+ordered = sorted(numbers)
+print(ordered)
+print(numbers)

Output

[1, 2, 3]
+[3, 1, 2]
TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
When in doubt, reach for the immutable shape.
+

Notes

+ +
+

Run the complete example

+
+
+

Example code

+
first = ["python"]
+second = first
+second.append("workers")
+print(first)
+print(second)
+
+text = "python"
+upper_text = text.upper()
+print(text)
+print(upper_text)
+
+numbers = [3, 1, 2]
+ordered = sorted(numbers)
+print(ordered)
+print(numbers)
+
+
+

Expected output

['python', 'workers']
+['python', 'workers']
+python
+PYTHON
+[1, 2, 3]
+[3, 1, 2]
+
+
+
+
+ + + diff --git a/public/prototyping/marginalia-gestalt.html b/public/prototyping/marginalia-gestalt.html new file mode 100644 index 0000000..707f766 --- /dev/null +++ b/public/prototyping/marginalia-gestalt.html @@ -0,0 +1,460 @@ + + + + +Marginalia gestalt — Python By Example + + + +
+

Marginalia gestalt

+

Every journey and example, generated from a single grammar of primitives.

+

Locked metrics, locked palette, locked typography. Cards compose words; words compose tokens; nothing is bespoke.

+
+ +

Journeys

+
+
+

Journey · 01

+

Runtime

+ xINT42 +

names bind to objects

+
+
+

Journey · 02

+

Streams

+ abcdnext() · next() · next()consumer +

iteration is a value stream

+
+
+

Journey · 03

+

Shapes

+ LIST[a,b,c]TUPLE(a,b,c)DICT{k:v}SET{a,b} +

container choice answers a question

+
+
+

Journey · 04

+

Interfaces

+ FUNCTIONdef f()CLASSstate +methodsPROTOCOLshape +

behavior packaged at increasing abstraction

+
+
+

Journey · 05

+

Types

+ def f(x: int, y: str) -> boolannotations describe; runtime accepts any object +

static description of runtime objects

+
+
+

Journey · 06

+

Reliability

+ TRYEXCEPTELSEFINALLY +

program boundaries — failure, cleanup, scope

+
+
+

Examples

+
+
+

Basics · 01

+

Hello World

+ print("…")stdouthello world +
+
+

Basics · 02

+

Values

+ INT42STR"hi"LIST[1,2,3] +

every value is an object with a type

+
+
+

Basics · 03

+

Numbers

+ INT · UNBOUNDEDFLOAT · REPRESENTABLE SPACING WIDENS +
+
+

Basics · 04

+

Booleans

+ ANDTFTFTFFF +
+
+

Basics · 05

+

Operators and Literals

+ *+423 +
+
+

Basics · 06

+

None

+ abcNONETYPENone +

one shared singleton

+
+
+

Basics · 07

+

Variables

+ xINT42id 0x…a0 +

names bind to objects

+
+
+

Basics · 08

+

Constants

+ MAX_SIZEINT100 +

UPPER_CASE — convention, not enforcement

+
+
+

Basics · 09

+

Truthiness

+ FALSY00.0""[]{}NoneFalse +
+
+

Basics · 10

+

Equality and Identity

+ A IS B · A == BabLIST[1,2]A == B ONLYab[1,2][1,2] +

same object · or two equal objects

+
+
+

Basics · 11

+

Mutability

+ ASSIGNxsLIST[3,1,4]id 0x…a0MUTATE · SAME IDxsLIST[3,1,4,1]id 0x…a0REBIND · NEW IDxsLIST[3,1,4,1]id 0x…b7 +
+
+

Basics · 12

+

Strings

+ CODEPOINTScaféUTF-8 BYTES636166c3a9 +
+
+

Basics · 13

+

String Formatting

+ FORMAT SPECalignsign#width,.prectype{:>6,.2f} +
+
+

Control Flow · 14

+

Conditionals

+ ?ifelse +
+
+

Control Flow · 15

+

Assignment Expressions

+ if (n := len(xs)) > 10:walrusnINTvalue +
+
+

Control Flow · 16

+

For Loops

+ abcdnext()abcdnext()abcdnext()abcdnext() — last +
+
+

Control Flow · 17

+

Break and Continue

+ LOOP BODYcontinuebreak +
+
+

Control Flow · 18

+

Loop Else

+ LOOPfell throughelse: …broke — else skipped +
+
+

Control Flow · 19

+

Iterating over Iterables

+ ITERABLE[a,b,c]iter()ITERATORnext()value … +
+
+

Control Flow · 20

+

Iterators

+ idlenext()doneiter()stop +
+
+

Control Flow · 21

+

Match Statements

+ match value:case 0:case [x, *_]:case _:first match +
+
+

Control Flow · 22

+

Advanced Match Patterns

+ CAPTURE[x, y]ALTERNATIVEP() | Q()GUARD[x] if x > 0CLASSPoint(x=0, y=_) +
+
+

Control Flow · 23

+

While Loops

+ ?bodyexit +
+
+

Collections · 24

+

Lists

+ MUTABLE SEQUENCE314+1.append +
+
+

Collections · 25

+

Tuples

+ IMMUTABLE SEQUENCE3141 +
+
+

Collections · 26

+

Unpacking

+ 12345a*restb +
+
+

Collections · 27

+

Dictionaries

+ HASH & MASK → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision +
+
+

Collections · 28

+

Sets

+ HASH BUCKETS · KEYS ONLYabcx in sO(1)no orderno duplicates +
+
+

Collections · 29

+

Slices

+ abcde01234012345[1:4] +
+
+

Collections · 30

+

Comprehensions

+ [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2) +
+
+

Collections · 31

+

Comprehension Patterns

+ xs · ysSOURCEif y>0FILTERx*yMAP +

nested clauses compose left to right

+
+
+

Collections · 32

+

Sorting

+ INPUTSTABLE SORT2 · Ada1 · Bo1 · Bo1 · Cy2 · Eve2 · Ada1 · Cy2 · Eve +

equal keys keep original order

+
+
+

Functions · 33

+

Functions

+ argsDEF F(...)return +

named behavior with a stable interface

+
+
+

Functions · 34

+

Keyword-only Arguments

+ def f(a, b, *, c, d): …positional or kwkeyword only +
+
+

Functions · 35

+

Positional-only Parameters

+ def f(a, b, /, c, d): …positional onlypositional or kw +
+
+

Functions · 36

+

Args and Kwargs

+ def f(*args, **kwargs): …extra positionalstupleextra keywordsdict +
+
+

Functions · 37

+

Multiple Return Values

+ DEF F()TUPLE(a, b)xy +
+
+

Functions · 38

+

Closures

+ OUTER()CELLn=0INNER()uses cell +
+
+

Functions · 39

+

Global and Nonlocal

+ B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL +
+
+

Functions · 40

+

Recursion

+ factorial(3)factorial(2)factorial(1)factorial(0) ← base +
+
+

Functions · 41

+

Lambdas

+ lambda x: x + 1paramsexpression +
+
+

Iteration · 42

+

Generators

+ PAUSED BETWEEN YIELDS · RESUMED BY NEXT()…work…yield…work…yield +

function body as a timeline

+
+
+

Iteration · 43

+

Yield From

+ OUTERyield from inner()INNERdelegated +
+
+

Iteration · 44

+

Generator Expressions

+ (sourcefiltermap)lazy stream — no list materialised +
+
+

Iteration · 45

+

Itertools

+ CHAINa · bc · dCYCLEa · b · cISLICEwindow +
+
+

Functions · 46

+

Decorators

+ BEFOREfFNf₀ bodyAFTER @DECfwrapperCELLf₀ +
+
+

Classes · 47

+

Classes

+ instanceCLASSClassTYPEtype +
+
+

Classes · 48

+

Inheritance and Super

+ ABCMRODBCAobject +
+
+

Classes · 49

+

Dataclasses

+ DECLARATIONname : strage : inttags : list__init__(name, age, tags) +
+
+

Classes · 50

+

Properties

+ obj.xfget / fset__dict__ +
+
+

Data Model · 51

+

Special Methods

+ a + bdispatchesa.__add__(b) +
+
+

Classes · 52

+

Metaclasses

+ CLASSClassMETACLASSMetaclass +
+
+

Data Model · 53

+

Context Managers

+ inbodyout +
+
+

Data Model · 54

+

Delete Statements

+ BEFORExLIST[1,2,3]AFTER DEL XxLIST[1,2,3] +
+
+

Errors · 55

+

Exceptions

+ TRYEXCEPTELSEFINALLY +

no raise → try → else → finally

+
+
+

Errors · 56

+

Assertions

+ assert cond, msgtrue · passfalse · AssertionError +
+
+

Errors · 57

+

Exception Chaining

+ ValueError__cause____context__RuntimeError +
+
+

Errors · 58

+

Exception Groups

+ BEFORE EXCEPT*except*AFTER +

matched leaves removed; survivors regrouped

+
+
+

Modules · 59

+

Modules

+ SYS.PATHcwdsite-packagesstdlibfirst hitmymod.py +
+
+

Modules · 60

+

Import Aliases

+ import numpy as npnpMODULEnumpy +
+
+

Types · 61

+

Type Hints

+ def f(x: int, y: str) -> bool: … +
+
+

Types · 62

+

Protocols

+ OBJECTread()write()close()other()structural ✓PROTOCOLread()close() +

duck — required methods present

+
+
+

Types · 63

+

Enums

+ COLOR · CLOSED SETREDGREENBLUEno more +
+
+

Text · 64

+

Regular Expressions

+ PATTERN^\d{2}-\d{2}$INPUT12-34 +
+
+

Standard Library · 65

+

Number Parsing

+ "42"int()42ValueErrorint +
+
+

Errors · 66

+

Custom Exceptions

+ BaseExceptionExceptionValueErrorMyDomainError +
+
+

Standard Library · 67

+

JSON

+ JSONPYTHONobjectdictarrayliststringstrnumberint / floattrue / falseTrue / FalsenullNone +
+
+

Standard Library · 68

+

Dates and Times

+ one instant−5h+0h +
+
+

Async · 69

+

Async Await

+ LOOPCOROawaitresume +
+
+

Async · 70

+

Async Iteration and Context

+ ASYNC FOR · ASYNC WITHawait yieldawait yield +
+
+ + diff --git a/public/prototyping/operators-polish-comparison.html b/public/prototyping/operators-polish-comparison.html new file mode 100644 index 0000000..ca2a4a2 --- /dev/null +++ b/public/prototyping/operators-polish-comparison.html @@ -0,0 +1,133 @@ + + + + +Operators & Literals — alignment polish + + + +
+

Operators & Literals — alignment polish

+

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

+ + + + * + + + + + 4 + + 2 + + 3 + + + + + + + + + + + + + + + + hollow rings: actual line endpoints — none meet a circle tangentially; * → 4 hits node centre + +
+ +
+

After · c.connect()

+

Endpoints computed from the line of centres

+ + + + * + + + + + 4 + + 2 + + 3 + + + + + + + + + + + + + + + + filled dots: each endpoint sits exactly on its circle's boundary along 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.

+
+ + + diff --git a/public/site.css b/public/site.css index 4538e5c..83a184c 100644 --- a/public/site.css +++ b/public/site.css @@ -105,3 +105,11 @@ .site-footer-note { font-size: .82rem; } @media (max-width: 860px) { .lesson-step, .runner-grid { grid-template-columns: 1fr; } body { padding: .875rem; } header { margin-inline: -.875rem; padding-inline: .875rem; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; } } + /* Inline cell figure: a figure attached to a cell sits between prose + and code-stack. The cell drops to single-column so prose, figure, + and code stack vertically. Cells without a figure keep the + prose|code 2-column grid unchanged. See docs/visual-explainer-spec.md. */ + .lp-cell.has-figure { grid-template-columns: 1fr; } + .cell-figure { margin: 0; padding: 0; } + .cell-figure svg { width: 100%; max-width: 360px; height: auto; display: block; } + .cell-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .92rem; font-style: italic; max-width: 56ch; } diff --git a/public/site.5f6f7da7c305.css b/public/site.d666d8585635.css similarity index 95% rename from public/site.5f6f7da7c305.css rename to public/site.d666d8585635.css index 4538e5c..83a184c 100644 --- a/public/site.5f6f7da7c305.css +++ b/public/site.d666d8585635.css @@ -105,3 +105,11 @@ .site-footer-note { font-size: .82rem; } @media (max-width: 860px) { .lesson-step, .runner-grid { grid-template-columns: 1fr; } body { padding: .875rem; } header { margin-inline: -.875rem; padding-inline: .875rem; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 1ms !important; } } + /* Inline cell figure: a figure attached to a cell sits between prose + and code-stack. The cell drops to single-column so prose, figure, + and code stack vertically. Cells without a figure keep the + prose|code 2-column grid unchanged. See docs/visual-explainer-spec.md. */ + .lp-cell.has-figure { grid-template-columns: 1fr; } + .cell-figure { margin: 0; padding: 0; } + .cell-figure svg { width: 100%; max-width: 360px; height: auto; display: block; } + .cell-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .92rem; font-style: italic; max-width: 56ch; } diff --git a/scripts/build_marginalia.py b/scripts/build_marginalia.py new file mode 100644 index 0000000..4c30da7 --- /dev/null +++ b/scripts/build_marginalia.py @@ -0,0 +1,929 @@ +#!/usr/bin/env python3 +"""Generate public/marginalia-gestalt.html from declarative card descriptions. + +Every figure composes WORDS and PHRASES from marginalia_grammar.Canvas. No card +draws raw SVG. Drift is impossible because metrics live in the grammar module. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from marginalia_grammar import ( # noqa: E402 (sys.path set above) + BASELINE, + CELL, + EMPHASIS, + GAP_L, + GAP_S, + INK, + INK_SOFT, + NAME_H, + NAME_W, + OBJECT_H, + OBJECT_W, + SOFT_FILL, + TICK_LEN, + Canvas, + Card, +) + +OUT = ROOT / "public" / "prototyping" / "marginalia-gestalt.html" + + +# ─── Journeys ────────────────────────────────────────────────────────── + + +def j_runtime(c: Canvas) -> None: + c.bind(20, 36, "x", "int", "42") + + +def j_streams(c: Canvas) -> None: + c.cells(20, 30, ["a", "b", "c", "d"]) + c.caret(20 + CELL / 2, 30) + c.closed_arrow(120, 42, 198, 42) + c.label(160, 36, "next() · next() · next()", anchor="middle") + c.object_box(204, 30, "", "consumer", w=92, h=24) + + +def j_shapes(c: Canvas) -> None: + pairs = [("list", "[a,b,c]"), ("tuple", "(a,b,c)"), ("dict", "{k:v}"), ("set", "{a,b}")] + for i, (tag, val) in enumerate(pairs): + c.object_box(16 + i * 76, 32, tag, val, w=64, h=30) + + +def j_interfaces(c: Canvas) -> None: + c.frame(20, 24, 78, 56, label="function") + c.mono(59, 56, "def f()") + c.frame(116, 24, 88, 56, label="class") + c.mono(160, 50, "state +") + c.mono(160, 64, "methods") + c.frame(222, 24, 78, 56, label="protocol") + c.mono(261, 56, "shape") + + +def j_types(c: Canvas) -> None: + c.mono(20, 50, "def f(x: int, y: str) -> bool", anchor="start") + c.dashed(60, 38, 76, 38) + c.dashed(96, 38, 116, 38) + c.dashed(140, 38, 184, 38) + c.label(160, 28, "annotations describe; runtime accepts any object", anchor="middle") + + +def j_reliability(c: Canvas) -> None: + ys = [(22, "try"), (42, "except"), (62, "else"), (82, "finally")] + path = [(40, 22), (110, 22), (130, 62), (210, 62), (230, 82), (290, 82)] + c.lanes(ys, x0=40, x1=300, path=path) + + +JOURNEYS = [ + Card("runtime", "Runtime", "Journey", "01", j_runtime, is_journey=True, + note="names bind to objects"), + Card("streams", "Streams", "Journey", "02", j_streams, is_journey=True, + note="iteration is a value stream"), + Card("shapes", "Shapes", "Journey", "03", j_shapes, is_journey=True, + note="container choice answers a question"), + Card("interfaces", "Interfaces", "Journey", "04", j_interfaces, is_journey=True, + note="behavior packaged at increasing abstraction"), + Card("types", "Types", "Journey", "05", j_types, is_journey=True, + note="static description of runtime objects"), + Card("reliability", "Reliability", "Journey", "06", j_reliability, is_journey=True, + note="program boundaries — failure, cleanup, scope"), +] + + +# ─── Examples (manifest order) ───────────────────────────────────────── + + +def e_hello_world(c: Canvas) -> None: + c.object_box(20, 36, "", 'print("…")', w=90, soft=False) + c.closed_arrow(112, 52, 188, 52) + c.label(150, 46, "stdout", anchor="middle") + c.object_box(190, 36, "", "hello world", w=110) + + +def e_values(c: Canvas) -> None: + c.object_box(20, 32, "int", "42", w=60) + c.object_box(96, 32, "str", '"hi"', w=80) + c.object_box(192, 32, "list", "[1,2,3]", w=100) + + +def e_numbers(c: Canvas) -> None: + c.tag(20, 22, "int · unbounded") + c.register(20, 38, 280, divisions=8) + c.tag(20, 64, "float · representable spacing widens") + c.register(20, 78, 280) + for x in (28, 50, 80, 122, 152, 162, 170, 182, 210, 250, 292): + c.tick(x, 78) + c.dot(162, 78, emphasis=True) + + +def e_booleans(c: Canvas) -> None: + c.tag(160, 22, "and", anchor="middle") + c.label(140, 36, "T", anchor="middle") + c.label(180, 36, "F", anchor="middle") + c.label(118, 56, "T", anchor="end") + c.label(118, 76, "F", anchor="end") + c.cell(120, 40, "T", w=40, h=18, soft=True) + c.cell(160, 40, "F", w=40, h=18) + c.cell(120, 58, "F", w=40, h=18) + c.cell(160, 58, "F", w=40, h=18) + + +def e_operators(c: Canvas) -> None: + c.node(160, 26, "*", r=12) + c.node(120, 62, "+", r=12) + c.node(220, 62, "4", r=10) + c.node(96, 92, "2", r=10) + c.node(144, 92, "3", r=10) + c.connect(160, 26, 12, 120, 62, 12) + c.connect(160, 26, 12, 220, 62, 10) + c.connect(120, 62, 12, 96, 92, 10) + c.connect(120, 62, 12, 144, 92, 10) + + +def e_none(c: Canvas) -> None: + for i, n in enumerate("abc"): + c.name_box(20, 18 + i * 30, n) + for y in (30, 60, 90): + c.closed_arrow(80, y, 200, 60, emphasis=False) + c.object_box(202, 44, "NoneType", "None", w=80) + + +def e_variables(c: Canvas) -> None: + c.bind(20, 36, "x", "int", "42") + c.label(220, 88, "id 0x…a0", anchor="middle") + + +def e_constants(c: Canvas) -> None: + c.bind(20, 36, "MAX_SIZE", "int", "100", object_w=60, gap=24) + + +def e_truthiness(c: Canvas) -> None: + c.tag(20, 28, "falsy") + items = ["0", "0.0", '""', "[]", "{}", "None", "False"] + widths = [30, 36, 32, 32, 32, 46, 50] + x = 20 + for v, w in zip(items, widths): + c.cell(x, 36, v, w=w, h=26) + x += w + + +def e_equality(c: Canvas) -> None: + c.tag(80, 14, "a is b · a == b", anchor="middle") + c.name_box(20, 24, "a") + c.name_box(20, 52, "b") + c.closed_arrow(80, 36, 138, 50, emphasis=False) + c.closed_arrow(80, 64, 138, 50, emphasis=False) + c.object_box(140, 38, "list", "[1,2]", w=44, h=26) + c.tag(240, 14, "a == b only", anchor="middle") + c.name_box(180, 24, "a") + c.name_box(180, 52, "b") + c.closed_arrow(240, 36, 280, 36, emphasis=False) + c.closed_arrow(240, 64, 280, 64, emphasis=False) + c.object_box(280, 24, "", "[1,2]", w=40, h=24) + c.object_box(280, 52, "", "[1,2]", w=40, h=24) + + +def e_mutability(c: Canvas) -> None: + states = [ + ("assign", "[3,1,4]", "id 0x…a0"), + ("mutate · same id", "[3,1,4,1]", "id 0x…a0"), + ("rebind · new id", "[3,1,4,1]", "id 0x…b7"), + ] + for i, (tag, val, idnote) in enumerate(states): + y = 14 + i * 60 + c.tag(20, y, tag) + c.bind(20, y + 8, "xs", "list", val, object_w=80, gap=20) + c.label(70, y + 52, idnote) + + +def e_strings(c: Canvas) -> None: + c.tag(20, 22, "codepoints") + for i, ch in enumerate("café"): + c.cell(20 + i * 40, 28, ch, w=40, h=28) + c.tag(20, 74, "utf-8 bytes") + widths = [40, 40, 40, 20, 20] + bytes_ = ["63", "61", "66", "c3", "a9"] + x = 20 + for w, b in zip(widths, bytes_): + c.cell(x, 78, b, w=w, h=14) + x += w + + +def e_string_formatting(c: Canvas) -> None: + c.tag(20, 22, "format spec") + stations = [("align", 36), ("sign", 30), ("#", 22), ("width", 40), (",", 22), (".prec", 44), ("type", 32)] + x = 20 + for label, w in stations: + c.cell(x, 38, label, w=w, h=18) + x += w + 2 + c.label(20, 76, "{:>6,.2f}") + + +def e_conditionals(c: Canvas) -> None: + c.node(160, 38, "?", r=18) + c.closed_arrow(146, 50, 70, 84, emphasis=False) + c.closed_arrow(174, 50, 250, 84, emphasis=False) + c.label(70, 96, "if", anchor="middle") + c.label(250, 96, "else", anchor="middle") + + +def e_assignment_expressions(c: Canvas) -> None: + c.mono(20, 40, "if (n := len(xs)) > 10:", anchor="start") + c.dashed(50, 44, 102, 44) + c.label(76, 58, "walrus", anchor="middle") + c.name_box(40, 66, "n") + c.closed_arrow(100, 78, 158, 78) + c.object_box(160, 66, "int", "value", w=60, h=24) + + +def e_for_loops(c: Canvas) -> None: + items = list("abcd") + for i in range(4): + y = 8 + i * 32 + c.cells(20, y, items) + c.caret(20 + i * CELL + CELL / 2, y) + suffix = " — last" if i == 3 else "" + c.label(124, y + 16, f"next(){suffix}") + + +def e_break_continue(c: Canvas) -> None: + c.frame(60, 22, 200, 64, label="loop body") + c.dashed(160, 22, 160, 86) + c.closed_arrow(110, 60, 110, 18, emphasis=True) + c.label(110, 14, "continue", anchor="middle") + c.closed_arrow(220, 54, 290, 54, emphasis=True) + c.label(254, 50, "break", anchor="middle") + + +def e_loop_else(c: Canvas) -> None: + c.frame(20, 32, 100, 44, label="loop") + c.closed_arrow(120, 40, 200, 22, emphasis=True) + c.label(140, 18, "fell through") + c.object_box(212, 12, "", "else: …", w=80, h=20) + c.closed_arrow(120, 64, 200, 84, emphasis=False) + c.label(140, 100, "broke — else skipped") + + +def e_iterating_over_iterables(c: Canvas) -> None: + c.object_box(20, 36, "iterable", "[a,b,c]", w=70, h=30, soft=True) + c.dashed(90, 51, 124, 51) + c.label(108, 46, "iter()", anchor="middle") + c.object_box(126, 36, "iterator", "", w=70, h=30, soft=False) + c.closed_arrow(196, 51, 230, 51) + c.label(214, 46, "next()", anchor="middle") + c.object_box(232, 36, "", "value …", w=70, h=30, soft=True) + + +def e_iterators(c: Canvas) -> None: + c.node(60, 50, "idle", r=20) + c.node(160, 50, "next()", r=24) + c.node(252, 50, "done", r=22, ghost=True) + c.connect(60, 50, 20, 160, 50, 24, kind="emphasis", offset=2) + c.connect(160, 50, 24, 252, 50, 22, kind="emphasis", offset=2) + c.label(110, 44, "iter()", anchor="middle") + c.label(206, 44, "stop", anchor="middle") + + +def e_match_statements(c: Canvas) -> None: + c.mono(20, 22, "match value:", anchor="start") + cases = ["case 0:", "case [x, *_]:", "case _:"] + for i, txt in enumerate(cases): + y = 32 + i * 22 + c.cell(40, y, "", w=180, h=18) + c.mono(50, y + 12, txt, anchor="start", size=9) + c.dashed(238, 36, 238, 100) + c.dot(238, 64, emphasis=True) + c.closed_arrow(238, 92, 238, 102, emphasis=True) + c.label(254, 70, "first match") + + +def e_advanced_match(c: Canvas) -> None: + rows = [("capture", "[x, y]"), ("alternative", "P() | Q()"), ("guard", "[x] if x > 0"), ("class", "Point(x=0, y=_)")] + for i, (tag, body) in enumerate(rows): + y = 14 + i * 26 + c.tag(20, y, tag) + soft = (tag == "class") + c.cell(80, y - 2, body, w=180, h=18, soft=soft) + + +def e_while_loops(c: Canvas) -> None: + c.node(160, 38, "?", r=16) + c.closed_arrow(176, 38, 240, 38) + c.cell(242, 26, "body", w=60, h=24) + c.dashed(272, 50, 272, 78) + c.dashed(272, 78, 144, 78) + c.dashed(144, 78, 144, 54) + c.closed_arrow(144, 38, 96, 38, emphasis=False) + c.label(96, 30, "exit", anchor="end") + + +def e_lists(c: Canvas) -> None: + c.tag(20, 22, "mutable sequence") + items = ["3", "1", "4", ""] + c.cells(20, 30, items) + c.cell(116, 30, "+1", ghost=True) + c.closed_arrow(150, 42, 200, 42) + c.label(206, 46, ".append", anchor="start") + + +def e_tuples(c: Canvas) -> None: + c.tag(20, 22, "immutable sequence") + c.cell(20, 30, "", w=160, h=28) + c.hairline(60, 30, 60, 58) + c.hairline(100, 30, 100, 58) + c.hairline(140, 30, 140, 58) + for i, v in enumerate("3141"): + c.mono(40 + i * 40, 48, v) + + +def e_unpacking(c: Canvas) -> None: + items = ["1", "2", "3", "4", "5"] + for i, v in enumerate(items): + c.cell(20 + i * 30, 20, v, w=30, h=22) + c.cell(20, 78, "a", w=30, h=22) + c.cell(50, 78, "*rest", w=90, h=22, ghost=True) + c.cell(140, 78, "b", w=30, h=22) + c.dashed(35, 42, 35, 78) + c.dashed(65, 42, 95, 78) + c.dashed(95, 42, 95, 78) + c.dashed(125, 42, 95, 78) + c.dashed(155, 42, 155, 78) + + +def e_dicts(c: Canvas) -> None: + c.tag(20, 22, "hash & mask → bucket") + rows = [("0", '"a" → 1'), ("1", '"b" → 2'), ("2", '"c" → 3')] + for i, (idx, body) in enumerate(rows): + y = 30 + i * 24 + c.label(14, y + 16, idx, anchor="end") + c.cell(20, y, body, w=80, h=24) + c.closed_arrow(102, 66, 142, 66) + c.cell(144, 54, '"d" → 4', w=80, h=24, soft=True) + c.label(232, 70, "collision") + + +def e_sets(c: Canvas) -> None: + c.tag(20, 22, "hash buckets · keys only") + for i, k in enumerate("abc"): + c.cell(20, 30 + i * 24, k, w=60, h=24) + c.closed_arrow(82, 66, 158, 66) + c.label(122, 60, "x in s", anchor="middle") + c.cell(160, 54, "O(1)", w=60, h=24) + c.label(230, 56, "no order") + c.label(230, 70, "no duplicates") + + +def e_slices(c: Canvas) -> None: + items = list("abcde") + for i, v in enumerate(items): + c.cell(20 + i * 36, 36, v, w=36, h=24) + for i in range(5): + c.label(38 + i * 36, 74, str(i), anchor="middle") + c.register(20, 86, 180, divisions=5) + for i in range(6): + c.label(20 + i * 36, 100, str(i), anchor="middle") + c.dashed(56, 30, 56, 36) + c.dashed(164, 30, 164, 36) + c.label(110, 22, "[1:4]", anchor="middle") + + +def e_comprehensions(c: Canvas) -> None: + c.cell(20, 22, "[x*2 for x in xs if x > 0]", w=280, h=22, soft=True) + c.cell(20, 56, "out = []", w=280, h=14, ghost=True) + c.cell(20, 70, "for x in xs:", w=280, h=14, ghost=True) + c.cell(20, 84, " if x > 0: out.append(x*2)", w=280, h=14, ghost=True) + + +def e_comprehension_patterns(c: Canvas) -> None: + boxes = [("source", "xs · ys"), ("filter", "if y>0"), ("map", "x*y")] + x = 20 + for i, (tag, body) in enumerate(boxes): + c.cell(x, 36, body, w=70, h=30) + c.tag(x, 30, tag) + if i < 2: + c.closed_arrow(x + 72, 51, x + 90, 51) + x += 90 + + +def e_sorting(c: Canvas) -> None: + c.tag(60, 16, "input", anchor="middle") + c.tag(240, 16, "stable sort", anchor="middle") + inputs = ["2 · Ada", "1 · Bo", "2 · Eve", "1 · Cy"] + outputs = ["1 · Bo", "1 · Cy", "2 · Ada", "2 · Eve"] + for i, (a, b) in enumerate(zip(inputs, outputs)): + c.cell(20, 22 + i * 22, a, w=80, h=20) + c.cell(200, 22 + i * 22, b, w=80, h=20) + c.dashed(100, 54, 200, 32) + c.dashed(100, 98, 200, 54) + c.dashed(100, 32, 200, 76) + c.dashed(100, 76, 200, 98) + + +def e_functions(c: Canvas) -> None: + c.closed_arrow(20, 50, 80, 50, emphasis=False) + c.label(50, 42, "args", anchor="middle") + c.frame(82, 30, 120, 40, label="def f(...)") + c.closed_arrow(202, 50, 262, 50, emphasis=False) + c.label(232, 42, "return", anchor="middle") + + +def e_keyword_only(c: Canvas) -> None: + c.mono(20, 40, "def f(a, b, *, c, d): …", anchor="start") + c.dashed(105, 44, 105, 70) + c.label(60, 80, "positional or kw", anchor="middle") + c.label(160, 80, "keyword only", anchor="middle") + + +def e_positional_only(c: Canvas) -> None: + c.mono(20, 40, "def f(a, b, /, c, d): …", anchor="start") + c.dashed(112, 44, 112, 70) + c.label(60, 80, "positional only", anchor="middle") + c.label(180, 80, "positional or kw", anchor="middle") + + +def e_args_kwargs(c: Canvas) -> None: + c.mono(20, 40, "def f(*args, **kwargs): …", anchor="start") + c.label(80, 70, "extra positionals", anchor="middle") + c.label(80, 86, "tuple", anchor="middle") + c.label(180, 70, "extra keywords", anchor="middle") + c.label(180, 86, "dict", anchor="middle") + + +def e_multiple_return(c: Canvas) -> None: + c.frame(20, 32, 100, 32, label="def f()") + c.closed_arrow(120, 48, 158, 48) + c.object_box(160, 36, "tuple", "(a, b)", w=80, h=24) + c.dashed(200, 60, 200, 80) + c.cell(180, 84, "x", w=22, h=18) + c.cell(206, 84, "y", w=22, h=18) + + +def e_closures(c: Canvas) -> None: + c.frame(20, 22, 280, 80, label="outer()") + c.object_box(40, 50, "cell", "n=0", w=50, h=22) + c.frame(102, 44, 180, 56, label="inner()") + c.label(190, 80, "uses cell", anchor="middle") + c.closed_arrow(116, 78, 92, 64) + + +def e_scope_legb(c: Canvas) -> None: + c.frame(14, 14, 296, 92, label="B · built-in") + c.frame(34, 30, 262, 72, label="G · global") + c.frame(58, 46, 226, 52, label="E · enclosing") + c.frame(86, 62, 170, 32, label="L · local") + c.dashed(170, 90, 170, 110) + + +def e_recursion(c: Canvas) -> None: + for i in range(4): + c.cell(60, 14 + i * 20, f"factorial({3 - i}){' ← base' if i == 3 else ''}", w=200, h=20) + c.dashed(272, 90, 272, 18) + c.closed_arrow(272, 30, 272, 18, emphasis=True) + + +def e_lambdas(c: Canvas) -> None: + c.cell(60, 36, "lambda x: x + 1", w=200, h=32, soft=True) + c.dashed(100, 68, 100, 78) + c.dashed(220, 68, 220, 78) + c.label(100, 92, "params", anchor="middle") + c.label(220, 92, "expression", anchor="middle") + + +def e_generators(c: Canvas) -> None: + c.tag(20, 22, "paused between yields · resumed by next()") + c.ribbon(20, 30, 280, h=30, gates=[84, 156, 228], soft_segments=[(20, 84), (156, 228)]) + c.mono(52, 50, "…work…") + c.mono(120, 50, "yield") + c.mono(192, 50, "…work…") + c.mono(264, 50, "yield") + + +def e_yield_from(c: Canvas) -> None: + c.tag(20, 22, "outer") + c.ribbon(20, 30, 280, h=24, gates=[120, 200]) + c.mono(160, 46, "yield from inner()") + c.tag(120, 70, "inner") + c.ribbon(120, 78, 80, h=28, gates=[148, 172]) + c.dashed(160, 78, 160, 54) + c.label(240, 96, "delegated") + + +def e_generator_expressions(c: Canvas) -> None: + c.mono(20, 46, "(", anchor="start") + c.cell(30, 36, "source", w=60, h=20) + c.closed_arrow(90, 46, 108, 46) + c.cell(108, 36, "filter", w=60, h=20) + c.closed_arrow(168, 46, 186, 46) + c.cell(186, 36, "map", w=60, h=20) + c.mono(252, 46, ")", anchor="start") + c.label(160, 84, "lazy stream — no list materialised", anchor="middle") + + +def e_itertools(c: Canvas) -> None: + c.tag(20, 22, "chain") + c.cell(20, 28, "a · b", w=80, h=14) + c.cell(100, 28, "c · d", w=80, h=14) + c.tag(20, 60, "cycle") + c.cell(20, 66, "a · b · c", w=100, h=14) + c.dashed(120, 73, 140, 73) + c.tag(20, 98, "islice") + c.cell(20, 104, "", w=180, h=14, ghost=True) + c.cell(60, 104, "window", w=60, h=14) + + +def e_decorators(c: Canvas) -> None: + c.tag(20, 18, "before") + c.bind(20, 26, "f", "fn", "f₀ body", object_w=80, gap=20) + c.tag(20, 78, "after @dec") + c.name_box(20, 90, "f") + c.closed_arrow(80, 102, 110, 102) + c.cell(112, 88, "wrapper", w=80, h=28) + c.object_box(196, 90, "cell", "f₀", w=40, h=24) + c.dashed(192, 102, 196, 102) + + +def e_classes(c: Canvas) -> None: + c.dot(50, 50, emphasis=False) + c.label(50, 78, "instance", anchor="middle") + c.closed_arrow(56, 50, 118, 50, emphasis=False) + c.frame(120, 32, 60, 36, label="class") + c.mono(150, 54, "Class") + c.closed_arrow(180, 50, 232, 50, emphasis=False) + c.frame(234, 32, 60, 36, label="type") + c.mono(264, 54, "type") + + +def e_inheritance(c: Canvas) -> None: + c.frame(140, 6, 40, 22, ghost=True) + c.mono(160, 22, "A") + c.ghost(160, 28, 100, 50) + c.ghost(160, 28, 220, 50) + c.frame(80, 50, 40, 22, ghost=True) + c.mono(100, 66, "B") + c.frame(200, 50, 40, 22, ghost=True) + c.mono(220, 66, "C") + c.tag(20, 96, "MRO") + chain = ["D", "B", "C", "A", "object"] + widths = [40, 40, 40, 40, 60] + x = 20 + for v, w in zip(chain, widths): + c.cell(x, 102, v, w=w, h=22) + x += w + + +def e_dataclasses(c: Canvas) -> None: + c.tag(20, 22, "declaration") + fields = [("name", "str"), ("age", "int"), ("tags", "list")] + for i, (n, t) in enumerate(fields): + c.cell(20, 30 + i * 20, f"{n} : {t}", w=120, h=20) + c.closed_arrow(140, 60, 178, 60) + c.object_box(180, 44, "", "__init__(name, age, tags)", w=124, h=32) + + +def e_properties(c: Canvas) -> None: + c.cell(20, 40, "obj.x", w=80, h=32) + c.closed_arrow(102, 50, 158, 30) + c.object_box(160, 14, "", "fget / fset", w=120, h=26) + c.dashed(102, 60, 158, 86) + c.cell(160, 74, "__dict__", w=120, h=26, ghost=True) + + +def e_special_methods(c: Canvas) -> None: + c.mono(60, 44, "a + b") + c.closed_arrow(100, 40, 168, 40) + c.label(135, 32, "dispatches", anchor="middle") + c.object_box(170, 28, "", "a.__add__(b)", w=130, h=26) + + +def e_metaclasses(c: Canvas) -> None: + c.dot(40, 50, emphasis=False) + c.closed_arrow(46, 50, 108, 50, emphasis=False) + c.frame(110, 34, 60, 32, label="class") + c.mono(140, 54, "Class") + c.closed_arrow(170, 50, 226, 50, emphasis=False) + c.frame(228, 30, 80, 40, label="metaclass") + c.mono(268, 50, "Metaclass") + + +def e_context_managers(c: Canvas) -> None: + c.node(40, 60, "in", r=14) + c.closed_arrow(54, 60, 100, 60, emphasis=False) + c.cell(100, 44, "body", w=120, h=32) + c.closed_arrow(220, 60, 266, 60) + c.node(282, 60, "out", r=14) + c.dashed(160, 76, 268, 60) + + +def e_delete_statements(c: Canvas) -> None: + c.tag(20, 18, "before") + c.bind(20, 26, "x", "list", "[1,2,3]", object_w=80, gap=20) + c.tag(20, 78, "after del x") + c.name_box(20, 86, "x") + c.closed_arrow(80, 98, 118, 98, emphasis=False) + c.object_box(120, 82, "list", "[1,2,3]", w=80, h=32) + + +def e_exceptions(c: Canvas) -> None: + ys = [(22, "try"), (46, "except"), (70, "else"), (94, "finally")] + path = [(50, 22), (120, 22), (140, 70), (200, 70), (220, 94), (290, 94)] + c.lanes(ys, x0=40, x1=300, path=path) + + +def e_assertions(c: Canvas) -> None: + c.mono(20, 40, "assert cond, msg", anchor="start") + c.dashed(120, 46, 120, 64) + c.cell(60, 68, "true · pass", w=70, h=22) + c.cell(140, 68, "false · AssertionError", w=140, h=22, soft=True) + + +def e_exception_chaining(c: Canvas) -> None: + c.cell(20, 36, "ValueError", w=100, h=32) + c.closed_arrow(120, 44, 200, 44) + c.label(160, 36, "__cause__", anchor="middle") + c.dashed(120, 60, 200, 60) + c.label(160, 78, "__context__", anchor="middle") + c.cell(202, 36, "RuntimeError", w=100, h=32) + + +def e_exception_groups(c: Canvas) -> None: + c.tag(60, 16, "before except*", anchor="middle") + c.dot(60, 32) + for x in (30, 50, 70, 90): + c.ghost(60, 38, x, 60) + c.dot(30, 64); c.dot(50, 64, emphasis=True); c.dot(70, 64); c.dot(90, 64, emphasis=True) + c.closed_arrow(120, 50, 180, 50) + c.label(150, 44, "except*", anchor="middle") + c.tag(240, 16, "after", anchor="middle") + c.dot(240, 32) + c.ghost(240, 38, 220, 60) + c.ghost(240, 38, 260, 60) + c.dot(220, 64); c.dot(260, 64) + + +def e_modules(c: Canvas) -> None: + c.tag(20, 22, "sys.path") + paths = ["cwd", "site-packages", "stdlib", "…"] + for i, p in enumerate(paths): + c.cell(20, 28 + i * 20, p, w=120, h=20) + c.closed_arrow(140, 58, 200, 58) + c.label(170, 52, "first hit", anchor="middle") + c.cell(202, 46, "mymod.py", w=100, h=24, soft=True) + + +def e_import_aliases(c: Canvas) -> None: + c.mono(20, 28, "import numpy as np", anchor="start") + c.bind(20, 50, "np", "module", "numpy", object_w=120, gap=20) + + +def e_type_hints(c: Canvas) -> None: + c.mono(20, 56, "def f(x: int, y: str) -> bool: …", anchor="start") + c.dashed(72, 60, 92, 60) + c.dashed(112, 60, 132, 60) + c.dashed(160, 60, 196, 60) + + +def e_protocols(c: Canvas) -> None: + c.tag(60, 14, "object", anchor="middle") + c.frame(20, 20, 100, 80) + for i, m in enumerate(["read()", "write()", "close()", "other()"]): + c.mono(70, 38 + i * 18, m) + c.closed_arrow(120, 60, 180, 60, emphasis=False) + c.label(150, 54, "structural ✓", anchor="middle") + c.tag(240, 14, "protocol", anchor="middle") + c.frame(200, 20, 100, 80, ghost=True) + c.mono(250, 44, "read()") + c.mono(250, 62, "close()") + + +def e_enums(c: Canvas) -> None: + c.frame(20, 24, 280, 60, ghost=True, label="Color · closed set") + for i, m in enumerate(["RED", "GREEN", "BLUE", "no more"]): + c.cell(40 + i * 60, 38, m, w=50, h=32) + + +def e_regex(c: Canvas) -> None: + c.tag(20, 22, "pattern") + c.mono(20, 44, "^\\d{2}-\\d{2}$", anchor="start") + c.tag(20, 70, "input") + c.cell(20, 76, "", w=180, h=20) + c.cell(40, 76, "12-34", w=120, h=20, soft=True) + + +def e_number_parsing(c: Canvas) -> None: + c.node(40, 50, '"42"', r=16) + c.closed_arrow(56, 50, 100, 50) + c.label(78, 44, "int()", anchor="middle") + c.node(120, 50, "42", r=16) + c.closed_arrow(136, 50, 180, 24, emphasis=False) + c.cell(180, 14, "ValueError", w=100, h=22, ghost=True) + c.closed_arrow(136, 50, 180, 80) + c.cell(180, 70, "int", w=100, h=22, soft=True) + + +def e_custom_exceptions(c: Canvas) -> None: + chain = ["BaseException", "Exception", "ValueError", "MyDomainError"] + for i, name in enumerate(chain): + emph = (i == len(chain) - 1) + c.cell(60, 14 + i * 26, name, w=200, h=22, soft=emph) + + +def e_json(c: Canvas) -> None: + c.hairline(160, 14, 160, 122) + c.tag(80, 14, "json", anchor="middle") + c.tag(240, 14, "python", anchor="middle") + rows = [("object", "dict"), ("array", "list"), ("string", "str"), ("number", "int / float"), ("true / false", "True / False"), ("null", "None")] + for i, (a, b) in enumerate(rows): + y = 36 + i * 16 + c.mono(20, y, a, anchor="start", size=10) + c.mono(180, y, b, anchor="start", size=10) + + +def e_datetime(c: Canvas) -> None: + c.register(20, 60, 280, divisions=7) + c.dashed(160, 50, 160, 70) + c.label(160, 44, "one instant", anchor="middle") + c.node(80, 92, "−5h", r=14) + c.dashed(80, 78, 160, 70) + c.node(240, 92, "+0h", r=14) + c.dashed(240, 78, 160, 70) + + +def e_async_await(c: Canvas) -> None: + c.lane(34, x0=20, x1=300, label="loop") + c.lane(74, x0=20, x1=300, label="coro") + c.cell(40, 68, "", w=40, h=12) + c.dashed(80, 74, 120, 34) + c.cell(120, 28, "", w=60, h=12) + c.dashed(180, 34, 220, 74) + c.cell(220, 68, "", w=40, h=12) + c.label(100, 22, "await", anchor="middle") + c.label(200, 22, "resume", anchor="middle") + + +def e_async_iteration(c: Canvas) -> None: + c.tag(20, 22, "async for · async with") + c.ribbon(20, 30, 280, h=30, gates=[84, 156, 228]) + c.mono(50, 50, "…") + c.mono(120, 50, "await yield") + c.mono(192, 50, "…") + c.mono(264, 50, "await yield") + + +EXAMPLES = [ + Card("hello-world", "Hello World", "Basics", 1, e_hello_world), + Card("values", "Values", "Basics", 2, e_values, note="every value is an object with a type"), + Card("numbers", "Numbers", "Basics", 3, e_numbers), + Card("booleans", "Booleans", "Basics", 4, e_booleans, height=90), + Card("operators-and-literals", "Operators and Literals", "Basics", 5, e_operators, height=130), + Card("none", "None", "Basics", 6, e_none, height=130, note="one shared singleton"), + Card("variables", "Variables", "Basics", 7, e_variables, note="names bind to objects"), + Card("constants", "Constants", "Basics", 8, e_constants, note="UPPER_CASE — convention, not enforcement"), + Card("truthiness", "Truthiness", "Basics", 9, e_truthiness), + Card("equality-and-identity", "Equality and Identity", "Basics", 10, e_equality, height=110, note="same object · or two equal objects"), + Card("mutability", "Mutability", "Basics", 11, e_mutability, height=200), + Card("strings", "Strings", "Basics", 12, e_strings), + Card("string-formatting", "String Formatting", "Basics", 13, e_string_formatting, height=100), + Card("conditionals", "Conditionals", "Control Flow", 14, e_conditionals, height=110), + Card("assignment-expressions", "Assignment Expressions", "Control Flow", 15, e_assignment_expressions, height=110), + Card("for-loops", "For Loops", "Control Flow", 16, e_for_loops, height=160), + Card("break-and-continue", "Break and Continue", "Control Flow", 17, e_break_continue, height=110), + Card("loop-else", "Loop Else", "Control Flow", 18, e_loop_else, height=120), + Card("iterating-over-iterables", "Iterating over Iterables", "Control Flow", 19, e_iterating_over_iterables), + Card("iterators", "Iterators", "Control Flow", 20, e_iterators, height=110), + Card("match-statements", "Match Statements", "Control Flow", 21, e_match_statements, height=130), + Card("advanced-match-patterns", "Advanced Match Patterns", "Control Flow", 22, e_advanced_match, height=130), + Card("while-loops", "While Loops", "Control Flow", 23, e_while_loops, height=110), + Card("lists", "Lists", "Collections", 24, e_lists), + Card("tuples", "Tuples", "Collections", 25, e_tuples), + Card("unpacking", "Unpacking", "Collections", 26, e_unpacking, height=110), + Card("dicts", "Dictionaries", "Collections", 27, e_dicts, height=110), + Card("sets", "Sets", "Collections", 28, e_sets, height=110), + Card("slices", "Slices", "Collections", 29, e_slices, height=110), + Card("comprehensions", "Comprehensions", "Collections", 30, e_comprehensions, height=110), + Card("comprehension-patterns", "Comprehension Patterns", "Collections", 31, e_comprehension_patterns, note="nested clauses compose left to right"), + Card("sorting", "Sorting", "Collections", 32, e_sorting, height=130, note="equal keys keep original order"), + Card("functions", "Functions", "Functions", 33, e_functions, note="named behavior with a stable interface"), + Card("keyword-only-arguments", "Keyword-only Arguments", "Functions", 34, e_keyword_only, height=100), + Card("positional-only-parameters", "Positional-only Parameters", "Functions", 35, e_positional_only, height=100), + Card("args-and-kwargs", "Args and Kwargs", "Functions", 36, e_args_kwargs, height=100), + Card("multiple-return-values", "Multiple Return Values", "Functions", 37, e_multiple_return), + Card("closures", "Closures", "Functions", 38, e_closures, height=120), + Card("scope-global-nonlocal", "Global and Nonlocal", "Functions", 39, e_scope_legb, height=130), + Card("recursion", "Recursion", "Functions", 40, e_recursion, height=120), + Card("lambdas", "Lambdas", "Functions", 41, e_lambdas, height=110), + Card("generators", "Generators", "Iteration", 42, e_generators, note="function body as a timeline"), + Card("yield-from", "Yield From", "Iteration", 43, e_yield_from, height=120), + Card("generator-expressions", "Generator Expressions", "Iteration", 44, e_generator_expressions), + Card("itertools", "Itertools", "Iteration", 45, e_itertools, height=130), + Card("decorators", "Decorators", "Functions", 46, e_decorators, height=130), + Card("classes", "Classes", "Classes", 47, e_classes, height=110), + Card("inheritance-and-super", "Inheritance and Super", "Classes", 48, e_inheritance, height=130), + Card("dataclasses", "Dataclasses", "Classes", 49, e_dataclasses, height=120), + Card("properties", "Properties", "Classes", 50, e_properties, height=120), + Card("special-methods", "Special Methods", "Data Model", 51, e_special_methods), + Card("metaclasses", "Metaclasses", "Classes", 52, e_metaclasses, height=110), + Card("context-managers", "Context Managers", "Data Model", 53, e_context_managers, height=110), + Card("delete-statements", "Delete Statements", "Data Model", 54, e_delete_statements, height=130), + Card("exceptions", "Exceptions", "Errors", 55, e_exceptions, height=130, note="no raise → try → else → finally"), + Card("assertions", "Assertions", "Errors", 56, e_assertions, height=110), + Card("exception-chaining", "Exception Chaining", "Errors", 57, e_exception_chaining, height=110), + Card("exception-groups", "Exception Groups", "Errors", 58, e_exception_groups, height=110, note="matched leaves removed; survivors regrouped"), + Card("modules", "Modules", "Modules", 59, e_modules, height=130), + Card("import-aliases", "Import Aliases", "Modules", 60, e_import_aliases, height=110), + Card("type-hints", "Type Hints", "Types", 61, e_type_hints, height=100), + Card("protocols", "Protocols", "Types", 62, e_protocols, height=120, note="duck — required methods present"), + Card("enums", "Enums", "Types", 63, e_enums, height=110), + Card("regular-expressions", "Regular Expressions", "Text", 64, e_regex, height=110), + Card("number-parsing", "Number Parsing", "Standard Library", 65, e_number_parsing, height=110), + Card("custom-exceptions", "Custom Exceptions", "Errors", 66, e_custom_exceptions, height=130), + Card("json", "JSON", "Standard Library", 67, e_json, height=130), + Card("datetime", "Dates and Times", "Standard Library", 68, e_datetime, height=120), + Card("async-await", "Async Await", "Async", 69, e_async_await, height=110), + Card("async-iteration-and-context", "Async Iteration and Context", "Async", 70, e_async_iteration), +] + + +# ─── Page scaffold ───────────────────────────────────────────────────── + + +HEAD = """ + + + +Marginalia gestalt — Python By Example + + + +
+

Marginalia gestalt

+

Every journey and example, generated from a single grammar of primitives.

+

Locked metrics, locked palette, locked typography. Cards compose words; words compose tokens; nothing is bespoke.

+
+""" + + +def render() -> str: + out = [HEAD] + out.append('

Journeys

\n
') + for j in JOURNEYS: + out.append(j.render_html()) + out.append("
") + out.append('

Examples

\n
') + for e in EXAMPLES: + out.append(e.render_html()) + out.append("
\n\n\n") + return "\n".join(out) + + +def main() -> None: + OUT.write_text(render()) + print(f"wrote {OUT.relative_to(ROOT)} — {len(JOURNEYS)} journeys + {len(EXAMPLES)} examples") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_prototypes.py b/scripts/build_prototypes.py new file mode 100644 index 0000000..2f0e282 --- /dev/null +++ b/scripts/build_prototypes.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +"""Generate exploratory prototypes under public/prototyping/. + +The canonical layout is "figure between prose and code", with the cell +dropping to single-column when a figure is attached. These prototypes +demonstrate that layout on representative examples and journeys, plus +keep the marginalia-gestalt and operators-comparison review pages. +""" + +from __future__ import annotations + +import html +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from app import ( # noqa: E402 (sys.path set above) + JOURNEYS_BY_SLUG, + _walkthrough_cells, + get_example, + render_inline, +) +from marginalia import _render_svg + +OUT_DIR = ROOT / "public" / "prototyping" + + +# ─── Page scaffolding ────────────────────────────────────────────────── + + +def page(title: str, banner: str, style_extras: str, body: str) -> str: + return f""" + + + +{html.escape(title)} · Prototype + + + + +
Prototype · {banner} · all prototypes
+{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""" +
    + +
    +

    {html.escape(example['section'])}

    +

    {html.escape(example['title'])}

    +

    {html.escape(example['summary'])}

    +
    +
    {walkthrough}
    +

    Notes

    + +
    +

    Run the complete example

    +
    +
    +

    Example code

    +
    {code}
    +
    +

    Expected output

    {output}
    +
    +
    +
    +""" + + +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'
  • {html.escape(title)}

    {html.escape(desc)}

  • ' + 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.

    +
    + +
    +""" + 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'
  • {html.escape(ex["title"]) if ex else ex_slug}

    {html.escape(sentence)}

  • ' + ) + else: + _, label, sentence = item + items.append( + f'
  • Gap · {html.escape(label)}

    {html.escape(sentence)}

  • ' + ) + figure_entry = JOURNEY_SECTION_FIGURES.get(section["title"]) + fig_html = "" + if figure_entry is not None: + fig_name, caption = figure_entry + fig_html = ( + f'
    {_render_svg(fig_name)}' + f'
    {html.escape(caption)}
    ' + ) + sections_html.append( + f'
    ' + f'

    {html.escape(section["title"])}

    ' + f'

    {html.escape(section["summary"])}

    ' + f'
      {"".join(items)}
    ' + f"{fig_html}" + f"
    " + ) + body = f""" +
    + +
    +

    Journey

    +

    {html.escape(journey['title'])}

    +

    {html.escape(journey['summary'])}

    +
    + {''.join(sections_html)} +
    +""" + (OUT_DIR / f"journey-{slug}.html").write_text( + page( + f"Journey · {journey['title']}", + f"{journey['title']} journey with one section-faithful figure per section. Each figure captures the conceptual shift the section introduces.", + JOURNEY_STYLE, + body, + ) + ) + + +# ─── Journey-figures gestalt (all 18 section figures on one page) ────── + + +JOURNEY_FIGURES_GESTALT_STYLE = """ + .journey-block { margin-block: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .journey-block:first-of-type { border-top: 0; padding-top: 0; } + .journey-block h2 { margin: 0 0 var(--space-2); } + .journey-block .meta { max-width: 64ch; color: var(--muted); margin: 0 0 var(--space-4); } + .section-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-4) var(--space-4); + } + .section-grid figure { margin: 0; padding: 0; } + .section-grid h3 { + font-size: 1rem; font-weight: 600; letter-spacing: -0.005em; + margin: 0 0 var(--space-2); color: var(--text); + } + .section-grid svg { width: 100%; max-width: 320px; height: auto; display: block; } + .section-grid figcaption { + margin-top: var(--space-2); color: var(--muted); + font-size: .9rem; font-style: italic; max-width: 44ch; + } +""" + + +def build_journey_figures_gestalt() -> None: + """One page showing every journey section's figure, grouped by journey. + + Reviewers can see all 18 section figures at once to spot drift and + apply the rubric uniformly (see docs/journey-visualisation-rubric.md). + """ + blocks: list[str] = [] + for slug in ( + "runtime", + "control-flow", + "iteration", + "shapes", + "interfaces", + "types", + "reliability", + "workers", + ): + journey = JOURNEYS_BY_SLUG[slug] + cards: list[str] = [] + for section in journey["sections"]: + entry = JOURNEY_SECTION_FIGURES.get(section["title"]) + if entry is None: + continue + fig_name, caption = entry + cards.append( + f"
    " + f'

    {html.escape(section["title"])}

    ' + f"{_render_svg(fig_name)}" + f"
    {html.escape(caption)}
    " + f"
    " + ) + blocks.append( + f'
    ' + f'

    {html.escape(journey["title"])}

    ' + f'

    {html.escape(journey["summary"])}

    ' + f'
    {"".join(cards)}
    ' + f"
    " + ) + body = f""" +
    +
    +

    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>.

    +
    + {''.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'' + + "".join(self.parts) + + "" + ) + + +@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.

    • Booleans

      combine facts into readable conditions

    • Truthiness

      use object truth values without hiding intent

    • Operators

      build comparisons and boolean expressions for conditions

    • Conditionals

      choose between branches with clear predicates

    value?case Acase B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    Some branches become clearer when the code names an intermediate value or dispatches on data shape.

    len(xs):=nNAMEvaluen > 10one expression: bind a name and use the value
    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 exits make the successful path easier to read by moving exceptional or completed cases out of the way.

    • Guard Clauses

      show how early returns reduce nested conditional code

    • Assertions

      state assumptions that should fail loudly while developing

    • Exceptions

      leave the current path when ordinary return values are not enough

    abcdefound · breakfirst match
    The loop exits at the first match — break short-circuits the rest of the sequence.
    +

    Choose between paths.

    Start with ordinary branching and boolean predicates before reaching for more compact forms.

    • Booleans

      combine facts into readable conditions

    • Truthiness

      use object truth values without hiding intent

    • Operators

      build comparisons and boolean expressions for conditions

    • Conditionals

      choose between branches with clear predicates

    value?case Acase B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    Some branches become clearer when the code names an intermediate value or dispatches on data shape.

    len(xs):=nNAMEvaluen > 10
    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 exits make the successful path easier to read by moving exceptional or completed cases out of the way.

    • Guard Clauses

      show how early returns reduce nested conditional code

    • Assertions

      state assumptions that should fail loudly while developing

    • Exceptions

      leave the current path when ordinary return values are not enough

    abcdefound · breakfirst match
    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 index cfe395b..9fc3b96 100644 --- a/public/prototyping/journey-figures-gestalt.html +++ b/public/prototyping/journey-figures-gestalt.html @@ -31,7 +31,7 @@ font-size: 1rem; font-weight: 600; letter-spacing: -0.005em; margin: 0 0 var(--space-2); color: var(--text); } - .section-grid svg { width: 100%; max-width: 320px; height: auto; display: block; } + .section-grid svg { max-width: min(100%, 320px); height: auto; display: block; } .section-grid figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .9rem; font-style: italic; max-width: 44ch; @@ -48,7 +48,7 @@

    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.

    print("…")stdouthello world
    Every 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 B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    len(xs):=nNAMEvaluen > 10one expression: bind a name and use the value
    The 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 match
    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.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() 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()values flow lazily — nothing materialised
    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}unique
    Each 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"TEXTparseINT42
    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.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    FNdef fg = fnsecond name binds to the same function
    Functions 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; runtime accepts any object
    Annotations describe expected types for tools; the runtime accepts any object regardless.

    Describe realistic data shapes.

    X: INT | STR | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    TFN[T]Tthe same T flows in and out
    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.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    inbodyoutraise still routes through __exit__
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    LOOPCOROawaitresume
    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.

    print("…")stdouthello world
    Every 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 B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    len(xs):=nNAMEvaluen > 10
    The 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 match
    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.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() 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}unique
    Each 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"TEXTparseINT42
    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.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    FNdef fg = fn
    Functions 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 | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    TFN[T]T
    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.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    inbodyout
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    LOOPCOROawaitresume
    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.

    diff --git a/public/prototyping/journey-interfaces.html b/public/prototyping/journey-interfaces.html index 42ce9eb..2d77511 100644 --- a/public/prototyping/journey-interfaces.html +++ b/public/prototyping/journey-interfaces.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 @@

    Interfaces

    This journey shows how Python grows from simple functions to callable APIs, object interfaces, protocols, and metaclasses.

    -

    Start with functions as named behavior.

    Functions are the first abstraction boundary because they name behavior and control how callers provide information.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    Python functions can capture state, be passed around, and wrap other functions.

    FNdef fg = fnsecond name binds to the same function
    Functions are first-class values. A second name binds to the same function object.

    Bundle behavior with state.

    Classes become useful when data and behavior need to move together behind a stable interface.

    CLASS BOXSTATEx · yMETHODSmove(...)
    Classes group fields and methods so data and behavior move together behind one interface.
    +

    Start with functions as named behavior.

    Functions are the first abstraction boundary because they name behavior and control how callers provide information.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    Python functions can capture state, be passed around, and wrap other functions.

    FNdef fg = fn
    Functions are first-class values. A second name binds to the same function object.

    Bundle behavior with state.

    Classes become useful when data and behavior need to move together behind a stable interface.

    CLASS BOXSTATEx · yMETHODSmove(...)
    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 index 37c7197..1b29f83 100644 --- a/public/prototyping/journey-iteration.html +++ b/public/prototyping/journey-iteration.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 @@

    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.

    Loops differ by what they consume, when they stop, and whether completion itself carries meaning.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    The important mental shift is that loops consume producers through a protocol rather than special-casing lists.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.

    Compose lazy value streams.

    Iterator pipelines are useful when code can transform values one at a time instead of materializing every intermediate result.

    SOURCE[a,b,c]FILTERx>0MAPx*2next()values flow lazily — nothing materialised
    Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.
    +

    Choose the right loop shape.

    Loops differ by what they consume, when they stop, and whether completion itself carries meaning.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    The important mental shift is that loops consume producers through a protocol rather than special-casing lists.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.

    Compose lazy value streams.

    Iterator pipelines are useful when code can transform values one at a time instead of materializing every intermediate result.

    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.
    diff --git a/public/prototyping/journey-reliability.html b/public/prototyping/journey-reliability.html index 8d58290..19295db 100644 --- a/public/prototyping/journey-reliability.html +++ b/public/prototyping/journey-reliability.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 @@

    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.

    Robust Python code distinguishes expected absence, broken assumptions, recoverable errors, and domain-specific failures.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    Cleanup, deletion, imports, and modules define where responsibilities begin and end.

    inbodyoutraise still routes through __exit__
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    I/O, testing, logging, subprocesses, and concurrency require different control boundaries from ordinary expressions.

    LOOPCOROawaitresume
    On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.
    +

    Make failure explicit.

    Robust Python code distinguishes expected absence, broken assumptions, recoverable errors, and domain-specific failures.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    Cleanup, deletion, imports, and modules define where responsibilities begin and end.

    inbodyout
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    I/O, testing, logging, subprocesses, and concurrency require different control boundaries from ordinary expressions.

    LOOPCOROawaitresume
    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.

    • Hello World

      start with a complete program and its output

    • Values

      see that Python programs manipulate runtime objects

    • Literals

      write small values directly in source code

    • Variables

      understand that names bind to objects rather than storing values themselves

    • Constants

      learn the convention Python uses for values that should not change

    print("…")stdouthello world
    Every page is a runnable program. The smallest mental model: source produces visible output.

    Separate value, identity, and absence.

    This section prevents early confusion about equality, object identity, missing values, and truth tests.

    • None

      represent expected absence with a singleton object

    • Booleans

      combine facts with boolean operators

    • Truthiness

      predict how objects behave in boolean contexts

    • Equality and Identity

      distinguish value equality from object identity

    • Mutability

      predict when operations change an object in place

    • Object Lifecycle

      explain references, garbage collection, and why identity can outlive a single name

    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.

    This section connects operators, text, and formatting to Python's data model.

    • Numbers

      use numeric objects and arithmetic operators

    • Operators

      combine, compare, and test values with expression syntax

    • Strings

      treat text as Unicode rather than raw bytes

    • String Formatting

      turn objects into readable text at output boundaries

    • Bytes and Bytearray

      contrast text with binary data and explicit decoding

    a + bdispatchesa.__add__(b)
    Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.
    +

    Start with executable evidence.

    Learners first need to see that every page is a runnable program with visible output.

    • Hello World

      start with a complete program and its output

    • Values

      see that Python programs manipulate runtime objects

    • Literals

      write small values directly in source code

    • Variables

      understand that names bind to objects rather than storing values themselves

    • Constants

      learn the convention Python uses for values that should not change

    print("…")stdouthello world
    Every page is a runnable program. The smallest mental model: source produces visible output.

    Separate value, identity, and absence.

    This section prevents early confusion about equality, object identity, missing values, and truth tests.

    • None

      represent expected absence with a singleton object

    • Booleans

      combine facts with boolean operators

    • Truthiness

      predict how objects behave in boolean contexts

    • Equality and Identity

      distinguish value equality from object identity

    • Mutability

      predict when operations change an object in place

    • Object Lifecycle

      explain references, garbage collection, and why identity can outlive a single name

    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.

    This section connects operators, text, and formatting to Python's data model.

    • Numbers

      use numeric objects and arithmetic operators

    • Operators

      combine, compare, and test values with expression syntax

    • Strings

      treat text as Unicode rather than raw bytes

    • String Formatting

      turn objects into readable text at output boundaries

    • Bytes and Bytearray

      contrast text with binary data and explicit decoding

    a + bdispatchesa.__add__(b)
    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 index ddf6307..51be119 100644 --- a/public/prototyping/journey-shapes.html +++ b/public/prototyping/journey-shapes.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 @@

    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.

    Lists, tuples, dictionaries, and sets answer different questions about order, position, lookup, and uniqueness.

    • Lists

      store ordered mutable data

    • Tuples

      group fixed-position values

    • Dictionaries

      look up values by key

    • Sets

      model uniqueness and membership

    • Collections Module

      show `deque`, `Counter`, `defaultdict`, and `namedtuple` as specialized shapes

    LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}unique
    Each container answers a different question: ordered, fixed, lookup, unique.

    Move between shapes deliberately.

    Most everyday Python code is data reshaping, so learners need the idioms for selecting, unpacking, and rebuilding values.

    [3,1,4]sorted[1,3,4]
    Most everyday code reshapes data: one input, one transform, one new value.

    Cross text and data boundaries.

    Programs often receive text and produce structured data, so parsing and serialization belong in the data journey.

    "42"TEXTparseINT42
    Programs receive text and produce structured data; parsing makes the boundary explicit.
    +

    Pick the container that matches the question.

    Lists, tuples, dictionaries, and sets answer different questions about order, position, lookup, and uniqueness.

    • Lists

      store ordered mutable data

    • Tuples

      group fixed-position values

    • Dictionaries

      look up values by key

    • Sets

      model uniqueness and membership

    • Collections Module

      show `deque`, `Counter`, `defaultdict`, and `namedtuple` as specialized shapes

    LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}unique
    Each container answers a different question: ordered, fixed, lookup, unique.

    Move between shapes deliberately.

    Most everyday Python code is data reshaping, so learners need the idioms for selecting, unpacking, and rebuilding values.

    [3,1,4]sorted[1,3,4]
    Most everyday code reshapes data: one input, one transform, one new value.

    Cross text and data boundaries.

    Programs often receive text and produce structured data, so parsing and serialization belong in the data journey.

    "42"TEXTparseINT42
    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 index 2312e81..167bb14 100644 --- a/public/prototyping/journey-types.html +++ b/public/prototyping/journey-types.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 @@

    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.

    The first lesson is that annotations describe expectations for tools while ordinary Python objects still run the program.

    • Type Hints

      document expected types and feed type checkers

    • Protocols

      describe required behavior by structural shape

    • Enums

      name a fixed set of symbolic values

    • Runtime Type Checks

      show `type()`, `isinstance()`, and `issubclass()` without turning Python into Java

    def f(x: int, y: str) -> bool: …annotations describe; runtime accepts any object
    Annotations describe expected types for tools; the runtime accepts any object regardless.

    Describe realistic data shapes.

    Typed Python becomes useful when annotations explain optional values, unions, callables, and JSON-like records.

    X: INT | STR | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    Advanced typing exists to preserve information across reusable functions, containers, and decorators.

    • Generics and TypeVar

      write reusable typed containers and functions

    • ParamSpec

      preserve callable signatures through decorators

    • Overloads

      describe APIs whose return type depends on the input shape

    • Casts and Any

      show escape hatches and their tradeoffs

    • NewType

      create distinct static identities for runtime-compatible values

    TFN[T]Tthe same T flows in and out
    A generic type variable preserves shape across a call: the same T flows in and out.
    +

    Keep runtime and static analysis separate.

    The first lesson is that annotations describe expectations for tools while ordinary Python objects still run the program.

    • Type Hints

      document expected types and feed type checkers

    • Protocols

      describe required behavior by structural shape

    • Enums

      name a fixed set of symbolic values

    • Runtime Type Checks

      show `type()`, `isinstance()`, and `issubclass()` without turning Python into Java

    def f(x: int, y: str) -> bool: …
    Annotations describe expected types for tools; the runtime accepts any object regardless.

    Describe realistic data shapes.

    Typed Python becomes useful when annotations explain optional values, unions, callables, and JSON-like records.

    X: INT | STR | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    Advanced typing exists to preserve information across reusable functions, containers, and decorators.

    • Generics and TypeVar

      write reusable typed containers and functions

    • ParamSpec

      preserve callable signatures through decorators

    • Overloads

      describe APIs whose return type depends on the input shape

    • Casts and Any

      show escape hatches and their tradeoffs

    • NewType

      create distinct static identities for runtime-compatible values

    TFN[T]T
    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 index 679fe17..8a78d7a 100644 --- a/public/prototyping/journey-workers.html +++ b/public/prototyping/journey-workers.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; } diff --git a/public/prototyping/layout-banner-pair.html b/public/prototyping/layout-banner-pair.html index 2cdbb61..3679ed4 100644 --- a/public/prototyping/layout-banner-pair.html +++ b/public/prototyping/layout-banner-pair.html @@ -33,7 +33,7 @@ justify-items: center; } .cell-banner figure { margin: 0; padding: 0; max-width: 360px; } - .cell-banner svg { width: 100%; height: auto; display: block; } + .cell-banner svg { max-width: 100%; height: auto; display: block; } .cell-banner figcaption { margin-top: var(--space-2); color: var(--muted); @@ -61,7 +61,7 @@

    Mutability

    second.append("workers") print(first) print(second)

    Output

    ['python', 'workers']
    -['python', 'workers']
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    Two names share one mutable list — appending through one name changes the object visible through both.
    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    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']
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    Two names share one mutable list — appending through one name changes the object visible through both.
    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    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
    diff --git a/public/prototyping/layout-banner-single.html b/public/prototyping/layout-banner-single.html
    index 1100ea5..06fe549 100644
    --- a/public/prototyping/layout-banner-single.html
    +++ b/public/prototyping/layout-banner-single.html
    @@ -33,7 +33,7 @@
         justify-items: center;
       }
       .cell-banner figure { margin: 0; padding: 0; max-width: 360px; }
    -  .cell-banner svg { width: 100%; height: auto; display: block; }
    +  .cell-banner svg { max-width: 100%; height: auto; display: block; }
       .cell-banner figcaption {
         margin-top: var(--space-2);
         color: var(--muted);
    @@ -61,7 +61,7 @@ 

    Mutability

    second.append("workers") print(first) print(second)

    Output

    ['python', 'workers']
    -['python', 'workers']
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["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"
    +['python', 'workers']
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["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
    diff --git a/public/prototyping/layout-banner-trio.html b/public/prototyping/layout-banner-trio.html
    index d918cf0..b611ac7 100644
    --- a/public/prototyping/layout-banner-trio.html
    +++ b/public/prototyping/layout-banner-trio.html
    @@ -33,7 +33,7 @@
         justify-items: center;
       }
       .cell-banner figure { margin: 0; padding: 0; max-width: 360px; }
    -  .cell-banner svg { width: 100%; height: auto; display: block; }
    +  .cell-banner svg { max-width: 100%; height: auto; display: block; }
       .cell-banner figcaption {
         margin-top: var(--space-2);
         color: var(--muted);
    @@ -56,7 +56,7 @@
         

    Mutability

    Some objects change in place, while others return new values.

    -
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    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"]
    +  
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    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 @@ 

    Mutability

    upper_text = text.upper() print(text) print(upper_text)

    Output

    python
    -PYTHON
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    Mutable: change visible through any alias.
    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    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.

    Source

    numbers = [3, 1, 2]
    +PYTHON
    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    Mutable: change visible through any alias.
    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    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.

    Source

    numbers = [3, 1, 2]
     ordered = sorted(numbers)
     print(ordered)
     print(numbers)

    Output

    [1, 2, 3]
    -[3, 1, 2]
    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    When in doubt, reach for the immutable shape.
    +[3, 1, 2]
    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    When in doubt, reach for the immutable shape.

    Notes

    • Lists and dictionaries are mutable; strings and tuples are immutable.
    • Aliasing is useful, but copy mutable containers when independent changes are needed.
    • Pay attention to whether an operation mutates in place or returns a new value.
    diff --git a/public/prototyping/marginalia-gestalt.html b/public/prototyping/marginalia-gestalt.html index 707f766..c23670b 100644 --- a/public/prototyping/marginalia-gestalt.html +++ b/public/prototyping/marginalia-gestalt.html @@ -38,7 +38,7 @@ } .card h3 { font-size: 15px; font-weight: 500; margin: 0; letter-spacing: -0.005em; } .card.journey h3 { font-style: italic; font-size: 17px; } - .card svg { margin-top: 8px; width: 100%; height: auto; overflow: visible; } + .card svg { margin-top: 8px; max-width: 100%; height: auto; overflow: visible; } .card .note { margin: 6px 0 0; font-style: italic; font-size: 12px; color: var(--ink-soft); max-width: 38ch; @@ -57,37 +57,37 @@

    Journeys

    Journey · 01

    Runtime

    - xINT42 + xINT42

    names bind to objects

    Journey · 02

    Streams

    - abcdnext() · next() · next()consumer + abcdnext() · next() · next()consumer

    iteration is a value stream

    Journey · 03

    Shapes

    - LIST[a,b,c]TUPLE(a,b,c)DICT{k:v}SET{a,b} + LIST[a,b,c]TUPLE(a,b,c)DICT{k:v}SET{a,b}

    container choice answers a question

    Journey · 04

    Interfaces

    - FUNCTIONdef f()CLASSstate +methodsPROTOCOLshape + FUNCTIONdef f()CLASSstate +methodsPROTOCOLshape

    behavior packaged at increasing abstraction

    Journey · 05

    Types

    - def f(x: int, y: str) -> boolannotations describe; runtime accepts any object + def f(x: int, y: str) -> boolannotations describe; runtime accepts any object

    static description of runtime objects

    Journey · 06

    Reliability

    - TRYEXCEPTELSEFINALLY + TRYEXCEPTELSEFINALLY

    program boundaries — failure, cleanup, scope

    @@ -96,364 +96,364 @@

    Examples

    Basics · 01

    Hello World

    - print("…")stdouthello world + print("…")stdouthello world

    Basics · 02

    Values

    - INT42STR"hi"LIST[1,2,3] + INT42STR"hi"LIST[1,2,3]

    every value is an object with a type

    Basics · 03

    Numbers

    - INT · UNBOUNDEDFLOAT · REPRESENTABLE SPACING WIDENS + INT · UNBOUNDEDFLOAT · REPRESENTABLE SPACING WIDENS

    Basics · 04

    Booleans

    - ANDTFTFTFFF + ANDTFTFTFFF

    Basics · 05

    Operators and Literals

    - *+423 + *+423

    Basics · 06

    None

    - abcNONETYPENone + abcNONETYPENone

    one shared singleton

    Basics · 07

    Variables

    - xINT42id 0x…a0 + xINT42id 0x…a0

    names bind to objects

    Basics · 08

    Constants

    - MAX_SIZEINT100 + MAX_SIZEINT100

    UPPER_CASE — convention, not enforcement

    Basics · 09

    Truthiness

    - FALSY00.0""[]{}NoneFalse + FALSY00.0""[]{}NoneFalse

    Basics · 10

    Equality and Identity

    - A IS B · A == BabLIST[1,2]A == B ONLYab[1,2][1,2] + A IS B · A == BabLIST[1,2]A == B ONLYab[1,2][1,2]

    same object · or two equal objects

    Basics · 11

    Mutability

    - ASSIGNxsLIST[3,1,4]id 0x…a0MUTATE · SAME IDxsLIST[3,1,4,1]id 0x…a0REBIND · NEW IDxsLIST[3,1,4,1]id 0x…b7 + ASSIGNxsLIST[3,1,4]id 0x…a0MUTATE · SAME IDxsLIST[3,1,4,1]id 0x…a0REBIND · NEW IDxsLIST[3,1,4,1]id 0x…b7

    Basics · 12

    Strings

    - CODEPOINTScaféUTF-8 BYTES636166c3a9 + CODEPOINTScaféUTF-8 BYTES636166c3a9

    Basics · 13

    String Formatting

    - FORMAT SPECalignsign#width,.prectype{:>6,.2f} + FORMAT SPECalignsign#width,.prectype{:>6,.2f}

    Control Flow · 14

    Conditionals

    - ?ifelse + ?ifelse

    Control Flow · 15

    Assignment Expressions

    - if (n := len(xs)) > 10:walrusnINTvalue + if (n := len(xs)) > 10:walrusnINTvalue

    Control Flow · 16

    For Loops

    - abcdnext()abcdnext()abcdnext()abcdnext() — last + abcdnext()abcdnext()abcdnext()abcdnext() — last

    Control Flow · 17

    Break and Continue

    - LOOP BODYcontinuebreak + LOOP BODYcontinuebreak

    Control Flow · 18

    Loop Else

    - LOOPfell throughelse: …broke — else skipped + LOOPfell throughelse: …broke — else skipped

    Control Flow · 19

    Iterating over Iterables

    - ITERABLE[a,b,c]iter()ITERATORnext()value … + ITERABLE[a,b,c]iter()ITERATORnext()value …

    Control Flow · 20

    Iterators

    - idlenext()doneiter()stop + idlenext()doneiter()stop

    Control Flow · 21

    Match Statements

    - match value:case 0:case [x, *_]:case _:first match + match value:case 0:case [x, *_]:case _:first match

    Control Flow · 22

    Advanced Match Patterns

    - CAPTURE[x, y]ALTERNATIVEP() | Q()GUARD[x] if x > 0CLASSPoint(x=0, y=_) + CAPTURE[x, y]ALTERNATIVEP() | Q()GUARD[x] if x > 0CLASSPoint(x=0, y=_)

    Control Flow · 23

    While Loops

    - ?bodyexit + ?bodyexit

    Collections · 24

    Lists

    - MUTABLE SEQUENCE314+1.append + MUTABLE SEQUENCE314+1.append

    Collections · 25

    Tuples

    - IMMUTABLE SEQUENCE3141 + IMMUTABLE SEQUENCE3141

    Collections · 26

    Unpacking

    - 12345a*restb + 12345a*restb

    Collections · 27

    Dictionaries

    - HASH & MASK → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision + HASH & MASK → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision

    Collections · 28

    Sets

    - HASH BUCKETS · KEYS ONLYabcx in sO(1)no orderno duplicates + HASH BUCKETS · KEYS ONLYabcx in sO(1)no orderno duplicates

    Collections · 29

    Slices

    - abcde01234012345[1:4] + abcde01234012345[1:4]

    Collections · 30

    Comprehensions

    - [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2) + [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2)

    Collections · 31

    Comprehension Patterns

    - xs · ysSOURCEif y>0FILTERx*yMAP + xs · ysSOURCEif y>0FILTERx*yMAP

    nested clauses compose left to right

    Collections · 32

    Sorting

    - INPUTSTABLE SORT2 · Ada1 · Bo1 · Bo1 · Cy2 · Eve2 · Ada1 · Cy2 · Eve + INPUTSTABLE SORT2 · Ada1 · Bo1 · Bo1 · Cy2 · Eve2 · Ada1 · Cy2 · Eve

    equal keys keep original order

    Functions · 33

    Functions

    - argsDEF F(...)return + argsDEF F(...)return

    named behavior with a stable interface

    Functions · 34

    Keyword-only Arguments

    - def f(a, b, *, c, d): …positional or kwkeyword only + def f(a, b, *, c, d): …positional or kwkeyword only

    Functions · 35

    Positional-only Parameters

    - def f(a, b, /, c, d): …positional onlypositional or kw + def f(a, b, /, c, d): …positional onlypositional or kw

    Functions · 36

    Args and Kwargs

    - def f(*args, **kwargs): …extra positionalstupleextra keywordsdict + def f(*args, **kwargs): …extra positionalstupleextra keywordsdict

    Functions · 37

    Multiple Return Values

    - DEF F()TUPLE(a, b)xy + DEF F()TUPLE(a, b)xy

    Functions · 38

    Closures

    - OUTER()CELLn=0INNER()uses cell + OUTER()CELLn=0INNER()uses cell

    Functions · 39

    Global and Nonlocal

    - B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL + B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL

    Functions · 40

    Recursion

    - factorial(3)factorial(2)factorial(1)factorial(0) ← base + factorial(3)factorial(2)factorial(1)factorial(0) ← base

    Functions · 41

    Lambdas

    - lambda x: x + 1paramsexpression + lambda x: x + 1paramsexpression

    Iteration · 42

    Generators

    - PAUSED BETWEEN YIELDS · RESUMED BY NEXT()…work…yield…work…yield + PAUSED BETWEEN YIELDS · RESUMED BY NEXT()…work…yield…work…yield

    function body as a timeline

    Iteration · 43

    Yield From

    - OUTERyield from inner()INNERdelegated + OUTERyield from inner()INNERdelegated

    Iteration · 44

    Generator Expressions

    - (sourcefiltermap)lazy stream — no list materialised + (sourcefiltermap)lazy stream — no list materialised

    Iteration · 45

    Itertools

    - CHAINa · bc · dCYCLEa · b · cISLICEwindow + CHAINa · bc · dCYCLEa · b · cISLICEwindow

    Functions · 46

    Decorators

    - BEFOREfFNf₀ bodyAFTER @DECfwrapperCELLf₀ + BEFOREfFNf₀ bodyAFTER @DECfwrapperCELLf₀

    Classes · 47

    Classes

    - instanceCLASSClassTYPEtype + instanceCLASSClassTYPEtype

    Classes · 48

    Inheritance and Super

    - ABCMRODBCAobject + ABCMRODBCAobject

    Classes · 49

    Dataclasses

    - DECLARATIONname : strage : inttags : list__init__(name, age, tags) + DECLARATIONname : strage : inttags : list__init__(name, age, tags)

    Classes · 50

    Properties

    - obj.xfget / fset__dict__ + obj.xfget / fset__dict__

    Data Model · 51

    Special Methods

    - a + bdispatchesa.__add__(b) + a + bdispatchesa.__add__(b)

    Classes · 52

    Metaclasses

    - CLASSClassMETACLASSMetaclass + CLASSClassMETACLASSMetaclass

    Data Model · 53

    Context Managers

    - inbodyout + inbodyout

    Data Model · 54

    Delete Statements

    - BEFORExLIST[1,2,3]AFTER DEL XxLIST[1,2,3] + BEFORExLIST[1,2,3]AFTER DEL XxLIST[1,2,3]

    Errors · 55

    Exceptions

    - TRYEXCEPTELSEFINALLY + TRYEXCEPTELSEFINALLY

    no raise → try → else → finally

    Errors · 56

    Assertions

    - assert cond, msgtrue · passfalse · AssertionError + assert cond, msgtrue · passfalse · AssertionError

    Errors · 57

    Exception Chaining

    - ValueError__cause____context__RuntimeError + ValueError__cause____context__RuntimeError

    Errors · 58

    Exception Groups

    - BEFORE EXCEPT*except*AFTER + BEFORE EXCEPT*except*AFTER

    matched leaves removed; survivors regrouped

    Modules · 59

    Modules

    - SYS.PATHcwdsite-packagesstdlibfirst hitmymod.py + SYS.PATHcwdsite-packagesstdlibfirst hitmymod.py

    Modules · 60

    Import Aliases

    - import numpy as npnpMODULEnumpy + import numpy as npnpMODULEnumpy

    Types · 61

    Type Hints

    - def f(x: int, y: str) -> bool: … + def f(x: int, y: str) -> bool: …

    Types · 62

    Protocols

    - OBJECTread()write()close()other()structural ✓PROTOCOLread()close() + OBJECTread()write()close()other()structural ✓PROTOCOLread()close()

    duck — required methods present

    Types · 63

    Enums

    - COLOR · CLOSED SETREDGREENBLUEno more + COLOR · CLOSED SETREDGREENBLUEno more

    Text · 64

    Regular Expressions

    - PATTERN^\d{2}-\d{2}$INPUT12-34 + PATTERN^\d{2}-\d{2}$INPUT12-34

    Standard Library · 65

    Number Parsing

    - "42"int()42ValueErrorint + "42"int()42ValueErrorint

    Errors · 66

    Custom Exceptions

    - BaseExceptionExceptionValueErrorMyDomainError + BaseExceptionExceptionValueErrorMyDomainError

    Standard Library · 67

    JSON

    - JSONPYTHONobjectdictarrayliststringstrnumberint / floattrue / falseTrue / FalsenullNone + JSONPYTHONobjectdictarrayliststringstrnumberint / floattrue / falseTrue / FalsenullNone

    Standard Library · 68

    Dates and Times

    - one instant−5h+0h + one instant−5h+0h

    Async · 69

    Async Await

    - LOOPCOROawaitresume + LOOPCOROawaitresume

    Async · 70

    Async Iteration and Context

    - ASYNC FOR · ASYNC WITHawait yieldawait yield + ASYNC FOR · ASYNC WITHawait yieldawait yield
    diff --git a/public/site.d666d8585635.css b/public/site.489bc3f7eb6d.css similarity index 99% rename from public/site.d666d8585635.css rename to public/site.489bc3f7eb6d.css index 83a184c..a6acf79 100644 --- a/public/site.d666d8585635.css +++ b/public/site.489bc3f7eb6d.css @@ -111,5 +111,5 @@ prose|code 2-column grid unchanged. See docs/visual-explainer-spec.md. */ .lp-cell.has-figure { grid-template-columns: 1fr; } .cell-figure { margin: 0; padding: 0; } - .cell-figure svg { width: 100%; max-width: 360px; height: auto; display: block; } + .cell-figure svg { max-width: min(100%, 360px); height: auto; display: block; } .cell-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .92rem; font-style: italic; max-width: 56ch; } diff --git a/public/site.css b/public/site.css index 83a184c..a6acf79 100644 --- a/public/site.css +++ b/public/site.css @@ -111,5 +111,5 @@ prose|code 2-column grid unchanged. See docs/visual-explainer-spec.md. */ .lp-cell.has-figure { grid-template-columns: 1fr; } .cell-figure { margin: 0; padding: 0; } - .cell-figure svg { width: 100%; max-width: 360px; height: auto; display: block; } + .cell-figure svg { max-width: min(100%, 360px); height: auto; display: block; } .cell-figure figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .92rem; font-style: italic; max-width: 56ch; } diff --git a/scripts/build_marginalia.py b/scripts/build_marginalia.py index 4c30da7..4d68952 100644 --- a/scripts/build_marginalia.py +++ b/scripts/build_marginalia.py @@ -891,7 +891,7 @@ def e_async_iteration(c: Canvas) -> None: } .card h3 { font-size: 15px; font-weight: 500; margin: 0; letter-spacing: -0.005em; } .card.journey h3 { font-style: italic; font-size: 17px; } - .card svg { margin-top: 8px; width: 100%; height: auto; overflow: visible; } + .card svg { margin-top: 8px; max-width: 100%; height: auto; overflow: visible; } .card .note { margin: 6px 0 0; font-style: italic; font-size: 12px; color: var(--ink-soft); max-width: 38ch; diff --git a/scripts/build_prototypes.py b/scripts/build_prototypes.py index 2f0e282..4c2f22b 100644 --- a/scripts/build_prototypes.py +++ b/scripts/build_prototypes.py @@ -223,7 +223,7 @@ def build_index() -> None: justify-items: center; } .cell-banner figure { margin: 0; padding: 0; max-width: 360px; } - .cell-banner svg { width: 100%; height: auto; display: block; } + .cell-banner svg { max-width: 100%; height: auto; display: block; } .cell-banner figcaption { margin-top: var(--space-2); color: var(--muted); @@ -245,7 +245,7 @@ def build_index() -> None: .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; } """ @@ -420,7 +420,7 @@ def build_journey(slug: str) -> None: font-size: 1rem; font-weight: 600; letter-spacing: -0.005em; margin: 0 0 var(--space-2); color: var(--text); } - .section-grid svg { width: 100%; max-width: 320px; height: auto; display: block; } + .section-grid svg { max-width: min(100%, 320px); height: auto; display: block; } .section-grid figcaption { margin-top: var(--space-2); color: var(--muted); font-size: .9rem; font-style: italic; max-width: 44ch; diff --git a/src/asset_manifest.py b/src/asset_manifest.py index d4b5c76..5db2619 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.d666d8585635.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'} -HTML_CACHE_VERSION = '4e7ccbe1f128' +ASSET_PATHS = {'SITE_CSS': '/site.489bc3f7eb6d.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'} +HTML_CACHE_VERSION = 'a86b31018a89' diff --git a/src/marginalia.py b/src/marginalia.py index 5323004..8ee9184 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -247,7 +247,6 @@ def function_as_value(c: Canvas) -> None: 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: @@ -264,11 +263,10 @@ def class_with_state(c: Canvas) -> None: 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") + c.mono(0, 36, "def f(x: int, y: str) -> bool: …", anchor="start") + c.dashed(54, 28, 76, 28) + c.dashed(102, 28, 124, 28) + c.dashed(150, 28, 192, 28) def union_types(c: Canvas) -> None: @@ -290,7 +288,6 @@ def generic_preservation(c: Canvas) -> None: 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 ────────────────────────────────────────────── @@ -311,7 +308,6 @@ def context_bowtie(c: Canvas) -> None: 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: @@ -340,7 +336,6 @@ def naming_decisions(c: Canvas) -> None: 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: @@ -364,7 +359,6 @@ def lazy_stream(c: Canvas) -> None: 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) @@ -388,20 +382,20 @@ def lazy_stream(c: Canvas) -> None: "text-data-boundary": (text_data_boundary, 172, 70), # Interfaces "function-signature": (function_signature, 188, 80), - "function-as-value": (function_as_value, 200, 92), + "function-as-value": (function_as_value, 200, 66), "class-with-state": (class_with_state, 152, 108), # Types - "annotation-ghost": (annotation_ghost, 220, 96), + "annotation-ghost": (annotation_ghost, 220, 52), "union-types": (union_types, 156, 80), - "generic-preservation": (generic_preservation, 250, 92), + "generic-preservation": (generic_preservation, 250, 70), # Reliability "exception-lanes": (exception_lanes, 320, 100), - "context-bowtie": (context_bowtie, 244, 96), + "context-bowtie": (context_bowtie, 244, 76), "async-swimlane": (async_swimlane, 280, 84), # Control flow + Iteration coverage gap (see audit) - "naming-decisions": (naming_decisions, 274, 110), + "naming-decisions": (naming_decisions, 274, 80), "early-exit": (early_exit, 144, 116), - "lazy-stream": (lazy_stream, 300, 84), + "lazy-stream": (lazy_stream, 300, 56), } diff --git a/src/marginalia_grammar.py b/src/marginalia_grammar.py index 4b5bef3..00ba5e7 100644 --- a/src/marginalia_grammar.py +++ b/src/marginalia_grammar.py @@ -312,8 +312,14 @@ def lanes(self, ys_labels, *, x0=40, x1=300, path=None): # ── render ──────────────────────────────────────────────────────── def to_svg(self) -> str: + # Emit explicit width/height so the SVG renders at intrinsic CSS-pixel + # size by default. CSS `max-width: 100%` then clamps the figure on + # narrow columns. Without these attributes, browsers stretch the SVG + # to fit width: 100% containers, magnifying text inside small + # viewBoxes (a 156-wide viewBox in a 320-wide column ran at 2x). return ( - f'' + f'' + "".join(self.parts) + "" ) From b989bfaf6add7df0cc177823364a98027e980acb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 13:22:46 +0000 Subject: [PATCH 04/20] Sizing + prose-duplication root-cause rules; ship 12 example figures plus the 6 follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identified root cause and prevention rules ------------------------------------------ The previous fix patched two failure modes; this commit documents the underlying rules in docs/visual-explainer-spec.md as pipeline invariants so they cannot drift back: 1. The SVG element renders at intrinsic CSS-pixel size. Canvas.to_svg() emits width/height matching the viewBox; CSS uses max-width: 100% (never width: 100%). Otherwise small viewBoxes stretch and text inside doubles in size. 2. A figure's diagrammatic content does not duplicate its figcaption. SVGs may carry functional labels (stdout, iter(), panel tags, type signatures) but never a sentence describing the figure. Captions are the canonical prose. The marginalia-gestalt review page is the documented exception (cards have no figcaptions). Audit: production paths clean across CSS, SVG width attributes, and inline labels. Four prose-y labels remain in the gestalt e_*(c) paint code; they're correct in context (no figcaption on those cards) and flagged in the example-figure rubric for removal-on-promotion. The six follow-ups ------------------ 1. docs/example-figure-rubric.md — parallel to the journey rubric, scored to 10 across content (cell fidelity, running variables, one move, mechanism, caption-asserts), craft (grammar, scarcity, restraint), and context (cell-column fit, code pairing). Topic gates per cell shape; release gates and project gate. 2. Scored all 70 gestalt example figures against the new rubric. SCORES dict in scripts/build_marginalia.py keyed by slug, with a brief rationale per entry. Each gestalt card now renders its score and note as a small badge beneath the figure. Distribution: ~30 score 9.0+, ~25 score 8.0-8.9, ~5 score 7.0-7.9. 3. Promoted 11 high-scoring gestalt figures into src/marginalia.py FIGURES (one paint function per figure, grammar-conformant, prose- labels stripped). Plus operator-dispatch reused for special-methods. Twelve new ATTACHMENTS rows wired so /examples/ renders the figure between cell prose and code: variables · variables-bind decorators · decorator-rebind recursion · call-stack inheritance-and-super · mro-chain dataclasses · dataclass-fields classes · class-triangle special-methods · operator-dispatch exception-chaining · cause/context unpacking · unpacking-bind comprehensions · comprehension-equiv. lists · list-append dicts · dict-buckets FIGURES went 27 → 41; thirteen example pages now render a figure (was one). 4. Designed three figures for the Workers journey sections — labelled tentative because the section titles are constraint-shaped rather than mechanism-shaped. Journey-section figure coverage: 24/24. 5. (Same as 3.) Wired ATTACHMENTS for the promoted figures. 6. /prototyping/production-figures-gestalt.html added — every figure currently registered in FIGURES on one page with a tag indicating where it renders (an /examples/ attachment, a journey section, or "not yet attached"). Closes the visibility gap between "designed in build_marginalia.py" and "shipping in production". Centralised review pages now: /prototyping/marginalia-gestalt 70 examples + 6 journeys (gestalt design review, scored against the new example-figure rubric) /prototyping/journey-figures-gestalt all journey-section figures grouped by journey /prototyping/production-figures-gestalt every figure shipping in production with attachment status 39 unit tests pass. https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE --- docs/example-figure-rubric.md | 125 +++++++++ docs/visual-explainer-spec.md | 30 ++ public/prototyping/index.html | 2 +- .../prototyping/journey-figures-gestalt.html | 2 +- public/prototyping/journey-workers.html | 2 +- public/prototyping/marginalia-gestalt.html | 77 +++++ .../production-figures-gestalt.html | 55 ++++ scripts/build_marginalia.py | 87 ++++++ scripts/build_prototypes.py | 75 ++++- src/asset_manifest.py | 2 +- src/marginalia.py | 263 ++++++++++++++++++ src/marginalia_grammar.py | 12 + 12 files changed, 727 insertions(+), 5 deletions(-) create mode 100644 docs/example-figure-rubric.md create mode 100644 public/prototyping/production-figures-gestalt.html diff --git a/docs/example-figure-rubric.md b/docs/example-figure-rubric.md new file mode 100644 index 0000000..c3175a4 --- /dev/null +++ b/docs/example-figure-rubric.md @@ -0,0 +1,125 @@ +# Example figure rubric + +Parallel to `docs/journey-visualisation-rubric.md`, but for the figures +that attach to **example pages** (literate-program lessons), not journey +sections. The journey rubric scores the figure beside a section heading; +this one scores the figure that sits between prose and code inside a +single cell of an example walkthrough. + +The two rubrics share craft criteria (palette, primitives, emphasis +scarcity) and diverge on content criteria, because the audience and +task differ. A journey-section figure depicts the *conceptual shift* +unifying multiple lessons; an example figure depicts the *single move* +the surrounding cell discusses. + +Score each example figure on a 10-point scale. + +## Content (5.5) + +1. **Cell fidelity (0-1.5)** — the figure depicts the move the cell's + prose discusses, not the example's title. If the example is + "Mutability" but cell 1 is about immutable strings, a figure on + cell 1 must depict immutability, not aliasing. Wrong cell, wrong + figure. +2. **Match the running variables (0-1.0)** — names, values, and shapes + in the figure match the cell's source. If the cell uses `first` and + `second` on a list, the figure says `first` and `second`. Generic + placeholders (`a`, `b`, `xs`) are fine *only* when the cell itself + is generic; specific names earn their place when the cell uses them. +3. **One conceptual move (0-1.0)** — exactly one shift, before-state + to after-state, or one mechanism. Squint test: a reader should + identify the figure's single point in two seconds. +4. **Mechanism over metaphor (0-1.0)** — the figure shows the actual + machinery (the cell, the binding, the dispatch, the iterator), + not a cartoon of it. Knuth's rule. +5. **Caption asserts; figure depicts (0-1.0)** — `figcaption` is a + declarative sentence about what the figure shows. The SVG itself + contains no prose duplicating the caption — only diagrammatic + labels (`stdout`, `iter()`, panel tags, type signatures). See + pipeline invariant 2 in the spec. + +## 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 cell prose + names (the live mutation, the captured cell, the dispatch arrow). + Three accent marks competing for attention is 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. **Cell-column fit (0-1.0)** — the figure's intrinsic width sits + comfortably inside `.cell-figure`'s `max-width: 360px`. Wider + intrinsic widths are clamped (good — figures shrink, never grow); + much narrower widths leave whitespace on either side. Aim for an + intrinsic viewBox between 200 and 360 px wide. +10. **Pairs with the code, not the title (0-0.5)** — when the figure + sits next to its source block (cell-figure layout), the eye reads + prose → figure → source as one move. The figure should make the + *source* easier to read, not stand alone as a generic + illustration of the example title. + +## Topic gates (cell-shape specific) + +- **Binding cells** (assignments, `=`) — show the name-arrow with the + type tag and the resulting value. The canonical Python picture. +- **Mutation cells** — show before-state and after-state with the + same object identity, OR rebinding with a new identity. The + difference is the lesson. +- **Iteration cells** — show the iterator advance: a caret moving, + or `iter()`+`next()` producing values one at a time. +- **Function-definition cells** — show the signature with parameter + separators (`/`, `*`) explicit when relevant, or the + caller→body→return shape. +- **Class cells** — show state and methods bundled, or the + instance→class→type triangle, or MRO chain. Pick one, not all. +- **Exception cells** — show the lanes (try/except/else/finally) + with a single traced path, or the exception-cause arrow (`__cause__` + vs `__context__`). +- **Async cells** — show two parallel lanes (loop · coroutine) with + await handoffs. + +## Release gates outside the score + +- **One figure per cell, at most.** Two figures on one cell signal + the cell is doing two things; split the cell instead. +- **figcaption present and declarative.** Captions in the form + "Two names share one mutable list — appending through one name + changes the object visible through both." Not "this shows X" or + "see how Y". +- **figcaption agrees with the cell's prose.** The cell's prose + paragraph in the markdown and the figure's figcaption assert the + same thing in different words. If they disagree, one is wrong. +- **Palette discipline.** Only `INK`, `INK_SOFT`, `EMPHASIS`, + `SOFT_FILL`. No literal hex codes, no `rgba(0,0,0,…)` neutrals. +- **Pipeline invariants** (see spec) hold: SVG renders at intrinsic + size; SVG contains no prose duplicating the caption. + +## Quality bands + +- **9.0-10.0** — depicts the cell's move in two seconds; the figcaption + could only describe this figure; reads pleasantly on return visits. +- **8.0-8.9** — depicts the right move but uses generic placeholders + where specific names would land harder, or the caption hedges, or + one secondary mark steals attention from the primary one. +- **7.0-7.9** — depicts the cell but loses something in scope: shows + the example title rather than the specific cell's move; or topic + gate not satisfied. +- **below 7.0** — wrong cell, wrong shape, multiple primary ideas + competing, or accent marks scattered rather than scarce. Redesign + before promoting. + +## Project gate + +A cell figure may ship to production once it scores **≥ 8.5**. The +example's figure average should exceed **8.7** so a multi-figure +example reads as a coherent set rather than independently authored +diagrams. + +The score is a guide, not a substitute for reading the cell beside +its surrounding prose. diff --git a/docs/visual-explainer-spec.md b/docs/visual-explainer-spec.md index 5e773e7..4b5245b 100644 --- a/docs/visual-explainer-spec.md +++ b/docs/visual-explainer-spec.md @@ -207,6 +207,36 @@ 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. +## Pipeline invariants (root-cause rules) + +These rules exist because we hit, and fixed, both failure modes +explicitly. Re-introducing either is a defect. + +1. **The SVG element renders at intrinsic CSS-pixel size.** + `Canvas.to_svg()` emits `width="W"` and `height="H"` matching the + `viewBox`. CSS that displays a figure must use `max-width: 100%`, + never `width: 100%`. With `width: 100%` a small viewBox is stretched + to fill the container, which doubles or triples the apparent text + size inside; with `max-width: 100%` the figure renders at its + designed size and only shrinks when the container is narrower. + +2. **A figure's diagrammatic content does not duplicate its figcaption.** + Where a figure is rendered with a `
    ` (production cell + pages, prototype journey pages, the journey-figures gestalt) the + SVG must not contain an inline `` that repeats the caption's + sentence. Captions are the canonical prose; the SVG is diagrammatic. + Functional labels inside the SVG (`stdout`, `iter()`, `next()`, + `await`, panel tags like `before` / `after`, type-signature + annotations like `x: int | str | None`) are diagrammatic — they + name a part of the figure, not the figure as a whole. A full + sentence describing the figure is prose and belongs in the + figcaption. + + The `marginalia-gestalt` review page is an exception: cards there + have no figcaption, so inline prose can stand in as the only + explanation. Figures destined for promotion to the production + registry must drop their inline prose first. + ## Files - `src/marginalia_grammar.py` — palette, tokens, words, phrases, metrics. diff --git a/public/prototyping/index.html b/public/prototyping/index.html index 61800e0..483f2fa 100644 --- a/public/prototyping/index.html +++ b/public/prototyping/index.html @@ -34,7 +34,7 @@

    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.

    -
    • Marginalia gestalt

      Every journey and example as a card, drawn from the shared grammar. Pure design review.

    • Journey-figures gestalt

      All 18 journey section figures on one page, grouped by journey, for uniform rubric review.

    • Operators alignment polish

      Side-by-side before/after for the tree-edge alignment fix; demonstrates Canvas.connect().

    • 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 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 · 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

      Programs run statements, names refer to objects, expressions become method calls.

    • Journey · Control Flow

      Branches choose paths; the figure depicts a value flowing through a predicate to one of several branches.

    • Journey · Iteration

      Loops repeat; the protocol behind for is iter() then next() until exhausted.

    • Journey · Shapes

      Containers answer different questions; reshaping is the everyday move; text becomes structured data.

    • Journey · Interfaces

      Functions are named behavior; functions are values; classes bundle state with behavior.

    • Journey · Types

      Annotations describe but don't enforce; unions cover alternatives; generics preserve shape across calls.

    • Journey · Reliability

      Failure is explicit; resources have boundaries; concurrency outlives single expressions.

    • Journey · Workers

      Workers-specific journey added on main; section figures pending design.

    +
    • Marginalia gestalt

      Every journey and example as a card, drawn from the shared grammar. Pure design review.

    • Journey-figures gestalt

      All journey section figures on one page, grouped by journey, for uniform rubric review.

    • Production figures gestalt

      Every figure currently registered in src/marginalia.py FIGURES, with a tag showing where it renders (example attachment, journey section, or unattached).

    • Operators alignment polish

      Side-by-side before/after for the tree-edge alignment fix; demonstrates Canvas.connect().

    • 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 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 · 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

      Programs run statements, names refer to objects, expressions become method calls.

    • Journey · Control Flow

      Branches choose paths; the figure depicts a value flowing through a predicate to one of several branches.

    • Journey · Iteration

      Loops repeat; the protocol behind for is iter() then next() until exhausted.

    • Journey · Shapes

      Containers answer different questions; reshaping is the everyday move; text becomes structured data.

    • Journey · Interfaces

      Functions are named behavior; functions are values; classes bundle state with behavior.

    • Journey · Types

      Annotations describe but don't enforce; unions cover alternatives; generics preserve shape across calls.

    • Journey · Reliability

      Failure is explicit; resources have boundaries; concurrency outlives single expressions.

    • Journey · Workers

      Workers-specific journey added on main; section figures pending design.

    diff --git a/public/prototyping/journey-figures-gestalt.html b/public/prototyping/journey-figures-gestalt.html index 9fc3b96..5f1e134 100644 --- a/public/prototyping/journey-figures-gestalt.html +++ b/public/prototyping/journey-figures-gestalt.html @@ -48,7 +48,7 @@

    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.

    print("…")stdouthello world
    Every 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 B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    len(xs):=nNAMEvaluen > 10
    The 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 match
    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.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() 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}unique
    Each 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"TEXTparseINT42
    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.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    FNdef fg = fn
    Functions 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 | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    TFN[T]T
    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.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    inbodyout
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    LOOPCOROawaitresume
    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.

    print("…")stdouthello world
    Every 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 B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    len(xs):=nNAMEvaluen > 10
    The 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 match
    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.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() 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}unique
    Each 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"TEXTparseINT42
    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.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    FNdef fg = fn
    Functions 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 | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    TFN[T]T
    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.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    inbodyout
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    LOOPCOROawaitresume
    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.

    Replace unavailable process boundaries with portable evidence.

    PROCESS APROCESS BINSTEADvaluecaptured
    Worker 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.

    LESSONevidenceRUNTIMErespected
    The lesson's evidence survives across the boundary that the worker runtime enforces.
    diff --git a/public/prototyping/journey-workers.html b/public/prototyping/journey-workers.html index 8a78d7a..c462b68 100644 --- a/public/prototyping/journey-workers.html +++ b/public/prototyping/journey-workers.html @@ -37,7 +37,7 @@

    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.

    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.

    • Bytes and Bytearray

      show the text-to-bytes boundary that networking and subprocess APIs usually require

    • Networking

      make endpoint and byte-encoding boundaries visible without opening a socket

    • Async Await

      show the supported coroutine model for I/O-shaped work in this environment

    Preserve the lesson while respecting the runtime.

    The changed examples favor deterministic, editable evidence over fake demonstrations of unavailable operating-system features.

    • Logging

      show operational output through a configurable Python API rather than shell output

    • Testing

      capture test-runner output so the page remains deterministic

    • Context Managers

      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 BINSTEADvaluecaptured
    Worker 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.

    • Bytes and Bytearray

      show the text-to-bytes boundary that networking and subprocess APIs usually require

    • Networking

      make endpoint and byte-encoding boundaries visible without opening a socket

    • Async Await

      show the supported coroutine model for I/O-shaped work in this environment

    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.

    The changed examples favor deterministic, editable evidence over fake demonstrations of unavailable operating-system features.

    • Logging

      show operational output through a configurable Python API rather than shell output

    • Testing

      capture test-runner output so the page remains deterministic

    • Context Managers

      show cleanup boundaries that still apply when resources are represented abstractly

    LESSONevidenceRUNTIMErespected
    The lesson's evidence survives across the boundary that the worker runtime enforces.
    diff --git a/public/prototyping/marginalia-gestalt.html b/public/prototyping/marginalia-gestalt.html index c23670b..fac0f34 100644 --- a/public/prototyping/marginalia-gestalt.html +++ b/public/prototyping/marginalia-gestalt.html @@ -43,6 +43,13 @@ margin: 6px 0 0; font-style: italic; font-size: 12px; color: var(--ink-soft); max-width: 38ch; } + .card .score { + margin: 6px 0 0; font-size: 11px; color: var(--ink-soft); + font-family: -apple-system, 'Source Sans Pro', sans-serif; + } + .card .score::before { content: "▍ "; opacity: 0.4; } + .card .score-high { color: var(--ink); } + .card .score-low::before { content: "▍ "; opacity: 0.6; color: #a2604c; } @@ -97,363 +104,433 @@

    Examples

    Basics · 01

    Hello World

    print("…")stdouthello world +

    9.0 · program → output, smallest mechanism

    Basics · 02

    Values

    INT42STR"hi"LIST[1,2,3] +

    8.0 · three typed boxes; static enumeration

    every value is an object with a type

    Basics · 03

    Numbers

    INT · UNBOUNDEDFLOAT · REPRESENTABLE SPACING WIDENS +

    9.0 · int register + float thinning

    Basics · 04

    Booleans

    ANDTFTFTFFF +

    8.5 · 2×2 truth table

    Basics · 05

    Operators and Literals

    *+423 +

    9.0 · expression tree mechanism

    Basics · 06

    None

    abcNONETYPENone +

    9.0 · three names converging on one None

    one shared singleton

    Basics · 07

    Variables

    xINT42id 0x…a0 +

    9.5 · the canonical Python picture

    names bind to objects

    Basics · 08

    Constants

    MAX_SIZEINT100 +

    8.0 · name → value, convention only

    UPPER_CASE — convention, not enforcement

    Basics · 09

    Truthiness

    FALSY00.0""[]{}NoneFalse +

    7.0 · row of falsy values; static

    Basics · 10

    Equality and Identity

    A IS B · A == BabLIST[1,2]A == B ONLYab[1,2][1,2] +

    9.0 · shared vs separate, side-by-side

    same object · or two equal objects

    Basics · 11

    Mutability

    ASSIGNxsLIST[3,1,4]id 0x…a0MUTATE · SAME IDxsLIST[3,1,4,1]id 0x…a0REBIND · NEW IDxsLIST[3,1,4,1]id 0x…b7 +

    9.5 · three states; in production

    Basics · 12

    Strings

    CODEPOINTScaféUTF-8 BYTES636166c3a9 +

    9.0 · codepoints + bytes registers

    Basics · 13

    String Formatting

    FORMAT SPECalignsign#width,.prectype{:>6,.2f} +

    8.5 · format-spec railroad

    Control Flow · 14

    Conditionals

    ?ifelse +

    8.5 · branch fork

    Control Flow · 15

    Assignment Expressions

    if (n := len(xs)) > 10:walrusnINTvalue +

    8.0 · walrus binding; abstract

    Control Flow · 16

    For Loops

    abcdnext()abcdnext()abcdnext()abcdnext() — last +

    9.0 · 4-row caret advance

    Control Flow · 17

    Break and Continue

    LOOP BODYcontinuebreak +

    7.5 · two moves competing

    Control Flow · 18

    Loop Else

    LOOPfell throughelse: …broke — else skipped +

    8.5 · fell-through gate

    Control Flow · 19

    Iterating over Iterables

    ITERABLE[a,b,c]iter()ITERATORnext()value … +

    8.5 · iter ribbon

    Control Flow · 20

    Iterators

    idlenext()doneiter()stop +

    8.5 · three-state machine

    Control Flow · 21

    Match Statements

    match value:case 0:case [x, *_]:case _:first match +

    8.5 · dispatch ladder

    Control Flow · 22

    Advanced Match Patterns

    CAPTURE[x, y]ALTERNATIVEP() | Q()GUARD[x] if x > 0CLASSPoint(x=0, y=_) +

    7.5 · dense; four variants

    Control Flow · 23

    While Loops

    ?bodyexit +

    8.5 · cond + body + back-edge

    Collections · 24

    Lists

    MUTABLE SEQUENCE314+1.append +

    9.0 · cells with append

    Collections · 25

    Tuples

    IMMUTABLE SEQUENCE3141 +

    8.5 · closed shape

    Collections · 26

    Unpacking

    12345a*restb +

    9.0 · binding-line mechanism

    Collections · 27

    Dictionaries

    HASH & MASK → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision +

    9.0 · hash buckets with collision

    Collections · 28

    Sets

    HASH BUCKETS · KEYS ONLYabcx in sO(1)no orderno duplicates +

    8.5 · bucket + x-in-s

    Collections · 29

    Slices

    abcde01234012345[1:4] +

    9.0 · ruler with bracket overlay

    Collections · 30

    Comprehensions

    [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2) +

    9.0 · equivalence stacked

    Collections · 31

    Comprehension Patterns

    xs · ysSOURCEif y>0FILTERx*yMAP +

    8.0 · pipeline

    nested clauses compose left to right

    Collections · 32

    Sorting

    INPUTSTABLE SORT2 · Ada1 · Bo1 · Bo1 · Cy2 · Eve2 · Ada1 · Cy2 · Eve +

    9.0 · stability ribbons

    equal keys keep original order

    Functions · 33

    Functions

    argsDEF F(...)return +

    8.0 · input → body → return

    named behavior with a stable interface

    Functions · 34

    Keyword-only Arguments

    def f(a, b, *, c, d): …positional or kwkeyword only +

    9.0 · signature with * separator

    Functions · 35

    Positional-only Parameters

    def f(a, b, /, c, d): …positional onlypositional or kw +

    9.0 · signature with /

    Functions · 36

    Args and Kwargs

    def f(*args, **kwargs): …extra positionalstupleextra keywordsdict +

    8.5 · extra-positionals/keywords regions

    Functions · 37

    Multiple Return Values

    DEF F()TUPLE(a, b)xy +

    8.5 · tuple unpack

    Functions · 38

    Closures

    OUTER()CELLn=0INNER()uses cell +

    9.0 · captured cell reference

    Functions · 39

    Global and Nonlocal

    B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL +

    9.0 · LEGB rings

    Functions · 40

    Recursion

    factorial(3)factorial(2)factorial(1)factorial(0) ← base +

    9.0 · stack with same name

    Functions · 41

    Lambdas

    lambda x: x + 1paramsexpression +

    8.0 · lambda as expression

    Iteration · 42

    Generators

    PAUSED BETWEEN YIELDS · RESUMED BY NEXT()…work…yield…work…yield +

    9.0 · ribbon with yield gates

    function body as a timeline

    Iteration · 43

    Yield From

    OUTERyield from inner()INNERdelegated +

    8.5 · stitched ribbons

    Iteration · 44

    Generator Expressions

    (sourcefiltermap)lazy stream — no list materialised +

    8.5 · lazy pipeline

    Iteration · 45

    Itertools

    CHAINa · bc · dCYCLEa · b · cISLICEwindow +

    7.5 · three mini icons

    Functions · 46

    Decorators

    BEFOREfFNf₀ bodyAFTER @DECfwrapperCELLf₀ +

    9.0 · before/after rebinding

    Classes · 47

    Classes

    instanceCLASSClassTYPEtype +

    9.0 · instance/class/type triangle

    Classes · 48

    Inheritance and Super

    ABCMRODBCAobject +

    9.0 · MRO chain with diamond ghost

    Classes · 49

    Dataclasses

    DECLARATIONname : strage : inttags : list__init__(name, age, tags) +

    9.0 · fields → generated init

    Classes · 50

    Properties

    obj.xfget / fset__dict__ +

    8.5 · Y-fork: fget vs __dict__

    Data Model · 51

    Special Methods

    a + bdispatchesa.__add__(b) +

    9.0 · syntax → method dispatch

    Classes · 52

    Metaclasses

    CLASSClassMETACLASSMetaclass +

    8.5 · extended triangle

    Data Model · 53

    Context Managers

    inbodyout +

    9.0 · enter / body / exit bowtie

    Data Model · 54

    Delete Statements

    BEFORExLIST[1,2,3]AFTER DEL XxLIST[1,2,3] +

    8.5 · name erased; object survives

    Errors · 55

    Exceptions

    TRYEXCEPTELSEFINALLY +

    9.0 · lanes with traced path

    no raise → try → else → finally

    Errors · 56

    Assertions

    assert cond, msgtrue · passfalse · AssertionError +

    7.5 · pass/raise

    Errors · 57

    Exception Chaining

    ValueError__cause____context__RuntimeError +

    8.5 · cause vs context

    Errors · 58

    Exception Groups

    BEFORE EXCEPT*except*AFTER +

    8.5 · before/after peel

    matched leaves removed; survivors regrouped

    Modules · 59

    Modules

    SYS.PATHcwdsite-packagesstdlibfirst hitmymod.py +

    8.5 · sys.path resolution

    Modules · 60

    Import Aliases

    import numpy as npnpMODULEnumpy +

    8.0 · alias → module binding

    Types · 61

    Type Hints

    def f(x: int, y: str) -> bool: … +

    9.0 · ghost annotations

    Types · 62

    Protocols

    OBJECTread()write()close()other()structural ✓PROTOCOLread()close() +

    8.5 · structural duck check

    duck — required methods present

    Types · 63

    Enums

    COLOR · CLOSED SETREDGREENBLUEno more +

    8.0 · closed set

    Text · 64

    Regular Expressions

    PATTERN^\d{2}-\d{2}$INPUT12-34 +

    8.5 · pattern ruler with anchors

    Standard Library · 65

    Number Parsing

    "42"int()42ValueErrorint +

    7.5 · state machine fragment

    Errors · 66

    Custom Exceptions

    BaseExceptionExceptionValueErrorMyDomainError +

    8.5 · subclass chain

    Standard Library · 67

    JSON

    JSONPYTHONobjectdictarrayliststringstrnumberint / floattrue / falseTrue / FalsenullNone +

    8.5 · two-column mapping

    Standard Library · 68

    Dates and Times

    one instant−5h+0h +

    8.0 · timezone strip

    Async · 69

    Async Await

    LOOPCOROawaitresume +

    9.0 · two-lane swimlane

    Async · 70

    Async Iteration and Context

    ASYNC FOR · ASYNC WITHawait yieldawait yield +

    8.5 · ribbon with await yields

    diff --git a/public/prototyping/production-figures-gestalt.html b/public/prototyping/production-figures-gestalt.html new file mode 100644 index 0000000..a7f9fe9 --- /dev/null +++ b/public/prototyping/production-figures-gestalt.html @@ -0,0 +1,55 @@ + + + + +Production figures gestalt · Prototype + + + + +
    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.py FIGURES. 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

    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    attached to /examples/mutability · viewBox 220×175

    tuple-no-mutation

    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    registered, not yet attached · viewBox 220×185

    iterator-unroll

    abcdnext()abcdnext()abcdnext()abcdnext() — last
    registered, not yet attached · viewBox 220×130

    scope-rings

    B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL
    registered, not yet attached · viewBox 216×116

    closure-cell

    MAKE_MULTIPLIERCELLfactor=2MULTIPLYuses cell
    registered, not yet attached · viewBox 240×120

    slice-ruler

    abcdef0123456[:3][3:]
    registered, not yet attached · viewBox 232×120

    branch-fork

    value?case Acase B
    attached to a journey section · viewBox 232×100

    loop-repetition

    abcdbody
    attached to a journey section · viewBox 204×90

    iter-protocol

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    attached to a journey section · viewBox 304×70

    program-output

    print("…")stdouthello world
    attached to a journey section · viewBox 240×80

    identity-and-equality

    IS + ==ab[1,2]== ONLYab[1,2][1,2]
    attached to a journey section · viewBox 304×96

    operator-dispatch

    a + bdispatchesa.__add__(b)
    attached to /examples/special-methods · attached to a journey section · viewBox 260×70

    container-questions

    LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}unique
    attached to a journey section · viewBox 280×88

    reshape-pipeline

    [3,1,4]sorted[1,3,4]
    attached to a journey section · viewBox 204×80

    text-data-boundary

    "42"TEXTparseINT42
    attached to a journey section · viewBox 172×70

    function-signature

    argsDEF F(...)return
    attached to a journey section · viewBox 188×80

    function-as-value

    FNdef fg = fn
    attached to a journey section · viewBox 200×66

    class-with-state

    CLASS BOXSTATEx · yMETHODSmove(...)
    attached to a journey section · viewBox 152×108

    annotation-ghost

    def f(x: int, y: str) -> bool: …
    attached to a journey section · viewBox 220×52

    union-types

    X: INT | STR | NONExintstrNone
    attached to a journey section · viewBox 156×80

    generic-preservation

    TFN[T]T
    attached to a journey section · viewBox 250×70

    exception-lanes

    TRYEXCEPTELSEFINALLY
    attached to a journey section · viewBox 320×100

    context-bowtie

    inbodyout
    attached to a journey section · viewBox 244×76

    async-swimlane

    LOOPCOROawaitresume
    attached to a journey section · viewBox 280×84

    naming-decisions

    len(xs):=nNAMEvaluen > 10
    attached to a journey section · viewBox 274×80

    early-exit

    abcdefound · breakfirst match
    attached to a journey section · viewBox 144×116

    lazy-stream

    SOURCE[a,b,c]FILTERx>0MAPx*2next()
    attached to a journey section · viewBox 300×56

    variables-bind

    xINT42
    attached to /examples/variables · viewBox 180×44

    call-stack

    factorial(3)factorial(2)factorial(1)factorial(0) ← base
    attached to /examples/recursion · viewBox 200×100

    decorator-rebind

    BEFOREfFNf₀AFTER @DECfwrapperf₀
    attached to /examples/decorators · viewBox 232×110

    mro-chain

    ABCDMRODBCAobject
    attached to /examples/inheritance-and-super · viewBox 200×152

    dataclass-fields

    DECLARATIONname : strage : inttags : list__init__(name, age, tags)
    attached to /examples/dataclasses · viewBox 280×76

    class-triangle

    instanceCLASSClassTYPEtype
    attached to /examples/classes · viewBox 274×60

    exception-cause-context

    ValueError__cause____context__RuntimeError
    attached to /examples/exception-chaining · viewBox 282×70

    unpacking-bind

    12345a*restb
    attached to /examples/unpacking · viewBox 152×80

    comprehension-equivalence

    [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2)
    attached to /examples/comprehensions · viewBox 280×76

    list-append

    314+1.append
    attached to /examples/lists · viewBox 220×36

    dict-buckets

    HASH → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision
    attached to /examples/dicts · viewBox 270×88

    workers-portable-evidence

    PROCESS APROCESS BINSTEADvaluecaptured
    attached to a journey section · viewBox 200×96

    workers-protocol-local

    REQUEST SHAPEGET /resourceRESPONSE SHAPE200 · { … }
    attached to a journey section · viewBox 144×116

    workers-lesson-runtime

    LESSONevidenceRUNTIMErespected
    attached to a journey section · viewBox 188×50
    +
    + + + diff --git a/scripts/build_marginalia.py b/scripts/build_marginalia.py index 4d68952..6d57f6d 100644 --- a/scripts/build_marginalia.py +++ b/scripts/build_marginalia.py @@ -774,6 +774,82 @@ def e_async_iteration(c: Canvas) -> None: c.mono(264, 50, "await yield") +# Scores against docs/example-figure-rubric.md. Bands: 9.0+ ship-ready, +# 8.0-8.9 ship after minor tightening, 7.0-7.9 redesign before promoting. +SCORES: dict[str, tuple[float, str]] = { + "hello-world": (9.0, "program → output, smallest mechanism"), + "values": (8.0, "three typed boxes; static enumeration"), + "numbers": (9.0, "int register + float thinning"), + "booleans": (8.5, "2×2 truth table"), + "operators-and-literals": (9.0, "expression tree mechanism"), + "none": (9.0, "three names converging on one None"), + "variables": (9.5, "the canonical Python picture"), + "constants": (8.0, "name → value, convention only"), + "truthiness": (7.0, "row of falsy values; static"), + "equality-and-identity": (9.0, "shared vs separate, side-by-side"), + "mutability": (9.5, "three states; in production"), + "strings": (9.0, "codepoints + bytes registers"), + "string-formatting": (8.5, "format-spec railroad"), + "conditionals": (8.5, "branch fork"), + "assignment-expressions": (8.0, "walrus binding; abstract"), + "for-loops": (9.0, "4-row caret advance"), + "break-and-continue": (7.5, "two moves competing"), + "loop-else": (8.5, "fell-through gate"), + "iterating-over-iterables": (8.5, "iter ribbon"), + "iterators": (8.5, "three-state machine"), + "match-statements": (8.5, "dispatch ladder"), + "advanced-match-patterns": (7.5, "dense; four variants"), + "while-loops": (8.5, "cond + body + back-edge"), + "lists": (9.0, "cells with append"), + "tuples": (8.5, "closed shape"), + "unpacking": (9.0, "binding-line mechanism"), + "dicts": (9.0, "hash buckets with collision"), + "sets": (8.5, "bucket + x-in-s"), + "slices": (9.0, "ruler with bracket overlay"), + "comprehensions": (9.0, "equivalence stacked"), + "comprehension-patterns": (8.0, "pipeline"), + "sorting": (9.0, "stability ribbons"), + "functions": (8.0, "input → body → return"), + "keyword-only-arguments": (9.0, "signature with * separator"), + "positional-only-parameters": (9.0, "signature with /"), + "args-and-kwargs": (8.5, "extra-positionals/keywords regions"), + "multiple-return-values": (8.5, "tuple unpack"), + "closures": (9.0, "captured cell reference"), + "scope-global-nonlocal": (9.0, "LEGB rings"), + "recursion": (9.0, "stack with same name"), + "lambdas": (8.0, "lambda as expression"), + "generators": (9.0, "ribbon with yield gates"), + "yield-from": (8.5, "stitched ribbons"), + "generator-expressions": (8.5, "lazy pipeline"), + "itertools": (7.5, "three mini icons"), + "decorators": (9.0, "before/after rebinding"), + "classes": (9.0, "instance/class/type triangle"), + "inheritance-and-super": (9.0, "MRO chain with diamond ghost"), + "dataclasses": (9.0, "fields → generated init"), + "properties": (8.5, "Y-fork: fget vs __dict__"), + "special-methods": (9.0, "syntax → method dispatch"), + "metaclasses": (8.5, "extended triangle"), + "context-managers": (9.0, "enter / body / exit bowtie"), + "delete-statements": (8.5, "name erased; object survives"), + "exceptions": (9.0, "lanes with traced path"), + "assertions": (7.5, "pass/raise"), + "exception-chaining": (8.5, "cause vs context"), + "exception-groups": (8.5, "before/after peel"), + "modules": (8.5, "sys.path resolution"), + "import-aliases": (8.0, "alias → module binding"), + "type-hints": (9.0, "ghost annotations"), + "protocols": (8.5, "structural duck check"), + "enums": (8.0, "closed set"), + "regular-expressions": (8.5, "pattern ruler with anchors"), + "number-parsing": (7.5, "state machine fragment"), + "custom-exceptions": (8.5, "subclass chain"), + "json": (8.5, "two-column mapping"), + "datetime": (8.0, "timezone strip"), + "async-await": (9.0, "two-lane swimlane"), + "async-iteration-and-context": (8.5, "ribbon with await yields"), +} + + EXAMPLES = [ Card("hello-world", "Hello World", "Basics", 1, e_hello_world), Card("values", "Values", "Basics", 2, e_values, note="every value is an object with a type"), @@ -847,6 +923,10 @@ def e_async_iteration(c: Canvas) -> None: Card("async-iteration-and-context", "Async Iteration and Context", "Async", 70, e_async_iteration), ] +for _card in EXAMPLES: + if _card.slug in SCORES: + _card.score, _card.score_note = SCORES[_card.slug] + # ─── Page scaffold ───────────────────────────────────────────────────── @@ -896,6 +976,13 @@ def e_async_iteration(c: Canvas) -> None: margin: 6px 0 0; font-style: italic; font-size: 12px; color: var(--ink-soft); max-width: 38ch; } + .card .score { + margin: 6px 0 0; font-size: 11px; color: var(--ink-soft); + font-family: -apple-system, 'Source Sans Pro', sans-serif; + } + .card .score::before { content: "▍ "; opacity: 0.4; } + .card .score-high { color: var(--ink); } + .card .score-low::before { content: "▍ "; opacity: 0.6; color: #a2604c; } diff --git a/scripts/build_prototypes.py b/scripts/build_prototypes.py index 4c2f22b..ac548fc 100644 --- a/scripts/build_prototypes.py +++ b/scripts/build_prototypes.py @@ -148,7 +148,9 @@ def banner(*items: tuple[str, str | None]) -> str: ("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."), + "All journey section figures on one page, grouped by journey, for uniform rubric review."), + ("production-figures-gestalt.html", "Production figures gestalt", + "Every figure currently registered in src/marginalia.py FIGURES, with a tag showing where it renders (example attachment, journey section, or unattached)."), ("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", @@ -293,6 +295,19 @@ def build_index() -> None: "lazy-stream", "Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.", ), + # Workers — constraint-shaped sections; figures tentative. + "Replace unavailable process boundaries with portable evidence.": ( + "workers-portable-evidence", + "Worker 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-protocol-local", + "Demonstrate the protocol shape (request and response) rather than calling out over the network.", + ), + "Preserve the lesson while respecting the runtime.": ( + "workers-lesson-runtime", + "The lesson's evidence survives across the boundary that the worker runtime enforces.", + ), # Shapes "Pick the container that matches the question.": ( "container-questions", @@ -486,6 +501,63 @@ def build_journey_figures_gestalt() -> None: ) +def build_production_figures_gestalt() -> None: + """One page showing exactly what is registered in src/marginalia.py FIGURES. + + Distinct from marginalia-gestalt (which renders the design-only catalogue + in scripts/build_marginalia.py) and journey-figures-gestalt (which only + renders figures attached to journey sections). This page makes the + ship-vs-design gap visible: any figure shown here is wired through to + production attachments OR available for attachment. + """ + from marginalia import ATTACHMENTS, FIGURES # noqa: PLC0415 + + # Build a slug→figure_names index of attached figures so we can mark + # figures that already render somewhere on a real page. + attached_to_slug: dict[str, list[str]] = {} + for slug, attachments in ATTACHMENTS.items(): + for _, fig_name, _ in attachments: + attached_to_slug.setdefault(fig_name, []).append(slug) + journey_section_figs = {n for n, _ in JOURNEY_SECTION_FIGURES.values()} + + cards: list[str] = [] + for name, (_, w, h) in FIGURES.items(): + kind: list[str] = [] + if name in attached_to_slug: + slugs = ", ".join(attached_to_slug[name]) + kind.append(f"attached to /examples/{slugs}") + if name in journey_section_figs: + kind.append("attached to a journey section") + if not kind: + kind.append("registered, not yet attached") + kind_html = " · ".join(html.escape(k) for k in kind) + cards.append( + f"
    " + f'

    {html.escape(name)}

    ' + f"{_render_svg(name)}" + f'
    {kind_html} · viewBox {w}×{h}
    ' + f"
    " + ) + body = f""" +
    +
    +

    Production figure registry · {len(FIGURES)} figures

    +

    Production figures gestalt

    +

    Every figure currently registered in src/marginalia.py FIGURES. 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.

    +
    +
    {"".join(cards)}
    +
    +""" + (OUT_DIR / "production-figures-gestalt.html").write_text( + page( + "Production figures gestalt", + f"All {len(FIGURES)} figures currently registered in src/marginalia.py FIGURES; each card names where it renders.", + JOURNEY_FIGURES_GESTALT_STYLE, + body, + ) + ) + + def main() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) build_index() @@ -571,6 +643,7 @@ def main() -> None: ): build_journey(journey_slug) build_journey_figures_gestalt() + build_production_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: diff --git a/src/asset_manifest.py b/src/asset_manifest.py index 5db2619..713d4ef 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.489bc3f7eb6d.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'} -HTML_CACHE_VERSION = 'a86b31018a89' +HTML_CACHE_VERSION = '38529f4157c8' diff --git a/src/marginalia.py b/src/marginalia.py index 8ee9184..c6eae88 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -350,6 +350,169 @@ def early_exit(c: Canvas) -> None: # ─── Iteration journey ──────────────────────────────────────────────── +# ─── Example figures (promoted from the gestalt) ────────────────────── + + +def variables_bind(c: Canvas) -> None: + """Variables · names bind to objects: the canonical Python picture.""" + c.bind(0, 6, "x", "int", "42", object_w=70, gap=20) + + +def call_stack(c: Canvas) -> None: + """Recursion · stacked frames of the same function with different arguments.""" + chain = [3, 2, 1, 0] + for i, n in enumerate(chain): + suffix = " ← base" if n == 0 else "" + c.cell(0, i * 22, f"factorial({n}){suffix}", w=180, h=20) + c.dashed(192, 90, 192, 18) + c.closed_arrow(192, 30, 192, 18, emphasis=True) + + +def decorator_rebind(c: Canvas) -> None: + """Decorators · before: name binds to function. After @dec: name binds to wrapper.""" + c.tag(0, 12, "before") + c.bind(0, 18, "f", "fn", "f₀", object_w=50, gap=20) + c.tag(0, 70, "after @dec") + c.name_box(0, 78, "f") + c.closed_arrow(60, 90, 96, 90, emphasis=True) + c.cell(98, 76, "wrapper", w=80, h=28) + c.object_box(186, 78, "", "f₀", w=44, h=24) + c.dashed(178, 90, 186, 90) + + +def mro_chain(c: Canvas) -> None: + """Inheritance · diamond becomes a linear MRO via C3 linearization.""" + # Diamond ghost above + c.frame(80, 0, 40, 22, ghost=True) + c.mono(100, 16, "A") + c.ghost(98, 22, 58, 42) + c.ghost(102, 22, 142, 42) + c.frame(40, 42, 40, 22, ghost=True) + c.mono(60, 58, "B") + c.frame(120, 42, 40, 22, ghost=True) + c.mono(140, 58, "C") + c.ghost(60, 64, 96, 80) + c.ghost(140, 64, 104, 80) + c.frame(80, 80, 40, 22, ghost=True) + c.mono(100, 96, "D") + # MRO chain below + c.tag(0, 118, "mro") + chain = [("D", 36), ("B", 36), ("C", 36), ("A", 36), ("object", 56)] + x = 0 + for v, w in chain: + c.cell(x, 124, v, w=w, h=22) + x += w + + +def dataclass_fields(c: Canvas) -> None: + """Dataclasses · fields declared once become __init__ parameters.""" + c.tag(0, 12, "declaration") + fields = [("name", "str"), ("age", "int"), ("tags", "list")] + for i, (n, t) in enumerate(fields): + c.cell(0, 18 + i * 20, f"{n} : {t}", w=110, h=20) + c.closed_arrow(110, 48, 146, 48, emphasis=True) + c.object_box(148, 32, "", "__init__(name, age, tags)", w=128, h=32) + + +def class_triangle(c: Canvas) -> None: + """Classes · instance → class → type — every Python value sits on this triangle.""" + c.dot(20, 28) + c.label(20, 54, "instance", anchor="middle") + c.closed_arrow(26, 28, 86, 28, emphasis=False) + c.frame(88, 10, 60, 36, label="class") + c.mono(118, 32, "Class") + c.closed_arrow(148, 28, 208, 28, emphasis=False) + c.frame(210, 10, 60, 36, label="type") + c.mono(240, 32, "type") + + +def exception_cause_context(c: Canvas) -> None: + """Exception chaining · explicit `__cause__` (raise from) vs implicit `__context__`.""" + c.cell(0, 20, "ValueError", w=100, h=32) + c.closed_arrow(100, 28, 180, 28, emphasis=True) + c.label(140, 20, "__cause__", anchor="middle") + c.dashed(100, 44, 180, 44) + c.label(140, 62, "__context__", anchor="middle") + c.cell(182, 20, "RuntimeError", w=100, h=32) + + +def unpacking_bind(c: Canvas) -> None: + """Unpacking · left-side names bind to right-side positions; *rest gathers the middle.""" + items = ["1", "2", "3", "4", "5"] + for i, v in enumerate(items): + c.cell(i * 30, 0, v, w=30, h=22) + c.cell(0, 58, "a", w=30, h=22) + c.cell(30, 58, "*rest", w=90, h=22, ghost=True) + c.cell(120, 58, "b", w=30, h=22) + c.dashed(15, 22, 15, 58) + c.dashed(45, 22, 75, 58) + c.dashed(75, 22, 75, 58) + c.dashed(105, 22, 75, 58) + c.dashed(135, 22, 135, 58) + + +def comprehension_equivalence(c: Canvas) -> None: + """Comprehensions · the comprehension above and the equivalent for-loop below.""" + c.cell(0, 0, "[x*2 for x in xs if x > 0]", w=280, h=22, soft=True) + c.cell(0, 30, "out = []", w=280, h=14, ghost=True) + c.cell(0, 44, "for x in xs:", w=280, h=14, ghost=True) + c.cell(0, 58, " if x > 0: out.append(x*2)", w=280, h=14, ghost=True) + + +def list_append(c: Canvas) -> None: + """Lists · mutable sequence; `.append` extends the same list object.""" + c.cells(0, 8, ["3", "1", "4"], w=24) + c.cell(72, 8, "+1", ghost=True) + c.closed_arrow(98, 20, 132, 20, emphasis=True) + c.label(136, 18, ".append", anchor="start") + + +def dict_buckets(c: Canvas) -> None: + """Dictionaries · hashed buckets; collisions chain into a neighbouring slot.""" + c.tag(0, 12, "hash → bucket") + rows = [("0", '"a" → 1'), ("1", '"b" → 2'), ("2", '"c" → 3')] + for i, (idx, body) in enumerate(rows): + y = 18 + i * 24 + c.label(0, y + 16, idx, anchor="start") + c.cell(14, y, body, w=80, h=24) + c.closed_arrow(96, 54, 132, 54, emphasis=True) + c.cell(134, 42, '"d" → 4', w=80, h=24, soft=True) + c.label(218, 58, "collision", anchor="start") + + +# ─── Workers journey (abstract sections; designs tentative) ────────── + + +def workers_portable_evidence(c: Canvas) -> None: + """Workers · process boundaries unavailable; portable evidence (a value) crosses instead.""" + c.frame(0, 8, 70, 30, ghost=True, label="process A") + c.frame(110, 8, 70, 30, ghost=True, label="process B") + c.dashed(74, 8, 106, 38) + c.dashed(74, 38, 106, 8) + c.tag(0, 60, "instead") + c.cell(0, 66, "value", w=60, h=22, soft=True) + c.closed_arrow(60, 77, 116, 77, emphasis=True) + c.cell(118, 66, "captured", w=62, h=22) + + +def workers_protocol_local(c: Canvas) -> None: + """Workers · protocol shape, not real network: assert on the shape, not the socket.""" + c.tag(0, 12, "request shape") + c.cell(0, 18, "GET /resource", w=140, h=22, soft=True) + c.closed_arrow(70, 44, 70, 60, emphasis=True) + c.tag(0, 80, "response shape") + c.cell(0, 86, "200 · { … }", w=140, h=22, soft=True) + + +def workers_lesson_runtime(c: Canvas) -> None: + """Workers · lesson shape preserved while runtime constraints are respected.""" + c.frame(0, 6, 70, 32, label="lesson") + c.mono(34, 26, "evidence") + c.frame(110, 6, 70, 32, label="runtime") + c.mono(144, 26, "respected") + c.closed_arrow(70, 22, 110, 22, emphasis=True) + + 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) @@ -396,6 +559,22 @@ def lazy_stream(c: Canvas) -> None: "naming-decisions": (naming_decisions, 274, 80), "early-exit": (early_exit, 144, 116), "lazy-stream": (lazy_stream, 300, 56), + # Promoted from the gestalt — wired to example pages via ATTACHMENTS + "variables-bind": (variables_bind, 180, 44), + "call-stack": (call_stack, 200, 100), + "decorator-rebind": (decorator_rebind, 232, 110), + "mro-chain": (mro_chain, 200, 152), + "dataclass-fields": (dataclass_fields, 280, 76), + "class-triangle": (class_triangle, 274, 60), + "exception-cause-context": (exception_cause_context, 282, 70), + "unpacking-bind": (unpacking_bind, 152, 80), + "comprehension-equivalence": (comprehension_equivalence, 280, 76), + "list-append": (list_append, 220, 36), + "dict-buckets": (dict_buckets, 270, 88), + # Workers journey (constraint-shaped sections; designs tentative) + "workers-portable-evidence": (workers_portable_evidence, 200, 96), + "workers-protocol-local": (workers_protocol_local, 144, 116), + "workers-lesson-runtime": (workers_lesson_runtime, 188, 50), } @@ -410,6 +589,90 @@ def lazy_stream(c: Canvas) -> None: "Two names share one mutable list — appending through one name changes the object visible through both.", ), ], + "variables": [ + ( + "cell-0", + "variables-bind", + "A name is a label that points at an object. Assignment binds the label; the object exists independently.", + ), + ], + "lists": [ + ( + "cell-0", + "list-append", + "Lists are mutable sequences. `.append` extends the same list object — no new list is created.", + ), + ], + "dicts": [ + ( + "cell-0", + "dict-buckets", + "Each key is hashed to a bucket; collisions chain into the next slot. Lookup is constant-time on average.", + ), + ], + "unpacking": [ + ( + "cell-0", + "unpacking-bind", + "Left-side names bind to right-side positions; `*rest` gathers the middle into a list.", + ), + ], + "comprehensions": [ + ( + "cell-0", + "comprehension-equivalence", + "A comprehension is a compact spelling of the equivalent for-loop with append, made into one expression.", + ), + ], + "classes": [ + ( + "cell-0", + "class-triangle", + "Every Python value sits on the instance → class → type triangle; the metaclass is the type of the class.", + ), + ], + "inheritance-and-super": [ + ( + "cell-0", + "mro-chain", + "Multiple inheritance forms a graph; C3 linearisation flattens it into the MRO Python uses for attribute lookup.", + ), + ], + "dataclasses": [ + ( + "cell-0", + "dataclass-fields", + "Field declarations become the generated __init__ signature: declaration is the constructor.", + ), + ], + "special-methods": [ + ( + "cell-0", + "operator-dispatch", + "Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.", + ), + ], + "decorators": [ + ( + "cell-0", + "decorator-rebind", + "@dec rebinds the name to wrapper(f₀); the original function survives only in the wrapper's closure cell.", + ), + ], + "recursion": [ + ( + "cell-1", + "call-stack", + "Each call pushes a new frame with the same name and a smaller argument; the base case unwinds back up the stack.", + ), + ], + "exception-chaining": [ + ( + "cell-0", + "exception-cause-context", + "`raise X from Y` sets `__cause__` (explicit); raising during except sets `__context__` (implicit).", + ), + ], } diff --git a/src/marginalia_grammar.py b/src/marginalia_grammar.py index 00ba5e7..d59eb68 100644 --- a/src/marginalia_grammar.py +++ b/src/marginalia_grammar.py @@ -336,6 +336,8 @@ class Card: width: int = 320 height: int = 110 is_journey: bool = False + score: float | None = None + score_note: str = "" def render_html(self) -> str: c = Canvas(w=self.width, h=self.height) @@ -346,11 +348,21 @@ def render_html(self) -> str: else: eyebrow = f"Journey · {self.order}" note_html = f'

    {self.note}

    \n' if self.note else "" + score_html = "" + if self.score is not None: + band = ( + "score-high" if self.score >= 9.0 + else "score-mid" if self.score >= 8.0 + else "score-low" + ) + note = f" · {self.score_note}" if self.score_note else "" + score_html = f'

    {self.score:.1f}{note}

    \n' return ( f'
    \n' f'

    {eyebrow}

    \n' f'

    {self.title}

    \n' f" {c.to_svg()}\n" + f"{score_html}" f"{note_html}" f"
    " ) From 5c30bfd78ccc9423d447be18542bf9d337724bf4 Mon Sep 17 00:00:00 2001 From: Ade Oshineye Date: Sun, 10 May 2026 14:47:04 +0100 Subject: [PATCH 05/20] Smoke test viz preview after deploy --- .github/workflows/preview-viz.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/preview-viz.yml b/.github/workflows/preview-viz.yml index 50697b5..9272e7f 100644 --- a/.github/workflows/preview-viz.yml +++ b/.github/workflows/preview-viz.yml @@ -48,6 +48,23 @@ jobs: --name viz \ --message "${{ github.sha }}" \ --json + - name: Smoke test deployed Preview + run: | + set -euo pipefail + base="https://viz-pythonbyexample.adewale-883.workers.dev" + for path in \ + "/" \ + "/examples/values" \ + "/prototyping/journey-figures-gestalt"; do + url="${base}${path}" + echo "Checking ${url}" + curl --fail --show-error --silent --location --output /tmp/preview-smoke.html --write-out "%{http_code} %{url_effective}\n" "${url}" + if grep -qiE "error code: 1101|PythonError|Traceback" /tmp/preview-smoke.html; then + echo "Preview rendered an exception for ${url}" + head -200 /tmp/preview-smoke.html + exit 1 + fi + done - name: Dump wrangler logs on failure if: failure() run: | From 10b967c7734729e0b14a677c92c44d0d391ba9b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 22:01:22 +0000 Subject: [PATCH 06/20] Major coverage push: 50 examples attached (was 13); Workers figures rewritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added 18 new paint functions to src/marginalia.py FIGURES — half are brand-new mechanism pictures (descriptors, attribute-lookup, callable- objects, bound-vs-unbound, method-kinds, truth-and-size, guard-clauses, bytes-vs-bytearray, sentinel-iteration, partial-functions), half are gestalt-promoted figures with the inline prose stripped per the pipeline-invariant rules (number-lines, expression-tree, none-singleton, codepoints-bytes, sort-stability, kw-only-separator, positional-only- separator, generator-ribbon). Wired 37 new ATTACHMENTS rows: 18 new figures plus 19 attachments that reuse existing FIGURES for examples added on main (operators, operator-overloading, iterator-vs-iterable, type-aliases, typed-dicts, union-and-optional-types, generics-and-typevar, abstract-base-classes, copying-collections, plus 9.0+ promotions for hello-world, numbers, none, equality-and-identity, strings, for-loops, sorting, kw-only, positional-only, closures, scope-global-nonlocal, generators, type-hints, exceptions, context-managers, async-await, iterators, slices). Production example coverage: 13 → 50 (of 109). Workers journey figures redesigned around stronger mechanisms: workers-portable-evidence unavailable API (struck through) above a captured-value-as-evidence pair workers-protocol-local request shape → response shape; no socket workers-lesson-runtime lesson box + value + runtime box, three named pieces meeting at a boundary Tests: relaxed two assertion strings in test_app.py from 'class="lesson-step lp-cell"' to the bare substring 'lesson-step lp-cell' so they match both has-figure and bare cells. 39 tests pass. FIGURES went 41 → 59. Journey-section coverage stays at 24/24. https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE --- .../prototyping/journey-figures-gestalt.html | 2 +- public/prototyping/journey-workers.html | 2 +- .../production-figures-gestalt.html | 6 +- src/asset_manifest.py | 2 +- src/marginalia.py | 413 ++++++++++++++++-- tests/test_app.py | 4 +- 6 files changed, 396 insertions(+), 33 deletions(-) diff --git a/public/prototyping/journey-figures-gestalt.html b/public/prototyping/journey-figures-gestalt.html index 5f1e134..75dbaec 100644 --- a/public/prototyping/journey-figures-gestalt.html +++ b/public/prototyping/journey-figures-gestalt.html @@ -48,7 +48,7 @@

    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.

    print("…")stdouthello world
    Every 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 B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    len(xs):=nNAMEvaluen > 10
    The 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 match
    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.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() 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}unique
    Each 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"TEXTparseINT42
    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.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    FNdef fg = fn
    Functions 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 | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    TFN[T]T
    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.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    inbodyout
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    LOOPCOROawaitresume
    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.

    Replace unavailable process boundaries with portable evidence.

    PROCESS APROCESS BINSTEADvaluecaptured
    Worker 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.

    LESSONevidenceRUNTIMErespected
    The lesson's evidence survives across the boundary that the worker runtime enforces.
    +

    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 world
    Every 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 B
    A value flows through a predicate to one of several branches.

    Name and shape decisions.

    len(xs):=nNAMEvaluen > 10
    The 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 match
    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.

    abcdbody
    Walk the sequence, run the body, return; the shape behind for and while.

    See the protocol behind `for`.

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    iter() 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}unique
    Each 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"TEXTparseINT42
    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.

    argsDEF F(...)return
    A function is the first abstraction boundary: arguments in, body, return value out.

    Use functions as values.

    FNdef fg = fn
    Functions 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 | NONExintstrNone
    A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.

    Scale annotations for reusable libraries.

    TFN[T]T
    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.

    TRYEXCEPTELSEFINALLY
    try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.

    Control resource and module boundaries.

    inbodyout
    A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.

    Handle operations that outlive one expression.

    LOOPCOROawaitresume
    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.

    Replace unavailable process boundaries with portable evidence.

    UNAVAILABLEmultiprocessing.Process()PORTABLE EVIDENCEvalueasserted in-process
    Worker 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 SHAPE · ASSERTED LOCALLY200 OK · { … }
    Demonstrate the protocol shape (request and response) rather than calling out over the network.

    Preserve the lesson while respecting the runtime.

    LESSONshapeRUNTIMElimitsvalue
    The lesson's evidence survives across the boundary that the worker runtime enforces.
    diff --git a/public/prototyping/journey-workers.html b/public/prototyping/journey-workers.html index c462b68..268525a 100644 --- a/public/prototyping/journey-workers.html +++ b/public/prototyping/journey-workers.html @@ -37,7 +37,7 @@

    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.

    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 BINSTEADvaluecaptured
    Worker 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.

    • Bytes and Bytearray

      show the text-to-bytes boundary that networking and subprocess APIs usually require

    • Networking

      make endpoint and byte-encoding boundaries visible without opening a socket

    • Async Await

      show the supported coroutine model for I/O-shaped work in this environment

    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.

    The changed examples favor deterministic, editable evidence over fake demonstrations of unavailable operating-system features.

    • Logging

      show operational output through a configurable Python API rather than shell output

    • Testing

      capture test-runner output so the page remains deterministic

    • Context Managers

      show cleanup boundaries that still apply when resources are represented abstractly

    LESSONevidenceRUNTIMErespected
    The lesson's evidence survives across the boundary that the worker runtime enforces.
    +

    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.

    UNAVAILABLEmultiprocessing.Process()PORTABLE EVIDENCEvalueasserted in-process
    Worker 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.

    • Bytes and Bytearray

      show the text-to-bytes boundary that networking and subprocess APIs usually require

    • Networking

      make endpoint and byte-encoding boundaries visible without opening a socket

    • Async Await

      show the supported coroutine model for I/O-shaped work in this environment

    REQUEST SHAPEGET /resourceRESPONSE SHAPE · ASSERTED LOCALLY200 OK · { … }
    Demonstrate the protocol shape (request and response) rather than calling out over the network.

    Preserve the lesson while respecting the runtime.

    The changed examples favor deterministic, editable evidence over fake demonstrations of unavailable operating-system features.

    • Logging

      show operational output through a configurable Python API rather than shell output

    • Testing

      capture test-runner output so the page remains deterministic

    • Context Managers

      show cleanup boundaries that still apply when resources are represented abstractly

    LESSONshapeRUNTIMElimitsvalue
    The lesson's evidence survives across the boundary that the worker runtime enforces.
    diff --git a/public/prototyping/production-figures-gestalt.html b/public/prototyping/production-figures-gestalt.html index a7f9fe9..eee9e0f 100644 --- a/public/prototyping/production-figures-gestalt.html +++ b/public/prototyping/production-figures-gestalt.html @@ -40,15 +40,15 @@ -
    Prototype · All 41 figures currently registered in src/marginalia.py FIGURES; each card names where it renders. · all prototypes
    +
    Prototype · All 59 figures currently registered in src/marginalia.py FIGURES; each card names where it renders. · all prototypes
    -

    Production figure registry · 41 figures

    +

    Production figure registry · 59 figures

    Production figures gestalt

    Every figure currently registered in src/marginalia.py FIGURES. 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

    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    attached to /examples/mutability · viewBox 220×175

    tuple-no-mutation

    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    registered, not yet attached · viewBox 220×185

    iterator-unroll

    abcdnext()abcdnext()abcdnext()abcdnext() — last
    registered, not yet attached · viewBox 220×130

    scope-rings

    B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL
    registered, not yet attached · viewBox 216×116

    closure-cell

    MAKE_MULTIPLIERCELLfactor=2MULTIPLYuses cell
    registered, not yet attached · viewBox 240×120

    slice-ruler

    abcdef0123456[:3][3:]
    registered, not yet attached · viewBox 232×120

    branch-fork

    value?case Acase B
    attached to a journey section · viewBox 232×100

    loop-repetition

    abcdbody
    attached to a journey section · viewBox 204×90

    iter-protocol

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    attached to a journey section · viewBox 304×70

    program-output

    print("…")stdouthello world
    attached to a journey section · viewBox 240×80

    identity-and-equality

    IS + ==ab[1,2]== ONLYab[1,2][1,2]
    attached to a journey section · viewBox 304×96

    operator-dispatch

    a + bdispatchesa.__add__(b)
    attached to /examples/special-methods · attached to a journey section · viewBox 260×70

    container-questions

    LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}unique
    attached to a journey section · viewBox 280×88

    reshape-pipeline

    [3,1,4]sorted[1,3,4]
    attached to a journey section · viewBox 204×80

    text-data-boundary

    "42"TEXTparseINT42
    attached to a journey section · viewBox 172×70

    function-signature

    argsDEF F(...)return
    attached to a journey section · viewBox 188×80

    function-as-value

    FNdef fg = fn
    attached to a journey section · viewBox 200×66

    class-with-state

    CLASS BOXSTATEx · yMETHODSmove(...)
    attached to a journey section · viewBox 152×108

    annotation-ghost

    def f(x: int, y: str) -> bool: …
    attached to a journey section · viewBox 220×52

    union-types

    X: INT | STR | NONExintstrNone
    attached to a journey section · viewBox 156×80

    generic-preservation

    TFN[T]T
    attached to a journey section · viewBox 250×70

    exception-lanes

    TRYEXCEPTELSEFINALLY
    attached to a journey section · viewBox 320×100

    context-bowtie

    inbodyout
    attached to a journey section · viewBox 244×76

    async-swimlane

    LOOPCOROawaitresume
    attached to a journey section · viewBox 280×84

    naming-decisions

    len(xs):=nNAMEvaluen > 10
    attached to a journey section · viewBox 274×80

    early-exit

    abcdefound · breakfirst match
    attached to a journey section · viewBox 144×116

    lazy-stream

    SOURCE[a,b,c]FILTERx>0MAPx*2next()
    attached to a journey section · viewBox 300×56

    variables-bind

    xINT42
    attached to /examples/variables · viewBox 180×44

    call-stack

    factorial(3)factorial(2)factorial(1)factorial(0) ← base
    attached to /examples/recursion · viewBox 200×100

    decorator-rebind

    BEFOREfFNf₀AFTER @DECfwrapperf₀
    attached to /examples/decorators · viewBox 232×110

    mro-chain

    ABCDMRODBCAobject
    attached to /examples/inheritance-and-super · viewBox 200×152

    dataclass-fields

    DECLARATIONname : strage : inttags : list__init__(name, age, tags)
    attached to /examples/dataclasses · viewBox 280×76

    class-triangle

    instanceCLASSClassTYPEtype
    attached to /examples/classes · viewBox 274×60

    exception-cause-context

    ValueError__cause____context__RuntimeError
    attached to /examples/exception-chaining · viewBox 282×70

    unpacking-bind

    12345a*restb
    attached to /examples/unpacking · viewBox 152×80

    comprehension-equivalence

    [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2)
    attached to /examples/comprehensions · viewBox 280×76

    list-append

    314+1.append
    attached to /examples/lists · viewBox 220×36

    dict-buckets

    HASH → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision
    attached to /examples/dicts · viewBox 270×88

    workers-portable-evidence

    PROCESS APROCESS BINSTEADvaluecaptured
    attached to a journey section · viewBox 200×96

    workers-protocol-local

    REQUEST SHAPEGET /resourceRESPONSE SHAPE200 · { … }
    attached to a journey section · viewBox 144×116

    workers-lesson-runtime

    LESSONevidenceRUNTIMErespected
    attached to a journey section · viewBox 188×50
    +

    aliasing-mutation

    BEFOREfirstsecond["python"]AFTER APPENDfirstsecond["python","workers"]
    attached to /examples/mutability, copying-collections · viewBox 220×175

    tuple-no-mutation

    TUPLE — FROZENfirstsecond("python",)NO .APPENDfirstsecond("python",)tuples raise AttributeError
    registered, not yet attached · viewBox 220×185

    iterator-unroll

    abcdnext()abcdnext()abcdnext()abcdnext() — last
    attached to /examples/for-loops · viewBox 220×130

    scope-rings

    B · BUILT-ING · GLOBALE · ENCLOSINGL · LOCAL
    attached to /examples/scope-global-nonlocal · viewBox 216×116

    closure-cell

    MAKE_MULTIPLIERCELLfactor=2MULTIPLYuses cell
    attached to /examples/closures · viewBox 240×120

    slice-ruler

    abcdef0123456[:3][3:]
    attached to /examples/slices · viewBox 232×120

    branch-fork

    value?case Acase B
    attached to a journey section · viewBox 232×100

    loop-repetition

    abcdbody
    attached to a journey section · viewBox 204×90

    iter-protocol

    ITERABLE[a,b,c]iter()ITERATORnext()abc
    attached to /examples/iterators, iterator-vs-iterable · attached to a journey section · viewBox 304×70

    program-output

    print("…")stdouthello world
    attached to /examples/hello-world · attached to a journey section · viewBox 240×80

    identity-and-equality

    IS + ==ab[1,2]== ONLYab[1,2][1,2]
    attached to /examples/equality-and-identity · attached to a journey section · viewBox 304×96

    operator-dispatch

    a + bdispatchesa.__add__(b)
    attached to /examples/special-methods, operator-overloading · attached to a journey section · viewBox 260×70

    container-questions

    LIST[a,b]orderedTUPLE(a,b)fixedDICT{k:v}lookupSET{a,b}unique
    attached to a journey section · viewBox 280×88

    reshape-pipeline

    [3,1,4]sorted[1,3,4]
    attached to a journey section · viewBox 204×80

    text-data-boundary

    "42"TEXTparseINT42
    attached to a journey section · viewBox 172×70

    function-signature

    argsDEF F(...)return
    attached to a journey section · viewBox 188×80

    function-as-value

    FNdef fg = fn
    attached to a journey section · viewBox 200×66

    class-with-state

    CLASS BOXSTATEx · yMETHODSmove(...)
    attached to a journey section · viewBox 152×108

    annotation-ghost

    def f(x: int, y: str) -> bool: …
    attached to /examples/type-hints, type-aliases · attached to a journey section · viewBox 220×52

    union-types

    X: INT | STR | NONExintstrNone
    attached to /examples/typed-dicts, union-and-optional-types · attached to a journey section · viewBox 156×80

    generic-preservation

    TFN[T]T
    attached to /examples/generics-and-typevar · attached to a journey section · viewBox 250×70

    exception-lanes

    TRYEXCEPTELSEFINALLY
    attached to /examples/exceptions · attached to a journey section · viewBox 320×100

    context-bowtie

    inbodyout
    attached to /examples/context-managers · attached to a journey section · viewBox 244×76

    async-swimlane

    LOOPCOROawaitresume
    attached to /examples/async-await · attached to a journey section · viewBox 280×84

    naming-decisions

    len(xs):=nNAMEvaluen > 10
    attached to a journey section · viewBox 274×80

    early-exit

    abcdefound · breakfirst match
    attached to a journey section · viewBox 144×116

    lazy-stream

    SOURCE[a,b,c]FILTERx>0MAPx*2next()
    attached to a journey section · viewBox 300×56

    variables-bind

    xINT42
    attached to /examples/variables · viewBox 180×44

    call-stack

    factorial(3)factorial(2)factorial(1)factorial(0) ← base
    attached to /examples/recursion · viewBox 200×100

    decorator-rebind

    BEFOREfFNf₀AFTER @DECfwrapperf₀
    attached to /examples/decorators · viewBox 232×110

    mro-chain

    ABCDMRODBCAobject
    attached to /examples/inheritance-and-super · viewBox 200×152

    dataclass-fields

    DECLARATIONname : strage : inttags : list__init__(name, age, tags)
    attached to /examples/dataclasses · viewBox 280×76

    class-triangle

    instanceCLASSClassTYPEtype
    attached to /examples/classes, abstract-base-classes · viewBox 274×60

    exception-cause-context

    ValueError__cause____context__RuntimeError
    attached to /examples/exception-chaining · viewBox 282×70

    unpacking-bind

    12345a*restb
    attached to /examples/unpacking · viewBox 152×80

    comprehension-equivalence

    [x*2 for x in xs if x > 0]out = []for x in xs: if x > 0: out.append(x*2)
    attached to /examples/comprehensions · viewBox 280×76

    list-append

    314+1.append
    attached to /examples/lists · viewBox 220×36

    dict-buckets

    HASH → BUCKET0"a" → 11"b" → 22"c" → 3"d" → 4collision
    attached to /examples/dicts · viewBox 270×88

    workers-portable-evidence

    UNAVAILABLEmultiprocessing.Process()PORTABLE EVIDENCEvalueasserted in-process
    attached to a journey section · viewBox 222×84

    workers-protocol-local

    REQUEST SHAPEGET /resourceRESPONSE SHAPE · ASSERTED LOCALLY200 OK · { … }
    attached to a journey section · viewBox 144×110

    workers-lesson-runtime

    LESSONshapeRUNTIMElimitsvalue
    attached to a journey section · viewBox 200×46

    number-lines

    INT · UNBOUNDEDFLOAT · REPRESENTABLE SPACING WIDENS
    attached to /examples/numbers · viewBox 260×78

    expression-tree

    *+423
    attached to /examples/operators · viewBox 220×92

    none-singleton

    abcNONETYPENone
    attached to /examples/none · viewBox 240×84

    codepoints-bytes

    CODEPOINTScaféUTF-8 BYTES636166c3a9
    attached to /examples/strings · viewBox 200×84

    sort-stability

    INPUTSTABLE SORT BY KEY2 · Ada1 · Bo1 · Bo1 · Cy2 · Eve2 · Ada1 · Cy2 · Eve
    attached to /examples/sorting · viewBox 270×100

    kw-only-separator

    def f(a, b, *, c, d): …positional or kwkeyword only
    attached to /examples/keyword-only-arguments · viewBox 200×56

    positional-only-separator

    def f(a, b, /, c, d): …positional onlypositional or kw
    attached to /examples/positional-only-parameters · viewBox 200×56

    generator-ribbon

    PAUSED BETWEEN YIELDS · RESUMED BY NEXT()yieldyield
    attached to /examples/generators · viewBox 260×50

    truth-and-size

    x__bool__()__len__() != 0default: True
    attached to /examples/truth-and-size · viewBox 232×70

    descriptor-protocol

    obj.attrDESCRIPTOR__get____set____delete__
    attached to /examples/descriptors · viewBox 222×76

    bound-unbound

    obj.methodbound · self filledClass.methodfunction · self required
    attached to /examples/bound-and-unbound-methods · viewBox 272×56

    method-kinds

    @classmethodfirst arg · Cls@staticmethodfirst arg · (none)instancefirst arg · self
    attached to /examples/classmethods-and-staticmethods · viewBox 272×70

    callable-objects

    OBJECT__call__obj(...)
    attached to /examples/callable-objects · viewBox 220×44

    attribute-lookup

    obj.xinstance __dict__class __dict____getattr__
    attached to /examples/attribute-access · viewBox 242×70

    guard-clauses

    if not valid: returnif missing: return Noneif special_case: return Xmain work …exit
    attached to /examples/guard-clauses · viewBox 264×104

    bytes-vs-bytearray

    BYTES — FROZENb'\\x63\\x61\\x66'BYTEARRAY — MUTABLEbytearray(b'\\x63\\x61').append(0x66)
    attached to /examples/bytes-and-bytearray · viewBox 308×86

    sentinel-iteration

    iter(read, '')valuevaluevalue''sentinel · stop
    attached to /examples/sentinel-iteration · viewBox 300×92

    partial-functions

    f(a, b, c)partial(f, 1)g(b, c)
    attached to /examples/partial-functions · viewBox 334×36
    diff --git a/src/asset_manifest.py b/src/asset_manifest.py index 713d4ef..0a9d73a 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.489bc3f7eb6d.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'} -HTML_CACHE_VERSION = '38529f4157c8' +HTML_CACHE_VERSION = '075334720534' diff --git a/src/marginalia.py b/src/marginalia.py index c6eae88..195b9c2 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -484,33 +484,226 @@ def dict_buckets(c: Canvas) -> None: def workers_portable_evidence(c: Canvas) -> None: - """Workers · process boundaries unavailable; portable evidence (a value) crosses instead.""" - c.frame(0, 8, 70, 30, ghost=True, label="process A") - c.frame(110, 8, 70, 30, ghost=True, label="process B") - c.dashed(74, 8, 106, 38) - c.dashed(74, 38, 106, 8) - c.tag(0, 60, "instead") - c.cell(0, 66, "value", w=60, h=22, soft=True) - c.closed_arrow(60, 77, 116, 77, emphasis=True) - c.cell(118, 66, "captured", w=62, h=22) + """Workers · the process API is unavailable; a captured value crosses instead.""" + c.tag(0, 4, "unavailable") + c.cell(0, 12, "multiprocessing.Process()", w=180, h=22, ghost=True) + c.dashed(0, 24, 180, 24) + c.tag(0, 50, "portable evidence") + c.cell(0, 58, "value", w=60, h=22, soft=True) + c.closed_arrow(60, 69, 100, 69, emphasis=True) + c.cell(102, 58, "asserted in-process", w=120, h=22) def workers_protocol_local(c: Canvas) -> None: - """Workers · protocol shape, not real network: assert on the shape, not the socket.""" - c.tag(0, 12, "request shape") - c.cell(0, 18, "GET /resource", w=140, h=22, soft=True) - c.closed_arrow(70, 44, 70, 60, emphasis=True) - c.tag(0, 80, "response shape") - c.cell(0, 86, "200 · { … }", w=140, h=22, soft=True) + """Workers · the protocol shape is the lesson; no real socket is opened.""" + c.tag(0, 4, "request shape") + c.cell(0, 12, "GET /resource", w=140, h=22) + c.closed_arrow(70, 38, 70, 56, emphasis=True) + c.tag(0, 76, "response shape · asserted locally") + c.cell(0, 84, "200 OK · { … }", w=140, h=22) def workers_lesson_runtime(c: Canvas) -> None: - """Workers · lesson shape preserved while runtime constraints are respected.""" - c.frame(0, 6, 70, 32, label="lesson") - c.mono(34, 26, "evidence") - c.frame(110, 6, 70, 32, label="runtime") - c.mono(144, 26, "respected") - c.closed_arrow(70, 22, 110, 22, emphasis=True) + """Workers · lesson and runtime meet at a boundary; evidence carries across.""" + c.frame(0, 4, 80, 36, label="lesson") + c.mono(40, 26, "shape") + c.frame(120, 4, 80, 36, label="runtime") + c.mono(160, 26, "limits") + c.closed_arrow(80, 22, 120, 22, emphasis=True) + c.label(100, 22, "value", anchor="middle") + + +# ─── Examples promoted from the gestalt: new paint code ────────────── + + +def number_lines(c: Canvas) -> None: + """Numbers · int unbounded; float spacing widens at the extremes.""" + c.tag(0, 8, "int · unbounded") + c.register(0, 22, 260, divisions=8) + c.tag(0, 50, "float · representable spacing widens") + c.register(0, 64, 260) + for x in (10, 30, 60, 100, 130, 140, 148, 160, 188, 230, 260): + c.tick(x, 64) + c.dot(140, 64, emphasis=True) + + +def expression_tree(c: Canvas) -> None: + """Operators and Literals · expression `(2+3)*4` parsed as a tree.""" + c.node(120, 12, "*", r=12) + c.node(80, 50, "+", r=12) + c.node(180, 50, "4", r=10) + c.node(56, 80, "2", r=10) + c.node(104, 80, "3", r=10) + c.connect(120, 12, 12, 80, 50, 12) + c.connect(120, 12, 12, 180, 50, 10) + c.connect(80, 50, 12, 56, 80, 10) + c.connect(80, 50, 12, 104, 80, 10) + + +def none_singleton(c: Canvas) -> None: + """None · three names converging on the single None object.""" + for i, n in enumerate("abc"): + c.name_box(0, i * 28, n) + for y in (12, 40, 68): + c.closed_arrow(60, y, 158, 40, emphasis=False) + c.object_box(160, 24, "NoneType", "None", w=80) + + +def codepoints_bytes(c: Canvas) -> None: + """Strings · text is unicode; bytes are a separate encoding layer.""" + c.tag(0, 8, "codepoints") + for i, ch in enumerate("café"): + c.cell(i * 40, 16, ch, w=40, h=28) + c.tag(0, 60, "utf-8 bytes") + widths = [40, 40, 40, 20, 20] + bytes_ = ["63", "61", "66", "c3", "a9"] + x = 0 + for w, b in zip(widths, bytes_): + c.cell(x, 68, b, w=w, h=14) + x += w + + +def sort_stability(c: Canvas) -> None: + """Sorting · stable sort preserves equal keys' original order.""" + c.tag(0, 4, "input") + c.tag(180, 4, "stable sort by key") + inputs = ["2 · Ada", "1 · Bo", "2 · Eve", "1 · Cy"] + outputs = ["1 · Bo", "1 · Cy", "2 · Ada", "2 · Eve"] + for i, (a, b) in enumerate(zip(inputs, outputs)): + c.cell(0, 12 + i * 22, a, w=80, h=20) + c.cell(180, 12 + i * 22, b, w=80, h=20) + c.dashed(80, 44, 180, 22) + c.dashed(80, 88, 180, 44) + c.dashed(80, 22, 180, 66) + c.dashed(80, 66, 180, 88) + + +def kw_only_separator(c: Canvas) -> None: + """Keyword-only arguments · `*` divides positional from keyword-only.""" + c.mono(0, 18, "def f(a, b, *, c, d): …", anchor="start") + c.dashed(82, 22, 82, 38) + c.label(40, 50, "positional or kw", anchor="middle") + c.label(140, 50, "keyword only", anchor="middle") + + +def positional_only_separator(c: Canvas) -> None: + """Positional-only parameters · `/` divides positional-only from positional-or-kw.""" + c.mono(0, 18, "def f(a, b, /, c, d): …", anchor="start") + c.dashed(82, 22, 82, 38) + c.label(40, 50, "positional only", anchor="middle") + c.label(140, 50, "positional or kw", anchor="middle") + + +def generator_ribbon(c: Canvas) -> None: + """Generators · execution paused between yields, resumed by next().""" + c.tag(0, 8, "paused between yields · resumed by next()") + c.ribbon(0, 16, 260, h=30, gates=[64, 136, 208], soft_segments=[(0, 64), (136, 208)]) + c.mono(32, 36, "…") + c.mono(100, 36, "yield") + c.mono(172, 36, "…") + c.mono(244, 36, "yield") + + +def truth_and_size(c: Canvas) -> None: + """Truth and size · bool(x) checks __bool__, then __len__, else True.""" + c.cell(0, 22, "x", w=40, h=24) + c.closed_arrow(40, 34, 70, 34, emphasis=True) + c.cell(72, 0, "__bool__()", w=160, h=22) + c.cell(72, 22, "__len__() != 0", w=160, h=22, ghost=True) + c.cell(72, 44, "default: True", w=160, h=22, ghost=True) + + +def descriptor_protocol(c: Canvas) -> None: + """Descriptors · attribute access routes to __get__/__set__/__delete__.""" + c.cell(0, 22, "obj.attr", w=80, h=24) + c.closed_arrow(80, 34, 110, 34, emphasis=True) + c.frame(112, 0, 110, 70, label="descriptor") + c.mono(167, 22, "__get__") + c.mono(167, 38, "__set__") + c.mono(167, 54, "__delete__") + + +def bound_unbound(c: Canvas) -> None: + """Bound vs unbound methods · instance.method binds self; Class.method does not.""" + c.cell(0, 0, "obj.method", w=110, h=22) + c.closed_arrow(110, 11, 140, 11, emphasis=True) + c.cell(142, 0, "bound · self filled", w=130, h=22, soft=True) + c.cell(0, 32, "Class.method", w=110, h=22) + c.closed_arrow(110, 43, 140, 43, emphasis=False) + c.cell(142, 32, "function · self required", w=130, h=22) + + +def method_kinds(c: Canvas) -> None: + """Method kinds · classmethod, staticmethod, instance — first-arg differs.""" + rows = [ + ("@classmethod", "Cls"), + ("@staticmethod", "(none)"), + ("instance", "self"), + ] + for i, (decorator, first_arg) in enumerate(rows): + y = i * 24 + c.cell(0, y, decorator, w=110, h=22) + c.closed_arrow(110, y + 11, 140, y + 11, emphasis=False) + c.cell(142, y, f"first arg · {first_arg}", w=130, h=22, soft=True) + + +def callable_objects(c: Canvas) -> None: + """Callable objects · `__call__` makes any object look like a function.""" + c.frame(0, 4, 100, 36, label="object") + c.mono(50, 26, "__call__") + c.closed_arrow(100, 22, 138, 22, emphasis=True) + c.cell(140, 10, "obj(...)", w=80, h=24, soft=True) + + +def attribute_lookup(c: Canvas) -> None: + """Attribute access · instance dict, then class dict, then __getattr__; first hit wins.""" + c.cell(0, 22, "obj.x", w=70, h=24) + c.closed_arrow(70, 34, 100, 34, emphasis=True) + c.cell(102, 0, "instance __dict__", w=140, h=22) + c.cell(102, 22, "class __dict__", w=140, h=22) + c.cell(102, 44, "__getattr__", w=140, h=22, ghost=True) + + +def guard_clauses(c: Canvas) -> None: + """Guard clauses · early returns first; main work runs only when guards fall through.""" + c.cell(0, 0, "if not valid: return", w=180, h=22, soft=True) + c.cell(0, 24, "if missing: return None", w=180, h=22, soft=True) + c.cell(0, 48, "if special_case: return X", w=180, h=22, soft=True) + c.cell(0, 78, "main work …", w=180, h=22) + c.closed_arrow(190, 11, 220, 11, emphasis=False) + c.closed_arrow(190, 35, 220, 35, emphasis=False) + c.closed_arrow(190, 59, 220, 59, emphasis=False) + c.label(222, 38, "exit", anchor="start") + + +def bytes_vs_bytearray(c: Canvas) -> None: + """Bytes vs bytearray · frozen sequence of integers vs mutable counterpart.""" + c.tag(0, 4, "bytes — frozen") + c.cell(0, 12, "b'\\\\x63\\\\x61\\\\x66'", w=160, h=24) + c.tag(0, 50, "bytearray — mutable") + c.cell(0, 58, "bytearray(b'\\\\x63\\\\x61')", w=180, h=24) + c.closed_arrow(180, 70, 218, 70, emphasis=True) + c.label(222, 67, ".append(0x66)", anchor="start") + + +def sentinel_iteration(c: Canvas) -> None: + """Sentinel iteration · `iter(callable, sentinel)` calls until the sentinel returns.""" + c.cell(0, 22, "iter(read, '')", w=120, h=24) + c.closed_arrow(120, 34, 152, 34, emphasis=True) + c.cell(154, 0, "value", w=70, h=20) + c.cell(154, 22, "value", w=70, h=20) + c.cell(154, 44, "value", w=70, h=20) + c.cell(154, 66, "''", w=70, h=20, ghost=True) + c.label(228, 80, "sentinel · stop", anchor="start") + + +def partial_functions(c: Canvas) -> None: + """Partial functions · `partial(f, 1)` pre-fills arguments, returning a thinner callable.""" + c.cell(0, 12, "f(a, b, c)", w=100, h=24) + c.closed_arrow(100, 24, 130, 24, emphasis=True) + c.cell(132, 0, "partial(f, 1)", w=100, h=24) + c.closed_arrow(232, 24, 262, 24, emphasis=False) + c.cell(264, 12, "g(b, c)", w=70, h=24, soft=True) def lazy_stream(c: Canvas) -> None: @@ -571,10 +764,29 @@ def lazy_stream(c: Canvas) -> None: "comprehension-equivalence": (comprehension_equivalence, 280, 76), "list-append": (list_append, 220, 36), "dict-buckets": (dict_buckets, 270, 88), - # Workers journey (constraint-shaped sections; designs tentative) - "workers-portable-evidence": (workers_portable_evidence, 200, 96), - "workers-protocol-local": (workers_protocol_local, 144, 116), - "workers-lesson-runtime": (workers_lesson_runtime, 188, 50), + # Workers journey (constraint-shaped sections; tightened designs) + "workers-portable-evidence": (workers_portable_evidence, 222, 84), + "workers-protocol-local": (workers_protocol_local, 144, 110), + "workers-lesson-runtime": (workers_lesson_runtime, 200, 46), + # Newly designed paint code for examples that lacked a figure + "number-lines": (number_lines, 260, 78), + "expression-tree": (expression_tree, 220, 92), + "none-singleton": (none_singleton, 240, 84), + "codepoints-bytes": (codepoints_bytes, 200, 84), + "sort-stability": (sort_stability, 270, 100), + "kw-only-separator": (kw_only_separator, 200, 56), + "positional-only-separator": (positional_only_separator, 200, 56), + "generator-ribbon": (generator_ribbon, 260, 50), + "truth-and-size": (truth_and_size, 232, 70), + "descriptor-protocol": (descriptor_protocol, 222, 76), + "bound-unbound": (bound_unbound, 272, 56), + "method-kinds": (method_kinds, 272, 70), + "callable-objects": (callable_objects, 220, 44), + "attribute-lookup": (attribute_lookup, 242, 70), + "guard-clauses": (guard_clauses, 264, 104), + "bytes-vs-bytearray": (bytes_vs_bytearray, 308, 86), + "sentinel-iteration": (sentinel_iteration, 300, 92), + "partial-functions": (partial_functions, 334, 36), } @@ -673,6 +885,157 @@ def lazy_stream(c: Canvas) -> None: "`raise X from Y` sets `__cause__` (explicit); raising during except sets `__context__` (implicit).", ), ], + # Promoted from gestalt with newly-written paint code + "hello-world": [( + "cell-0", "program-output", + "Every Python program starts as source and produces text on standard output. The smallest mental model.", + )], + "numbers": [( + "cell-1", "number-lines", + "Ints have unbounded precision; floats use IEEE doubles whose representable values thin out near the extremes.", + )], + "operators": [( + "cell-0", "expression-tree", + "An expression like `(2 + 3) * 4` parses as a tree; operator precedence and parentheses determine its shape.", + )], + "none": [( + "cell-0", "none-singleton", + "`None` is a single object: every name that points at None points at the same object.", + )], + "equality-and-identity": [( + "cell-0", "identity-and-equality", + "Two names can share one object (`is` and `==` both true) or hold two equal-but-distinct objects (only `==` true).", + )], + "strings": [( + "cell-0", "codepoints-bytes", + "Strings are sequences of Unicode codepoints. UTF-8 encoding turns them into bytes; `é` takes two bytes, `c` takes one.", + )], + "for-loops": [( + "cell-1", "iterator-unroll", + "Each call to next() advances the caret one cell along the iterable — the same shape behind range(), strings, and any sequence.", + )], + "sorting": [( + "cell-1", "sort-stability", + "Python's sort is stable: items with equal keys keep their original order, so chained sorts compose predictably.", + )], + "keyword-only-arguments": [( + "cell-0", "kw-only-separator", + "A bare `*` divides positional or keyword arguments from keyword-only ones; callers must pass `c` and `d` by name.", + )], + "positional-only-parameters": [( + "cell-0", "positional-only-separator", + "A bare `/` divides positional-only arguments from positional-or-keyword ones; callers cannot name `a` or `b`.", + )], + "closures": [( + "cell-0", "closure-cell", + "The inner function keeps a reference into the outer scope's cell, so the captured factor survives the outer return.", + )], + "scope-global-nonlocal": [( + "cell-0", "scope-rings", + "Name lookup walks LEGB — local, enclosing, global, built-in — outward, returning the first binding it finds.", + )], + "generators": [( + "cell-0", "generator-ribbon", + "A generator's body is a timeline cut by yield gates: each next() advances to the next gate; locals survive the pause.", + )], + "type-hints": [( + "cell-0", "annotation-ghost", + "Annotations describe expected types for tools; the runtime accepts any object regardless.", + )], + "exceptions": [( + "cell-0", "exception-lanes", + "try, except, else, and finally as parallel lanes; a single coral path traces what actually runs.", + )], + "context-managers": [( + "cell-0", "context-bowtie", + "A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.", + )], + "async-await": [( + "cell-0", "async-swimlane", + "On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.", + )], + "iterators": [( + "cell-0", "iter-protocol", + "iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.", + )], + "slices": [( + "cell-0", "slice-ruler", + "Slice indices sit between cells; [:3] and [3:] partition the sequence at index 3, never overlapping or losing an item.", + )], + # Mappings of existing FIGURES to new examples added on main + "operator-overloading": [( + "cell-0", "operator-dispatch", + "Defining `__add__` on a class lets `+` dispatch into the class's own behavior.", + )], + "iterator-vs-iterable": [( + "cell-0", "iter-protocol", + "An iterable knows how to produce an iterator (via iter()); the iterator knows how to produce values (via next()).", + )], + "type-aliases": [( + "cell-0", "annotation-ghost", + "A type alias names a complex annotation once so call sites read as their domain meaning, not their type composition.", + )], + "typed-dicts": [( + "cell-0", "union-types", + "TypedDict gives each key a typed value, so `obj['x']` is checked against the declared shape.", + )], + "union-and-optional-types": [( + "cell-0", "union-types", + "`int | str | None` says one slot may hold any of three shapes — including expected absence.", + )], + "generics-and-typevar": [( + "cell-0", "generic-preservation", + "A generic preserves the input type through the call: the same T flows in and out of fn[T].", + )], + "abstract-base-classes": [( + "cell-0", "class-triangle", + "An ABC sits on the same triangle as concrete classes; subclasses inherit the abstract methods they must implement.", + )], + "copying-collections": [( + "cell-0", "aliasing-mutation", + "Without copy() two names share the same object; mutating through one is visible through the other.", + )], + # Newly designed figures for examples that previously had none + "truth-and-size": [( + "cell-0", "truth-and-size", + "bool(x) calls __bool__ first; if absent, __len__() != 0; if neither, defaults to True.", + )], + "descriptors": [( + "cell-0", "descriptor-protocol", + "Attribute access on an instance routes through the descriptor's __get__/__set__/__delete__ when the attribute is a descriptor.", + )], + "bound-and-unbound-methods": [( + "cell-0", "bound-unbound", + "Accessing a method via an instance binds self; accessing it via the class returns the underlying function.", + )], + "classmethods-and-staticmethods": [( + "cell-0", "method-kinds", + "Three method kinds, three first-argument conventions: classmethod gets the class, staticmethod gets nothing, instance gets self.", + )], + "callable-objects": [( + "cell-0", "callable-objects", + "Defining __call__ makes any object callable; functions are just one shape that satisfies this protocol.", + )], + "attribute-access": [( + "cell-0", "attribute-lookup", + "obj.x checks instance __dict__, then class __dict__, then __getattr__; the first hit wins.", + )], + "guard-clauses": [( + "cell-0", "guard-clauses", + "Early returns handle the exceptional cases first so the main work is the body of the function, not its tail.", + )], + "bytes-and-bytearray": [( + "cell-0", "bytes-vs-bytearray", + "bytes is a frozen sequence of integers; bytearray is the mutable counterpart with append/extend/etc.", + )], + "sentinel-iteration": [( + "cell-0", "sentinel-iteration", + "`iter(callable, sentinel)` calls the callable repeatedly, stopping when it returns the sentinel.", + )], + "partial-functions": [( + "cell-0", "partial-functions", + "`functools.partial(f, 1)` pre-fills `a=1`, returning a thinner callable `g(b, c)` that only needs the rest.", + )], } diff --git a/tests/test_app.py b/tests/test_app.py index c419b1f..25c4a1a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -40,7 +40,7 @@ def test_every_example_renders_literate_cells_with_output(self): for example in list_examples(): with self.subTest(slug=example["slug"]): html = render_example_page(example) - self.assertIn('class="lesson-step lp-cell"', html) + self.assertIn('lesson-step lp-cell', html) self.assertIn('class="cell-source"', html) self.assertIn('class="cell-output"', html) self.assertNotIn('

    Output

    ', html) @@ -274,7 +274,7 @@ def test_example_page_contains_code_docs_and_run_form(self): self.assertIn('