Skip to content

Commit 31b2d7c

Browse files
committed
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.
1 parent 72bee60 commit 31b2d7c

5 files changed

Lines changed: 190 additions & 85 deletions

File tree

docs/example-figure-rubric.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,15 @@ the figure can merge.
138138
- **Stroke-weight discipline.** Only `W_HAIRLINE`, `W_STROKE`,
139139
`W_EMPHASIS`, `W_GHOST`. *Contract 5c.*
140140
- **Emphasis scarcity, enforced.** At most ONE accent mark
141-
(`EMPHASIS`-coloured arrowhead, caret, dot, or rect stroke) per
142-
figure. Was a soft v1 criterion; now hard. *Contract 9.*
141+
(`EMPHASIS`-coloured arrowhead, caret, dot, traced path, or rect
142+
stroke) per figure. Was a soft v1 criterion; now hard. The census
143+
is semantic, counted by the grammar itself (`Canvas.accent_count`),
144+
because an arrow's shaft and a standalone gate line are
145+
indistinguishable in raw SVG. Two codified carve-outs: all gates in
146+
a figure collectively count as ONE accent (repeated structural
147+
punctuation — every pause point on a ribbon — reads as one system),
148+
and a `lanes` traced path plus its terminal dot count as one mark.
149+
A gate set plus any focal accent still fails. *Contract 9.*
143150
- **Banner-fit, enforced.** Every figure's intrinsic width
144151
(Canvas.w + 2 · PAD_X) must fit `.cell-banner--1`'s 440px max
145152
ceiling. *Contract 8.*

src/asset_manifest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Generated by scripts/fingerprint_assets.py. Do not edit by hand.
22
ASSET_PATHS = {'SITE_CSS': '/site.e87d4baf77e6.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.a4a7766e1b9b.js'}
3-
HTML_CACHE_VERSION = '35cf77d48ca5'
3+
HTML_CACHE_VERSION = 'c09a7489d1e1'

src/marginalia.py

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -34,36 +34,17 @@
3434

3535
def aliasing_mutation(c: Canvas) -> None:
3636
"""Two names binding to one mutable list, before and after a mutation."""
37-
c.tag(0, 12, "before")
38-
c.name_box(0, 18, "first")
39-
c.name_box(0, 48, "second")
40-
c.closed_arrow(60, 30, 86, 46, emphasis=False)
41-
c.closed_arrow(60, 60, 86, 46, emphasis=False)
42-
c.object_box(88, 32, "", '["python"]', w=88, h=28)
43-
44-
c.tag(0, 100, "after append")
45-
c.name_box(0, 108, "first")
46-
c.name_box(0, 138, "second")
47-
c.closed_arrow(60, 120, 86, 136, emphasis=False)
48-
c.closed_arrow(60, 150, 86, 136, emphasis=False)
49-
c.object_box(88, 122, "", '["python","workers"]', w=130, h=28)
37+
c.two_names_one_object(0, 18, "before", "first", "second", '["python"]', object_w=88)
38+
c.two_names_one_object(0, 108, "after append", "first", "second",
39+
'["python","workers"]', object_w=130)
5040

5141

5242
def tuple_no_mutation(c: Canvas) -> None:
5343
"""The contrast: two names binding to one immutable tuple — no mutation possible."""
54-
c.tag(0, 12, "tuple — frozen")
55-
c.name_box(0, 18, "first")
56-
c.name_box(0, 48, "second")
57-
c.closed_arrow(60, 30, 86, 46, emphasis=False)
58-
c.closed_arrow(60, 60, 86, 46, emphasis=False)
59-
c.object_box(88, 32, "", '("python",)', w=110, h=28)
60-
61-
c.tag(0, 100, "no .append")
62-
c.name_box(0, 108, "first")
63-
c.name_box(0, 138, "second")
64-
c.closed_arrow(60, 120, 86, 136, emphasis=False)
65-
c.closed_arrow(60, 150, 86, 136, emphasis=False)
66-
c.object_box(88, 122, "", '("python",)', w=110, h=28)
44+
c.two_names_one_object(0, 18, "tuple — frozen", "first", "second",
45+
'("python",)', object_w=110)
46+
c.two_names_one_object(0, 108, "no .append", "first", "second",
47+
'("python",)', object_w=110)
6748
c.label(150, 170, "tuples raise AttributeError", anchor="middle")
6849

6950

@@ -597,14 +578,7 @@ def dataclass_fields(c: Canvas) -> None:
597578

598579
def class_triangle(c: Canvas) -> None:
599580
"""Classes · instance → class → type — every Python value sits on this triangle."""
600-
c.dot(20, 28)
601-
c.label(20, 54, "instance", anchor="middle")
602-
c.closed_arrow(26, 28, 86, 28, emphasis=False)
603-
c.frame(88, 10, 60, 36, label="class")
604-
c.mono(118, 32, "Class")
605-
c.closed_arrow(148, 28, 208, 28, emphasis=False)
606-
c.frame(210, 10, 60, 36, label="type")
607-
c.mono(240, 32, "type")
581+
c.type_triangle("type", "type")
608582

609583

610584
def exception_cause_context(c: Canvas) -> None:
@@ -729,17 +703,15 @@ def sort_stability(c: Canvas) -> None:
729703
def kw_only_separator(c: Canvas) -> None:
730704
"""Keyword-only arguments · `*` divides positional from keyword-only."""
731705
c.mono(0, 18, "def f(a, b, *, c, d): …", anchor="start")
732-
# JetBrains Mono advances ~6px per char at fs=10; '*' sits at index 12.
733-
c.dashed(75, 22, 75, 38)
706+
c.mono_divider(0, 12, 22, 38) # the '*' sits at index 12
734707
c.label(33, 50, "positional or kw", anchor="middle")
735708
c.label(120, 50, "keyword only", anchor="middle")
736709

737710

738711
def positional_only_separator(c: Canvas) -> None:
739712
"""Positional-only parameters · `/` divides positional-only from positional-or-kw."""
740713
c.mono(0, 18, "def f(a, b, /, c, d): …", anchor="start")
741-
# JetBrains Mono advances ~6px per char at fs=10; '/' sits at index 12.
742-
c.dashed(75, 22, 75, 38)
714+
c.mono_divider(0, 12, 22, 38) # the '/' sits at index 12
743715
c.label(33, 50, "positional only", anchor="middle")
744716
c.label(120, 50, "positional or kw", anchor="middle")
745717

@@ -899,14 +871,7 @@ def property_fork(c: Canvas) -> None:
899871

900872
def metaclass_triangle(c: Canvas) -> None:
901873
"""Metaclasses · instance → class → metaclass; the metaclass is the type of the class."""
902-
c.dot(20, 30)
903-
c.label(20, 56, "instance", anchor="middle")
904-
c.closed_arrow(26, 30, 86, 30, emphasis=False)
905-
c.frame(88, 12, 60, 36, label="class")
906-
c.mono(118, 34, "Class")
907-
c.closed_arrow(148, 30, 218, 30, emphasis=False)
908-
c.frame(220, 12, 80, 36, label="metaclass")
909-
c.mono(260, 34, "type")
874+
c.type_triangle("metaclass", "type", third_w=80)
910875

911876

912877
def sys_path_resolution(c: Canvas) -> None:

src/marginalia_grammar.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,73 @@
6565
SIZE_TAG = 8
6666
BASELINE = 4 # add to box-center y to render text vertically centered
6767

68+
# ─── Text metrics ──────────────────────────────────────────────────────
69+
# Single source of truth for character advance. Paint code uses
70+
# MONO_ADVANCE (the font's exact advance) to compute positions; the
71+
# geometry contracts use BBOX_ADVANCE (deliberately conservative
72+
# over-estimates) to detect clipping and collision. Keeping both here —
73+
# instead of one in a paint comment and one in the test file — means a
74+
# recalibration happens in one place and both consumers move together.
75+
MONO_ADVANCE = 0.6 # JetBrains Mono advances 600/1000 units per em.
76+
BBOX_ADVANCE = {
77+
"mono": 0.62, # JetBrains Mono / IBM Plex Mono
78+
"sans_upper": 0.65, # Source Sans Pro uppercase (tag font)
79+
"sans": 0.55, # Source Sans Pro mixed-case (label font)
80+
"serif": 0.52, # Iowan Old Style / Charter italic
81+
}
82+
83+
84+
def font_class(family: str) -> str:
85+
if "Mono" in family or "monospace" in family:
86+
return "mono"
87+
# "sans-serif" contains "serif" as a substring; check sans first
88+
# so the system-sans fallback string doesn't misclassify.
89+
if "sans" in family.lower():
90+
return "sans"
91+
if "serif" in family or "Iowan" in family or "Charter" in family:
92+
return "serif"
93+
return "sans"
94+
95+
96+
def text_width(content: str, family: str, size: float, tracking: float = 0.0) -> float:
97+
"""Conservative rendered width of a text run, for bbox math.
98+
99+
Upper-cased sans glyphs (the tag font: LOOP, INT, …) advance ~18%
100+
wider than mixed-case sans; differentiating the two keeps the
101+
contracts tight enough to catch real clips without over-flagging
102+
every mixed-case label that kisses a sibling rect.
103+
"""
104+
klass = font_class(family)
105+
if klass == "sans" and content == content.upper() and any(ch.isalpha() for ch in content):
106+
per_char = BBOX_ADVANCE["sans_upper"] * size
107+
else:
108+
per_char = BBOX_ADVANCE[klass] * size
109+
return (per_char + tracking) * len(content)
110+
68111

69112
@dataclass
70113
class Canvas:
71114
w: int = 320
72115
h: int = 110
73116
parts: list[str] = field(default_factory=list)
117+
# Semantic accent census. Every primitive that paints EMPHASIS
118+
# increments _accents (or sets _gates_painted); the scarcity
119+
# contract asserts accent_count() <= 1 from here instead of trying
120+
# to reverse-engineer marks from SVG output, where an arrow's
121+
# shaft+head pair and a standalone gate line are indistinguishable.
122+
_accents: int = 0
123+
_gates_painted: bool = False
124+
125+
def accent_count(self) -> int:
126+
"""Number of accent marks on the canvas, per the scarcity rule.
127+
128+
Gates are repeated structural punctuation — every pause point on
129+
a ribbon, in every ribbon of the figure — and read as one system,
130+
so all gates collectively count as a single accent. A gate set
131+
plus any focal accent (emphasis arrow, caret, dot, traced path)
132+
is still two marks competing for attention, and still fails.
133+
"""
134+
return self._accents + (1 if self._gates_painted else 0)
74135

75136
# ── tokens (private; cards should not reach for these) ────────────
76137
def _add(self, s: str) -> None:
@@ -98,6 +159,8 @@ def dashed(self, x1, y1, x2, y2):
98159
self._line(x1, y1, x2, y2, weight=W_HAIRLINE, dash=DASH)
99160

100161
def dot(self, x, y, *, emphasis=False):
162+
if emphasis:
163+
self._accents += 1
101164
self._add(f'<circle cx="{x}" cy="{y}" r="{DOT_R}" fill="{EMPHASIS if emphasis else INK}"/>')
102165

103166
def tick(self, x, y, *, length=TICK_LEN):
@@ -123,6 +186,8 @@ def closed_arrow(self, x1, y1, x2, y2, *, emphasis=False):
123186
per figure — the one mark the surrounding prose explicitly names.
124187
Saturated --accent strokes everywhere break visual scarcity.
125188
"""
189+
if emphasis:
190+
self._accents += 1
126191
color = EMPHASIS if emphasis else INK
127192
weight = W_EMPHASIS if emphasis else W_STROKE
128193
dx, dy = x2 - x1, y2 - y1
@@ -214,9 +279,24 @@ def caret(self, x, y_top, *, emphasis=True):
214279
prose only names one of them — the others paint in ink so the
215280
scarce-emphasis rule still holds.
216281
"""
282+
if emphasis:
283+
self._accents += 1
217284
fill = EMPHASIS if emphasis else INK
218285
self._add(f'<polygon points="{x},{y_top - 1} {x - 4},{y_top - 7} {x + 4},{y_top - 7}" fill="{fill}"/>')
219286

287+
def mono_divider(self, x_start, index, y_top, y_bot, *, size=SIZE_MONO):
288+
"""Dashed vertical centred on character `index` of a start-anchored
289+
mono string drawn at x_start.
290+
291+
The x is computed from the font's advance (MONO_ADVANCE), not
292+
eyeballed: hand-tuned positions drift, computed positions match
293+
the rendered glyph. Returns the computed x.
294+
"""
295+
advance = MONO_ADVANCE * size
296+
x = x_start + index * advance + advance / 2
297+
self.dashed(x, y_top, x, y_bot)
298+
return x
299+
220300
def register(self, x, y, w, *, divisions=None, between=False):
221301
"""Hairline with regular ticks."""
222302
self.hairline(x, y, x + w, y)
@@ -251,6 +331,7 @@ def frame(self, x, y, w, h, *, label=None, ghost=False):
251331

252332
def gate(self, x, y_top, y_bot):
253333
"""Vertical EMPHASIS line crossing a ribbon."""
334+
self._gates_painted = True
254335
self._line(x, y_top, x, y_bot, color=EMPHASIS, weight=W_EMPHASIS)
255336

256337
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):
281362
self.closed_arrow(nx + 2, ny, ox - 2, oy)
282363
return (x, y, nx + gap + object_w, y + OBJECT_H)
283364

365+
def two_names_one_object(self, x, y, tag_text, name_a, name_b, value, *, object_w=OBJECT_W):
366+
"""Two names bound to one shared object — the aliasing picture.
367+
368+
Twin panels and twin figures (aliasing-mutation,
369+
tuple-no-mutation) must keep this layout coordinate-identical;
370+
composing it as a phrase makes drift structurally impossible.
371+
y is the top of the first name box; the tag paints 6 above it.
372+
"""
373+
if tag_text:
374+
self.tag(x, y - 6, tag_text)
375+
self.name_box(x, y, name_a)
376+
self.name_box(x, y + 30, name_b)
377+
self.closed_arrow(x + NAME_W, y + 12, x + NAME_W + 26, y + 28, emphasis=False)
378+
self.closed_arrow(x + NAME_W, y + 42, x + NAME_W + 26, y + 28, emphasis=False)
379+
self.object_box(x + NAME_W + 28, y + 14, "", value, w=object_w, h=28)
380+
381+
def type_triangle(self, third_label, third_value, *, third_w=60):
382+
"""instance → class → <third> — the triangle shared by the
383+
class-triangle / metaclass-triangle twin figures. The shared
384+
coordinates live here so the twins cannot drift apart; only the
385+
third frame's label, value, and width vary.
386+
"""
387+
self.dot(20, 28)
388+
self.label(20, 54, "instance", anchor="middle")
389+
self.closed_arrow(26, 28, 86, 28, emphasis=False)
390+
self.frame(88, 10, 60, 36, label="class")
391+
self.mono(118, 32, "Class")
392+
self.closed_arrow(148, 28, 208, 28, emphasis=False)
393+
self.frame(210, 10, third_w, 36, label=third_label)
394+
self.mono(210 + third_w / 2, 32, third_value)
395+
284396
def dispatch(self, x, y, src, dst, *, src_w=70, dst_w=120):
285397
"""Source form → method form."""
286398
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):
322434
for y, lab in ys_labels:
323435
self.lane(y, x0=x0, x1=x1, label=lab)
324436
if path:
437+
# The traced path and its terminal dot are one mark: count
438+
# once here and paint the dot directly so dot() doesn't
439+
# count it a second time.
440+
self._accents += 1
325441
d = " ".join(("M" if i == 0 else "L") + f"{px},{py}" for i, (px, py) in enumerate(path))
326442
self._add(f'<path d="{d}" stroke="{EMPHASIS}" stroke-width="{W_EMPHASIS}" fill="none"/>')
327-
self.dot(path[-1][0], path[-1][1], emphasis=True)
443+
self._add(f'<circle cx="{path[-1][0]}" cy="{path[-1][1]}" r="{DOT_R}" fill="{EMPHASIS}"/>')
328444

329445
# ── render ────────────────────────────────────────────────────────
330446
# Figures render at INTRINSIC_SCALE × their viewBox dimensions. The
@@ -351,9 +467,14 @@ def to_svg(self) -> str:
351467
vb_h = self.h + pad_top + pad_bottom
352468
out_w = round(vb_w * self.INTRINSIC_SCALE)
353469
out_h = round(vb_h * self.INTRINSIC_SCALE)
470+
# aria-hidden: the figcaption is the canonical voice for every
471+
# figure; without it screen readers walk the SVG's internal
472+
# <text> fragments ("STR", "next()", …) out of context before
473+
# reaching the caption.
354474
return (
355475
f'<svg viewBox="-{pad_x} -{pad_top} {vb_w} {vb_h}" '
356476
f'width="{out_w}" height="{out_h}" '
477+
f'aria-hidden="true" focusable="false" '
357478
f'xmlns="http://www.w3.org/2000/svg">'
358479
+ "".join(self.parts)
359480
+ "</svg>"

0 commit comments

Comments
 (0)