6565SIZE_TAG = 8
6666BASELINE = 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
70113class 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