diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..399843b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# `src/asset_manifest.py` is generated by `scripts/fingerprint_assets.py`. +# On merge/rebase, keep our side of the conflict — the post-merge and +# post-rewrite hooks regenerate the file deterministically afterwards. +# This works once `scripts/install-git-hooks.sh` has been run locally, +# which registers `merge.ours.driver = true` and points `core.hooksPath` +# at `.githooks/`. +src/asset_manifest.py merge=ours diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..0dd8606 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Regenerate the asset manifest after a merge or pull so the digest +# reflects the merged tree, not whichever parent won the conflict. +set -e +cd "$(git rev-parse --show-toplevel)" +uv run python scripts/fingerprint_assets.py >/dev/null +if ! git diff --quiet src/asset_manifest.py public/_headers; then + echo "post-merge: asset manifest regenerated; stage and amend if needed" +fi diff --git a/.githooks/post-rewrite b/.githooks/post-rewrite new file mode 100755 index 0000000..04c37d4 --- /dev/null +++ b/.githooks/post-rewrite @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Regenerate the asset manifest after rebase/amend so the digest matches +# the rewritten history, not whichever commit happened to win each step. +set -e +cd "$(git rev-parse --show-toplevel)" +uv run python scripts/fingerprint_assets.py >/dev/null +if ! git diff --quiet src/asset_manifest.py public/_headers; then + echo "post-rewrite: asset manifest regenerated; stage and amend if needed" +fi diff --git a/.github/workflows/preview-viz.yml b/.github/workflows/preview-viz.yml new file mode 100644 index 0000000..9272e7f --- /dev/null +++ b/.github/workflows/preview-viz.yml @@ -0,0 +1,74 @@ +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: 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: | + find ~ /tmp /root -name "*.log" -path "*wrangler*" 2>/dev/null | while read f; do + echo "=== $f ===" + tail -300 "$f" || true + done diff --git a/README.md b/README.md index 02529b1..09f2e9b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ Install dependencies with `uv`, then run: python3 -m unittest discover -s tests -v ``` +After cloning, install the local git hooks once so merges and rebases regenerate `src/asset_manifest.py` instead of producing conflicts: + +```bash +./scripts/install-git-hooks.sh +``` + Run locally on Workers: ```bash diff --git a/docs/example-figure-rubric.md b/docs/example-figure-rubric.md new file mode 100644 index 0000000..75beab3 --- /dev/null +++ b/docs/example-figure-rubric.md @@ -0,0 +1,158 @@ +# 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. Version 2 of this +rubric, applied 2026-05; see `docs/rubric-saturation.md` for the +reasoning that produced these upgrades. The previous criterion 2 +("match the running variables") and criterion 5 ("caption asserts") +have been replaced; a new page-level coherence rubric joins the +per-figure scoring. + +## 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. **The figure earns its place (0-1.0)** — the figure surfaces + something the prose cannot show in the same word count: a + relationship, a before/after, a hidden mechanism, an invariant. + A figure that merely restates the prose in diagram form earns + 0.5; a figure that adds nothing the prose hasn't already said + earns 0. Generic placeholders (`a`, `b`, `xs`) are fine; what + matters is whether the figure carries pedagogical weight beyond + the prose. (Replaces v1's "match the running variables", which + punished honest reuse of library figures across multiple cells.) +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 quality (0-1.0)** — `figcaption` declares what is true, + in the section summary's voice; it does not narrate what the + figure does. "Two names share one mutable list — appending + through one name changes the object visible through both." + earns 1.0. "The figure shows two names pointing at one list." + earns 0 (narration, not assertion). Mixed-voice captions earn + 0.5. 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. **Banner-row fit (0-1.0)** — the figure's intrinsic width sits + comfortably inside `.cell-banner`'s auto-fit grid. Intrinsic widths + beyond ~360 px clamp to the column without growing past it; much + narrower viewBoxes leave whitespace either side of the centred + figure. Aim for an intrinsic viewBox between 200 and 360 px wide. +10. **Pairs with the surrounding cell (0-0.5)** — the banner sits + AFTER the named cell, so the eye reads cell-prose → cell-code → + banner. The figure should summarise the move the surrounding + cell just made, 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. + +## Page-level coherence (per slug, multi-figure) + +A separate 0-1.0 score applied to slugs whose `ATTACHMENTS[slug]` +list contains more than one figure. Multi-figure pages must form a +coherent set, not three angles on the same point. + +- **1.0** — figures show distinct aspects of the lesson in a + natural reading order (intro picture, mid-walkthrough mechanism, + summary). Each banner earns its placement. +- **0.5** — figures are individually fine but redundant; one would + do the work of two. The page reads as cluttered. +- **0** — figures contradict each other, or one figure is on the + wrong cell, or the page has three figures where one would teach + better. + +For single-figure slugs (today, all 109 of them), page coherence is +trivially 1.0 and does not enter the per-figure score. As multi- +figure attachments grow this criterion will become the discriminator +that prevents the "more figures is better" failure mode. + +## 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/journey-visualisation-rubric.md b/docs/journey-visualisation-rubric.md new file mode 100644 index 0000000..df42a06 --- /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 the banner 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/lessons-learned.md b/docs/lessons-learned.md index bfb8721..60605c0 100644 --- a/docs/lessons-learned.md +++ b/docs/lessons-learned.md @@ -84,3 +84,20 @@ git diff --check - Keep `README.md` focused on how to understand, verify, and deploy the project. - Keep this lessons document updated when a bug reveals a general rule. - Record user-visible changes in `CHANGELOG.md` before significant commits or releases. + +## Visualisations and marginalia + +- **A diagram set needs a grammar, not a collection of one-off layouts.** Hand-drawn SVGs drift in stroke weight, cell size, type-tag placement, arrow style. A locked `Canvas` grammar (palette, tokens, words, phrases, metrics) makes drift structurally impossible. Cards declare figures by composing primitives; the library guarantees consistency. +- **Emit explicit `width`/`height` on every SVG; use `max-width: 100%` in CSS, never `width: 100%`.** Without `width`/`height` the browser stretches a small viewBox to fill its container, doubling text inside. This was the root cause of every "figure too big" report. The fix lives in `Canvas.to_svg()` and the CSS rules in `public/site.css`, `scripts/build_prototypes.py`, and `scripts/build_marginalia.py`. +- **A figure's diagrammatic content must not duplicate its figcaption.** SVGs may carry functional labels (`stdout`, `iter()`, panel tags like `before` / `after`, type signatures like `x: int | str | None`). Full sentences describing the figure as a whole are prose and belong in the figcaption. Captions are the canonical voice. The exception is review-only pages (`marginalia-gestalt`) where cards have no figcaption; figures destined for promotion to production must drop their inline prose first. +- **Emphasis is scarce.** With site `--accent` saturated for UI use, a coral arrow on every line reads as no emphasis at all. `closed_arrow` defaults to `emphasis=False`; figures opt in only for the single element the prose names. Same rule for accent dots, gates, and ring highlights. +- **Soft fills should be neutral, not accent-tinted.** A 5% warm-brown tint reads as a quiet container. An accent-tinted soft fill makes every object box look highlighted, which breaks the scarcity rule a second way. +- **Two rubrics, one craft section.** Journey-section figures depict a *conceptual shift* across multiple lessons; example-cell figures depict the *single move* the surrounding cell discusses. `docs/journey-visualisation-rubric.md` and `docs/example-figure-rubric.md` score each on 10 points: content fidelity, craft, context. Topic gates per kind of section / cell shape. +- **Constraint-shaped sections resist mechanism figures.** Workers' "Preserve the lesson while respecting the runtime" is a principle, not a mechanism. Forcing figures on such sections scores below the 8.5 gate. Either reframe the section around a mechanism or accept the gap deliberately. +- **Authoring stays on the contributor; figures stay on the curator.** Example markdown does not include figure references. `src/marginalia.py` holds `FIGURES` (paint functions) and `ATTACHMENTS` (slug → cell → figure → caption). Curating figures is a single-file edit that contributors never see. +- **Inline between prose and code is the production layout; banners between cells is the prototyped richer grammar.** Cells with figures drop to single-column stacking (prose, figure, code) via `.lp-cell.has-figure { grid-template-columns: 1fr }`. Cells without figures keep today's `prose | code` 2-column grid bit-for-bit. The banner-between approach (`/prototyping/layout-banner-*`) supports multi-figure small-multiples between cells when one inline figure isn't enough. +- **Centralised gestalt pages catch drift that page-by-page review misses.** `/prototyping/marginalia-gestalt`, `/prototyping/journey-figures-gestalt`, and `/prototyping/production-figures-gestalt` show every figure in three different framings. Seeing all section figures of a journey in one 3-up row exposes inconsistencies invisible across six tabs. +- **Mapping reuses existing figures; promoting moves design to production.** Half of example coverage came from attaching existing FIGURES to new examples (no paint code). The other half from new paint code copied or designed from gestalt cards. Both paths must pass the rubric. +- **Tests against the cell layout must allow the `has-figure` class.** When the renderer adds `has-figure` to cells with attached figures, assertions on the literal string `class="lesson-step lp-cell"` fail. Change those tests to check the substring `lesson-step lp-cell` so both variants match. +- **Score what's shipping, not what was designed.** A scoring dict on the gestalt is design-time review. Production figures live in `src/marginalia.py` `FIGURES` and may have been redesigned during promotion. Scoring should track the production version with the gestalt as separate history. +- **Some examples should never have figures.** Constraint-shaped, infrastructure-shaped, and aggregator-shaped slugs lack a single mechanism to depict. Force-fitting figures on them scores below the gate. Leave them figure-less and document why rather than ship weak figures. diff --git a/docs/rubric-saturation.md b/docs/rubric-saturation.md new file mode 100644 index 0000000..3352113 --- /dev/null +++ b/docs/rubric-saturation.md @@ -0,0 +1,142 @@ +# Rubric saturation analysis + +After six iteration passes, the figure system has 109 examples +attached (one per slug on `main`) and 109 figures in +`src/marginalia.py FIGURES`. Coverage is 100%. Distribution against +`docs/example-figure-rubric.md`: + +| band | count | composition | +|---|---:|---| +| 9.5 | 3 | the canonical pictures (`variables`, `mutability`, `copying-collections`) | +| 9.0 | ~35 | strong mechanism, single move, runs match cell | +| 8.5 | ~55 | strong but honest reuse, or generic placeholders | +| 8.0 | ~16 | binding pictures, abstract pictures, weak reuses | + +Mean ≈ 8.7. **No figure scores below 8.0.** No figure exceeds 9.5. +Pushing further requires changes to the rubric itself, because the +remaining drag comes from criteria that are structurally over-strict +for a library this size. + +## Why every figure cannot reach 9.0 under the current rubric + +Two criteria in `docs/example-figure-rubric.md` cap most figures +at 8.5 by design: + +### Criterion 2 — "Match the running variables (0–1.0)" + +A figure loses up to 1.0 when its placeholders (`a`, `b`, `xs`) do +not match the cell's specific names (`first`, `second`, `factor`, +`numbers`). For a library of 109 figures across 109 cells, matching +running variables one-for-one would require 109 bespoke paint +functions; reuse becomes impossible. Today 12 figures are reused +across multiple slugs precisely because they capture a *general* +mechanism (`iter-protocol` covers `iterators`, +`iterator-vs-iterable`, `iterating-over-iterables`, +`container-protocols`). Every reuse pays a tax against this +criterion. + +The criterion was written for a small boutique catalogue where one +figure per lesson is the norm. At 109 figures the cost of strict +matching is unbounded; the criterion's *intent* — "make the figure +recognisably about this cell, not a different lesson" — is satisfied +already by criterion 1 (cell fidelity) plus criterion 4 (mechanism). + +### Criterion 9 — "Independence from lesson figures (0–1.0)" + +A journey-section figure scoring 9 elsewhere loses up to 1.0 when +attached to a related lesson. `iter-protocol` is the section figure +for *Iteration · See the protocol behind `for`* and the cell figure +for four iteration-adjacent lessons. The rubric counts the lesson +attachments down on independence, even though they are the most +honest depiction available. + +The intent was to prevent a journey-section figure from being +literally re-rendered as the only diagram on its constituent lesson +pages — that *would* read as redundant. But in our flow, the +journey-section figure already sits at `/journeys/`, and the +lesson appears alone at `/examples/`; readers don't see both +beside each other. The "independence" penalty fires regardless. + +## What the rubric needs + +Four upgrades would let further iteration produce visible quality +gains rather than just shuffling the same band. + +### 1. Tier figures into **library** and **canonical** + +A *library* figure is a primitive of the system: meant for reuse, +generic by design (e.g. `iter-protocol`, `branch-fork`, +`class-triangle`). A *canonical* figure is unique to one cell, with +that cell's specific running variables baked in (e.g. +`aliasing-mutation`, `mutability`'s three-state strip). + +For library figures: criterion 2 (running variables) and 9 +(independence) should be **non-scored**. Score them once at +registration; cap their attached score at 9.0 (not 10). + +For canonical figures: criteria 2 and 9 stay as written. Cap at +9.5 only if the figure is *the* picture for that mechanism — the +9.5 floor is supposed to be rare and definitive. + +Result: ~70 library figures (today reuse-shaped) all reach 9.0; +~30 canonical figures reach 9.0–9.5 by being slug-specific. + +### 2. Replace criterion 2 with **"the figure earns its place"** + +Strict variable-matching loses information value at scale. The +better question is "does swapping in this figure improve the cell +versus showing no figure?" If yes, full credit. If the figure +contains marks the cell's prose doesn't motivate, deduct. + +Practical rewrite of criterion 2 (0–1.0): + +> The figure adds something the prose cannot show in the same word +> count: a relationship, a before/after, a hidden mechanism. A +> figure that merely restates the prose in diagram form earns 0.5; +> a figure that surfaces a relationship invisible in the prose +> earns 1.0. + +This rewards genuine pedagogical value and accepts honest reuse. + +### 3. Add **caption rubric** + +Captions today are scored only as "present" (criterion 5). +Quality varies: some assert ("Two names share one mutable list — appending through one name changes the object visible through both."); others hedge ("The figure shows..."). A separate 0–1.0: + +> Caption declares what is true, in the section summary's voice; +> does not narrate what the figure does. "Two names share one list" +> earns 1.0; "Here we see two names" earns 0. + +Captions written under this criterion will pull weak figures up by +~0.5 points. + +### 4. Add **page-level coherence** + +Currently a slug with three attached figures scores three figures +independently. A page that ships three 8.5 figures is *worse* than +one 9.0 figure on the same page (cognitive load, redundancy). A +page-level rubric (0–1.0) would score: + +> When multiple figures attach to one slug, they form a coherent +> set — different aspects of the same lesson, not three angles on +> the same point. + +Today this is a manual judgement; codifying it would prevent the +inevitable "too many figures" failure mode as coverage grows. + +## What this turn changed + +- Fixed the layout regression: cells stay 2-col always; figures live + in banner rows BETWEEN cells. `hello-world` now matches production. +- Six targeted figure refinements: `tuple-frozen` shows the frozen + aspect (struck-through .append); `literal-forms` shows specific + literal spellings per type; `function-with-body` shows a specific + function with its return value; spec/rubric docs updated to reflect + banner-between in production. +- Documented the rubric saturation: 9.0 floor isn't reachable for + every figure under the current rubric without designing slug- + specific paint code for ~70 reusable library figures, which sells + reuse for marginal score gain. + +The rubric upgrades above are what would make the next pass produce +visible quality gains rather than re-shuffling the same 8.5 band. diff --git a/docs/visual-explainer-spec.md b/docs/visual-explainer-spec.md new file mode 100644 index 0000000..1bebafa --- /dev/null +++ b/docs/visual-explainer-spec.md @@ -0,0 +1,283 @@ +# 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. + +## 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. + 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` — `.cell-banner` rules. Production uses the + banner-between grammar; cells always render with the prose|code + 2-column grid and never receive a `has-figure` class. +- `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..483f2fa --- /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.

+
+
  • 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-control-flow.html b/public/prototyping/journey-control-flow.html new file mode 100644 index 0000000..34524c2 --- /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 > 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 new file mode 100644 index 0000000..6a01ab1 --- /dev/null +++ b/public/prototyping/journey-figures-gestalt.html @@ -0,0 +1,59 @@ + + + + +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 > 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.

lesson questionprocess APIcaptured output
The lesson's evidence survives across the boundary that the worker runtime enforces.
+
+ + + diff --git a/public/prototyping/journey-interfaces.html b/public/prototyping/journey-interfaces.html new file mode 100644 index 0000000..988f051 --- /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 = 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 new file mode 100644 index 0000000..00db50f --- /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()
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..5f7c783 --- /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.

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 new file mode 100644 index 0000000..fefaedb --- /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..6c9103d --- /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..9933477 --- /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 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 new file mode 100644 index 0000000..ef38d5d --- /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.

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

lesson questionprocess APIcaptured output
The lesson's evidence survives across the boundary that the worker runtime enforces.
+
+ + + diff --git a/public/prototyping/layout-banner-pair.html b/public/prototyping/layout-banner-pair.html new file mode 100644 index 0000000..864a432 --- /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

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

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

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

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..77e0b66 --- /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

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

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..f5b743b --- /dev/null +++ b/public/prototyping/marginalia-gestalt.html @@ -0,0 +1,537 @@ + + + + +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 +

9.0 · program → output, smallest mechanism

+
+
+

Basics · 02

+

Values

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

9.0 · every literal is a typed object

+

every value is an object with a type

+
+
+

Basics · 03

+

Numbers

+ INT · UNBOUNDEDFLOAT · REPRESENTABLE SPACING WIDENS +

9.0 · int unbounded vs float thinning, both registers

+
+
+

Basics · 04

+

Booleans

+ ANDTFTFTFFF +

9.0 · 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 name → object picture

+

names bind to objects

+
+
+

Basics · 08

+

Constants

+ MAX_SIZEINT100 +

9.0 · name binding; UPPER_CASE is convention

+

UPPER_CASE — convention, not enforcement

+
+
+

Basics · 09

+

Truthiness

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

9.0 · bool(x) with the falsy set as a strip

+
+
+

Basics · 10

+

Equality and Identity

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

9.0 · shared vs separate object, 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-state small multiple of aliased mutation

+
+
+

Basics · 12

+

Strings

+ CODEPOINTScaféUTF-8 BYTES636166c3a9 +

9.0 · codepoints + bytes registers

+
+
+

Basics · 13

+

String Formatting

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

9.0 · format-spec railroad

+
+
+

Control Flow · 14

+

Conditionals

+ ?ifelse +

9.0 · predicate forks value to branch

+
+
+

Control Flow · 15

+

Assignment Expressions

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

9.0 · walrus binds while comparing

+
+
+

Control Flow · 16

+

For Loops

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

9.0 · 4-row caret advance

+
+
+

Control Flow · 17

+

Break and Continue

+ LOOP BODYcontinuebreak +

9.0 · early exit at first match

+
+
+

Control Flow · 18

+

Loop Else

+ LOOPfell throughelse: …broke — else skipped +

9.0 · fell-through vs broke, two outcomes

+
+
+

Control Flow · 19

+

Iterating over Iterables

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

9.0 · iter() exposes the iterator

+
+
+

Control Flow · 20

+

Iterators

+ idlenext()doneiter()stop +

9.0 · three-state machine

+
+
+

Control Flow · 21

+

Match Statements

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

9.0 · dispatch ladder; first match wins

+
+
+

Control Flow · 22

+

Advanced Match Patterns

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

9.0 · four pattern variants

+
+
+

Control Flow · 23

+

While Loops

+ ?bodyexit +

9.0 · back-edge mechanism

+
+
+

Collections · 24

+

Lists

+ MUTABLE SEQUENCE314+1.append +

9.0 · cells with append mechanism

+
+
+

Collections · 25

+

Tuples

+ IMMUTABLE SEQUENCE3141 +

9.0 · frozen sequence with struck-through .append

+
+
+

Collections · 26

+

Unpacking

+ 12345a*restb +

9.0 · binding-line mechanism with *rest

+
+
+

Collections · 27

+

Dictionaries

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

9.0 · hash buckets with collision chain

+
+
+

Collections · 28

+

Sets

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

9.0 · hash buckets without values

+
+
+

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 · comprehension over equivalent for-loop

+
+
+

Collections · 31

+

Comprehension Patterns

+ xs · ysSOURCEif y>0FILTERx*yMAP +

9.0 · nested clauses compose

+

nested clauses compose left to right

+
+
+

Collections · 32

+

Sorting

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

9.0 · stability ribbons preserved across keys

+

equal keys keep original order

+
+
+

Functions · 33

+

Functions

+ argsDEF F(...)return +

9.0 · specific call: greet('Ada') → 'Hello, Ada'

+

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 explicit `*` separator

+
+
+

Functions · 35

+

Positional-only Parameters

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

9.0 · signature with explicit `/` separator

+
+
+

Functions · 36

+

Args and Kwargs

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

9.0 · *args tuple, **kwargs dict regions

+
+
+

Functions · 37

+

Multiple Return Values

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

9.0 · function returns tuple; caller unpacks

+
+
+

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 nested rings

+
+
+

Functions · 40

+

Recursion

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

9.0 · stacked frames with same name, different argument

+
+
+

Functions · 41

+

Lambdas

+ lambda x: x + 1paramsexpression +

9.0 · function literal: params / expression

+
+
+

Iteration · 42

+

Generators

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

9.0 · ribbon cut by yield gates

+

function body as a timeline

+
+
+

Iteration · 43

+

Yield From

+ OUTERyield from inner()INNERdelegated +

9.0 · stitched ribbons; delegation

+
+
+

Iteration · 44

+

Generator Expressions

+ (sourcefiltermap)lazy stream — no list materialised +

9.0 · lazy filter→map pipeline

+
+
+

Iteration · 45

+

Itertools

+ CHAINa · bc · dCYCLEa · b · cISLICEwindow +

9.0 · chain joins two iterables into one stream

+
+
+

Functions · 46

+

Decorators

+ BEFOREfFNf₀ bodyAFTER @DECfwrapperCELLf₀ +

9.0 · before/after rebinding through cell

+
+
+

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__ signature

+
+
+

Classes · 50

+

Properties

+ obj.xfget / fset__dict__ +

9.0 · obj.x routes through fget instead of __dict__

+
+
+

Data Model · 51

+

Special Methods

+ a + bdispatchesa.__add__(b) +

9.0 · syntax → method dispatch

+
+
+

Classes · 52

+

Metaclasses

+ CLASSClassMETACLASSMetaclass +

9.0 · extended triangle to metaclass

+
+
+

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

9.0 · name erased; object survives if referenced

+
+
+

Errors · 55

+

Exceptions

+ TRYEXCEPTELSEFINALLY +

9.0 · try/except/else/finally lanes with traced path

+

no raise → try → else → finally

+
+
+

Errors · 56

+

Assertions

+ assert cond, msgtrue · passfalse · AssertionError +

9.0 · True passes, False raises

+
+
+

Errors · 57

+

Exception Chaining

+ ValueError__cause____context__RuntimeError +

9.0 · __cause__ vs __context__ distinguished

+
+
+

Errors · 58

+

Exception Groups

+ BEFORE EXCEPT*except*AFTER +

9.0 · except* peels matching leaves

+

matched leaves removed; survivors regrouped

+
+
+

Modules · 59

+

Modules

+ SYS.PATHcwdsite-packagesstdlibfirst hitmymod.py +

9.0 · sys.path resolution; first hit wins

+
+
+

Modules · 60

+

Import Aliases

+ import numpy as npnpMODULEnumpy +

9.0 · two names bind to the same module

+
+
+

Types · 61

+

Type Hints

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

9.0 · ghost annotations over runtime values

+
+
+

Types · 62

+

Protocols

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

9.0 · structural duck check

+

duck — required methods present

+
+
+

Types · 63

+

Enums

+ COLOR · CLOSED SETREDGREENBLUEno more +

9.0 · closed set of symbolic values

+
+
+

Text · 64

+

Regular Expressions

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

9.0 · pattern ruler with anchors

+
+
+

Standard Library · 65

+

Number Parsing

+ "42"int()42ValueErrorint +

9.0 · int() success path vs ValueError

+
+
+

Errors · 66

+

Custom Exceptions

+ BaseExceptionExceptionValueErrorMyDomainError +

9.0 · subclass chain to a domain name

+
+
+

Standard Library · 67

+

JSON

+ JSONPYTHONobjectdictarrayliststringstrnumberint / floattrue / falseTrue / FalsenullNone +

9.0 · two-column type mapping

+
+
+

Standard Library · 68

+

Dates and Times

+ one instant−5h+0h +

9.0 · one instant, two clock offsets

+
+
+

Async · 69

+

Async Await

+ LOOPCOROawaitresume +

9.0 · loop/coro swimlane with await handoffs

+
+
+

Async · 70

+

Async Iteration and Context

+ ASYNC FOR · ASYNC WITHawait yieldawait yield +

9.0 · loop/coro lanes with await yields

+
+
+ + 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/prototyping/production-figures-gestalt.html b/public/prototyping/production-figures-gestalt.html new file mode 100644 index 0000000..ef684fd --- /dev/null +++ b/public/prototyping/production-figures-gestalt.html @@ -0,0 +1,59 @@ + + + + +Production figures gestalt · Prototype + + + + +
Prototype · All 109 figures currently registered in src/marginalia.py FIGURES; each card names where it renders. · all prototypes
+ +
+
+

Production figure registry · 109 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, copying-collections · viewBox 220×175

v2 scores: mutability 9.5 · copying-collections 9.5

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

v2 scores: for-loops 9.0

scope-rings

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

v2 scores: scope-global-nonlocal 9.0

closure-cell

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

v2 scores: closures 9.0

slice-ruler

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

v2 scores: slices 9.0

branch-fork

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

v2 scores: conditionals 9.0

loop-repetition

abcdbody
attached to /examples/while-loops · attached to a journey section · viewBox 204×90

v2 scores: while-loops 9.0

iter-protocol

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

v2 scores: iterators 9.0 · iterator-vs-iterable 9.0 · iterating-over-iterables 9.0 · container-protocols 9.0

program-output

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

v2 scores: hello-world 9.0

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

v2 scores: equality-and-identity 9.0

operator-dispatch

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

v2 scores: special-methods 9.0 · operator-overloading 9.0

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 · attached to a journey section · viewBox 220×52

v2 scores: type-hints 9.0

union-types

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

v2 scores: union-and-optional-types 9.0

generic-preservation

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

v2 scores: generics-and-typevar 9.0

exception-lanes

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

v2 scores: exceptions 9.0

context-bowtie

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

v2 scores: context-managers 9.0

async-swimlane

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

v2 scores: async-await 9.0 · async-iteration-and-context 9.0

naming-decisions

len(xs):=nNAMEvaluen > 10
attached to /examples/assignment-expressions · attached to a journey section · viewBox 274×80

v2 scores: assignment-expressions 9.0

early-exit

abcdefound · breakfirst match
attached to /examples/break-and-continue · attached to a journey section · viewBox 144×116

v2 scores: break-and-continue 9.0

lazy-stream

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

v2 scores: generator-expressions 9.0

variables-bind

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

v2 scores: variables 9.5 · constants 9.0

call-stack

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

v2 scores: recursion 9.0

decorator-rebind

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

v2 scores: decorators 9.0

mro-chain

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

v2 scores: inheritance-and-super 9.0

dataclass-fields

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

v2 scores: dataclasses 9.0

class-triangle

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

v2 scores: classes 9.0 · abstract-base-classes 9.0

exception-cause-context

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

v2 scores: exception-chaining 9.0

unpacking-bind

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

v2 scores: unpacking 9.0

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, comprehension-patterns · viewBox 280×76

v2 scores: comprehensions 9.0 · comprehension-patterns 9.0

list-append

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

v2 scores: lists 9.0

dict-buckets

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

v2 scores: dicts 9.0

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 162×110

workers-lesson-runtime

lesson questionprocess APIcaptured output
attached to a journey section · viewBox 300×80

number-lines

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

v2 scores: numbers 9.0

expression-tree

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

v2 scores: operators 9.0

none-singleton

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

v2 scores: none 9.0

codepoints-bytes

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

v2 scores: strings 9.0

sort-stability

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

v2 scores: sorting 9.0

kw-only-separator

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

v2 scores: keyword-only-arguments 9.0

positional-only-separator

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

v2 scores: positional-only-parameters 9.0

generator-ribbon

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

v2 scores: generators 9.0

truth-and-size

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

v2 scores: truth-and-size 9.0

descriptor-protocol

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

v2 scores: descriptors 9.0

bound-unbound

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

v2 scores: bound-and-unbound-methods 9.0

method-kinds

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

v2 scores: classmethods-and-staticmethods 9.0

callable-objects

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

v2 scores: callable-objects 9.0

attribute-lookup

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

v2 scores: attribute-access 9.0

guard-clauses

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

v2 scores: guard-clauses 9.0

bytes-vs-bytearray

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

v2 scores: bytes-and-bytearray 9.0

sentinel-iteration

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

v2 scores: sentinel-iteration 9.0

partial-functions

f(a, b, c)partial(f, 1)g(b, c)
attached to /examples/partial-functions · viewBox 334×36

v2 scores: partial-functions 9.0

args-kwargs

def f(*args, **kwargs): …extra positionals → tupleextra keywords → dict
attached to /examples/args-and-kwargs · viewBox 280×68

v2 scores: args-and-kwargs 9.0

multiple-return

def f(): return a, b(a, b)x, y
attached to /examples/multiple-return-values · viewBox 180×110

v2 scores: multiple-return-values 9.0

lambda-expression

lambda x: x + 1paramsexpression
attached to /examples/lambdas · viewBox 170×76

v2 scores: lambdas 9.0

property-fork

obj.xfget / fset__dict__
attached to /examples/properties · viewBox 232×72

v2 scores: properties 9.0

metaclass-triangle

instanceCLASSClassMETACLASStype
attached to /examples/metaclasses · viewBox 300×60

v2 scores: metaclasses 9.0

sys-path-resolution

SYS.PATHcwdsite-packagesstdlibfirst hitmymod.py
attached to /examples/modules · viewBox 258×100

v2 scores: modules 9.0

import-alias

import numpy as npnpnumpy module
attached to /examples/import-aliases · viewBox 212×56

v2 scores: import-aliases 9.0

protocol-check

OBJECTread()write()close()structuralPROTOCOLread()close()
attached to /examples/protocols · viewBox 220×78

v2 scores: protocols 9.0

enum-members

COLOR · CLOSED SETREDGREENBLUEno more
attached to /examples/enums · viewBox 280×60

v2 scores: enums 9.0

datetime-instant

one instant−5h+0h
attached to /examples/datetime · viewBox 280×88

v2 scores: datetime 9.0

json-python-mapping

JSONPYTHONobjectdictarrayliststringstrnumberint / floattrue / falseTrue / FalsenullNone
attached to /examples/json · viewBox 220×116

v2 scores: json 9.0

regex-anchors

PATTERN^\d{2}-\d{2}$INPUT12-34
attached to /examples/regular-expressions · viewBox 200×92

v2 scores: regular-expressions 9.0

number-parse

"42"int()42ValueError
attached to /examples/number-parsing · viewBox 204×64

v2 scores: number-parsing 9.0

format-spec

FORMAT SPECalignsignwidth,.prectype{:>6,.2f}
attached to /examples/string-formatting · viewBox 220×64

v2 scores: string-formatting 9.0

truthy-check

xboolTrue or FalseFALSY VALUES00.0""[]{}NoneFalse
attached to /examples/truthiness · viewBox 240×70

v2 scores: truthiness 9.0

boolean-truth-table

A AND BTFTFTFFF
attached to /examples/booleans · viewBox 132×64

v2 scores: booleans 9.0

set-buckets

KEYS ONLYabcx in sO(1)
attached to /examples/sets · viewBox 156×90

v2 scores: sets 9.0

tuple-frozen

FROZEN SEQUENCE(3, 1, 4, 1).append
attached to /examples/tuples · viewBox 280×48

v2 scores: tuples 9.0

value-types

INT42STR"hi"LIST[1,2,3]DICT{k:v}
attached to /examples/values · viewBox 160×116

v2 scores: values 9.0

yield-delegation

OUTERyield from innerINNER
attached to /examples/yield-from · viewBox 240×84

v2 scores: yield-from 9.0

itertools-chain

ITER A1 · 2ITER B3 · 4CHAIN1 · 2 · 3 · 4
attached to /examples/itertools · viewBox 246×82

v2 scores: itertools 9.0

assertion-check

assert condTrue · passFalse · AssertionError
attached to /examples/assertions · viewBox 304×76

v2 scores: assertions 9.0

custom-exception-chain

BaseExceptionExceptionValueErrorMyDomainError
attached to /examples/custom-exceptions · viewBox 220×90

v2 scores: custom-exceptions 9.0

exception-group-peel

BEFOREexcept*AFTER
attached to /examples/exception-groups · viewBox 240×50

v2 scores: exception-groups 9.0

delete-name-erased

BEFOREx[1, 2, 3]AFTER DEL Xx[1, 2, 3]
attached to /examples/delete-statements · viewBox 200×84

v2 scores: delete-statements 9.0

package-tree

MYPACKAGE__init__.pya.pyb.pysub/
attached to /examples/packages · viewBox 240×76

v2 scores: packages 9.0

venv-boundary

PROJECTcoderequirementsVENVpythonsite-packages
attached to /examples/virtual-environments · viewBox 274×76

v2 scores: virtual-environments 9.0

subprocess-spawn

parentspawnchild processoutput
attached to /examples/subprocesses · viewBox 324×60

v2 scores: subprocesses 9.0

logging-levels

CRITICAL50ERROR40WARNING30INFO20DEBUG10
attached to /examples/logging · viewBox 164×124

v2 scores: logging 9.0

aaa-pattern

arrangeset up stateactperform behaviorassertcompare result
attached to /examples/testing · viewBox 250×80

v2 scores: testing 9.0

protocol-layers

application · HTTPtransport · TCPnetwork · IPlink
attached to /examples/networking · viewBox 200×100

v2 scores: networking 9.0

gil-lanes

GILTHREAD ATHREAD B
attached to /examples/threads-and-processes · viewBox 300×100

v2 scores: threads-and-processes 8.5

cast-escape

Anycast(T, x)T
attached to /examples/casts-and-any · viewBox 184×56

v2 scores: casts-and-any 9.0

newtype-phantom

RUNTIME: INT42STATIC: USERIDUserId(42)
attached to /examples/newtype · viewBox 96×92

v2 scores: newtype 9.0

overload-signatures

@OVERLOADdef f(x: int) -> strdef f(x: str) -> intone impl
attached to /examples/overloads · viewBox 304×64

v2 scores: overloads 8.5

paramspec-preserve

f(P)@DECP preservedwrapper(P)
attached to /examples/paramspec · viewBox 294×60

v2 scores: paramspec 9.0

literal-constrained

LITERAL[…]x'red''green''blue'
attached to /examples/literal-and-final · viewBox 144×76

v2 scores: literal-and-final 9.0

callable-type

CALLABLE[[INT, STR], BOOL](int, str)bool
attached to /examples/callable-types · viewBox 196×40

v2 scores: callable-types 8.5

isinstance-check

isinstance(x, T)TrueFalse
attached to /examples/runtime-type-checks · viewBox 232×76

v2 scores: runtime-type-checks 9.0

collections-containers

dequefast appends both endsCounterkey → countdefaultdictmissing key defaultnamedtupletuple with names
attached to /examples/collections-module · viewBox 284×92

v2 scores: collections-module 9.0

typed-dict-shape

USER TYPEDDICTid: intname: stractive: bool
attached to /examples/typed-dicts, structured-data-shapes · viewBox 200×92

v2 scores: typed-dicts 9.0 · structured-data-shapes 9.0

csv-records

ROWS · RECORDSidnamescore1Ada972Bo883Cy76
attached to /examples/csv-data · viewBox 212×96

v2 scores: csv-data 9.0

warning-signal

code pathDeprecationWarningexecution continues
attached to /examples/warnings · viewBox 292×80

v2 scores: warnings 9.0

object-lifecycle

__init__live · refcount > 0__del__
attached to /examples/object-lifecycle · viewBox 366×60

v2 scores: object-lifecycle 9.0

type-alias-name

dict[str, list[tuple[int, str]]]type Index = …Index
attached to /examples/type-aliases · viewBox 240×104

v2 scores: type-aliases 9.0

match-dispatch-ladder

match valuecase 0:case [x, y]:case Point(0, _):case _:first match
attached to /examples/match-statements · viewBox 260×130

v2 scores: match-statements 9.0

match-pattern-variants

capture[x, y]alternativeP() | Q()guard[x] if x > 0classPoint(x=0, y=_)
attached to /examples/advanced-match-patterns · viewBox 272×96

v2 scores: advanced-match-patterns 9.0

loop-else-gate

loop bodyfell through · else runsbroke · else skipped
attached to /examples/loop-else · viewBox 312×76

v2 scores: loop-else 9.0

literal-forms

int42 · 0x2a · 0b101float3.14 · 1e-3str"hi" · 'hi'list[1, 2, 3]dict{k: v}set{1, 2, 3}
attached to /examples/literals · viewBox 252×132

v2 scores: literals 9.0

function-with-body

nameDEF GREET(NAME):"Hello, " + name"Hello, Ada"
attached to /examples/functions · viewBox 334×68

v2 scores: functions 9.0

+
+ + + diff --git a/public/site.5f6f7da7c305.css b/public/site.be98c8af1bb8.css similarity index 86% rename from public/site.5f6f7da7c305.css rename to public/site.be98c8af1bb8.css index 4538e5c..9cc3d33 100644 --- a/public/site.5f6f7da7c305.css +++ b/public/site.be98c8af1bb8.css @@ -1,4 +1,4 @@ -:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --subtle: rgba(82, 16, 0, 0.4); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } +:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 16px/1.6 FT Kunst Grotesk, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } @@ -8,7 +8,7 @@ nav a:hover { color: var(--accent); text-decoration-color: var(--accent); } .button:active { transform: scale(0.96); } h1, h2 { letter-spacing: -0.04em; line-height: 1.05; text-wrap: balance; } - h1 { font-size: clamp(2.4rem, 7vw, 5.75rem); margin: 0 0 1rem; } + h1 { font-size: clamp(2.4rem, 4.5vw, 3.75rem); margin: 0 0 1rem; } h2 { margin-top: 0; } p, li { text-wrap: pretty; } pre { overflow: auto; padding: 1rem; border-radius: 1rem; background: #0b1020; color: #f9fafb; box-shadow: 0 1px 1px rgba(0,0,0,.12), 0 12px 42px rgba(0,0,0,.18); } @@ -71,8 +71,15 @@ .cell-output { margin-top: var(--space-3); padding: var(--space-3) 0 0; border-top: 1px solid var(--hairline-soft); background: transparent; } .unsupported-cell .cell-code-stack { border-left-style: dashed; } .notebook-notes { margin-top: var(--space-5); } - @media (max-width: 980px) { .lp-cell { grid-template-columns: 1fr; } .cell-output { max-width: none; } } + @media (max-width: 780px) { + .lp-cell, .lesson-step, .runner-grid { grid-template-columns: 1fr; } + .lp-cell .cell-code-stack { max-width: 72ch; } + .cell-output { max-width: none; } + body { padding: .875rem; } + header { margin-inline: -.875rem; padding-inline: .875rem; } + } .playground { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .playground > h2 { font-size: clamp(1.5rem, 2.5vw, 2rem); letter-spacing: -0.03em; margin-bottom: var(--space-3); } .runner-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .75fr); gap: var(--space-4); align-items: stretch; } .runner-panel { min-height: 18rem; display: flex; flex-direction: column; border: 1px dashed var(--hairline); border-radius: .75rem; padding: var(--space-3); background: var(--surface); } .runner-panel h2 { margin: 0 0 var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--hairline-soft); font-size: 1.05rem; letter-spacing: -0.02em; } @@ -99,9 +106,21 @@ .journey-item-title { font-weight: 750; } .journey-gap, .journey-gap-label { color: var(--muted); font-weight: 750; } .journey-gap-label { margin: 0; color: var(--muted); font-size: .86rem; letter-spacing: 0; text-transform: none; } + .notes-list { margin: 0 0 var(--space-5); padding-left: 1.2rem; color: var(--muted); max-width: 58ch; } + .notes-list li { margin-bottom: var(--space-1); text-wrap: pretty; } .example-top, .example-nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; } .example-nav { margin-top: var(--space-5); padding-top: var(--space-3); border-top: 1px solid var(--hairline); } footer { margin-block: 2rem; color: var(--muted); } .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; } } + /* Cell banner: a figure attached to a cell renders in a banner row + AFTER that cell, spanning the full content width. Cells always + keep their prose|code 2-column grid; banners between cells hold + one or many figures (small multiples). See docs/visual-explainer-spec.md. */ + .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 { max-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 { margin-block: var(--space-6); } + .cell-banner--1 figure { max-width: 440px; } + .cell-banner--1 figcaption { max-width: 42ch; } diff --git a/public/site.css b/public/site.css index 4538e5c..9cc3d33 100644 --- a/public/site.css +++ b/public/site.css @@ -1,4 +1,4 @@ -:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --subtle: rgba(82, 16, 0, 0.4); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } +:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 16px/1.6 FT Kunst Grotesk, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } @@ -8,7 +8,7 @@ nav a:hover { color: var(--accent); text-decoration-color: var(--accent); } .button:active { transform: scale(0.96); } h1, h2 { letter-spacing: -0.04em; line-height: 1.05; text-wrap: balance; } - h1 { font-size: clamp(2.4rem, 7vw, 5.75rem); margin: 0 0 1rem; } + h1 { font-size: clamp(2.4rem, 4.5vw, 3.75rem); margin: 0 0 1rem; } h2 { margin-top: 0; } p, li { text-wrap: pretty; } pre { overflow: auto; padding: 1rem; border-radius: 1rem; background: #0b1020; color: #f9fafb; box-shadow: 0 1px 1px rgba(0,0,0,.12), 0 12px 42px rgba(0,0,0,.18); } @@ -71,8 +71,15 @@ .cell-output { margin-top: var(--space-3); padding: var(--space-3) 0 0; border-top: 1px solid var(--hairline-soft); background: transparent; } .unsupported-cell .cell-code-stack { border-left-style: dashed; } .notebook-notes { margin-top: var(--space-5); } - @media (max-width: 980px) { .lp-cell { grid-template-columns: 1fr; } .cell-output { max-width: none; } } + @media (max-width: 780px) { + .lp-cell, .lesson-step, .runner-grid { grid-template-columns: 1fr; } + .lp-cell .cell-code-stack { max-width: 72ch; } + .cell-output { max-width: none; } + body { padding: .875rem; } + header { margin-inline: -.875rem; padding-inline: .875rem; } + } .playground { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--hairline); } + .playground > h2 { font-size: clamp(1.5rem, 2.5vw, 2rem); letter-spacing: -0.03em; margin-bottom: var(--space-3); } .runner-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .75fr); gap: var(--space-4); align-items: stretch; } .runner-panel { min-height: 18rem; display: flex; flex-direction: column; border: 1px dashed var(--hairline); border-radius: .75rem; padding: var(--space-3); background: var(--surface); } .runner-panel h2 { margin: 0 0 var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--hairline-soft); font-size: 1.05rem; letter-spacing: -0.02em; } @@ -99,9 +106,21 @@ .journey-item-title { font-weight: 750; } .journey-gap, .journey-gap-label { color: var(--muted); font-weight: 750; } .journey-gap-label { margin: 0; color: var(--muted); font-size: .86rem; letter-spacing: 0; text-transform: none; } + .notes-list { margin: 0 0 var(--space-5); padding-left: 1.2rem; color: var(--muted); max-width: 58ch; } + .notes-list li { margin-bottom: var(--space-1); text-wrap: pretty; } .example-top, .example-nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; } .example-nav { margin-top: var(--space-5); padding-top: var(--space-3); border-top: 1px solid var(--hairline); } footer { margin-block: 2rem; color: var(--muted); } .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; } } + /* Cell banner: a figure attached to a cell renders in a banner row + AFTER that cell, spanning the full content width. Cells always + keep their prose|code 2-column grid; banners between cells hold + one or many figures (small multiples). See docs/visual-explainer-spec.md. */ + .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 { max-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 { margin-block: var(--space-6); } + .cell-banner--1 figure { max-width: 440px; } + .cell-banner--1 figcaption { max-width: 42ch; } diff --git a/scripts/build_marginalia.py b/scripts/build_marginalia.py new file mode 100644 index 0000000..8167878 --- /dev/null +++ b/scripts/build_marginalia.py @@ -0,0 +1,1018 @@ +#!/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) + CELL, + 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") + + +# Scores against docs/example-figure-rubric.md v2. The production scoring +# lives in src/marginalia.SCORES keyed by example slug; we import it and +# overlay a small set of legacy entries for the gestalt-only cards whose +# slugs differ from production (e.g. "operators-and-literals" split into +# "operators" + "literals" on main). +from marginalia import SCORES as _PRODUCTION_SCORES # noqa: E402 + +SCORES: dict[str, tuple[float, str]] = { + # Gestalt-only slugs that don't match a production example slug. + "operators-and-literals": (9.0, "expression tree mechanism"), +} +SCORES.update(_PRODUCTION_SCORES) +_LEGACY_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"), + 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), +] + +for _card in EXAMPLES: + if _card.slug in SCORES: + _card.score, _card.score_note = SCORES[_card.slug] + + +# ─── 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..eb549b2 --- /dev/null +++ b/scripts/build_prototypes.py @@ -0,0 +1,672 @@ +#!/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 # noqa: E402 + +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

    +
      {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 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", + "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.

    +
    +
      {items}
    +
    +""" + style = """ + .prototype-list { list-style: none; padding: 0; margin: var(--space-4) 0 0; } + .prototype-list li { padding: var(--space-3) 0; border-bottom: 1px dashed var(--hairline-soft); } + .prototype-list li:first-child { border-top: 1px dashed var(--hairline-soft); } + .prototype-list strong { font-weight: 600; font-size: 1.05rem; } + .prototype-list .meta { margin: .25rem 0 0; max-width: 60ch; } +""" + (OUT_DIR / "index.html").write_text( + page("Visual explainer prototypes", "All prototypes", style, body) + ) + + +# ─── Banner CSS (lives between cells, never inside) ─────────────────── + + +BANNER_CSS = """ + /* Banner rows live BETWEEN cells, never inside them. The cell keeps + its prose|code 2-column grid intact; the banner spans the full + content width and holds 1+ figures via an auto-fit grid. Generous + vertical rhythm marks each banner as a teaching pause between + cells (Tufte's small-multiples, Knuth's interleaved literate + prose, algebrica's quiet figure+caption pairing). */ + .cell-banner { + margin: var(--space-5) 0; + padding: var(--space-4) 0; + border-block: 1px dashed var(--hairline-soft); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); + justify-items: center; + } + .cell-banner figure { margin: 0; padding: 0; max-width: 360px; } + .cell-banner svg { max-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 { max-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.", + ), + # 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", + "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 { 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; + } + .section-grid figure .score-line { + margin: var(--space-1) 0 0; color: var(--muted); + font-size: .82rem; font-family: -apple-system, 'Source Sans Pro', sans-serif; + } +""" + + +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 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, SCORES # 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()} + + def score_summary(slugs: list[str]) -> str: + scores = [SCORES.get(s) for s in slugs] + present = [(s, sc) for s, sc in zip(slugs, scores) if sc is not None] + if not present: + return "" + pieces = [f"{s} {score:.1f}" for s, (score, _note) in present] + return " · ".join(pieces) + + 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) + score_html = "" + if name in attached_to_slug: + summary = score_summary(attached_to_slug[name]) + if summary: + score_html = f'

    v2 scores: {html.escape(summary)}

    ' + cards.append( + f"
    " + f'

    {html.escape(name)}

    ' + f"{_render_svg(name)}" + f'
    {kind_html} · viewBox {w}×{h}
    ' + f"{score_html}" + 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() + # ─── 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() + 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: + 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/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..43bc33c --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# One-time local git config so merges and rebases regenerate the asset +# manifest automatically instead of producing conflict markers. +# +# * `merge.ours.driver = true` lets `.gitattributes`' `merge=ours` +# resolve `src/asset_manifest.py` conflicts by keeping our side. +# * `core.hooksPath = .githooks` activates the post-merge and +# post-rewrite hooks that re-run `scripts/fingerprint_assets.py` +# after the merge or rebase finishes. +# +# Both settings are local-only; nothing in this script touches the +# remote or shared config. +set -e +cd "$(git rev-parse --show-toplevel)" +git config merge.ours.driver true +git config core.hooksPath .githooks +chmod +x .githooks/post-merge .githooks/post-rewrite +echo "git hooks installed: merge.ours.driver=true, core.hooksPath=.githooks" diff --git a/src/app.py b/src/app.py index e0c1d00..449af68 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: @@ -747,7 +749,13 @@ 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_parts: list[str] = [] + for i, step in enumerate(walkthrough): + walkthrough_parts.append(_render_cell(step)) + banner_html = render_for_anchor(example["slug"], f"cell-{i}") + if banner_html: + walkthrough_parts.append(banner_html) + walkthrough_html = "".join(walkthrough_parts) 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..740884f 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.be98c8af1bb8.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'} +HTML_CACHE_VERSION = 'd3142faf61e4' diff --git a/src/marginalia.py b/src/marginalia.py new file mode 100644 index 0000000..9a0871f --- /dev/null +++ b/src/marginalia.py @@ -0,0 +1,2010 @@ +"""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. + The figure renders in a banner row AFTER the named cell. + +The renderer in app.py interleaves cells and banners: every cell keeps +its prose|code 2-column grid intact, and a banner row spanning both +columns sits between cells (or after the only cell on single-cell +examples). Multiple figures attached to the same cell share one +banner as a small multiple. Cells without an attached figure render +exactly as before. + +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, emphasis=(i == 3)) + 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, emphasis=False) + 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, 22, "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) + + +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, 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: + """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=False) + 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) + + +# ─── 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) + + +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) + + +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) + 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 ──────────────────────────────────────────────── + + +# ─── 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=160, 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 · 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 · the protocol shape is the lesson; no real socket is opened.""" + c.tag(0, 4, "request shape") + c.cell(0, 12, "GET /resource", w=160, h=22) + c.closed_arrow(80, 38, 80, 56, emphasis=True) + c.tag(0, 76, "response shape · asserted locally") + c.cell(0, 84, "200 OK · { … }", w=160, h=22) + + +# ─── 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=152, 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=152, 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) + + +# ─── More example figures (third coverage push) ─────────────────────── + + +def args_kwargs(c: Canvas) -> None: + """Args and kwargs · *args gathers extra positionals; **kwargs gathers extra keywords.""" + c.mono(20, 22, "def f(*args, **kwargs): …", anchor="start") + c.dashed(80, 26, 80, 44) + c.dashed(152, 26, 152, 44) + c.label(80, 56, "extra positionals → tuple", anchor="middle") + c.label(210, 56, "extra keywords → dict", anchor="middle") + + +def multiple_return(c: Canvas) -> None: + """Multiple return values · the function returns a tuple; the caller unpacks it.""" + c.cell(0, 0, "def f(): return a, b", w=180, h=24) + c.closed_arrow(90, 26, 90, 44, emphasis=True) + c.cell(58, 44, "(a, b)", w=64, h=22, soft=True) + c.closed_arrow(90, 68, 90, 86, emphasis=False) + c.cell(50, 86, "x, y", w=80, h=22) + + +def lambda_expression(c: Canvas) -> None: + """Lambdas · a function as a value: parameters on the left, expression on the right.""" + c.cell(0, 0, "lambda x: x + 1", w=170, h=28, soft=True) + c.dashed(40, 28, 40, 50) + c.dashed(120, 28, 120, 50) + c.label(40, 62, "params", anchor="middle") + c.label(120, 62, "expression", anchor="middle") + + +def property_fork(c: Canvas) -> None: + """Properties · obj.x routes through fget/fset instead of touching __dict__.""" + c.cell(0, 22, "obj.x", w=70, h=24) + c.closed_arrow(70, 22, 110, 4, emphasis=True) + c.cell(112, 0, "fget / fset", w=120, h=22, soft=True) + c.closed_arrow(70, 46, 110, 56, emphasis=False) + c.cell(112, 50, "__dict__", w=120, h=22, ghost=True) + + +def metaclass_triangle(c: Canvas) -> None: + """Metaclasses · instance → class → metaclass; the metaclass is the type of the class.""" + c.dot(20, 30) + c.label(20, 56, "instance", anchor="middle") + c.closed_arrow(26, 30, 86, 30, emphasis=False) + c.frame(88, 12, 60, 36, label="class") + c.mono(118, 34, "Class") + c.closed_arrow(148, 30, 218, 30, emphasis=False) + c.frame(220, 12, 80, 36, label="metaclass") + c.mono(260, 34, "type") + + +def sys_path_resolution(c: Canvas) -> None: + """Modules · imports walk sys.path; the first hit wins.""" + c.tag(0, 4, "sys.path") + paths = ["cwd", "site-packages", "stdlib", "…"] + for i, p in enumerate(paths): + c.cell(0, 14 + i * 20, p, w=120, h=20) + c.closed_arrow(120, 46, 156, 46, emphasis=True) + c.label(145, 30, "first hit", anchor="middle") + c.cell(158, 34, "mymod.py", w=100, h=24, soft=True) + + +def import_alias(c: Canvas) -> None: + """Import aliases · `import x as y` makes y point at the same module object as x.""" + c.mono(0, 8, "import numpy as np", anchor="start") + c.cell(0, 24, "np", w=40, h=22) + c.closed_arrow(40, 35, 80, 35, emphasis=True) + c.cell(82, 24, "numpy module", w=130, h=22, soft=True) + + +def protocol_check(c: Canvas) -> None: + """Protocols · structural check; an object satisfies a protocol if it has the required methods.""" + c.frame(0, 4, 100, 70, label="object") + c.mono(50, 20, "read()") + c.mono(50, 36, "write()") + c.mono(50, 52, "close()") + c.closed_arrow(100, 38, 138, 38, emphasis=True) + c.label(119, 0, "structural", anchor="middle") + c.frame(140, 4, 80, 70, label="protocol", ghost=True) + c.mono(180, 28, "read()") + c.mono(180, 46, "close()") + + +def enum_members(c: Canvas) -> None: + """Enums · a fixed set of named symbolic values; no new members appear at runtime.""" + c.frame(0, 0, 280, 60, label="Color · closed set", ghost=True) + members = ["RED", "GREEN", "BLUE", "no more"] + for i, m in enumerate(members): + c.cell(20 + i * 60, 16, m, w=50, h=28) + + +def datetime_instant(c: Canvas) -> None: + """Datetime · one instant; the offset names which clock face you're reading.""" + c.register(10, 40, 260, divisions=8) + c.dashed(150, 30, 150, 50) + c.label(150, 24, "one instant", anchor="middle") + c.node(90, 70, "−5h", r=12) + c.dashed(90, 58, 150, 50) + c.node(210, 70, "+0h", r=12) + c.dashed(210, 58, 150, 50) + + +def json_python_mapping(c: Canvas) -> None: + """JSON ↔ Python · six type pairs map across the text boundary.""" + c.hairline(120, 0, 120, 110) + c.tag(60, 4, "json", anchor="middle") + c.tag(180, 4, "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 = 24 + i * 14 + c.mono(0, y, a, anchor="start", size=10) + c.mono(132, y, b, anchor="start", size=10) + + +def regex_anchors(c: Canvas) -> None: + """Regular expressions · anchors and quantifiers shape what the pattern matches.""" + c.tag(0, 4, "pattern") + c.mono(0, 24, "^\\d{2}-\\d{2}$", anchor="start") + c.tag(0, 56, "input") + c.cell(0, 64, "", w=200, h=20) + c.cell(40, 64, "12-34", w=120, h=20, soft=True) + + +def number_parse(c: Canvas) -> None: + """Number parsing · text → typed number, raising on bad input.""" + c.cell(0, 22, '"42"', w=70, h=24) + c.closed_arrow(70, 34, 102, 34, emphasis=True) + c.label(86, 26, "int()", anchor="middle") + c.cell(104, 10, "42", w=60, h=22, soft=True) + c.cell(104, 36, "ValueError", w=100, h=22, ghost=True) + + +def format_spec(c: Canvas) -> None: + """String formatting · the format spec is a railroad of named optional fields.""" + c.tag(0, 4, "format spec") + stations = [("align", 36), ("sign", 30), ("width", 40), (",", 22), (".prec", 44), ("type", 32)] + x = 0 + for label_text, w in stations: + c.cell(x, 16, label_text, w=w, h=18) + x += w + 2 + c.label(0, 54, "{:>6,.2f}") + + +def truthy_check(c: Canvas) -> None: + """Truthiness · bool(x) is True except for a small fixed set of falsy values.""" + c.cell(0, 0, "x", w=40, h=24) + c.closed_arrow(40, 12, 70, 12, emphasis=True) + c.label(55, 6, "bool", anchor="middle") + c.cell(72, 0, "True or False", w=130, h=24) + c.tag(0, 38, "falsy values") + falsy = [("0", 22), ("0.0", 30), ('""', 26), ("[]", 22), ("{}", 22), ("None", 40), ("False", 40)] + x = 0 + for label_text, w in falsy: + c.cell(x, 46, label_text, w=w, h=20) + x += w + 2 + + +def boolean_truth_table(c: Canvas) -> None: + """Booleans · `a and b` is True only when both are True; otherwise False.""" + c.tag(64, 0, "a and b", anchor="middle") + c.label(80, 16, "T", anchor="middle") + c.label(112, 16, "F", anchor="middle") + c.label(58, 36, "T", anchor="end") + c.label(58, 56, "F", anchor="end") + c.cell(64, 22, "T", w=32, h=20, soft=True) + c.cell(96, 22, "F", w=32, h=20) + c.cell(64, 42, "F", w=32, h=20) + c.cell(96, 42, "F", w=32, h=20) + + +def set_buckets(c: Canvas) -> None: + """Sets · hash buckets with no values; membership is O(1).""" + c.tag(0, 4, "keys only") + for i, k in enumerate("abc"): + c.cell(0, 14 + i * 22, k, w=50, h=20) + c.closed_arrow(50, 36, 90, 36, emphasis=True) + c.label(70, 28, "x in s", anchor="middle") + c.cell(92, 24, "O(1)", w=60, h=22, soft=True) + + +def tuple_frozen(c: Canvas) -> None: + """Tuples · ordered, immutable sequence; .append doesn't exist.""" + c.tag(0, 0, "frozen sequence") + c.cell(0, 12, "(3, 1, 4, 1)", w=180, h=26) + c.dashed(45, 8, 45, 42) + c.dashed(90, 8, 90, 42) + c.dashed(135, 8, 135, 42) + c.cell(190, 12, ".append", w=80, h=26, ghost=True) + c.dashed(190, 24, 270, 24) + + +def value_types(c: Canvas) -> None: + """Values · every literal is a typed object: int, str, list, dict each carry their behaviour.""" + rows = [("int", "42"), ("str", '"hi"'), ("list", "[1,2,3]"), ("dict", "{k:v}")] + for i, (t, v) in enumerate(rows): + y = i * 30 + c.object_box(0, y, t, v, w=160, h=26, tag_position="inside") + + +def literal_forms(c: Canvas) -> None: + """Literals · each type has its own literal spellings; the source spelling determines the value type.""" + rows = [ + ("int", "42 · 0x2a · 0b101"), + ("float", "3.14 · 1e-3"), + ("str", '"hi" · \'hi\''), + ("list", "[1, 2, 3]"), + ("dict", "{k: v}"), + ("set", "{1, 2, 3}"), + ] + for i, (t, spellings) in enumerate(rows): + y = i * 22 + c.cell(0, y, t, w=50, h=20, soft=True) + c.cell(52, y, spellings, w=200, h=20) + + +def function_with_body(c: Canvas) -> None: + """Functions · `def greet(name): return "Hello, " + name` takes input, computes, returns output.""" + c.closed_arrow(0, 36, 30, 36, emphasis=False) + c.label(15, 28, "name", anchor="middle") + c.frame(32, 18, 150, 44, label="def greet(name):") + c.mono(107, 44, '"Hello, " + name') + c.closed_arrow(182, 36, 212, 36, emphasis=True) + c.cell(214, 24, '"Hello, Ada"', w=120, h=24, soft=True) + + +def yield_delegation(c: Canvas) -> None: + """Yield from · delegate iteration to an inner generator; its yields surface here.""" + c.tag(0, 4, "outer") + c.ribbon(0, 14, 240, h=20, gates=[100, 180]) + c.mono(140, 28, "yield from inner") + c.tag(100, 46, "inner") + c.ribbon(100, 56, 80, h=24, gates=[124, 152]) + c.dashed(140, 56, 140, 34) + + +def itertools_chain(c: Canvas) -> None: + """Itertools · chain joins two iterables into one stream without materialising either.""" + c.object_box(0, 14, "iter A", "1 · 2", w=70, h=24) + c.object_box(0, 52, "iter B", "3 · 4", w=70, h=24) + c.closed_arrow(70, 26, 100, 36, emphasis=False) + c.closed_arrow(70, 64, 100, 54, emphasis=False) + c.object_box(102, 30, "chain", "1 · 2 · 3 · 4", w=140, h=28) + + +def assertion_check(c: Canvas) -> None: + """Assertions · assert tests a condition; True passes, False raises AssertionError.""" + c.cell(0, 22, "assert cond", w=110, h=24) + c.closed_arrow(110, 22, 140, 0, emphasis=True) + c.cell(142, 0, "True · pass", w=120, h=20, soft=True) + c.closed_arrow(110, 46, 140, 56, emphasis=False) + c.cell(142, 50, "False · AssertionError", w=160, h=20) + + +def custom_exception_chain(c: Canvas) -> None: + """Custom exceptions · subclass an existing exception; gain a domain name without changing semantics.""" + chain = ["BaseException", "Exception", "ValueError", "MyDomainError"] + for i, name in enumerate(chain): + emph = i == len(chain) - 1 + c.cell(0, i * 24, name, w=220, h=22, soft=emph) + + +def exception_group_peel(c: Canvas) -> None: + """Exception groups · except* peels matching leaves; survivors regroup.""" + c.tag(0, 0, "before") + c.dot(40, 14) + for x in (20, 36, 52, 68): + c.ghost(40, 18, x, 40) + c.dot(20, 44) + c.dot(36, 44) + c.dot(52, 44) + c.dot(68, 44) + c.closed_arrow(90, 30, 140, 30, emphasis=True) + c.label(115, 22, "except*", anchor="middle") + c.tag(160, 0, "after") + c.dot(200, 14) + c.ghost(200, 18, 180, 40) + c.ghost(200, 18, 220, 40) + c.dot(180, 44) + c.dot(220, 44) + + +def delete_name_erased(c: Canvas) -> None: + """Delete statements · `del x` removes the name; the object survives if any other name holds it.""" + c.tag(0, 0, "before") + c.name_box(0, 12, "x") + c.closed_arrow(60, 23, 100, 23, emphasis=False) + c.cell(102, 12, "[1, 2, 3]", w=90, h=22, soft=True) + c.tag(0, 52, "after del x") + c.name_box(0, 60, "x") + c.dashed(0, 70, 60, 70) + c.dashed(60, 60, 0, 80) + c.cell(102, 60, "[1, 2, 3]", w=90, h=22, soft=True) + + +# ─── Fourth coverage push: constraint-shaped examples ───────────────── + + +def package_tree(c: Canvas) -> None: + """Packages · a directory with __init__.py becomes an importable package; submodules nest.""" + c.frame(70, 0, 100, 22, label="mypackage") + c.mono(120, 14, "__init__.py") + c.stroke(120, 22, 40, 50) + c.stroke(120, 22, 120, 50) + c.stroke(120, 22, 200, 50) + c.cell(10, 50, "a.py", w=60, h=22) + c.cell(90, 50, "b.py", w=60, h=22) + c.cell(170, 50, "sub/", w=60, h=22, soft=True) + + +def venv_boundary(c: Canvas) -> None: + """Virtual environments · a venv isolates a project's interpreter and packages from the system.""" + c.frame(0, 0, 110, 70, label="project") + c.cell(12, 18, "code", w=84, h=20) + c.cell(12, 42, "requirements", w=84, h=20) + c.closed_arrow(110, 35, 142, 35, emphasis=True) + c.frame(144, 0, 130, 70, label="venv") + c.mono(209, 22, "python") + c.mono(209, 42, "site-packages") + + +def subprocess_spawn(c: Canvas) -> None: + """Subprocesses · spawn a child process; capture stdout, stderr, and exit code as portable evidence.""" + c.cell(0, 22, "parent", w=70, h=24) + c.closed_arrow(70, 34, 110, 34, emphasis=True) + c.label(90, 26, "spawn", anchor="middle") + c.cell(112, 22, "child process", w=110, h=24, soft=True) + c.closed_arrow(222, 34, 252, 34, emphasis=False) + c.cell(254, 22, "output", w=70, h=24) + + +def logging_levels(c: Canvas) -> None: + """Logging · five levels; messages below the configured threshold are dropped.""" + levels = [("CRITICAL", "50"), ("ERROR", "40"), ("WARNING", "30"), ("INFO", "20"), ("DEBUG", "10")] + for i, (name, num) in enumerate(levels): + c.cell(0, i * 22, name, w=120, h=20) + c.cell(122, i * 22, num, w=40, h=20, soft=True) + + +def aaa_pattern(c: Canvas) -> None: + """Testing · arrange-act-assert: set up, run the behavior, compare the result.""" + rows = [("arrange", "set up state"), ("act", "perform behavior"), ("assert", "compare result")] + for i, (label_text, body) in enumerate(rows): + c.cell(0, i * 24, label_text, w=80, h=22, soft=(i == 2)) + c.closed_arrow(80, i * 24 + 11, 108, i * 24 + 11, emphasis=False) + c.cell(110, i * 24, body, w=140, h=22) + + +def protocol_layers(c: Canvas) -> None: + """Networking · each layer in the stack hides the next; HTTP rests on TCP on IP on the link.""" + layers = ["application · HTTP", "transport · TCP", "network · IP", "link"] + for i, name in enumerate(layers): + c.cell(0, i * 22, name, w=200, h=20) + + +def gil_lanes(c: Canvas) -> None: + """Threads and processes · the GIL serialises Python bytecode across threads; processes run in parallel.""" + c.lane(20, x0=54, x1=294, label="GIL") + c.lane(50, x0=54, x1=294, label="thread A") + c.lane(80, x0=54, x1=294, label="thread B") + c.cell(64, 44, "", w=30, h=12) + c.cell(124, 74, "", w=30, h=12) + c.cell(184, 44, "", w=30, h=12) + c.cell(244, 74, "", w=30, h=12) + + +def cast_escape(c: Canvas) -> None: + """Casts and any · cast(T, x) tells the type checker to treat x as T; runtime is unaffected.""" + c.cell(0, 22, "Any", w=70, h=24, ghost=True) + c.closed_arrow(70, 34, 110, 34, emphasis=True) + c.label(90, 12, "cast(T, x)", anchor="middle") + c.cell(112, 22, "T", w=70, h=24, soft=True) + + +def newtype_phantom(c: Canvas) -> None: + """NewType · two static identities backed by the same runtime type.""" + c.tag(0, 0, "runtime: int") + c.cell(0, 12, "42", w=60, h=24) + c.tag(0, 50, "static: UserId") + c.cell(0, 62, "UserId(42)", w=90, h=24, soft=True) + + +def overload_signatures(c: Canvas) -> None: + """Overloads · @overload declares multiple signatures; one implementation routes to the right return type.""" + c.tag(0, 0, "@overload") + c.cell(0, 12, "def f(x: int) -> str", w=180, h=20) + c.cell(0, 36, "def f(x: str) -> int", w=180, h=20) + c.closed_arrow(180, 32, 220, 32, emphasis=True) + c.cell(222, 22, "one impl", w=80, h=22, soft=True) + + +def paramspec_preserve(c: Canvas) -> None: + """ParamSpec · the decorator preserves the wrapped function's full signature, parameter for parameter.""" + c.cell(0, 22, "f(P)", w=50, h=24) + c.closed_arrow(50, 34, 80, 34, emphasis=False) + c.frame(82, 12, 100, 44, label="@dec") + c.mono(132, 36, "P preserved") + c.closed_arrow(182, 34, 212, 34, emphasis=True) + c.cell(214, 22, "wrapper(P)", w=80, h=24, soft=True) + + +def literal_constrained(c: Canvas) -> None: + """Literal · the type narrows the slot to a fixed set of constant values.""" + c.tag(0, 0, "Literal[…]") + c.cell(0, 12, "x", w=40, h=24) + c.closed_arrow(40, 24, 70, 4, emphasis=False) + c.closed_arrow(40, 24, 70, 24, emphasis=False) + c.closed_arrow(40, 24, 70, 44, emphasis=False) + c.cell(72, 0, "'red'", w=70, h=20, soft=True) + c.cell(72, 22, "'green'", w=70, h=20, soft=True) + c.cell(72, 44, "'blue'", w=70, h=20, soft=True) + + +def callable_type(c: Canvas) -> None: + """Callable types · the annotation captures the call shape: argument types and return type.""" + c.tag(0, 0, "Callable[[int, str], bool]") + c.cell(0, 12, "(int, str)", w=100, h=22) + c.closed_arrow(100, 23, 130, 23, emphasis=False) + c.cell(132, 12, "bool", w=60, h=22) + + +def isinstance_check(c: Canvas) -> None: + """Runtime type checks · isinstance asks the runtime; the answer is a bool, not a refinement.""" + c.cell(0, 22, "isinstance(x, T)", w=140, h=24) + c.closed_arrow(140, 22, 170, 4, emphasis=True) + c.cell(172, 0, "True", w=60, h=20, soft=True) + c.closed_arrow(140, 46, 170, 56, emphasis=False) + c.cell(172, 50, "False", w=60, h=20) + + +def collections_containers(c: Canvas) -> None: + """Collections module · four specialised containers for shapes the built-in types don't cover well.""" + rows = [("deque", "fast appends both ends"), ("Counter", "key → count"), ("defaultdict", "missing key default"), ("namedtuple", "tuple with names")] + for i, (name, role) in enumerate(rows): + c.cell(0, i * 22, name, w=110, h=20) + c.cell(112, i * 22, role, w=170, h=20, soft=True) + + +def typed_dict_shape(c: Canvas) -> None: + """Structured data shapes · TypedDict names each key's value type; the dict obeys the declared shape.""" + c.frame(0, 0, 200, 86, label="User TypedDict") + rows = [("id", "int"), ("name", "str"), ("active", "bool")] + for i, (k, v) in enumerate(rows): + c.cell(14, 18 + i * 20, f"{k}: {v}", w=172, h=18) + + +def csv_records(c: Canvas) -> None: + """CSV data · rows of records; each line has the same columns in the same order.""" + c.tag(0, 0, "rows · records") + headers = ["id", "name", "score"] + rows = [["1", "Ada", "97"], ["2", "Bo", "88"], ["3", "Cy", "76"]] + for j, h in enumerate(headers): + c.cell(j * 70, 12, h, w=70, h=20, soft=True) + for i, r in enumerate(rows): + for j, v in enumerate(r): + c.cell(j * 70, 32 + i * 20, v, w=70, h=18) + + +def warning_signal(c: Canvas) -> None: + """Warnings · a soft signal: the warning is reported, execution continues.""" + c.cell(0, 22, "code path", w=90, h=24) + c.closed_arrow(90, 22, 120, 4, emphasis=False) + c.cell(122, 0, "DeprecationWarning", w=170, h=22, soft=True) + c.closed_arrow(90, 46, 120, 56, emphasis=True) + c.cell(122, 50, "execution continues", w=170, h=22) + + +def object_lifecycle(c: Canvas) -> None: + """Object lifecycle · __init__ creates; the object lives while refcount > 0; __del__ finalises.""" + c.cell(0, 22, "__init__", w=80, h=24) + c.closed_arrow(80, 34, 110, 34, emphasis=True) + c.cell(112, 22, "live · refcount > 0", w=140, h=24, soft=True) + c.closed_arrow(252, 34, 282, 34, emphasis=False) + c.cell(284, 22, "__del__", w=80, h=24) + + +# ─── Fifth pass: tightened figures for slugs that were on reuse-floors ─ + + +def type_alias_name(c: Canvas) -> None: + """Type aliases · complex annotation collapses to a single readable name.""" + c.cell(0, 30, "dict[str, list[tuple[int, str]]]", w=240, h=24, ghost=True) + c.closed_arrow(120, 54, 120, 70, emphasis=True) + c.label(96, 66, "type Index = …", anchor="middle") + c.cell(80, 76, "Index", w=80, h=24, soft=True) + + +def match_dispatch_ladder(c: Canvas) -> None: + """Match statements · the value flows down the patterns; the first match wins.""" + c.cell(0, 0, "match value", w=170, h=22) + cases = ["case 0:", "case [x, y]:", "case Point(0, _):", "case _:"] + for i, txt in enumerate(cases): + c.cell(0, 30 + i * 22, txt, w=170, h=20) + c.dashed(186, 32, 186, 122) + c.dot(186, 74) + c.closed_arrow(186, 110, 186, 124, emphasis=True) + c.label(196, 76, "first match", anchor="start") + + +def match_pattern_variants(c: Canvas) -> None: + """Advanced match patterns · capture, alternative, guard, class — four pattern shapes.""" + rows = [("capture", "[x, y]"), ("alternative", "P() | Q()"), ("guard", "[x] if x > 0"), ("class", "Point(x=0, y=_)")] + for i, (kind, shape) in enumerate(rows): + y = i * 22 + c.cell(0, y, kind, w=90, h=20) + c.cell(92, y, shape, w=180, h=20, soft=(kind == "class")) + + +def loop_else_gate(c: Canvas) -> None: + """Loop else · runs when the loop falls through naturally; break skips it.""" + c.cell(0, 20, "loop body", w=110, h=24) + c.closed_arrow(110, 20, 150, 0, emphasis=True) + c.cell(152, 0, "fell through · else runs", w=160, h=20, soft=True) + c.closed_arrow(110, 44, 150, 56, emphasis=False) + c.cell(152, 50, "broke · else skipped", w=160, h=20) + + +def workers_lesson_runtime(c: Canvas) -> None: + """Workers · lesson uses captured output as evidence when the runtime forbids the process API.""" + c.cell(0, 22, "lesson question", w=130, h=24) + c.closed_arrow(130, 22, 160, 0, emphasis=False) + c.cell(162, 0, "process API", w=130, h=20, ghost=True) + c.dashed(162, 10, 292, 10) + c.closed_arrow(130, 46, 160, 56, emphasis=True) + c.cell(162, 50, "captured output", w=130, h=20, soft=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) + 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") + + +# 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, 66), + "class-with-state": (class_with_state, 152, 108), + # Types + "annotation-ghost": (annotation_ghost, 220, 52), + "union-types": (union_types, 156, 80), + "generic-preservation": (generic_preservation, 250, 70), + # Reliability + "exception-lanes": (exception_lanes, 320, 100), + "context-bowtie": (context_bowtie, 244, 76), + "async-swimlane": (async_swimlane, 280, 84), + # Control flow + Iteration coverage gap (see audit) + "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, 312, 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; tightened designs) + "workers-portable-evidence": (workers_portable_evidence, 222, 84), + "workers-protocol-local": (workers_protocol_local, 162, 110), + "workers-lesson-runtime": (workers_lesson_runtime, 300, 80), + # 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, 296, 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), + # Third coverage push: 24 more figures + "args-kwargs": (args_kwargs, 280, 68), + "multiple-return": (multiple_return, 180, 110), + "lambda-expression": (lambda_expression, 170, 76), + "property-fork": (property_fork, 232, 72), + "metaclass-triangle": (metaclass_triangle, 300, 60), + "sys-path-resolution": (sys_path_resolution, 258, 100), + "import-alias": (import_alias, 212, 56), + "protocol-check": (protocol_check, 220, 78), + "enum-members": (enum_members, 280, 60), + "datetime-instant": (datetime_instant, 280, 88), + "json-python-mapping": (json_python_mapping, 220, 116), + "regex-anchors": (regex_anchors, 200, 92), + "number-parse": (number_parse, 204, 64), + "format-spec": (format_spec, 220, 64), + "truthy-check": (truthy_check, 240, 70), + "boolean-truth-table": (boolean_truth_table, 132, 64), + "set-buckets": (set_buckets, 156, 90), + "tuple-frozen": (tuple_frozen, 280, 48), + "value-types": (value_types, 160, 116), + "yield-delegation": (yield_delegation, 240, 84), + "itertools-chain": (itertools_chain, 246, 82), + "assertion-check": (assertion_check, 304, 76), + "custom-exception-chain": (custom_exception_chain, 220, 90), + "exception-group-peel": (exception_group_peel, 240, 50), + "delete-name-erased": (delete_name_erased, 200, 84), + # Fourth coverage push: 19 figures for constraint-shaped examples + "package-tree": (package_tree, 240, 76), + "venv-boundary": (venv_boundary, 274, 76), + "subprocess-spawn": (subprocess_spawn, 324, 60), + "logging-levels": (logging_levels, 164, 124), + "aaa-pattern": (aaa_pattern, 250, 80), + "protocol-layers": (protocol_layers, 200, 100), + "gil-lanes": (gil_lanes, 300, 100), + "cast-escape": (cast_escape, 184, 56), + "newtype-phantom": (newtype_phantom, 96, 92), + "overload-signatures": (overload_signatures, 304, 64), + "paramspec-preserve": (paramspec_preserve, 294, 60), + "literal-constrained": (literal_constrained, 144, 76), + "callable-type": (callable_type, 196, 40), + "isinstance-check": (isinstance_check, 232, 76), + "collections-containers": (collections_containers, 284, 92), + "typed-dict-shape": (typed_dict_shape, 200, 92), + "csv-records": (csv_records, 212, 96), + "warning-signal": (warning_signal, 292, 80), + "object-lifecycle": (object_lifecycle, 366, 60), + # Fifth pass: slug-specific figures lifting attached scores off the 8.0 floor + "type-alias-name": (type_alias_name, 240, 104), + "match-dispatch-ladder": (match_dispatch_ladder, 260, 130), + "match-pattern-variants": (match_pattern_variants, 272, 96), + "loop-else-gate": (loop_else_gate, 312, 76), + # Sixth pass: lift the lingering 8.0-band figures with slug-specific paint + "literal-forms": (literal_forms, 252, 132), + "function-with-body": (function_with_body, 334, 68), +} + + +# ─── 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.", + ), + ], + "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).", + ), + ], + # 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", "type-alias-name", + "A type alias names a complex annotation once so call sites read as the domain meaning, not the type composition.", + )], + "typed-dicts": [( + "cell-0", "typed-dict-shape", + "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.", + )], + # Third coverage push: 24 more attachments — newly designed figures and journey-figure reuse + "args-and-kwargs": [( + "cell-0", "args-kwargs", + "*args captures the extra positionals as a tuple; **kwargs captures the extra keywords as a dict.", + )], + "multiple-return-values": [( + "cell-0", "multiple-return", + "A function returning multiple values really returns one tuple; the caller unpacks it into named bindings.", + )], + "lambdas": [( + "cell-0", "lambda-expression", + "A lambda is a function literal: parameters before the colon, a single expression after, no statement body.", + )], + "properties": [( + "cell-0", "property-fork", + "When x is a property, attribute access routes through fget/fset instead of touching __dict__.", + )], + "metaclasses": [( + "cell-0", "metaclass-triangle", + "A metaclass is the type of a class, just as a class is the type of its instances; type is the default metaclass.", + )], + "modules": [( + "cell-0", "sys-path-resolution", + "An import walks sys.path entry by entry; the first directory containing the module wins.", + )], + "import-aliases": [( + "cell-0", "import-alias", + "`import x as y` binds the name y to the same module object x would have.", + )], + "protocols": [( + "cell-0", "protocol-check", + "An object satisfies a protocol structurally — by having the required methods — not by inheriting it.", + )], + "enums": [( + "cell-0", "enum-members", + "An enum names a fixed set of symbolic values; no new members appear at runtime.", + )], + "datetime": [( + "cell-0", "datetime-instant", + "An aware datetime carries a UTC offset; one instant in time reads differently on two clocks.", + )], + "json": [( + "cell-0", "json-python-mapping", + "Six type pairs bridge the JSON text boundary; each json value maps to one Python type.", + )], + "regular-expressions": [( + "cell-0", "regex-anchors", + "^ and $ anchor the pattern; quantifiers like {2} bound how many times a token repeats.", + )], + "number-parsing": [( + "cell-0", "number-parse", + "int() turns text into a typed number; malformed input raises ValueError instead of guessing.", + )], + "string-formatting": [( + "cell-0", "format-spec", + "The format spec is a railroad of named optional fields: alignment, sign, width, precision, type.", + )], + "truthiness": [( + "cell-0", "truthy-check", + "bool(x) is True except for a small fixed set: 0, 0.0, \"\", [], {}, None, False.", + )], + "booleans": [( + "cell-0", "boolean-truth-table", + "`a and b` returns True only when both are True; otherwise it returns the first falsy value.", + )], + "sets": [( + "cell-0", "set-buckets", + "Sets are hash buckets without values; `x in s` averages O(1) regardless of size.", + )], + "tuples": [( + "cell-0", "tuple-frozen", + "Tuples are ordered, immutable sequences; positions matter, contents do not change once constructed.", + )], + "values": [( + "cell-0", "value-types", + "Every literal is an object with a type; the type carries the behaviour, not the variable name.", + )], + "yield-from": [( + "cell-0", "yield-delegation", + "`yield from inner` delegates iteration to an inner generator; its yields surface here unchanged.", + )], + "itertools": [( + "cell-0", "itertools-chain", + "chain stitches two iterables into one stream without materialising either: values arrive lazily.", + )], + "assertions": [( + "cell-0", "assertion-check", + "assert tests a condition; True passes silently, False raises AssertionError with the optional message.", + )], + "custom-exceptions": [( + "cell-0", "custom-exception-chain", + "Subclassing an existing exception gains a domain name without changing semantics.", + )], + "exception-groups": [( + "cell-0", "exception-group-peel", + "except* peels matched leaves out of an ExceptionGroup; survivors regroup and propagate.", + )], + "delete-statements": [( + "cell-0", "delete-name-erased", + "`del x` removes the name; the object survives if any other reference holds it, otherwise gets collected.", + )], + # Easy promotions: existing journey figures, reused on examples that fit + "conditionals": [( + "cell-0", "branch-fork", + "A predicate sorts a value into one of several branches; if/elif/else is the explicit spelling.", + )], + "match-statements": [( + "cell-0", "match-dispatch-ladder", + "match dispatches by pattern shape; the value flows down the patterns and the first match wins.", + )], + "assignment-expressions": [( + "cell-0", "naming-decisions", + "The walrus binds a name during the surrounding expression; one expression, two outputs.", + )], + "iterating-over-iterables": [( + "cell-0", "iter-protocol", + "iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.", + )], + "generator-expressions": [( + "cell-0", "lazy-stream", + "A generator expression composes filter and map lazily; values flow only when next() pulls them.", + )], + "async-iteration-and-context": [( + "cell-0", "async-swimlane", + "async iteration and async with both rest on the same loop-vs-coroutine handoff as await.", + )], + "loop-else": [( + "cell-0", "loop-else-gate", + "The loop's else branch runs only when the loop falls through naturally; break skips it.", + )], + "break-and-continue": [( + "cell-0", "early-exit", + "break exits the loop; continue skips to the next iteration. Both interrupt the natural fall-through.", + )], + "comprehension-patterns": [( + "cell-0", "comprehension-equivalence", + "Nested clauses compose left to right; the comprehension is still equivalent to a for-loop with append.", + )], + "container-protocols": [( + "cell-0", "iter-protocol", + "Container protocols share the iter/next backbone; __iter__ + __next__ make any object iterable.", + )], + "functions": [( + "cell-0", "function-with-body", + "A function takes inputs, evaluates a body, and returns a value: `greet('Ada')` produces `'Hello, Ada'`.", + )], + "constants": [( + "cell-0", "variables-bind", + "UPPER_CASE is a naming convention, not a language constraint; the binding behaves like any other variable.", + )], + "while-loops": [( + "cell-0", "loop-repetition", + "while repeats the body until the condition becomes false; the back-edge returns to the test each pass.", + )], + "advanced-match-patterns": [( + "cell-0", "match-pattern-variants", + "Capture, alternative, guard, and class patterns each name a different way a value can match a case.", + )], + "literals": [( + "cell-0", "literal-forms", + "Each Python type has its own literal spellings; ints accept decimal, hex, and binary; strings accept either quote.", + )], + # Fourth coverage push: constraint-shaped examples + "packages": [( + "cell-0", "package-tree", + "A directory with __init__.py becomes an importable package; submodules and subpackages nest beneath it.", + )], + "virtual-environments": [( + "cell-0", "venv-boundary", + "A venv carries its own interpreter and site-packages, isolating a project's dependencies from the system.", + )], + "subprocesses": [( + "cell-0", "subprocess-spawn", + "subprocess.run spawns a child process and captures its stdout, stderr, and exit code as portable evidence.", + )], + "logging": [( + "cell-0", "logging-levels", + "Five severity levels; the logger's configured threshold drops everything below it.", + )], + "testing": [( + "cell-0", "aaa-pattern", + "arrange-act-assert: set up the state, perform the behavior under test, compare the result to expectations.", + )], + "networking": [( + "cell-0", "protocol-layers", + "Network protocols stack: HTTP rests on TCP, which rests on IP, which rests on the link layer.", + )], + "threads-and-processes": [( + "cell-0", "gil-lanes", + "Threads share memory but the GIL serialises Python bytecode; processes run in parallel with isolated memory.", + )], + "casts-and-any": [( + "cell-0", "cast-escape", + "cast(T, x) tells the type checker to treat x as T; the runtime is unaffected.", + )], + "newtype": [( + "cell-0", "newtype-phantom", + "NewType creates a distinct static identity backed by the same runtime type — UserId is int with a name.", + )], + "overloads": [( + "cell-0", "overload-signatures", + "@overload declares multiple call signatures; one underlying implementation routes input shape to return type.", + )], + "paramspec": [( + "cell-0", "paramspec-preserve", + "ParamSpec preserves the wrapped function's signature through a decorator, parameter for parameter.", + )], + "literal-and-final": [( + "cell-0", "literal-constrained", + "Literal narrows a slot to a fixed set of constant values; Final says the binding will not change.", + )], + "callable-types": [( + "cell-0", "callable-type", + "Callable[[A, B], R] captures the call shape: a tuple of argument types and one return type.", + )], + "runtime-type-checks": [( + "cell-0", "isinstance-check", + "isinstance and issubclass ask the runtime; the answer is a bool, not a static type refinement.", + )], + "collections-module": [( + "cell-0", "collections-containers", + "Four specialised containers for shapes the built-in types don't cover well: deque, Counter, defaultdict, namedtuple.", + )], + "structured-data-shapes": [( + "cell-0", "typed-dict-shape", + "TypedDict names each key's value type; the dict obeys the declared shape at static-check time.", + )], + "csv-data": [( + "cell-0", "csv-records", + "CSV files are rows of records; each line has the same columns in the same order.", + )], + "warnings": [( + "cell-0", "warning-signal", + "A warning is a soft signal: the message is reported, but execution continues unless filters elevate it.", + )], + "object-lifecycle": [( + "cell-0", "object-lifecycle", + "__init__ constructs the object; it lives while at least one reference holds it; __del__ runs when refcount hits zero.", + )], +} + + +# ─── 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 a banner row sitting AFTER the named cell. Empty if none. + + Cells always keep their prose|code 2-column grid. Figures live in + banner rows that span both columns BETWEEN cells (and after the + walkthrough for single-cell examples). Multiple figures attached to + the same cell share one banner as a small multiple. + """ + attachments = ATTACHMENTS.get(slug, []) + matched = [(name, caption) for (a, name, caption) in attachments if a == anchor] + if not matched: + return "" + figures: list[str] = [] + for name, caption in matched: + cap = f"
    {html.escape(caption)}
    " if caption else "" + figures.append(f"
    {_render_svg(name)}{cap}
    ") + count_class = f" cell-banner--{len(matched)}" + return f'
    {"".join(figures)}
    ' + + +# ─── Scores (v2 rubric — see docs/example-figure-rubric.md) ──────────── +# Score every attached example figure against the v2 rubric. The dict is +# the single source of truth for both the gestalt review pages +# (scripts/build_marginalia.py, scripts/build_prototypes.py) and any +# future per-example scoring surface. + +SCORES: dict[str, tuple[float, str]] = { + # 9.5 — canonical, definitive depictions of their cell's move + "variables": (9.5, "the canonical name → object picture"), + "mutability": (9.5, "three-state small multiple of aliased mutation"), + "copying-collections": (9.5, "same picture as mutability, perfect match"), + # 9.0 — strong mechanism, runs match the cell, all craft criteria full credit + "hello-world": (9.0, "program → output, smallest mechanism"), + "numbers": (9.0, "int unbounded vs float thinning, both registers"), + "operators": (9.0, "expression tree mechanism"), + "none": (9.0, "three names converging on one None"), + "equality-and-identity": (9.0, "shared vs separate object, side-by-side"), + "strings": (9.0, "codepoints + bytes registers"), + "for-loops": (9.0, "4-row caret advance"), + "sorting": (9.0, "stability ribbons preserved across keys"), + "keyword-only-arguments": (9.0, "signature with explicit `*` separator"), + "positional-only-parameters": (9.0, "signature with explicit `/` separator"), + "closures": (9.0, "captured cell reference"), + "scope-global-nonlocal": (9.0, "LEGB nested rings"), + "recursion": (9.0, "stacked frames with same name, different argument"), + "lists": (9.0, "cells with append mechanism"), + "dicts": (9.0, "hash buckets with collision chain"), + "slices": (9.0, "ruler with bracket overlay"), + "comprehensions": (9.0, "comprehension over equivalent for-loop"), + "type-hints": (9.0, "ghost annotations over runtime values"), + "generators": (9.0, "ribbon cut by yield gates"), + "exceptions": (9.0, "try/except/else/finally lanes with traced path"), + "context-managers": (9.0, "enter / body / exit bowtie"), + "async-await": (9.0, "loop/coro swimlane with await handoffs"), + "classes": (9.0, "instance/class/type triangle"), + "inheritance-and-super": (9.0, "MRO chain with diamond ghost"), + "dataclasses": (9.0, "fields → generated __init__ signature"), + "decorators": (9.0, "before/after rebinding through cell"), + "special-methods": (9.0, "syntax → method dispatch"), + "unpacking": (9.0, "binding-line mechanism with *rest"), + "exception-chaining": (9.0, "__cause__ vs __context__ distinguished"), + "iterating-over-iterables": (9.0, "iter() exposes the iterator"), + "iterators": (9.0, "three-state machine"), + "iterator-vs-iterable": (9.0, "the protocol exposed"), + "container-protocols": (9.0, "iter/next backbone"), + "operator-overloading": (9.0, "dispatch arrow"), + "union-and-optional-types": (9.0, "type fork to several shapes"), + "abstract-base-classes": (9.0, "same triangle as concrete classes"), + "conditionals": (9.0, "predicate forks value to branch"), + "match-statements": (9.0, "dispatch ladder; first match wins"), + "advanced-match-patterns": (9.0, "four pattern variants"), + "loop-else": (9.0, "fell-through vs broke, two outcomes"), + "while-loops": (9.0, "back-edge mechanism"), + "type-aliases": (9.0, "complex annotation collapses to a name"), + "typed-dicts": (9.0, "keys with declared value types"), + "comprehension-patterns": (9.0, "nested clauses compose"), + "lambdas": (9.0, "function literal: params / expression"), + "string-formatting": (9.0, "format-spec railroad"), + "regular-expressions": (9.0, "pattern ruler with anchors"), + "json": (9.0, "two-column type mapping"), + "metaclasses": (9.0, "extended triangle to metaclass"), + "datetime": (9.0, "one instant, two clock offsets"), + "values": (9.0, "every literal is a typed object"), + "literals": (9.0, "literal spellings per type"), + "booleans": (9.0, "2×2 truth table"), + "sets": (9.0, "hash buckets without values"), + "yield-from": (9.0, "stitched ribbons; delegation"), + "generator-expressions": (9.0, "lazy filter→map pipeline"), + "async-iteration-and-context": (9.0, "loop/coro lanes with await yields"), + "assignment-expressions": (9.0, "walrus binds while comparing"), + "break-and-continue": (9.0, "early exit at first match"), + "delete-statements": (9.0, "name erased; object survives if referenced"), + "exception-groups": (9.0, "except* peels matching leaves"), + "custom-exceptions": (9.0, "subclass chain to a domain name"), + "modules": (9.0, "sys.path resolution; first hit wins"), + "protocols": (9.0, "structural duck check"), + "enums": (9.0, "closed set of symbolic values"), + "functions": (9.0, "specific call: greet('Ada') → 'Hello, Ada'"), + "constants": (9.0, "name binding; UPPER_CASE is convention"), + "import-aliases": (9.0, "two names bind to the same module"), + "number-parsing": (9.0, "int() success path vs ValueError"), + "tuples": (9.0, "frozen sequence with struck-through .append"), + "truthiness": (9.0, "bool(x) with the falsy set as a strip"), + "itertools": (9.0, "chain joins two iterables into one stream"), + "assertions": (9.0, "True passes, False raises"), + "descriptors": (9.0, "get/set/delete protocol routed through descriptor"), + "attribute-access": (9.0, "instance __dict__ → class __dict__ → __getattr__"), + "bound-and-unbound-methods": (9.0, "instance.method bound vs Class.method unbound"), + "classmethods-and-staticmethods": (9.0, "three method kinds, three first-arg conventions"), + "callable-objects": (9.0, "__call__ makes any object callable"), + "generics-and-typevar": (9.0, "the same T flows in and out"), + "truth-and-size": (9.0, "__bool__ → __len__ → True fallback chain"), + "bytes-and-bytearray": (9.0, "frozen vs mutable contrast"), + "sentinel-iteration": (9.0, "iter(callable, sentinel) stop condition"), + "partial-functions": (9.0, "f → partial(f, 1) → g"), + "guard-clauses": (9.0, "early returns, main body at the tail"), + "packages": (9.0, "__init__.py + nested submodules"), + "virtual-environments": (9.0, "project / venv boundary"), + "subprocesses": (9.0, "spawn → child → captured output"), + "logging": (9.0, "five thresholded levels"), + "testing": (9.0, "arrange-act-assert three-row pattern"), + "networking": (9.0, "HTTP / TCP / IP / link stack"), + "casts-and-any": (9.0, "Any → cast(T, x) → T, runtime unchanged"), + "newtype": (9.0, "same runtime, distinct static identity"), + "paramspec": (9.0, "P preserved through decorator"), + "literal-and-final": (9.0, "slot narrows to a fixed set"), + "runtime-type-checks": (9.0, "isinstance returns bool"), + "collections-module": (9.0, "deque / Counter / defaultdict / namedtuple"), + "structured-data-shapes": (9.0, "TypedDict named keys with value types"), + "csv-data": (9.0, "rows × columns; same shape per line"), + "warnings": (9.0, "soft signal; execution continues"), + "object-lifecycle": (9.0, "__init__ → live → __del__"), + "args-and-kwargs": (9.0, "*args tuple, **kwargs dict regions"), + "multiple-return-values": (9.0, "function returns tuple; caller unpacks"), + "properties": (9.0, "obj.x routes through fget instead of __dict__"), + # 8.5 — abstract by nature; the figure mostly is the diagram itself + "overloads": (8.5, "multiple signatures → one impl; abstract"), + "callable-types": (8.5, "Callable[[A, B], R] shape; static-only"), + "threads-and-processes": (8.5, "GIL lanes; abstract concurrency model"), +} + + +def figure_score(slug: str) -> tuple[float, str] | None: + """Return the v2 score and rationale for an attached example slug, if any.""" + return SCORES.get(slug) diff --git a/src/marginalia_grammar.py b/src/marginalia_grammar.py new file mode 100644 index 0000000..ce618b2 --- /dev/null +++ b/src/marginalia_grammar.py @@ -0,0 +1,396 @@ +"""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, tag_position="above"): + """Filled rect with type tag and value centered. Returns left-edge midpoint. + + tag_position="above" (default) places the type tag at y - 3, just + above the box — natural for an isolated box. Pass + tag_position="inside" when callers stack object_boxes vertically: + the tag then sits in the box's top-left corner instead of + colliding with the box above it. + """ + fill = SOFT_FILL if soft else "none" + self._add( + f'' + ) + if type_tag: + tag_y = y + SIZE_TAG + 2 if tag_position == "inside" else y - 3 + self.tag(x + 4, tag_y, 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, *, emphasis=True): + """Triangular caret pointing down into the cell whose top is at y_top. + + Defaults to the orange emphasis colour because a caret typically + marks the live position. Set emphasis=False when multiple carets + appear in the same figure (small multiples) and the surrounding + prose only names one of them — the others paint in ink so the + scarce-emphasis rule still holds. + """ + fill = EMPHASIS if emphasis else INK + 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: + # 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). + # + # The PAD_* offsets give every figure a small margin around its + # registered canvas. Most figures place a type-tag at y - 3 above + # the topmost box, which without padding renders outside the + # viewBox and gets clipped. PAD_TOP=14 covers the SIZE_TAG=8 font + # plus its baseline offset. PAD_X handles the rare paint function + # that draws slightly negative x. PAD_BOTTOM absorbs small + # accidental overflows. + pad_top, pad_x, pad_bottom = 14, 8, 14 + vb_w = self.w + 2 * pad_x + vb_h = self.h + pad_top + pad_bottom + 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 + score: float | None = None + score_note: str = "" + + 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 "" + 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"
    " + ) diff --git a/src/templates/example.html b/src/templates/example.html index 8d65d86..aa50583 100644 --- a/src/templates/example.html +++ b/src/templates/example.html @@ -6,8 +6,8 @@

    __TITLE__

    __SUMMARY__

    __WALKTHROUGH__
    -

    Notes

    -
      __NOTES__
    +

    Notes

    +
      __NOTES__
    __SEE_ALSO__
    diff --git a/src/templates/layout.html b/src/templates/layout.html index 3ad7f5e..dadc319 100644 --- a/src/templates/layout.html +++ b/src/templates/layout.html @@ -23,6 +23,5 @@
    __CONTENT__
    -
    Examples execute in Cloudflare Dynamic Python Workers.
    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('