diff --git a/.github/workflows/preview-viz.yml b/.github/workflows/preview.yml similarity index 79% rename from .github/workflows/preview-viz.yml rename to .github/workflows/preview.yml index 2c1412b..97d206f 100644 --- a/.github/workflows/preview-viz.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 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. 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 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