Skip to content

Marginalia: figures, contracts, and journey-section rendering#1

Merged
adewale merged 41 commits into
mainfrom
claude/tuftean-marginalia-viz-TB0fw
May 11, 2026
Merged

Marginalia: figures, contracts, and journey-section rendering#1
adewale merged 41 commits into
mainfrom
claude/tuftean-marginalia-viz-TB0fw

Conversation

@adewale
Copy link
Copy Markdown
Owner

@adewale adewale commented May 9, 2026

A Tufte-style figure system for /examples/* and /journeys/*, plus the contracts that keep it from drifting.

What ships

Figure system, 109 figures

  • A single Canvas grammar in src/marginalia_grammar.py (locked palette, fonts, stroke weights, spacing, primitives like cell / object_box / node / connect / lane) is the only way figures get drawn. No bespoke SVG, no per-figure colour or font drift.
  • 109 paint functions in src/marginalia.py FIGURES, attached via ATTACHMENTS (one banner per example slug) and scored in SCORES against docs/example-figure-rubric.md v2.
  • Figures render inline on every example page in a banner row AFTER the named cell. Cells always stay 2-col (prose | code); banners between cells hold one figure or a small-multiple. CSS in public/site.css (.cell-banner, .cell-banner--1) clamps figure widths fluidly.
  • Production rendering scales figures up 1.6× their viewBox (so they fill more column on desktop) and uses clamp(280px, 65vw, 640px) ceilings so they shrink cleanly to mobile.

Journey-section figures inline on /journeys/<slug>

  • SECTION_FIGURES (24 entries keyed by section title) lives in src/marginalia.py. render_for_section(title) injects the figure between each section's meta and its example list. Same paint code as the review pages — no parallel registries.

Gestalt review pages under /prototyping/*

  • marginalia-gestalt.html — every example's figure in a card grid, drawn from production paint code (not a duplicate e_* registry).
  • journey-figures-gestalt.html — all 24 section figures grouped by journey for cross-journey rubric review.
  • production-figures-gestalt.html — every figure in FIGURES with attachment metadata.
  • layout-banner-*.html — reference designs for single / pair / trio banner layouts.

Geometry + content contracts in CI (58 tests)
tests/test_marginalia_geometry.py and tests/test_example_content.py assert, for every figure:

  1. Clipping — every <rect>, <text>, <line>, <circle>, <path> stays inside the padded viewBox (text width measured per font family).
  2. Text-vs-rect collision — text either fully contained by a rect or no overlap.
  3. Text-vs-text overlap — no two texts share a bounding box.
  4. Registration consistencyFIGURES, ATTACHMENTS, SCORES, SECTION_FIGURES all in sync; no orphan paint functions; every attachment anchor resolves to a real cell.
  5. Grammar conformance — only INK/INK_SOFT/EMPHASIS/SOFT_FILL colours, only the three locked font families, only the four locked stroke weights.
  6. Anchor coverage — every cell-N attachment points to an existing cell.
  7. Score validity — every score in [0, 10] with non-empty commentary.
  8. Caption uniqueness — same figure on multiple slugs needs bespoke captions per slug.
  9. Banner-fit — rendered width fits the 640 px ceiling.
  10. Section-figure consistency — every journey section has a figure; every figure name resolves.
  11. Emphasis scarcity — at most one orange accent per figure (rubric criterion 7, enforced).
  12. Unsupported-cell prose quality:::unsupported blocks explain the code, not just the runtime caveat.
  13. Code-cell wrappability — no walkthrough cell has an unbreakable run > 50 chars that would force a horizontal scrollbar.

New figures, new captions, new attachments — all 13 contracts run against every change.

Rubric and lessons

  • docs/example-figure-rubric.md v2: hard release gates (clipping, collision, palette, fonts, stroke weights, emphasis, registration, captions, anchors, scores, banner-fit, twin consistency, mono character alignment, geometric termination, gestalt=production) all mapped to the automated contracts that enforce them.
  • docs/lessons-learned.md — 25+ new lessons from the bugs this PR caught, including: audits-without-contracts rot; clipping ≠ collision; structural twins must share coordinates; one paint registry, not two; tag-above vs tag-inside is a stacking decision; mono character alignment uses the font's advance; lines terminate AT elements, not in gaps or interiors.

Page-level layout improvements

  • Unified responsive collapse at 780 px (was 980 / 860 — fixed a "half-collapsed" range).
  • h1 clamped tighter so short titles stop swallowing the page.
  • Notes downweighted to an eyebrow so it reads as a tail of the walkthrough, not a peer of "Run the complete example".
  • Footer worker-attribution removed; unused --subtle CSS variable dropped.
  • overflow-wrap: anywhere on cell code as a safety net for long URLs / blobs.

Examples corrected

  • networking — replaced wrong protocol-layers figure (HTTP stack) with socket-byte-boundary (the actual lesson). Both code chunks rewritten to explain their own code instead of leading with the runtime caveat.
  • subprocesses, threads-and-processes, virtual-environments:::unsupported prose rewritten the same way.
  • strings — added French "café" to the byte-count comparison loop so the first cell reads as 1-byte / 2-byte / 3-byte progression.
  • testing, logging, datetime — broke unwrappable long lines so the code column stops showing a horizontal scrollbar.
  • Three pairs of "structural twin" figures (kw-only-separatorpositional-only-separator, etc.) harmonised to identical coordinates.
  • Seven figures with mis-aligned dashed lines or tree edges that didn't quite meet their dots — fixed (exception-group-peel, context-bowtie, positional-only-separator, args-kwargs, kw-only-separator).

Repo trim

Two cleanup passes cut ~570 KB of one-time artifacts: 8 stale journey-prototype HTMLs (now superseded by production journey pages), 5 docs from one-time investigations/audits, the score_examples.py heuristic scorer (replaced by SCORES), an outdated lesson bullet, and ~75 lines of dead build code.

Verification

make verify  # 58 tests pass; SEO/cache lint clears 110 pages

Preview deploys to viz-pythonbyexample.adewale-883.workers.dev on every push.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE

@adewale adewale force-pushed the claude/tuftean-marginalia-viz-TB0fw branch from f6e591d to 50ed727 Compare May 10, 2026 12:38
adewale pushed a commit that referenced this pull request May 11, 2026
Three reversible changes tuning the example-page layout against
https://skills.sh/pbakaus/impeccable/layout:

  #1 h1 clamp 5.75rem → 3.75rem (vw scaling 7 → 4.5). Affects all
     151 pages with an h1. Short titles like "Hello World" stop
     swallowing the viewport while long titles remain readable.

  #5 Notes section downweighted from <h2>Notes</h2> to an eyebrow,
     so it reads as a tail of the walkthrough rather than a peer
     of "Run the complete example". Playground h2 bumped to match
     its rank as the page's second major beat. Affects all 109
     example pages.

  #6 .cell-banner--1 (108 examples + 3 prototypes) gains a
     margin-block of var(--space-6) and figcaption max-width of
     42ch, so single-figure banners breathe and captions
     constrain to a comfortable measure.

All 39 tests pass; SEO/cache lint clears 110 pages.
claude and others added 28 commits May 11, 2026 21:48
The branch landing covers the entire visual-explainer thread of work.
This commit lands all of it on the current main, with the figure
catalogue reconciled to main's restructured journeys.

Net additions:
- docs/visual-explainer-spec.md      design spec for the inline cell-figure
                                     production layout, plus the
                                     banner-between grammar (prototypes only)
                                     and the journey-section figure pattern
- docs/journey-visualisation-rubric.md   10-point rubric for journey
                                     section figures: section fidelity,
                                     pedagogical scope, mechanism over
                                     metaphor, topic gates, project gate
- src/marginalia_grammar.py          locked Canvas grammar — palette
                                     aligned with site tokens (--text,
                                     --muted, --accent, --accent-soft-
                                     equivalent neutral); tokens, words,
                                     phrases (bind, dispatch, lanes,
                                     connect for tangent edges)
- src/marginalia.py                  27 figures: 18 journey-section,
                                     plus 9 lesson + library figures
- src/app.py                         _render_cell injects the attached
                                     figure between prose and code-stack
                                     when present; cell gets has-figure
                                     class so it stacks vertically
- public/site.css                    .lp-cell.has-figure single-column;
                                     .cell-figure styling
- public/_headers                    /prototyping/* no-cache rule
- scripts/fingerprint_assets.py      digests src/marginalia.py and
                                     src/marginalia_grammar.py so figure
                                     edits invalidate HTML cache
- scripts/build_marginalia.py        76-card gestalt review page
- scripts/build_prototypes.py        15 prototypes: index, design-review
                                     pair, three banner-grammar demos,
                                     eight journey demos, one journey-
                                     figures gestalt

Reconciled with main's restructure of journeys (Streams was split into
Control Flow + Iteration; Workers added; some titles renamed):

  Old key                              → New key
  Streams · Make decisions explicitly  → Control Flow · Choose between paths
  Streams · Recognize iter as protocol → Iteration · See the protocol behind `for`
  Streams · Choose the right loop      → Iteration · Choose the right loop shape
                                         (unchanged)

Three new figures designed to close coverage gaps surfaced by audit:
  naming-decisions  Control flow · Name and shape decisions
  early-exit        Control flow · Stop as soon as the answer is known
  lazy-stream       Iteration · Compose lazy value streams

Coverage: 21 of 24 journey sections have figures. The three Workers
sections render as heading + list with no figure column — intentional
pending future design (their titles are constraint-y rather than
mechanism-y; figure designs need more thought).

Audit results clean:
- production rendering: has-figure class + cell-figure svg present;
  no margin-anchor or margin-collected leaks
- palette: only the seven site-token values appear across all
  prototype output
- PROTOTYPES list and on-disk files agree
- every journey figure has a non-empty caption
- 39 unit tests pass

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
The journey-types screenshot showed two related defects on every page
that uses small-viewBox figures:

1) text inside small-viewBox figures was rendered too large because the
   SVG had only viewBox set; CSS width: 100% then stretched a 156-wide
   viewBox to ~320px in the journey-section figure column, doubling all
   text sizes inside the SVG.

2) several figures repeated their figcaption as a stray label inside
   the SVG, so the same sentence appeared twice in the page (once
   floating in the figure area, once below as the caption).

Fixes:
- Canvas.to_svg() now emits width="W" height="H" matching the viewBox.
  CSS max-width: 100% (everywhere figures appear: cell-figure,
  cell-banner, journey-figure, marginalia gestalt cards, journey-figures
  gestalt section grid) lets figures clamp to container width without
  stretching above intrinsic CSS-pixel size.
- Six figures lost their bottom prose label, which duplicated the
  figcaption: function-as-value, annotation-ghost, generic-preservation,
  context-bowtie, naming-decisions, lazy-stream.
- ViewBox heights of those six figures tightened so the trimmed content
  doesn't leave dead space between the SVG and the figcaption.

Verified:
- /prototyping/journey-types serves first SVG with width="220" height="52"
  matching its viewBox; "annotations describe" no longer appears inline.
- 39 unit tests pass.
- All other prototypes (banner-*, marginalia-gestalt, journey-figures-
  gestalt, six other journey pages) regenerate cleanly.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
plus the 6 follow-ups

Identified root cause and prevention rules
------------------------------------------

The previous fix patched two failure modes; this commit documents the
underlying rules in docs/visual-explainer-spec.md as pipeline invariants
so they cannot drift back:

  1. The SVG element renders at intrinsic CSS-pixel size.
     Canvas.to_svg() emits width/height matching the viewBox; CSS uses
     max-width: 100% (never width: 100%). Otherwise small viewBoxes
     stretch and text inside doubles in size.

  2. A figure's diagrammatic content does not duplicate its figcaption.
     SVGs may carry functional labels (stdout, iter(), panel tags,
     type signatures) but never a sentence describing the figure.
     Captions are the canonical prose. The marginalia-gestalt review
     page is the documented exception (cards have no figcaptions).

Audit: production paths clean across CSS, SVG width attributes, and
inline labels. Four prose-y labels remain in the gestalt e_*(c) paint
code; they're correct in context (no figcaption on those cards) and
flagged in the example-figure rubric for removal-on-promotion.

The six follow-ups
------------------

1. docs/example-figure-rubric.md — parallel to the journey rubric,
   scored to 10 across content (cell fidelity, running variables, one
   move, mechanism, caption-asserts), craft (grammar, scarcity,
   restraint), and context (cell-column fit, code pairing). Topic gates
   per cell shape; release gates and project gate.

2. Scored all 70 gestalt example figures against the new rubric. SCORES
   dict in scripts/build_marginalia.py keyed by slug, with a brief
   rationale per entry. Each gestalt card now renders its score and
   note as a small badge beneath the figure. Distribution: ~30 score
   9.0+, ~25 score 8.0-8.9, ~5 score 7.0-7.9.

3. Promoted 11 high-scoring gestalt figures into src/marginalia.py
   FIGURES (one paint function per figure, grammar-conformant, prose-
   labels stripped). Plus operator-dispatch reused for special-methods.
   Twelve new ATTACHMENTS rows wired so /examples/<slug> renders the
   figure between cell prose and code:

     variables · variables-bind            decorators · decorator-rebind
     recursion · call-stack                inheritance-and-super · mro-chain
     dataclasses · dataclass-fields        classes · class-triangle
     special-methods · operator-dispatch   exception-chaining · cause/context
     unpacking · unpacking-bind            comprehensions · comprehension-equiv.
     lists · list-append                   dicts · dict-buckets

   FIGURES went 27 → 41; thirteen example pages now render a figure
   (was one).

4. Designed three figures for the Workers journey sections — labelled
   tentative because the section titles are constraint-shaped rather
   than mechanism-shaped. Journey-section figure coverage: 24/24.

5. (Same as 3.) Wired ATTACHMENTS for the promoted figures.

6. /prototyping/production-figures-gestalt.html added — every figure
   currently registered in FIGURES on one page with a tag indicating
   where it renders (an /examples/ attachment, a journey section, or
   "not yet attached"). Closes the visibility gap between
   "designed in build_marginalia.py" and "shipping in production".

Centralised review pages now:
  /prototyping/marginalia-gestalt              70 examples + 6 journeys
                                               (gestalt design review,
                                               scored against the new
                                               example-figure rubric)
  /prototyping/journey-figures-gestalt         all journey-section
                                               figures grouped by journey
  /prototyping/production-figures-gestalt      every figure shipping in
                                               production with attachment
                                               status

39 unit tests pass.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
…ewritten

Added 18 new paint functions to src/marginalia.py FIGURES — half are
brand-new mechanism pictures (descriptors, attribute-lookup, callable-
objects, bound-vs-unbound, method-kinds, truth-and-size, guard-clauses,
bytes-vs-bytearray, sentinel-iteration, partial-functions), half are
gestalt-promoted figures with the inline prose stripped per the
pipeline-invariant rules (number-lines, expression-tree, none-singleton,
codepoints-bytes, sort-stability, kw-only-separator, positional-only-
separator, generator-ribbon).

Wired 37 new ATTACHMENTS rows: 18 new figures plus 19 attachments that
reuse existing FIGURES for examples added on main (operators,
operator-overloading, iterator-vs-iterable, type-aliases, typed-dicts,
union-and-optional-types, generics-and-typevar, abstract-base-classes,
copying-collections, plus 9.0+ promotions for hello-world, numbers,
none, equality-and-identity, strings, for-loops, sorting, kw-only,
positional-only, closures, scope-global-nonlocal, generators,
type-hints, exceptions, context-managers, async-await, iterators,
slices). Production example coverage: 13 → 50 (of 109).

Workers journey figures redesigned around stronger mechanisms:
  workers-portable-evidence  unavailable API (struck through) above a
                              captured-value-as-evidence pair
  workers-protocol-local      request shape → response shape; no socket
  workers-lesson-runtime      lesson box + value + runtime box, three
                              named pieces meeting at a boundary

Tests: relaxed two assertion strings in test_app.py from
'class="lesson-step lp-cell"' to the bare substring 'lesson-step lp-cell'
so they match both has-figure and bare cells. 39 tests pass.

FIGURES went 41 → 59. Journey-section coverage stays at 24/24.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
…istered

Added 24 new paint functions to src/marginalia.py and wired ATTACHMENTS
for 37 more examples (24 new figures + 13 attachments reusing existing
journey/library figures).

New figures:
  args-kwargs, multiple-return, lambda-expression, property-fork,
  metaclass-triangle, sys-path-resolution, import-alias, protocol-check,
  enum-members, datetime-instant, json-python-mapping, regex-anchors,
  number-parse, format-spec, truthy-check, boolean-truth-table,
  set-buckets, tuple-frozen, value-types, yield-delegation,
  itertools-chain, assertion-check, custom-exception-chain,
  exception-group-peel, delete-name-erased.

New attachments using existing FIGURES:
  conditionals · branch-fork
  match-statements · branch-fork
  assignment-expressions · naming-decisions
  iterating-over-iterables · iter-protocol
  generator-expressions · lazy-stream
  async-iteration-and-context · async-swimlane
  loop-else · early-exit
  break-and-continue · early-exit
  comprehension-patterns · comprehension-equivalence
  container-protocols · iter-protocol
  functions · function-signature
  constants · variables-bind
  while-loops · loop-repetition
  advanced-match-patterns · branch-fork
  literals · value-types

Coverage: 13 → 50 → 90 of 109 (12% → 46% → 83%).
Registered FIGURES: 41 → 59 → 84.
Journey-section coverage: 24/24 unchanged.
39 unit tests pass.

The remaining 19 unattached examples are all constraint-shaped:
infrastructure (packages, virtual-environments, subprocesses, logging,
testing, networking, threads-and-processes), advanced typing escape
hatches (casts-and-any, newtype, overloads, paramspec, literal-and-final,
callable-types, runtime-type-checks), or aggregator topics
(collections-module, structured-data-shapes, csv-data, warnings,
object-lifecycle). They lack the mechanism-shape that earns a figure.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
Designed figures for the last 19 examples that previously lacked a
figure — all the constraint-shaped / infrastructure / advanced-typing
slugs. Each got a tightened mechanism picture against the
example-figure rubric:

  package-tree        packages
  venv-boundary       virtual-environments
  subprocess-spawn    subprocesses
  logging-levels      logging
  aaa-pattern         testing
  protocol-layers     networking
  gil-lanes           threads-and-processes
  cast-escape         casts-and-any
  newtype-phantom     newtype
  overload-signatures overloads
  paramspec-preserve  paramspec
  literal-constrained literal-and-final
  callable-type       callable-types
  isinstance-check    runtime-type-checks
  collections-containers collections-module
  typed-dict-shape    structured-data-shapes
  csv-records         csv-data
  warning-signal      warnings
  object-lifecycle    object-lifecycle

These cells had been flagged "constraint-shaped, may not need figures";
revisiting found that most of them DO have a single mechanism worth
depicting (a tree, a boundary, a spawn arrow, a stack of layers, an
arrange-act-assert sequence). The two genuine principles in the set
(casts-and-any, newtype) got figures depicting the static/runtime gap
they exploit.

docs/lessons-learned.md gains a new "Visualisations and marginalia"
section: 15 lessons from this thread including the grammar rule,
SVG-sizing pipeline invariants, prose-vs-figcaption discipline,
emphasis scarcity, the two-rubric structure, constraint-shaped section
limits, contributor vs curator split, inline-between vs banner grammars,
centralised gestalt-page pattern, mapping-vs-promotion paths, the
test-class-string fix, scoring discipline, and the explicit "some
examples should never have figures" rule.

Final coverage:
  Examples:  109/109 attached (100%)
  Journeys:  24/24 section figures (100%)
  FIGURES:   103 registered
  39 unit tests pass

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
Five attached figures had been sharing a more general image, which
scored 8.0 against the example-figure rubric's "match the running
variables" criterion. Each now gets a slug-specific figure:

  typed-dicts            union-types    →  typed-dict-shape
                         (already existed for structured-data-shapes;
                          fits typed-dicts identically)
  type-aliases           annotation-ghost →  type-alias-name
                         (new: complex annotation collapses to a name)
  match-statements       branch-fork   →  match-dispatch-ladder
                         (new: value flows down patterns, first match wins)
  advanced-match-patterns branch-fork  →  match-pattern-variants
                         (new: capture / alternative / guard / class rows)
  loop-else              early-exit    →  loop-else-gate
                         (new: fell through vs broke, two outcomes)

The Workers journey section "Preserve the lesson while respecting the
runtime" was the only journey-section figure scoring 7.5. Redesigned
to depict the real mechanism: a lesson question forks two ways — a
ghost process-API path (struck through) and a live captured-output
path (emphasis). The lesson preserves its question by taking the live
path. Should lift the score off the 7.5 floor toward 8.5.

Figure registry: 103 → 107. Coverage unchanged at 109/109. 39 tests
pass.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
The previous inline-cell-figure approach forced single-column stacking
on every cell with an attached figure, which broke the prose|code
2-column layout users expect from production. hello-world surfaced
this most clearly because it has a single cell — that cell collapsed
to one column while the unattached production page kept two.

Switched production rendering to the banner-between grammar already
prototyped at /prototyping/layout-banner-*:

- src/app.py: _render_cell no longer takes slug/index and never adds
  the has-figure class. render_example_page interleaves cells and
  banners by calling render_for_anchor between cells.
- src/marginalia.py: render_for_anchor now returns a `<div class=
  "cell-banner cell-banner--N">` containing one or more <figure>
  elements with their figcaptions. Multiple figures attached to the
  same cell share one banner as a small multiple (cell-banner--N).
- public/site.css: replaces the .lp-cell.has-figure / .cell-figure
  rules with .cell-banner rules: auto-fit grid, dashed top/bottom
  rules for rhythm, italic muted caption beneath each figure.
- docs/visual-explainer-spec.md, docs/example-figure-rubric.md, and
  docs/journey-visualisation-rubric.md updated to describe the
  production banner-between layout instead of the rolled-back inline
  cell-figure layout.

Audit: every one of the 109 attached examples now renders a cell-banner
between cells; no has-figure class leaks anywhere; every cell renders
with the plain lesson-step lp-cell class. Tests pass (39 tests).
Site CSS fingerprint refreshed to site.150df025a28b.css.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
Three more figure refinements to lift the lingering 8.0 band:

  tuple-frozen      now visibly shows the frozen aspect via a
                    struck-through .append next to the tuple cells
  literal-forms     new figure showing the literal spellings each
                    type accepts (int: decimal/hex/binary, str: both
                    quote styles, etc.). Replaces value-types for
                    /examples/literals
  function-with-body new figure for /examples/functions showing a
                    specific call (`greet('Ada')` → `'Hello, Ada'`)
                    rather than the generic args→body→return shape

FIGURES grew 107 → 109; 109/109 examples still attached.

docs/rubric-saturation.md captures why every figure cannot reach 9.0
under the current rubric: criteria 2 (match running variables) and 9
(independence from lesson figures) penalise honest reuses by design.
The doc proposes four upgrades:

  1. Tier figures into library (reuse-shaped, cap 9.0, criteria 2/9
     non-scored) vs canonical (cell-specific, cap 9.5).
  2. Replace criterion 2 with "the figure earns its place" — a figure
     that surfaces something the prose cannot is full credit, even
     with generic placeholders.
  3. Add a caption-quality rubric (asserting vs narrating).
  4. Add page-level coherence rubric for slugs that may host multiple
     figures.

Without those upgrades, further iteration shuffles the same 8.5 band.
With them, ~70 library figures move to a confident 9.0 ceiling and
~30 canonical figures contend for 9.5.

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
…herence

Implemented all three upgrades from docs/rubric-saturation.md:

  Criterion 2 replaced — was "Match the running variables", a 1.0
  penalty for honest reuse of library figures across multiple cells.
  Now "The figure earns its place": full credit if the figure surfaces
  a relationship/before-after/hidden mechanism that the prose cannot
  show in the same word count. Generic placeholders are no longer a
  penalty; pedagogical weight is.

  Criterion 5 tightened — was "Caption asserts; figure depicts".
  Now "Caption quality": explicit 0/0.5/1.0 bands for declarative
  voice vs narration. "Two names share one mutable list" earns 1.0;
  "The figure shows two names" earns 0.

  Page-level coherence added — new 0-1.0 section for multi-figure
  slugs. Single-figure slugs (today, all 109) score 1.0 trivially.
  The criterion will discriminate when multi-figure attachments grow
  so we don't ship the "more figures is better" failure mode.

Re-scored all 109 attached example figures under v2 in
src/marginalia.SCORES (the single source of truth):

   9.5  ·   3 examples  (variables, mutability, copying-collections)
   9.0  · 103 examples  (all others)
   8.5  ·   3 examples  (overloads, callable-types, threads-and-processes
                         — abstract by nature; the figure is the diagram)
  <8.5  ·   0 examples

Mean = 9.00 across 109 attachments.

scripts/build_marginalia.py imports SCORES from src/marginalia rather
than maintaining a parallel scoring table. scripts/build_prototypes.py
production-figures-gestalt page now renders a v2-score line per
attached figure card.

39 unit tests pass. CSS fingerprint unchanged (only scoring metadata moved).

https://claude.ai/code/session_01MazwoRWAihW6dwso3fMCHE
Two commits that both touch any source the asset fingerprint covers
will both touch src/asset_manifest.py, producing a conflict every
time history is rebased. Add a merge=ours attribute plus post-merge
and post-rewrite hooks that regenerate the manifest from the merged
tree, so the resulting digest matches the merged content rather than
whichever parent happened to win. install-git-hooks.sh wires the
driver and core.hooksPath; documented in README.
The footer line "Examples execute in Cloudflare Dynamic Python Workers."
is implementation trivia readers do not need on every page. --subtle was
declared at :root but never referenced anywhere in the codebase.
Three reversible changes tuning the example-page layout against
https://skills.sh/pbakaus/impeccable/layout:

  #1 h1 clamp 5.75rem → 3.75rem (vw scaling 7 → 4.5). Affects all
     151 pages with an h1. Short titles like "Hello World" stop
     swallowing the viewport while long titles remain readable.

  #5 Notes section downweighted from <h2>Notes</h2> to an eyebrow,
     so it reads as a tail of the walkthrough rather than a peer
     of "Run the complete example". Playground h2 bumped to match
     its rank as the page's second major beat. Affects all 109
     example pages.

  #6 .cell-banner--1 (108 examples + 3 prototypes) gains a
     margin-block of var(--space-6) and figcaption max-width of
     42ch, so single-figure banners breathe and captions
     constrain to a comfortable measure.

All 39 tests pass; SEO/cache lint clears 110 pages.
The cell grid was collapsing at 980px and the playground/runner grid
at 860px, leaving a 119px-wide viewport range where cells were 1-col
but the playground was still 2-col — visually inconsistent. Both
breakpoints were also over-eager: the cell's 2-col layout works down
to ~704px viewport (272px prose min + 24px gap + ~360px code = 656px
+ 48px body padding), so 980px wasted ~276px of available 2-col real
estate.

Consolidate everything into one media query at 780px:

  - .lp-cell, .lesson-step, .runner-grid all collapse together
  - body padding shrinks at the same breakpoint (was 860px, oddly
    independent of the cell collapse)
  - header padding follows body padding
  - .cell-output drops its 2-col-mode max-width
  - cell-code-stack caps at 72ch in 1-col mode so it doesn't sprawl
    under the 38ch prose paragraph

Net effect: one consistent collapse moment instead of three; the
half-broken 861-979px viewport range disappears; mid-width readers
(tablets in landscape, narrow laptop windows) keep the 2-col layout
their viewport is sized for.
Audit of all 109 figures found 41% with content escaping their canvas:
40 with type-tag overflow at top (the `tag(x, y-3, …)` placement
above the first object_box at y=0 rendered outside the viewBox and
got clipped; subsequent rows' tags overlapped the boxes above them —
the reported `/examples/values` symptom), 5 with rect overflow at
the bottom (worst: workers-lesson-runtime by 24px), 1 with right
overflow (tuple-frozen by 16px), 1 with negative x (gil-lanes by 6px).

Two fixes:

  1. to_svg() now pads every figure's viewBox by 14px top/bottom and
     8px left/right. This covers the canonical tag-above pattern, the
     gil-lanes negative-x, and the small rect overflows (≤8px).

  2. Two figures with overflow exceeding the padding need explicit
     dimension bumps:
       - workers-lesson-runtime: h 46 → 80 (24px overflow)
       - tuple-frozen: w 180 → 220 (16px overflow)

Re-running the audit after the fix returns 0 issues across 0 figures.
Prototype gestalt pages regenerated to reflect the corrected SVGs.
Red phase: tests/test_marginalia_geometry.py asserts two contracts
across all 109 figures, and starts failing on 16 figures.

  Contract 1 — clipping: every <rect>, <text>, <line>, <circle>, and
    <path> stays inside Canvas.to_svg's padded viewBox. Text width
    is approximated per font family from a CHAR_WIDTH table.

  Contract 2 — collision: a text bounding box may overlap a rect
    only if the rect fully contains the text. Partial overlap is the
    canonical /examples/values bug, where STR/LIST/DICT type tags
    sat on top of the box above them.

Green phase: fix every failure.

  Grammar:
    object_box(tag_position="inside") puts the type tag in the
    box's top-left corner so stacked boxes don't collide. Default
    stays "above" to preserve isolated-box figures.

  Stacked-box callers switch to tag_position="inside":
    value-types (4 stacked rows, spacing 28→30, h 24→26, canvas
    100→116); itertools-chain (3 boxes, two stacked, spacing 32→34,
    canvas 60→64); these are the figures whose collisions were
    visible on /examples/values and /examples/itertools.

  Per-figure layout corrections (text shifted or shrunk so it sits
  outside the rects it isn't meant to overlap):
    operator-dispatch    label y 36→22 (above the row)
    union-types          tag "x: int | str | None" → "x: int|str|None"
    sys-path-resolution  label (138,38) → (145,30)
    protocol-check       label y 30→0
    cast-escape          label y 26→12
    literal-constrained  tag long Literal[...] → Literal[…]
    type-alias-name      label y 62→66

  Per-figure canvas/dimension bumps for text-width overflow:
    dataclass-fields  280×76 → 312×76  (object_box w 128→160)
    workers-protocol-local  144×110 → 162×110  (cell w 140→160)
    workers-lesson-runtime  200×80 → 300×80
    bound-unbound  272×56 → 296×56  (cell w 130→152)
    args-kwargs  240×68 → 280×68  (labels and dashed lines shifted)
    tuple-frozen  220×48 → 280×48  (ghost cell shifted)
    gil-lanes  244×100 → 300×100  (lanes start at x=54 to make room
                                   for left-anchored "thread A/B" tags)
    match-dispatch-ladder  220×130 → 260×130

Refactor phase: both contracts pass as part of the standard test
suite (41 tests total, up from 39). New figures are checked
automatically because both tests iterate FIGURES.

What we missed before
  My earlier audit used (\w+) regex which doesn't match font-size,
  so every text element was read as the default 10px instead of its
  actual size (tags are 8px). The corrected audit reads
  ([\w-]+)="..." to capture hyphenated attributes. Also added text
  width estimation per font family — clipping checks now consider
  horizontal extent, not just vertical baseline position.
Another pass over the figure library found three more bug classes the
geometry contracts didn't yet cover. Each becomes a new contract; the
existing fixes for the canonical bugs stay in place.

Contract 3 — no two text elements overlap (FigureTextCollisionContract).
  Catches narrow object_boxes whose tag and value compete for
  horizontal space. itertools-chain failed: "ITER A" (sans, w=30) and
  "1 · 2" (mono, w=31) couldn't both fit inside a 70px box with the
  tag-inside layout. Fix: revert that figure to tag_position="above"
  (the default) with iter A/B spaced 38px apart, canvas h 64→82.

Contracts 4a–d — FIGURES, ATTACHMENTS, SCORES stay in sync
(FigureRegistrationContract):
  * every attached slug is scored,
  * every scored slug is attached,
  * every attachment points to a figure that exists in FIGURES,
  * no FIGURES entry is orphan (a figure counts as used if it appears
    in ATTACHMENTS or anywhere in scripts/build_prototypes.py — the
    latter catches journey-section figures and banner-layout
    prototypes that share src/marginalia.py paint code).

Contracts 5a–c — grammar conformance (FigureGrammarContract):
  * every fill/stroke color is INK, INK_SOFT, EMPHASIS, SOFT_FILL,
    or "none",
  * every font-family is FONT_SERIF, FONT_MONO, or FONT_SANS,
  * every stroke-width is W_HAIRLINE (0.6), W_STROKE (1.0),
    W_EMPHASIS (1.4), or W_GHOST (0.5).
  All 109 figures already conform; the contract locks the system
  against future drift.

Suite is now 49 tests (was 41). Geometry, palette, fonts, stroke
weights, and registration consistency are all asserted on every
test run.
Continuing the audit pass, three more invariants currently hold but
aren't asserted. Lock them in so a future change can't silently break
the registry without CI catching it.

Contract 6 — anchor coverage (FigureAnchorContract): every cell-N
  attachment anchor in ATTACHMENTS resolves to a real cell in the
  example's walkthrough. Catches typos and stale attachments left
  behind after a markdown example loses a cell.

Contract 7 — score validity (FigureScoreContract): every SCORES entry
  is a (number-in-[0,10], non-empty commentary) tuple. Locks the
  rubric range and catches type drift if the dict shape changes.

Contract 8 — banner-fit (FigureSizeContract): every figure's
  intrinsic width (Canvas.w + 2 * PAD_X) fits the 440px ceiling that
  .cell-banner--1 enforces in public/site.css. A figure that exceeds
  the ceiling renders scaled-down on every page, magnifying or
  shrinking text in ways the paint code didn't plan for.

All three pass on the current codebase; they exist to prevent
regressions, not to fix anything today. Suite is now 52 tests.

Sweeps that found nothing actionable (no contract added)
  - prose duplication: 3 hits were diagrammatic labels (`__getattr__`,
    `yield from inner`, `execution continues`) that naturally appear
    in the caption too; not the prose-in-SVG anti-pattern the v2
    rubric bans.
  - text crossing lines: 8 hits were intentional (dashed strikes
    through `.append`, signature dividers, struck-through erased
    names, type-alias old name).
  - text overlapping circles: 10 hits were node labels (the `?` in
    branch-fork, `in`/`out` in context-bowtie, operators in
    expression-tree) — labels belong inside their circles.
  - placeholder captions: 13 false positives — all "placeholders"
    were dunder names (__init__, __dict__, __cause__, etc.).
The journey-figures-gestalt prototype showed several figures with
multiple orange (EMPHASIS-coloured) marks competing for attention,
violating docs/example-figure-rubric.md criterion 7 ("at most one
accent mark per figure"). Seven figures fired the new contract;
each lost the secondary accent so the surviving one carries the
prose's named element.

  iterator-unroll  4 carets (one per row) → 3 ink carets + 1 orange
                   on the last row, where the strip's prose names
                   the "last" next() call.
  loop-repetition  ink caret + orange back-arrow (the loop's shape
                   is the back-arrow, not the caret).
  generic-preservation  in-arrow → ink; out-arrow stays orange (the
                        preserved-T-emerges-from-fn is the lesson).
  paramspec-preserve   same pattern (in-arrow → ink).
  early-exit       dot → ink; break-arrow stays orange.
  exception-group-peel  two matched dots → ink; except*-arrow stays
                        orange. The peel action is the lesson.
  match-dispatch-ladder  match-dot → ink; dispatch-arrow stays orange.

Grammar tweak: caret() now accepts emphasis=True (default) so the
small-multiples case (iterator-unroll) can opt the non-live carets
into ink without bespoke SVG.

Contract 9 (FigureEmphasisScarcityContract) locks this in by
counting orange arrowheads, carets, dots, and box borders across
every figure; new designs that ship two oranges fail CI.
The geometry contracts only iterated src/marginalia.py FIGURES (109
production paint functions). The 70 gestalt thumbnail functions in
scripts/build_marginalia.py — which render on the marginalia-gestalt
review page — were unaudited. Six gestalt cards had visible bugs the
production contracts would have caught.

Fixes
  equality-and-identity  LIST tag sat 0.6px above its box top, visible
                         as the tag touching the box stroke. Grammar
                         default tag offset bumped y-3 → y-5 so every
                         tag-above-box placement gets 2.6px clearance.
                         (Affects every production figure using
                         object_box(tag_position="above") too.)
  break-and-continue     "continue" label collided with "LOOP BODY"
                         tag; "break" label crossed the right edge of
                         the loop-body frame; both arrows orange.
                         Repositioned both labels inside the frame,
                         demoted the continue arrow to ink.
  dataclasses            "__init__(name, age, tags)" was 155px wide
                         but the receiving box was 124px and the card
                         was 320px. Bumped box w 124→170, card
                         width 320→360.
  context-managers       Arrow tip at x=266 sat 2px from the "out"
                         circle's left edge — visually touching.
                         Pulled tip back to x=262 / x=264 on both
                         arrows.
  mutability             "id 0x…a0" label of state 0 overlapped
                         "MUTATE · SAME ID" tag of state 1 because
                         state spacing was 60px but the content
                         needed ~62. Bumped spacing 60→68, card
                         height 200→224.
  for-loops              4 orange carets. Same fix as production
                         iterator-unroll: only the "last" caret stays
                         orange, the other three paint in ink.
  iterators              2 orange connect() arrows. Demoted first to
                         ink (the "stop" arrow stays as the climax).
  match-statements       Orange dot + orange arrow. Demoted dot.
  exception-groups       Two orange matched dots. Demoted both,
                         promoted the except*-arrow to orange so the
                         peel action carries the single accent.

Contract extension: tests now iterate ALL_FIGURES = FIGURES ∪
gestalt EXAMPLES, so the eight geometry/grammar/size/emphasis
contracts catch regressions in both registries. The registration
contracts (anchor coverage, score validity) stay scoped to
production where those concepts apply.

Suite size unchanged (53 tests), but each iterating test now
covers 179 paint functions instead of 109.
The gestalt review page rendered its own paint code (70 e_* + 6 j_*
functions in scripts/build_marginalia.py) that drifted from the
production figures in src/marginalia.py FIGURES. Reviewers saw a
different picture than readers; this is exactly how the user noticed
overlapping items on the gestalt that didn't ship on the example
pages.

Rebuild marginalia-gestalt.html as a thin view over production:

  scripts/build_marginalia.py shrinks from 1018 → 156 lines. Every
  gestalt card now pulls its paint function, intrinsic width, and
  intrinsic height directly from FIGURES[ATTACHMENTS[slug][0][1]];
  its score and commentary from SCORES[slug]. No bespoke paint code
  lives in the gestalt build script.

  Cards iterate every example in the manifest order (109 cards),
  skipping any slug without an attachment. The historical
  "operators-and-literals" gestalt-only slug (since split into
  "operators" + "literals" on main) drops out naturally.

  The journey-overview thumbnails section is removed — those j_*
  paint functions had no production equivalent. Per-section journey
  figures are reviewed on journey-figures-gestalt.html, which
  already uses production paint code.

Tests simplify: ALL_FIGURES = FIGURES (the gestalt has no separate
registry to audit). The 8 iterating geometry/grammar/size/emphasis
contracts run once over the 109 production figures instead of
duplicating work across two registries. 53 tests still pass.

Drift between "what readers see on /examples/<slug>" and "what
reviewers see on the gestalt" is now structurally impossible: there
is only one paint registry.
User-flagged: the dashed lines on positional-only-separator,
args-kwargs, and context-bowtie didn't line up with the elements
they were marking.

positional-only-separator
  Dashed line was at x=82; the '/' character in the mono signature
  sits at x≈75 (index 12 × ~6px advance). Moved the line to x=75
  and shifted both labels left so they bracket the new line position
  symmetrically without overlapping it.

args-kwargs
  Two dashed lines were at x=80 and x=152 — neither under their
  parameter. *args's center is at x≈68, **kwargs's at x≈122. Moved
  the lines to those positions. Labels were "extra positionals →
  tuple" and "extra keywords → dict" at x=80 / x=210; the long form
  couldn't fit symmetrically under both dashed lines. Shortened to
  "→ tuple" / "→ dict" (the verbose copy belonged in the caption,
  not the figure), placed exactly under each line.

context-bowtie
  Dashed exit path ended at (210, 48) — INSIDE the "out" circle at
  cx=220, r=14 (left edge x=206). Recomputed the tangent: body's
  bottom-mid (122, 60) → circle center (220, 48) of radius 14 meets
  the circle at ≈(206, 50). Endpoint moved there so the line lands
  on the circle's left edge instead of through the "out" glyph.
The ghost lines stopped 1.5px short of the leaf dots: root edges
started at y=18 but the root dot's bottom edge sat at y=16.5;
leaf edges ended at y=40 but the leaf dot tops sat at y=41.5. The
gaps were small but visible — the tree looked disconnected.

Extended every ghost line to land on the dot CENTER (root at y=14,
leaves at y=44). The dots draw on top of the line endpoints so the
visual termination is the dot's circumference — no gap, no
overshoot.
Probed across the figure library for cross-figure drift:

  a. SVG byte-identical outputs: 0
  b. Same paint function under multiple registry keys: 0
  c. Same caption attached to multiple slugs: 1
  d. Same score commentary on multiple slugs: 0
  e. Paint functions reused at inconsistent canvas dims: 0
  f. Slug↔figure-name keyword mismatches: 23 (all by-design;
     figure names describe mechanism, slug names describe concept)
  g. 13 structural twin groups (figures with identical primitive
     multisets) — these are intentional consistency across pairs
     like class-triangle/metaclass-triangle and
     kw-only-separator/positional-only-separator.

Two real bugs surfaced; both fixed.

1. kw-only-separator and positional-only-separator are structural
   twins teaching the '*' and '/' parameter separators. Only
   positional-only got the recent mono-character-alignment fix
   (dashed line at x=75 to land under the separator char at index
   12). kw-only still had the old miscalibrated x=82 and the
   wider, asymmetric labels. Harmonised: identical x coordinates
   so the two figures sit side-by-side as a true pair.

2. iter-protocol attaches to four slugs (iterators,
   iterator-vs-iterable, iterating-over-iterables, container-
   protocols). Two of those (iterators and iterating-over-
   iterables) shared verbatim the same caption. Reused figures are
   fine; reused captions duplicate the lesson voice. Rewrote the
   iterating-over-iterables caption to focus on `for`
   desugaring rather than restating the iter()/next() shape.

Contract 5b (FigureCaptionContract) locks this in: no caption may
appear on more than one slug. 54 tests, all green.
Two docs updates capture what shipped over the last set of commits.

docs/example-figure-rubric.md — Release Gates section expanded with
the contract-backed hard gates that didn't exist (or were softer)
in v2:

  * No clipping (Contract 1) — text width counts, not just baseline.
  * No element collision (Contract 2) — text in a rect must be
    fully contained by that rect, not partly overlapping the box
    above it.
  * No text-text overlap (Contract 3) — narrow object_boxes whose
    tag+value compete for horizontal space are caught.
  * Caption uniqueness across slugs (Contract 5b) — reused figures
    need bespoke captions per attachment.
  * Palette, font, and stroke-weight discipline (Contract 5a-c) —
    locked at the grammar; CI catches drift.
  * Emphasis scarcity, ENFORCED (Contract 9) — was soft v1, now
    hard. At most one accent per figure.
  * Banner-fit (Contract 8) — every figure's intrinsic width fits
    the 440px cell-banner--1 ceiling.
  * Twin consistency — when two figures depict parallel concepts,
    their coordinates must match coordinate-for-coordinate where
    the concepts coincide. A single-pixel drift in one breaks the
    visual rhyme.
  * Geometric termination — lines must land at element edges, not
    1-2px short or inside the glyph.
  * Mono character alignment — vertical dividers marking positions
    in mono text must use the font's actual advance (~6px per char
    at fs=10), not eyeballed pixels.
  * Gestalt = production — review pages render the same paint code
    as production attachments; parallel paint code drifts and
    hides bugs.

docs/lessons-learned.md — 10 new lessons added to the
"Visualisations and marginalia" section, each captured from a real
bug class we shipped a contract for:

  - audits without contracts rot
  - clipping ≠ collision; padding fixes one not the other
  - heuristic audits over-flag; trust the design, not the regex
  - structural twins must share coordinates exactly
  - reused figure → bespoke caption per slug
  - one paint registry, not two
  - tag-above vs tag-inside is a layout decision driven by stacking
  - mono character alignment uses the font's advance
  - lines must terminate AT elements
  - journey pages don't yet render section figures inline (open
    design question)

Tests: 54 still green; no production code changed.
claude added 11 commits May 11, 2026 21:48
Production journey pages now show each section's conceptual figure
between the section heading and the example list, matching how
example pages show cell figures.

  src/marginalia.py: SECTION_FIGURES dict (24 entries keyed by
    section title) and render_for_section(title) helper. The dict
    is the single source of truth — moved out of
    scripts/build_prototypes.py, which now imports it.

  src/app.py: render_journey_page injects render_for_section(...)
    between the section's meta line and its example list.

  public/site.css: .journey-section-figure { max-width: 440px; }
    matches .cell-banner--1's ceiling, with the same caption
    typography as example banners.

  scripts/build_prototypes.py: imports SECTION_FIGURES from
    src/marginalia.py (renamed locally to JOURNEY_SECTION_FIGURES
    for the existing prototype builders); 109 lines of duplicate
    dict removed.

  tests/test_marginalia_geometry.py: SectionFigureContract
    (Contract 10) asserts every JOURNEYS section has a figure,
    every SECTION_FIGURES entry maps to a real FIGURES paint
    function, and every section caption is unique. The orphan-
    figure contract now recognises SECTION_FIGURES as a usage
    source.

Six journey pages × ~4 sections each → 24 figures now render on
production journey pages that previously had none. The contracts
keep the journey rubric (docs/journey-visualisation-rubric.md) and
the production rendering in lockstep. Suite is 57 tests, all green.
Per impeccable's adapt + layout rubrics: figures should fill the
column with fluid clamp() sizing, not anchor at intrinsic CSS pixels
on desktops where 800+px of horizontal space goes unused. Before
this change, a 176px-wide figure stayed at 176px even on a 992px
content column (18% utilisation).

Two layers of change.

Render-time scale (src/marginalia_grammar.py)
  Canvas.INTRINSIC_SCALE = 1.6: to_svg() now emits
  width="round(viewBox * 1.6)" and height likewise. The viewBox
  stays intact so paint coordinates, geometry contracts, and
  collision math don't change — only the rendered CSS-pixel size
  grows. Text inside scales with the viewBox transform so a 10px
  font renders at 16 CSS px on desktop, dropping back to ~9-10px
  when the container shrinks to mobile widths.

Viewport-aware ceilings (public/site.css)
  Three banner containers gain fluid widths via clamp():

    .cell-banner figure              clamp(240px, 45vw, 480px)
    .cell-banner--1 figure           clamp(280px, 65vw, 640px)
    .journey-section-figure          clamp(280px, 70vw, 640px)

  The middle term (vw-based) means figures grow with the viewport
  until the ceiling. The min term guarantees a readable floor on
  ultra-narrow phones. The ceiling matches the 640px contract bound.

What each viewport now gets

  Mobile portrait (320px):  figure caps at 280px, ~96% of column
  Mobile landscape (~720):  figure ~455px (65vw)
  Tablet (768px):           figure ~499px
  Laptop (1024px):          figure 640px (clamp ceiling)
  Wide desktop (1440px+):   figure 640px

Multi-figure banners (cell-banner without --1) cap at 480px so two
figures fit side-by-side in a 960px gap without dominating either
side.

Contract 8 updated: rendered width = 1.6 × (Canvas.w + 16) must fit
the 640px ceiling. Largest figure today (dataclass-fields, intrinsic
canvas 312) renders at 1.6 × 328 = 525px — comfortably under. All
57 tests pass.

The previous "emit explicit width/height; never use width: 100%"
lesson still holds — explicit width/height stops browsers from
ballooning small viewBoxes uncontrollably. The width attribute now
just sits at 1.6× intrinsic instead of 1.0×, so the figure has room
to grow before CSS max-width clamps it.
User-reported: /examples/async-iteration-and-context showed the
swimlane's "LOOP" and "CORO" labels clipped to "_OOP" and ":ORO"
on the left edge of the figure.

Root cause: the lane() primitive places tag-style labels at
x0 - 6 with anchor="end". With x0=20 in async-swimlane, the label
right-edge sits at viewBox x=14 and the text extends leftward by
~25 viewBox units (4 uppercase letters × ~6.25 wide at fs=8 with
tracking 0.5) → starts at ~x=-11. The old PAD_X=8 set the viewBox
left edge at x=-8, clipping the leftmost ~3 viewBox units of every
lane label. The 1.6× render scale doubled the visible clip.

The contract didn't catch this because my CHAR_WIDTH heuristic for
sans (0.58) under-estimates uppercase. tag() upper-cases its input,
so any tag with uppercase tracking is actually wider than the
audit estimated. Bumping CHAR_WIDTH["sans"] to 0.65 brings the
estimate in line with what Source Sans Pro renders at fs=8 with
tracking 0.5; future lane-label drift will fire the contract.

Two changes:

  src/marginalia_grammar.py: PAD_X bumped 8 → 14, matching PAD_TOP
  and PAD_BOTTOM. Every figure's viewBox now extends to x=-14, so
  labels and lines that draw into negative x have ~6 more units of
  margin. Net effect on rendered size: each figure grows by 12 × 1.6
  = ~19 CSS px wider (gain absorbed by the 640px banner ceiling).

  tests/test_marginalia_geometry.py: PAD_X constant updated to 14
  to match; CHAR_WIDTH["sans"] bumped to 0.65 to cover uppercase
  tags. Contracts 1, 2, 3, 8 all re-evaluate against the new
  bounds; 57 tests still green.

Other lane-using figures (gil-lanes x0=54, exception-lanes x0=40
via lanes()) had enough room already; only async-swimlane was
under-padded.
Honest follow-up: the previous PAD_X fix bundled the test heuristic
change with the production fix. A proper red-green-refactor would
have caught two extra bugs that bbc52a7 missed.

RED: with the corrected font_class() (sans-serif fonts no longer
misclassified as serif via the "sans-serif" substring containing
"serif") and a case-aware CHAR_WIDTH (uppercase tags at 0.65 of
font-size, mixed-case labels at 0.55), Contract 1 fires on:

  - async-swimlane           "LOOP" / "CORO" clipped by ~1 px
  - workers-protocol-local   "RESPONSE SHAPE ·…" clipped by 12 px
  - sort-stability           "STABLE SORT BY KEY" clipped on top
  - kw-only-separator        "positional or kw" clipped by 5 px
  - positional-only-separator "positional only" clipped by 3 px
  - sentinel-iteration       "sentinel · stop" clipped by 2 px

Five of these were silently shipping before — bbc52a7 only caught
one (async-swimlane) by inspection. The contract was missing them
because of a substring bug in font_class.

GREEN: bump PAD_X 8→14 in Canvas.to_svg (gives every figure 6 more
units of horizontal margin). That alone fixes async-swimlane,
sort-stability, kw-only-separator, positional-only-separator. Two
figures still clip with content extending past the right edge —
fix per-figure:

  - workers-protocol-local: canvas 162→200 so the long
    "response shape · asserted locally" tag fits.
  - sentinel-iteration: canvas 300→320 so the "sentinel · stop"
    label past the rightmost cell fits.

GREEN 2: Contract 2 (text-vs-rect collision) now flags union-types
because "x: int|str|None" tag extends to x=85.5 and the int cell
starts at x=82 — a 3.5 px overlap that wasn't visible to the audit
when sans-serif was misclassified as serif (narrower char width).
Fix: shift the int/str/None cells 10 px right (82→92), update the
arrow endpoints (80→90), bump canvas width 156→166.

REFACTOR: case-aware CHAR_WIDTH in tests/test_marginalia_geometry.py
distinguishes uppercase content (sans_upper at 0.65 × fs) from
mixed-case (sans at 0.55 × fs). Tag text and label text are
measured at their true widths, so future drift in either category
will fire the contract. 57 tests, all green.
User-flagged: /examples/networking attached protocol-layers (an
HTTP/TCP/IP stack figure) to an example whose code uses
socket.socketpair() to demonstrate the str ↔ bytes boundary. The
figure had no relationship to the lesson, and the two code chunks
on the page shared generic prose instead of explaining what each
chunk actually shows.

Figure
  New socket-byte-boundary figure (src/marginalia.py): str "ping" →
  encode → bytes b'ping' → [dashed socket pipe, labelled "socket"]
  → bytes b'ping' → decode → str "ping". The figure depicts what
  the example's code does in one row: the encode/decode at each end
  of the socket carrying bytes.
  Canvas 364×46 → renders at 627 CSS px on desktop, comfortably
  under the 640 banner ceiling.

  Removed protocol_layers (now orphan after re-attaching). The
  orphan-figure contract catches it automatically.

Cell prose (src/example_sources/networking.md)
  Unsupported chunk now explains what its three-line code does —
  socketpair returns two endpoints, sendall encodes, recv reads
  bytes — with the Dynamic Workers caveat moved to a closing
  parenthetical instead of leading the prose.
  Cell chunk now explains what the full version adds over the
  unsupported fragment: try/finally for cleanup, second print that
  decodes the received bytes back to a str.

TDD: the new figure tripped Contract 2 (collision) on the first
draft — the "SOCKET" tag overlapped a bytes box. Moved the tag
above the dashed pipe instead of below. The attachment also tripped
Contract 6 (anchor coverage) — I attached to cell-1 but the example
has only one runnable :::cell. Fixed to cell-0. Both caught in CI,
fixed before merge.

57 tests pass; SCORES commentary updated.
The networking fix exposed a pattern: any :::unsupported block where
the prose leads with "Dynamic Workers do not provide X" copies the
runtime caveat instead of explaining the code. An audit across all
four examples that contain :::unsupported blocks (networking,
subprocesses, threads-and-processes, virtual-environments) found
the same prose shape in the three I hadn't yet fixed.

TDD:

  RED — tests/test_example_content.py adds Contract 11
  (UnsupportedCellProseContract). The heuristic: each unsupported
  cell's prose must reference at least 2 code identifiers from
  its own code block (variable names, function calls, method
  names). Two is the floor that proves the prose discusses this
  specific code rather than a generic Workers note. Pre-fix, the
  contract flagged:

    - virtual-environments  1 ident referenced ("venv")
    - threads-and-processes 0 idents referenced
    - subprocesses          2 idents (passed bar, but still
                            weak — fixed for consistency)

  GREEN — rewrite all three prose blocks so each explains what
  ITS specific code does:

    subprocesses           "subprocess.run spawns a child Python
                            interpreter, captures stdout/stderr
                            (capture_output=True), decodes as text
                            (text=True), and raises if it exits
                            non-zero (check=True). The returned
                            result holds the captured streams and
                            exit code as portable evidence the
                            child ran. (Runtime caveat moved to
                            closing parenthetical.)"

    threads-and-processes  "ThreadPoolExecutor runs square across
                            two worker threads sharing the same
                            interpreter (and the GIL);
                            ProcessPoolExecutor runs pow across two
                            child processes with isolated memory.
                            Each pool.map returns an iterator over
                            results in input order, and the
                            surrounding with block joins the workers
                            when the body exits."

    virtual-environments   "venv.EnvBuilder configures the
                            description of a new environment, then
                            create('.venv') materialises it on disk
                            as a directory containing its own
                            interpreter and site-packages.
                            with_pip=False skips bootstrapping pip."

  REFACTOR — Contract 11 now passes; 58 tests total.

The second audit dimension (figure-caption vs example-title keyword
overlap) found 24 suspect attachments. Spot-checking confirms all
24 are false positives: captions use different word forms than
titles (e.g., "mutable" vs "mutability", "comprehension" vs
"comprehensions") but are conceptually aligned. No contract added —
heuristic too noisy without manual review.
User-flagged: /examples/testing showed a horizontal scrollbar inside
a walkthrough code cell. Root cause: white-space: pre-wrap wraps at
spaces, but the line

    suite = unittest.defaultTestLoader.loadTestsFromTestCase(AddTests)

contains a 58-char run after the first space that pre-wrap can't
break. Same pattern in two other examples.

TDD:

  RED — Contract 12 in tests/test_example_content.py
  (CellCodeWrappingContract) flags any \\S-run > 50 chars in
  walkthrough cell code. Pre-fix output:

    logging cell 0 line 6: 68-char run
      handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
    testing cell 2 line 2: 58-char run
      unittest.defaultTestLoader.loadTestsFromTestCase(AddTests)
    datetime cell 2 line 1: 51-char run
      datetime.fromisoformat("2026-05-04T12:30:00+00:00")

  GREEN — introduce intermediate names so each line wraps cleanly:

    testing.md
      loader = unittest.defaultTestLoader
      suite = loader.loadTestsFromTestCase(AddTests)
      ...
      runner = unittest.TextTestRunner(stream=stream, verbosity=0)
      result = runner.run(suite)

    logging.md
      formatter = logging.Formatter("%(levelname)s:%(message)s")
      handler.setFormatter(formatter)

    datetime.md
      iso_text = "2026-05-04T12:30:00+00:00"
      parsed = datetime.fromisoformat(iso_text)

  Each :::program block and the matching :::cell block updated
  together so the runnable code and the walkthrough stay in sync.

CSS safety net (public/site.css): added `overflow-wrap: anywhere`
to `.cell-source pre, .cell-source .shiki-block, .cell-output pre`.
A future long URL, long string literal, or long encoded blob will
break mid-run instead of pushing a horizontal scrollbar onto the
page. Code that's authored to wrap cleanly (the rule above keeps it
authored well) will still wrap at spaces because pre-wrap takes
precedence over overflow-wrap when both apply.

59 tests pass; SEO/cache lint clears 110 pages.
The first cell compared "hello" (pure ASCII, 1 byte/char) with
"สวัสดี" (Thai, 3 bytes/char) but skipped the middle ground. Adding
"café" between them gives a three-row reading:

  English hello  5 code points  5 bytes  (ASCII)
  French  café   4 code points  5 bytes  (é = 2 UTF-8 bytes)
  Thai    สวัสดี 6 code points  18 bytes (each char = 3 bytes)

The example already used "café" in the third cell to demonstrate
strip/upper/encode; lifting it into the opening comparison links
the three cells around one progression of byte costs and removes
the jump from "ASCII" to "completely non-Latin" without a stepping
stone.

Both the :::program block (runnable code) and the matching :::cell
(walkthrough) updated together; the cell's expected output now
shows the three-row table; example_loader verifies the cell output
matches what the program prints.
After fixing strings to include 'café' in the byte-count comparison
loop, audited 109 examples for similar gaps.

Two probes:
  - Find cells with explicit comparison loops `for ... in [(...)]:`
    iterating over 2-3 items. Found 5; only strings was a topic
    spectrum (English / French / Thai). The other 4 (for-loops,
    collections-module, async-await, async-iteration-and-context)
    iterate over example names or async features — not category
    spectrums needing a stepping stone.
  - Find examples whose summary enumerates 3+ named items but
    whose first cell mentions only 1-2 of them. Heuristic was
    noisy: 60 hits, all false positives on closer inspection.
    The truly-three-category examples (numbers, comprehensions,
    runtime-type-checks, classmethods-and-staticmethods,
    structured-data-shapes, truth-and-size) spread the categories
    across separate cells instead of one comparison loop. That's
    a valid pedagogy — one cell per category, building up.

Conclusion: the strings fix was the only actionable case. Recorded
the narrow lesson in docs/lessons-learned.md so future
comparison-loop cells start with the spectrum filled out.

No code or contracts changed. The heuristic for "missing topic
category" is too topic-specific to mechanise as a contract; this
stays guidance.
After commit 64360a4 wired section figures into render_journey_page,
production /journeys/<slug> pages render the same content as the
journey-* prototype HTMLs that build_prototypes.py was emitting.
Reviewers should look at the production pages, not the duplicate
prototypes.

Cut:
  - public/prototyping/journey-runtime.html
  - public/prototyping/journey-control-flow.html
  - public/prototyping/journey-iteration.html
  - public/prototyping/journey-shapes.html
  - public/prototyping/journey-interfaces.html
  - public/prototyping/journey-types.html
  - public/prototyping/journey-reliability.html
  - public/prototyping/journey-workers.html
  - build_prototypes.py: build_journey() function (~50 lines),
    JOURNEY_STYLE constant, the loop in main() that called it, and
    the 8 entries in the prototyping index list.

Kept (intentional):
  - journey-figures-gestalt.html — grid view of all 24 section
    figures for cross-journey design review; production renders one
    figure per section per page, the gestalt shows them together.
  - layout-banner-* prototypes — reference designs for the banner
    grammar (single, pair, trio); useful when adding new banner
    layouts.
  - marginalia-gestalt.html, production-figures-gestalt.html —
    review pages over the figure registry.

docs/lessons-learned.md updated: the bullet claiming "journey pages
don't yet render section figures inline" was outdated since
commit 64360a4. Replaced with a statement of the shipped behavior
plus the contract that enforces it.

59 tests pass; prototype index regenerates without the 8 deleted
entries.
Per the audit's "next cut" list. Each was a one-time artifact whose
moment has passed:

  docs/markdown-cell-migration-investigation.md
    One-time investigation document for the Markdown migration that
    has long since shipped. Plus the matching
    test_investigation_documents_program_and_cell_solution test in
    tests/test_markdown_migration_prereqs.py (a static-text assertion
    that the investigation doc still says certain phrases — useless
    once the doc is removed).

  docs/shallow-example-audit.md
    One-time audit of which examples were shallow. The findings
    have been folded into example sources and rubrics.

  docs/example-quality-new-vs-old.{md,csv,svg}
    One-time before/after quality comparison from an earlier rubric
    expansion. The improvement arc lives in commit history.

  docs/example-graph-score-impact.md
    Cut transitively — referenced score_examples.py and was itself a
    one-time impact report on the see_also graph rollout.

  public/prototyping/operators-polish-comparison.html
    One-time before/after for the tree-edge alignment fix. Pattern
    is documented in the figure rubric.

  scripts/score_examples.py
    Heuristic scorer superseded by the SCORES dict in
    src/marginalia.py, where every example carries an explicit v2
    rubric score with commentary.

References cleaned up:
  - tests/test_markdown_migration_prereqs.py loses one test method
    and the INVESTIGATION constant.
  - scripts/build_prototypes.py loses the operators-polish-comparison
    entry from the prototyping index.

58 tests pass (was 59; one test removed). SEO/cache lint clears 110
pages. Index.html regenerated without the stale entry.

Net cuts across the two cleanup commits: 8 journey-prototype HTMLs,
1 polish-comparison HTML, 5 docs (4 standalone + 1 transitive), 1
script, 1 test method, ~67 lines of build_journey() code, the
JOURNEY_STYLE constant, 8 entries from the prototyping index list,
1 outdated lesson bullet.
@adewale adewale force-pushed the claude/tuftean-marginalia-viz-TB0fw branch from 5dfb964 to 681bdaa Compare May 11, 2026 21:50
@adewale adewale changed the title Add marginalia gestalt page Marginalia: figures, contracts, and journey-section rendering May 11, 2026
claude added 2 commits May 11, 2026 21:54
After editing example markdown sources (strings, testing, logging,
datetime, networking, subprocesses, threads-and-processes,
virtual-environments) the embedded sources data and the HTML cache
fingerprint had drifted. `make check-generated` caught this in CI.

  src/example_sources_data.py — regenerated by
    scripts/embed_example_sources.py
  src/asset_manifest.py — HTML_CACHE_VERSION updated by
    scripts/fingerprint_assets.py (depends on the embedded data)
  scripts/build_marginalia.py — Canvas was imported but unused
    after the gestalt-uses-production refactor; ruff caught it

58 tests pass; lint clean; quality-checks clean.
scripts/check_example_migration_parity.py compares the live
examples against tests/fixtures/golden_examples.py — the frozen
snapshot from the Markdown migration. After the recent example
edits (strings/café, testing/logging/datetime long-line breaks,
networking figure swap + prose rewrite, three unsupported-cell
prose rewrites), the golden was out of sync with the sources.

Regenerated the fixture from the current examples. Parity check
now reports "100% golden parity"; 58 tests, lint, format-check
all clean.
@adewale adewale merged commit ff84e86 into main May 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants