From 103f6944bc2167b549c797dfaed716e0f29c10a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:51:21 +0000 Subject: [PATCH 1/4] Fix example count and pin tests to Python 3.13 README claimed 104 examples; the catalog has 109. `make test` ran bare python3, which fails on any system where python3 is not 3.13 (the example loader executes 3.12+ `type` statements). Route it through `uv run --python 3.13` and point the README at `make test`. https://claude.ai/code/session_012cj8czQW6Cd1Gw2PPgapED --- .github/workflows/{preview-viz.yml => preview.yml} | 0 Makefile | 2 +- README.md | 6 ++++-- 3 files changed, 5 insertions(+), 3 deletions(-) rename .github/workflows/{preview-viz.yml => preview.yml} (100%) diff --git a/.github/workflows/preview-viz.yml b/.github/workflows/preview.yml similarity index 100% rename from .github/workflows/preview-viz.yml rename to .github/workflows/preview.yml diff --git a/Makefile b/Makefile index df6eb30..afaa442 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test embed-examples build check-generated fingerprint browser-layout-test seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy lint test: - python3 -m unittest discover -s tests -v + uv run --python 3.13 python -m unittest discover -s tests -v embed-examples: scripts/embed_example_sources.py diff --git a/README.md b/README.md index 09f2e9b..ce86e72 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Production: (`workers.dev` remains enabled as ## Features -- 104 curated Python 3.13 examples in learning order +- 109 curated Python 3.13 examples in learning order - Literate source/output cells for each example walkthrough - Editable complete examples powered by CodeMirror - Read-only syntax highlighting powered by Shiki @@ -60,9 +60,11 @@ This project is developed with red-green-refactor TDD: Install dependencies with `uv`, then run: ```bash -python3 -m unittest discover -s tests -v +make test ``` +The test suite (and the example loader it imports) requires Python 3.13; `make test` runs it through `uv run --python 3.13` so a system `python3` on another version still works. + After cloning, install the local git hooks once so merges and rebases regenerate `src/asset_manifest.py` instead of producing conflicts: ```bash From 85a63d76d10bf1ab22ec330ddd065d8dcce88af4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:51:21 +0000 Subject: [PATCH 2/4] Generalize preview workflow; auto-regenerate generated files on main preview-viz.yml only triggered on a feature branch that has since merged. Replace it with an on-demand Preview workflow taking the preview Worker name as a workflow_dispatch input. GitHub UI merges bypass the local git hooks that keep generated files fresh, so merges to main could land with stale asset fingerprints and a red Verify run. Add a workflow that reruns `make build` on push to main and commits the regenerated files when they drifted. https://claude.ai/code/session_012cj8czQW6Cd1Gw2PPgapED --- .github/workflows/preview.yml | 17 +++++--- .../workflows/regenerate-generated-files.yml | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/regenerate-generated-files.yml diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2c1412b..97d206f 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,16 +1,19 @@ -name: Preview viz +name: Preview on: - push: - branches: - - claude/tuftean-marginalia-viz-TB0fw workflow_dispatch: + inputs: + name: + description: Preview Worker name prefix + required: false + default: preview + type: string permissions: contents: read concurrency: - group: preview-viz + group: preview-${{ inputs.name || 'preview' }} cancel-in-progress: true jobs: @@ -45,11 +48,11 @@ jobs: run: | set -x uv run pywrangler preview \ - --name viz \ + --name "${{ inputs.name || 'preview' }}" \ --message "${{ github.sha }}" \ --json - name: Smoke test deployed Preview - run: scripts/smoke_deployment.py https://viz-pythonbyexample.adewale-883.workers.dev + run: scripts/smoke_deployment.py "https://${{ inputs.name || 'preview' }}-pythonbyexample.adewale-883.workers.dev" - name: Dump wrangler logs on failure if: failure() run: | diff --git a/.github/workflows/regenerate-generated-files.yml b/.github/workflows/regenerate-generated-files.yml new file mode 100644 index 0000000..f79408c --- /dev/null +++ b/.github/workflows/regenerate-generated-files.yml @@ -0,0 +1,43 @@ +name: Regenerate generated files + +# Merges made through the GitHub UI bypass the local git hooks that keep +# src/asset_manifest.py and friends in sync, so a merge to main can land with +# stale generated files (and a red Verify run). This workflow regenerates them +# and pushes a fix-up commit. Pushes made with GITHUB_TOKEN do not trigger +# other workflows, so the fix-up commit is not re-verified by CI; it only ever +# contains deterministic `make build` output. + +on: + push: + branches: + - main + +permissions: + contents: write + +concurrency: + group: regenerate-generated-files + cancel-in-progress: false + +jobs: + regenerate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Regenerate embedded examples and fingerprinted assets + run: make build + - name: Commit and push regenerated files if they drifted + run: | + git add -A src/example_sources_data.py src/asset_manifest.py public + if git diff --cached --quiet; then + echo "Generated files are current." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Regenerate fingerprinted assets" + git pull --rebase origin main + git push origin main From 72bee609cacd279c4987e0be65c462e9c7714298 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:51:21 +0000 Subject: [PATCH 3/4] Cut the 2026-05-16 changelog release Everything under Unreleased has been deployed to production since May 16. Date the section and add the entries that were never logged: journeys, the marginalia figure system and its geometry contracts, quality registries, Turnstile runner protection, wide-event observability, deployment smoke, the custom domain, and the footer repository link. https://claude.ai/code/session_012cj8czQW6Cd1Gw2PPgapED --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a35fffa..767081b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,19 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ## Unreleased +## 2026-05-16 + ### Added +- Production custom domain `www.pythonbyexample.dev` (with `workers.dev` as fallback). +- Learning journeys: curated multi-example arcs with per-section learner outcomes and a journeys index. +- Marginalia figure system: a locked SVG diagram grammar (`src/marginalia_grammar.py`), curator-owned example-cell figures and journey section figures (`src/marginalia.py`), and gestalt review pages under `/prototyping/*`. +- Figure geometry contract tests covering clipping, collision, palette, caption uniqueness, and anchor resolution for every registered figure. +- Quality registries (`docs/quality-registries.toml`) with criterion-level scoring, confusable-pair checks, footgun coverage, no-figure rationales, journey outcome contracts, and enforced quality-score gates. +- Optional Turnstile protection for edited-code runs: secret-gated, session-scoped clearance cookie, Invisible-mode widget loaded only when the server requires a challenge, and a smoke-test bypass header. +- Structured wide-event observability for the Worker, with smoke checks asserting the custom event payload. +- Deployment smoke script (`scripts/smoke_deployment.py`) checking rendered pages and representative Dynamic Worker POST runs. +- Footer link to the GitHub repository. - MIT license. - SEO metadata for the home page and all example pages. - SEO/cache linter for future page additions. From d6feef30d2c3b9842f2ccf068615b0da9a6a3832 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:15:09 +0000 Subject: [PATCH 4/4] Close the four diagram-grammar gaps 1. Emphasis scarcity is now counted semantically by the grammar (Canvas.accent_count) instead of reverse-engineered from SVG, where an arrow's shaft and a gate line are indistinguishable. Gates collectively count as one accent per figure; a lanes traced path and its terminal dot count as one mark. Two new contract tests enforce the counter and catch primitives that paint EMPHASIS without counting. This closes the blind spot where generator-ribbon shipped 3 uncounted gate lines and control-stop-boundary's gate was invisible to the contract. 2. Twin layouts are now phrases, so coordinate drift is structurally impossible: two_names_one_object (aliasing-mutation / tuple-no-mutation), type_triangle (class-triangle / metaclass-triangle), and mono_divider computes a character divider's x from the font advance instead of an eyeballed pixel. This normalizes two live drifts: the aliasing/tuple second-panel tag gap (8px vs the first panel's 6px) and metaclass-triangle sitting 2px lower and 10px wider-gapped than its class-triangle twin. 3. Text metrics are single-sourced: MONO_ADVANCE (exact, for positioning) and BBOX_ADVANCE/text_width (conservative, for the clipping/collision contracts) live in marginalia_grammar.py; the geometry contracts import them instead of redefining their own. 4. Figure SVGs now carry aria-hidden/focusable=false so screen readers get the figcaption (the canonical voice) instead of the SVG's internal text fragments out of context. 117 of 123 figures are geometry-identical; the 6 that changed are the twin normalizations above. HTML_CACHE_VERSION regenerated. https://claude.ai/code/session_012cj8czQW6Cd1Gw2PPgapED --- docs/example-figure-rubric.md | 11 ++- src/asset_manifest.py | 2 +- src/marginalia.py | 57 +++----------- src/marginalia_grammar.py | 123 +++++++++++++++++++++++++++++- tests/test_marginalia_geometry.py | 82 +++++++++++--------- 5 files changed, 190 insertions(+), 85 deletions(-) diff --git a/docs/example-figure-rubric.md b/docs/example-figure-rubric.md index c93692a..16d5a3b 100644 --- a/docs/example-figure-rubric.md +++ b/docs/example-figure-rubric.md @@ -138,8 +138,15 @@ the figure can merge. - **Stroke-weight discipline.** Only `W_HAIRLINE`, `W_STROKE`, `W_EMPHASIS`, `W_GHOST`. *Contract 5c.* - **Emphasis scarcity, enforced.** At most ONE accent mark - (`EMPHASIS`-coloured arrowhead, caret, dot, or rect stroke) per - figure. Was a soft v1 criterion; now hard. *Contract 9.* + (`EMPHASIS`-coloured arrowhead, caret, dot, traced path, or rect + stroke) per figure. Was a soft v1 criterion; now hard. The census + is semantic, counted by the grammar itself (`Canvas.accent_count`), + because an arrow's shaft and a standalone gate line are + indistinguishable in raw SVG. Two codified carve-outs: all gates in + a figure collectively count as ONE accent (repeated structural + punctuation — every pause point on a ribbon — reads as one system), + and a `lanes` traced path plus its terminal dot count as one mark. + A gate set plus any focal accent still fails. *Contract 9.* - **Banner-fit, enforced.** Every figure's intrinsic width (Canvas.w + 2 · PAD_X) must fit `.cell-banner--1`'s 440px max ceiling. *Contract 8.* diff --git a/src/asset_manifest.py b/src/asset_manifest.py index e8136e8..2572020 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.e87d4baf77e6.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.a4a7766e1b9b.js'} -HTML_CACHE_VERSION = '35cf77d48ca5' +HTML_CACHE_VERSION = 'c09a7489d1e1' diff --git a/src/marginalia.py b/src/marginalia.py index 348b0bc..07e2cb8 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -34,36 +34,17 @@ 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) + c.two_names_one_object(0, 18, "before", "first", "second", '["python"]', object_w=88) + c.two_names_one_object(0, 108, "after append", "first", "second", + '["python","workers"]', object_w=130) 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.two_names_one_object(0, 18, "tuple — frozen", "first", "second", + '("python",)', object_w=110) + c.two_names_one_object(0, 108, "no .append", "first", "second", + '("python",)', object_w=110) c.label(150, 170, "tuples raise AttributeError", anchor="middle") @@ -597,14 +578,7 @@ def dataclass_fields(c: Canvas) -> None: 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") + c.type_triangle("type", "type") def exception_cause_context(c: Canvas) -> None: @@ -729,8 +703,7 @@ def sort_stability(c: Canvas) -> None: 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") - # JetBrains Mono advances ~6px per char at fs=10; '*' sits at index 12. - c.dashed(75, 22, 75, 38) + c.mono_divider(0, 12, 22, 38) # the '*' sits at index 12 c.label(33, 50, "positional or kw", anchor="middle") c.label(120, 50, "keyword only", anchor="middle") @@ -738,8 +711,7 @@ def kw_only_separator(c: Canvas) -> None: 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") - # JetBrains Mono advances ~6px per char at fs=10; '/' sits at index 12. - c.dashed(75, 22, 75, 38) + c.mono_divider(0, 12, 22, 38) # the '/' sits at index 12 c.label(33, 50, "positional only", anchor="middle") c.label(120, 50, "positional or kw", anchor="middle") @@ -899,14 +871,7 @@ def property_fork(c: Canvas) -> None: 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") + c.type_triangle("metaclass", "type", third_w=80) def sys_path_resolution(c: Canvas) -> None: diff --git a/src/marginalia_grammar.py b/src/marginalia_grammar.py index bf8d5a8..3cbf272 100644 --- a/src/marginalia_grammar.py +++ b/src/marginalia_grammar.py @@ -65,12 +65,73 @@ SIZE_TAG = 8 BASELINE = 4 # add to box-center y to render text vertically centered +# ─── Text metrics ────────────────────────────────────────────────────── +# Single source of truth for character advance. Paint code uses +# MONO_ADVANCE (the font's exact advance) to compute positions; the +# geometry contracts use BBOX_ADVANCE (deliberately conservative +# over-estimates) to detect clipping and collision. Keeping both here — +# instead of one in a paint comment and one in the test file — means a +# recalibration happens in one place and both consumers move together. +MONO_ADVANCE = 0.6 # JetBrains Mono advances 600/1000 units per em. +BBOX_ADVANCE = { + "mono": 0.62, # JetBrains Mono / IBM Plex Mono + "sans_upper": 0.65, # Source Sans Pro uppercase (tag font) + "sans": 0.55, # Source Sans Pro mixed-case (label font) + "serif": 0.52, # Iowan Old Style / Charter italic +} + + +def font_class(family: str) -> str: + if "Mono" in family or "monospace" in family: + return "mono" + # "sans-serif" contains "serif" as a substring; check sans first + # so the system-sans fallback string doesn't misclassify. + if "sans" in family.lower(): + return "sans" + if "serif" in family or "Iowan" in family or "Charter" in family: + return "serif" + return "sans" + + +def text_width(content: str, family: str, size: float, tracking: float = 0.0) -> float: + """Conservative rendered width of a text run, for bbox math. + + Upper-cased sans glyphs (the tag font: LOOP, INT, …) advance ~18% + wider than mixed-case sans; differentiating the two keeps the + contracts tight enough to catch real clips without over-flagging + every mixed-case label that kisses a sibling rect. + """ + klass = font_class(family) + if klass == "sans" and content == content.upper() and any(ch.isalpha() for ch in content): + per_char = BBOX_ADVANCE["sans_upper"] * size + else: + per_char = BBOX_ADVANCE[klass] * size + return (per_char + tracking) * len(content) + @dataclass class Canvas: w: int = 320 h: int = 110 parts: list[str] = field(default_factory=list) + # Semantic accent census. Every primitive that paints EMPHASIS + # increments _accents (or sets _gates_painted); the scarcity + # contract asserts accent_count() <= 1 from here instead of trying + # to reverse-engineer marks from SVG output, where an arrow's + # shaft+head pair and a standalone gate line are indistinguishable. + _accents: int = 0 + _gates_painted: bool = False + + def accent_count(self) -> int: + """Number of accent marks on the canvas, per the scarcity rule. + + Gates are repeated structural punctuation — every pause point on + a ribbon, in every ribbon of the figure — and read as one system, + so all gates collectively count as a single accent. A gate set + plus any focal accent (emphasis arrow, caret, dot, traced path) + is still two marks competing for attention, and still fails. + """ + return self._accents + (1 if self._gates_painted else 0) # ── tokens (private; cards should not reach for these) ──────────── def _add(self, s: str) -> None: @@ -98,6 +159,8 @@ 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): + if emphasis: + self._accents += 1 self._add(f'') def tick(self, x, y, *, length=TICK_LEN): @@ -123,6 +186,8 @@ def closed_arrow(self, x1, y1, x2, y2, *, emphasis=False): per figure — the one mark the surrounding prose explicitly names. Saturated --accent strokes everywhere break visual scarcity. """ + if emphasis: + self._accents += 1 color = EMPHASIS if emphasis else INK weight = W_EMPHASIS if emphasis else W_STROKE dx, dy = x2 - x1, y2 - y1 @@ -214,9 +279,24 @@ def caret(self, x, y_top, *, emphasis=True): prose only names one of them — the others paint in ink so the scarce-emphasis rule still holds. """ + if emphasis: + self._accents += 1 fill = EMPHASIS if emphasis else INK self._add(f'') + def mono_divider(self, x_start, index, y_top, y_bot, *, size=SIZE_MONO): + """Dashed vertical centred on character `index` of a start-anchored + mono string drawn at x_start. + + The x is computed from the font's advance (MONO_ADVANCE), not + eyeballed: hand-tuned positions drift, computed positions match + the rendered glyph. Returns the computed x. + """ + advance = MONO_ADVANCE * size + x = x_start + index * advance + advance / 2 + self.dashed(x, y_top, x, y_bot) + return x + def register(self, x, y, w, *, divisions=None, between=False): """Hairline with regular ticks.""" self.hairline(x, y, x + w, y) @@ -251,6 +331,7 @@ def frame(self, x, y, w, h, *, label=None, ghost=False): def gate(self, x, y_top, y_bot): """Vertical EMPHASIS line crossing a ribbon.""" + self._gates_painted = True self._line(x, y_top, x, y_bot, color=EMPHASIS, weight=W_EMPHASIS) def ribbon(self, x, y, w, *, h=30, gates=(), soft_segments=()): @@ -281,6 +362,37 @@ def bind(self, x, y, name, type_tag, value, *, object_w=OBJECT_W, gap=40): self.closed_arrow(nx + 2, ny, ox - 2, oy) return (x, y, nx + gap + object_w, y + OBJECT_H) + def two_names_one_object(self, x, y, tag_text, name_a, name_b, value, *, object_w=OBJECT_W): + """Two names bound to one shared object — the aliasing picture. + + Twin panels and twin figures (aliasing-mutation, + tuple-no-mutation) must keep this layout coordinate-identical; + composing it as a phrase makes drift structurally impossible. + y is the top of the first name box; the tag paints 6 above it. + """ + if tag_text: + self.tag(x, y - 6, tag_text) + self.name_box(x, y, name_a) + self.name_box(x, y + 30, name_b) + self.closed_arrow(x + NAME_W, y + 12, x + NAME_W + 26, y + 28, emphasis=False) + self.closed_arrow(x + NAME_W, y + 42, x + NAME_W + 26, y + 28, emphasis=False) + self.object_box(x + NAME_W + 28, y + 14, "", value, w=object_w, h=28) + + def type_triangle(self, third_label, third_value, *, third_w=60): + """instance → class → — the triangle shared by the + class-triangle / metaclass-triangle twin figures. The shared + coordinates live here so the twins cannot drift apart; only the + third frame's label, value, and width vary. + """ + self.dot(20, 28) + self.label(20, 54, "instance", anchor="middle") + self.closed_arrow(26, 28, 86, 28, emphasis=False) + self.frame(88, 10, 60, 36, label="class") + self.mono(118, 32, "Class") + self.closed_arrow(148, 28, 208, 28, emphasis=False) + self.frame(210, 10, third_w, 36, label=third_label) + self.mono(210 + third_w / 2, 32, third_value) + 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) @@ -322,9 +434,13 @@ def lanes(self, ys_labels, *, x0=40, x1=300, path=None): for y, lab in ys_labels: self.lane(y, x0=x0, x1=x1, label=lab) if path: + # The traced path and its terminal dot are one mark: count + # once here and paint the dot directly so dot() doesn't + # count it a second time. + self._accents += 1 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) + self._add(f'') # ── render ──────────────────────────────────────────────────────── # Figures render at INTRINSIC_SCALE × their viewBox dimensions. The @@ -351,9 +467,14 @@ def to_svg(self) -> str: vb_h = self.h + pad_top + pad_bottom out_w = round(vb_w * self.INTRINSIC_SCALE) out_h = round(vb_h * self.INTRINSIC_SCALE) + # aria-hidden: the figcaption is the canonical voice for every + # figure; without it screen readers walk the SVG's internal + # fragments ("STR", "next()", …) out of context before + # reaching the caption. return ( f'' + "".join(self.parts) + "" diff --git a/tests/test_marginalia_geometry.py b/tests/test_marginalia_geometry.py index 34b3279..e6e4a5f 100644 --- a/tests/test_marginalia_geometry.py +++ b/tests/test_marginalia_geometry.py @@ -21,7 +21,7 @@ import unittest from src.marginalia import ATTACHMENTS, FIGURES, SCORES -from src.marginalia_grammar import Canvas +from src.marginalia_grammar import Canvas, text_width # The gestalt review pages under /prototyping/* render the same paint @@ -35,31 +35,12 @@ # src/marginalia_grammar.py. PAD_TOP, PAD_X, PAD_BOTTOM = 14, 14, 14 -# Approximate character widths as a multiple of font-size. Sans-serif -# glyph advance varies sharply by case: uppercase letters (L, O, P) -# average ~0.65 of font-size, mixed-case ~0.55, narrow lowercase -# (l, i) ~0.40. The tag() primitive upper-cases its argument; label() -# uses mixed case. Different bounds catch both: too-loose missed the -# async-swimlane "LOOP" clip; too-tight over-flags every mixed-case -# label kissing a sibling rect. -CHAR_WIDTH = { - "mono": 0.62, # JetBrains Mono / IBM Plex Mono - "sans_upper": 0.65, # Source Sans Pro uppercase (tag font) - "sans": 0.55, # Source Sans Pro mixed-case (label font) - "serif": 0.52, # Iowan Old Style / Charter italic -} - - -def font_class(family: str) -> str: - if "Mono" in family or "monospace" in family: - return "mono" - # "sans-serif" contains "serif" as a substring; check sans first - # so the system-sans fallback string doesn't misclassify. - if "sans" in family.lower(): - return "sans" - if "serif" in family or "Iowan" in family or "Charter" in family: - return "serif" - return "sans" +# Character advance and text-width estimation live in +# src/marginalia_grammar.py (BBOX_ADVANCE / text_width) so the paint +# code and these contracts share one source of truth. The bounds are +# deliberately conservative: too-loose missed the async-swimlane "LOOP" +# clip; too-tight over-flags every mixed-case label kissing a sibling +# rect. Recalibrate there, not here. def text_bbox(d: dict, content: str) -> tuple[float, float, float, float]: @@ -70,15 +51,7 @@ def text_bbox(d: dict, content: str) -> tuple[float, float, float, float]: anchor = d.get("text-anchor", "start") family = d.get("font-family", "sans-serif") tracking = float(d.get("letter-spacing", 0)) - klass = font_class(family) - # Differentiate uppercase sans (tag font: LOOP, INT, …) from - # mixed-case sans (label font: next(), stdout, …); upper-cased - # glyphs are ~18 % wider per advance. - if klass == "sans" and content == content.upper() and any(ch.isalpha() for ch in content): - per_char = CHAR_WIDTH["sans_upper"] * fs - else: - per_char = CHAR_WIDTH[klass] * fs - width = (per_char + tracking) * len(content) + width = text_width(content, family, fs, tracking) if anchor == "middle": x -= width / 2 elif anchor == "end": @@ -574,8 +547,47 @@ class FigureEmphasisScarcityContract(unittest.TestCase): pair that `closed_arrow(emphasis=True)` emits), one orange caret, or one element whose fill or stroke is the EMPHASIS colour and isn't part of those compound shapes. + + Two complementary checks: + + 1. The grammar's semantic counter (Canvas.accent_count) — the + authoritative census. It sees what the output census cannot: + EMPHASIS gates and traces, which are + indistinguishable from an arrow's shaft in raw SVG. Gates + collectively count as ONE accent per figure (repeated + structural punctuation reads as one system); a gate set plus + any focal accent still fails. + 2. The output census below — a backstop that catches any future + primitive painting EMPHASIS fills/strokes without counting. """ + def test_grammar_accent_counter_allows_at_most_one(self): + failures: list[str] = [] + for name, (paint, w, h) in ALL_FIGURES.items(): + canvas = Canvas(w=w, h=h) + paint(canvas) + if canvas.accent_count() > 1: + failures.append( + f"{name}: {canvas.accent_count()} accent marks (rubric allows at most 1)" + ) + self.assertEqual(failures, [], "\n " + "\n ".join(failures)) + + def test_uncounted_figures_paint_no_emphasis_colour(self): + """If the counter says zero accents, no EMPHASIS ink may appear. + + Catches a primitive that paints the accent colour without + incrementing the census — the way the contract itself would rot. + """ + from src.marginalia_grammar import EMPHASIS + + failures: list[str] = [] + for name, (paint, w, h) in ALL_FIGURES.items(): + canvas = Canvas(w=w, h=h) + paint(canvas) + if canvas.accent_count() == 0 and any(EMPHASIS in part for part in canvas.parts): + failures.append(f"{name}: paints EMPHASIS but accent_count() == 0") + self.assertEqual(failures, [], "\n " + "\n ".join(failures)) + def test_at_most_one_accent_per_figure(self): from src.marginalia_grammar import EMPHASIS