Skip to content

Commit 64360a4

Browse files
committed
Render journey-section figures inline on /journeys/<slug>
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.
1 parent d448b13 commit 64360a4

7 files changed

Lines changed: 187 additions & 117 deletions

File tree

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/site.css

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/build_prototypes.py

Lines changed: 1 addition & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_example,
2323
render_inline,
2424
)
25+
from marginalia import SECTION_FIGURES as JOURNEY_SECTION_FIGURES # noqa: E402
2526
from marginalia import _render_svg # noqa: E402
2627

2728
OUT_DIR = ROOT / "public" / "prototyping"
@@ -252,115 +253,6 @@ def build_index() -> None:
252253
"""
253254

254255

255-
# Each journey section maps to ONE figure that captures the section's
256-
# conceptual shift. Keys are section titles exactly as they appear in
257-
# JOURNEYS in app.py. Each value is (figure_name, caption).
258-
JOURNEY_SECTION_FIGURES: dict[str, tuple[str, str]] = {
259-
# Runtime
260-
"Start with executable evidence.": (
261-
"program-output",
262-
"Every page is a runnable program. The smallest mental model: source produces visible output.",
263-
),
264-
"Separate value, identity, and absence.": (
265-
"identity-and-equality",
266-
"Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).",
267-
),
268-
"Read expressions as object operations.": (
269-
"operator-dispatch",
270-
"Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.",
271-
),
272-
# Control Flow
273-
"Choose between paths.": (
274-
"branch-fork",
275-
"A value flows through a predicate to one of several branches.",
276-
),
277-
"Name and shape decisions.": (
278-
"naming-decisions",
279-
"The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.",
280-
),
281-
"Stop as soon as the answer is known.": (
282-
"early-exit",
283-
"The loop exits at the first match — break short-circuits the rest of the sequence.",
284-
),
285-
# Iteration
286-
"Choose the right loop shape.": (
287-
"loop-repetition",
288-
"Walk the sequence, run the body, return; the shape behind for and while.",
289-
),
290-
"See the protocol behind `for`.": (
291-
"iter-protocol",
292-
"iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.",
293-
),
294-
"Compose lazy value streams.": (
295-
"lazy-stream",
296-
"Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.",
297-
),
298-
# Workers — constraint-shaped sections; figures tentative.
299-
"Replace unavailable process boundaries with portable evidence.": (
300-
"workers-portable-evidence",
301-
"Worker isolation breaks the usual cross-process pathways; the lesson preserves a captured value as portable evidence instead.",
302-
),
303-
"Keep network lessons local to the protocol boundary.": (
304-
"workers-protocol-local",
305-
"Demonstrate the protocol shape (request and response) rather than calling out over the network.",
306-
),
307-
"Preserve the lesson while respecting the runtime.": (
308-
"workers-lesson-runtime",
309-
"The lesson's evidence survives across the boundary that the worker runtime enforces.",
310-
),
311-
# Shapes
312-
"Pick the container that matches the question.": (
313-
"container-questions",
314-
"Each container answers a different question: ordered, fixed, lookup, unique.",
315-
),
316-
"Move between shapes deliberately.": (
317-
"reshape-pipeline",
318-
"Most everyday code reshapes data: one input, one transform, one new value.",
319-
),
320-
"Cross text and data boundaries.": (
321-
"text-data-boundary",
322-
"Programs receive text and produce structured data; parsing makes the boundary explicit.",
323-
),
324-
# Interfaces
325-
"Start with functions as named behavior.": (
326-
"function-signature",
327-
"A function is the first abstraction boundary: arguments in, body, return value out.",
328-
),
329-
"Use functions as values.": (
330-
"function-as-value",
331-
"Functions are first-class values. A second name binds to the same function object.",
332-
),
333-
"Bundle behavior with state.": (
334-
"class-with-state",
335-
"Classes group fields and methods so data and behavior move together behind one interface.",
336-
),
337-
# Types
338-
"Keep runtime and static analysis separate.": (
339-
"annotation-ghost",
340-
"Annotations describe expected types for tools; the runtime accepts any object regardless.",
341-
),
342-
"Describe realistic data shapes.": (
343-
"union-types",
344-
"A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.",
345-
),
346-
"Scale annotations for reusable libraries.": (
347-
"generic-preservation",
348-
"A generic type variable preserves shape across a call: the same T flows in and out.",
349-
),
350-
# Reliability
351-
"Make failure explicit.": (
352-
"exception-lanes",
353-
"try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.",
354-
),
355-
"Control resource and module boundaries.": (
356-
"context-bowtie",
357-
"A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.",
358-
),
359-
"Handle operations that outlive one expression.": (
360-
"async-swimlane",
361-
"On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.",
362-
),
363-
}
364256

365257

366258
def build_journey(slug: str) -> None:

src/app.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
try:
1111
from .asset_manifest import ASSET_PATHS
1212
from .examples import EXAMPLES, EXAMPLES_BY_SLUG, PYTHON_VERSION, REFERENCE_URL
13-
from .marginalia import render_for_anchor
13+
from .marginalia import render_for_anchor, render_for_section
1414
except ImportError: # Cloudflare Python Workers import sibling modules from main's directory.
1515
from asset_manifest import ASSET_PATHS
1616
from examples import EXAMPLES, EXAMPLES_BY_SLUG, PYTHON_VERSION, REFERENCE_URL
17-
from marginalia import render_for_anchor
17+
from marginalia import render_for_anchor, render_for_section
1818

1919

2020
class AppResponse:
@@ -572,7 +572,8 @@ def render_journey_page(journey):
572572
else:
573573
sentence = f"This gap should {description}."
574574
rows.append(f'<li><p class="journey-gap-label">Gap · {html.escape(value)}</p><p class="meta">{html.escape(sentence)}</p></li>')
575-
sections.append(f'<section class="journey-section"><h2>{html.escape(section["title"])}</h2><p class="meta">{html.escape(section["summary"])}</p><ul class="journey-list">{"".join(rows)}</ul></section>')
575+
figure_html = render_for_section(section["title"])
576+
sections.append(f'<section class="journey-section"><h2>{html.escape(section["title"])}</h2><p class="meta">{html.escape(section["summary"])}</p>{figure_html}<ul class="journey-list">{"".join(rows)}</ul></section>')
576577
content = f'''
577578
<article class="example-shell journey-page">
578579
<div class="example-top"><a class="text-link" href="/">← All examples</a><a class="text-link" href="{html.escape(REFERENCE_URL)}">Python docs reference</a></div>

src/asset_manifest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Generated by scripts/fingerprint_assets.py. Do not edit by hand.
2-
ASSET_PATHS = {'SITE_CSS': '/site.be98c8af1bb8.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'}
3-
HTML_CACHE_VERSION = 'c361f35f812c'
2+
ASSET_PATHS = {'SITE_CSS': '/site.2cfe9ad1f9db.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'}
3+
HTML_CACHE_VERSION = '815cee0ddd64'

src/marginalia.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,6 +1890,134 @@ def render_for_anchor(slug: str, anchor: str) -> str:
18901890
return f'<div class="cell-banner{count_class}">{"".join(figures)}</div>'
18911891

18921892

1893+
# ─── Journey-section figures ──────────────────────────────────────────
1894+
# One figure per journey section, keyed by section title. The figure
1895+
# depicts the conceptual shift the section's examples share — the
1896+
# journey-section rubric (docs/journey-visualisation-rubric.md) scores
1897+
# these. Rendered inline on /journeys/<slug> between the section
1898+
# heading and the example list. Reviewed all together on
1899+
# /prototyping/journey-figures-gestalt.
1900+
1901+
SECTION_FIGURES: dict[str, tuple[str, str]] = {
1902+
# Runtime
1903+
"Start with executable evidence.": (
1904+
"program-output",
1905+
"Every page is a runnable program. The smallest mental model: source produces visible output.",
1906+
),
1907+
"Separate value, identity, and absence.": (
1908+
"identity-and-equality",
1909+
"Two names can share one object (left, both `is` and `==` true) or hold two equal-but-distinct objects (right, only `==` true).",
1910+
),
1911+
"Read expressions as object operations.": (
1912+
"operator-dispatch",
1913+
"Operators are method calls. `a + b` dispatches to `a.__add__(b)`; the data model exposes the syntax.",
1914+
),
1915+
# Control Flow
1916+
"Choose between paths.": (
1917+
"branch-fork",
1918+
"A value flows through a predicate to one of several branches.",
1919+
),
1920+
"Name and shape decisions.": (
1921+
"naming-decisions",
1922+
"The walrus binds a name while the surrounding expression uses its value: one expression, two outputs.",
1923+
),
1924+
"Stop as soon as the answer is known.": (
1925+
"early-exit",
1926+
"The loop exits at the first match — break short-circuits the rest of the sequence.",
1927+
),
1928+
# Iteration
1929+
"Choose the right loop shape.": (
1930+
"loop-repetition",
1931+
"Walk the sequence, run the body, return; the shape behind for and while.",
1932+
),
1933+
"See the protocol behind `for`.": (
1934+
"iter-protocol",
1935+
"iter() exposes the iterator behind for; next() pulls one value at a time until exhausted.",
1936+
),
1937+
"Compose lazy value streams.": (
1938+
"lazy-stream",
1939+
"Filters and maps compose without materialising intermediate lists; values flow through the pipeline only when next() pulls them.",
1940+
),
1941+
# Shapes
1942+
"Pick the container that matches the question.": (
1943+
"container-questions",
1944+
"Each container answers a different question: ordered, fixed, lookup, unique.",
1945+
),
1946+
"Move between shapes deliberately.": (
1947+
"reshape-pipeline",
1948+
"Most everyday code reshapes data: one input, one transform, one new value.",
1949+
),
1950+
"Cross text and data boundaries.": (
1951+
"text-data-boundary",
1952+
"Programs receive text and produce structured data; parsing makes the boundary explicit.",
1953+
),
1954+
# Interfaces
1955+
"Start with functions as named behavior.": (
1956+
"function-signature",
1957+
"A function is the first abstraction boundary: arguments in, body, return value out.",
1958+
),
1959+
"Use functions as values.": (
1960+
"function-as-value",
1961+
"Functions are first-class values. A second name binds to the same function object.",
1962+
),
1963+
"Bundle behavior with state.": (
1964+
"class-with-state",
1965+
"Classes group fields and methods so data and behavior move together behind one interface.",
1966+
),
1967+
# Types
1968+
"Keep runtime and static analysis separate.": (
1969+
"annotation-ghost",
1970+
"Annotations describe expected types for tools; the runtime accepts any object regardless.",
1971+
),
1972+
"Describe realistic data shapes.": (
1973+
"union-types",
1974+
"A typed slot can accept one of several shapes — `int | str | None` covers expected absence and alternatives.",
1975+
),
1976+
"Scale annotations for reusable libraries.": (
1977+
"generic-preservation",
1978+
"A generic type variable preserves shape across a call: the same T flows in and out.",
1979+
),
1980+
# Reliability
1981+
"Make failure explicit.": (
1982+
"exception-lanes",
1983+
"try, except, else, and finally as parallel lanes; the path traced through them is the actual control flow.",
1984+
),
1985+
"Control resource and module boundaries.": (
1986+
"context-bowtie",
1987+
"A context manager pairs setup with reliable cleanup; the raise path still routes through __exit__.",
1988+
),
1989+
"Handle operations that outlive one expression.": (
1990+
"async-swimlane",
1991+
"On await, the coroutine yields to the loop; the loop runs other work and resumes when the awaitable is ready.",
1992+
),
1993+
# Workers — constraint-shaped sections.
1994+
"Replace unavailable process boundaries with portable evidence.": (
1995+
"workers-portable-evidence",
1996+
"Worker isolation breaks the usual cross-process pathways; the lesson preserves a captured value as portable evidence instead.",
1997+
),
1998+
"Keep network lessons local to the protocol boundary.": (
1999+
"workers-protocol-local",
2000+
"Demonstrate the protocol shape (request and response) rather than calling out over the network.",
2001+
),
2002+
"Preserve the lesson while respecting the runtime.": (
2003+
"workers-lesson-runtime",
2004+
"The lesson's evidence survives across the boundary that the worker runtime enforces.",
2005+
),
2006+
}
2007+
2008+
2009+
def render_for_section(section_title: str) -> str:
2010+
"""HTML for a section figure on a journey page. Empty if no
2011+
figure is registered for this section title.
2012+
"""
2013+
entry = SECTION_FIGURES.get(section_title)
2014+
if not entry:
2015+
return ""
2016+
name, caption = entry
2017+
cap = f"<figcaption>{html.escape(caption)}</figcaption>" if caption else ""
2018+
return f'<figure class="journey-section-figure">{_render_svg(name)}{cap}</figure>'
2019+
2020+
18932021
# ─── Scores (v2 rubric — see docs/example-figure-rubric.md) ────────────
18942022
# Score every attached example figure against the v2 rubric. The dict is
18952023
# the single source of truth for both the gestalt review pages

tests/test_marginalia_geometry.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,15 +266,20 @@ def test_every_attachment_points_to_a_real_figure(self):
266266
self.assertEqual(orphan_refs, set(), f"attachments reference unknown figures: {sorted(orphan_refs)}")
267267

268268
def test_no_unused_figure_paint_functions(self):
269-
# A figure name counts as "used" if it appears in ATTACHMENTS
270-
# (example-page wiring) or anywhere in scripts/build_prototypes.py
271-
# (journey-section figures, banner prototypes, gestalt galleries).
269+
# A figure name counts as "used" if it ships on an example page
270+
# (ATTACHMENTS), a journey section (SECTION_FIGURES), or appears
271+
# anywhere in scripts/build_prototypes.py (banner layouts and
272+
# gestalt galleries that aren't covered by the structured
273+
# registries).
272274
from pathlib import Path
273275

276+
from src.marginalia import SECTION_FIGURES
277+
274278
prototype_src = (
275279
Path(__file__).resolve().parents[1] / "scripts" / "build_prototypes.py"
276280
).read_text()
277281
used = {name for items in ATTACHMENTS.values() for _, name, _ in items}
282+
used |= {name for name, _ in SECTION_FIGURES.values()}
278283
used |= {name for name in FIGURES if f'"{name}"' in prototype_src or f"'{name}'" in prototype_src}
279284
unused = set(FIGURES) - used
280285
self.assertEqual(
@@ -334,6 +339,44 @@ def test_every_stroke_width_is_from_the_locked_set(self):
334339
self.assertEqual(failures, [], "\n " + "\n ".join(failures))
335340

336341

342+
class SectionFigureContract(unittest.TestCase):
343+
"""Contract 10: every journey section in app.py JOURNEYS has a
344+
figure in SECTION_FIGURES, and every SECTION_FIGURES entry maps
345+
to a real FIGURES paint function.
346+
"""
347+
348+
def test_every_journey_section_has_a_figure(self):
349+
from src.app import JOURNEYS
350+
from src.marginalia import SECTION_FIGURES
351+
352+
section_titles = {s["title"] for j in JOURNEYS for s in j["sections"]}
353+
missing = section_titles - set(SECTION_FIGURES)
354+
self.assertEqual(
355+
missing, set(),
356+
f"journey sections without a figure: {sorted(missing)}",
357+
)
358+
359+
def test_every_section_figure_points_to_a_real_paint_function(self):
360+
from src.marginalia import SECTION_FIGURES
361+
362+
orphan_refs = {name for name, _ in SECTION_FIGURES.values()} - set(FIGURES)
363+
self.assertEqual(
364+
orphan_refs, set(),
365+
f"SECTION_FIGURES references unknown figures: {sorted(orphan_refs)}",
366+
)
367+
368+
def test_every_section_figure_caption_is_unique(self):
369+
from collections import defaultdict
370+
from src.marginalia import SECTION_FIGURES
371+
372+
caption_to_titles: dict[str, list[str]] = defaultdict(list)
373+
for title, (_, caption) in SECTION_FIGURES.items():
374+
if caption:
375+
caption_to_titles[caption].append(title)
376+
duplicates = {c: t for c, t in caption_to_titles.items() if len(t) > 1}
377+
self.assertEqual(duplicates, {}, f"duplicate section captions: {duplicates}")
378+
379+
337380
class FigureCaptionContract(unittest.TestCase):
338381
"""Contract 5b: every attachment caption is unique.
339382

0 commit comments

Comments
 (0)