rust_widgets 2.8.1

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
# Changelog

The canonical project changelog is maintained at [docs/reports/CHANGELOG.md](docs/reports/CHANGELOG.md).

This root-level file exists for tools and release automation that expect `CHANGELOG.md` at repository root.
When the two disagree, this file is the one that ships; `tools/check_changelog_sync.sh` keeps them identical.

## 2.8.1 (2026-09-28) — Disabled-state contrast, a Cupertino collapse, and signature-pad fidelity

Backward compatible for every public signature. This release is **defects removed from paths that
were reachable but never measured**, plus the shared primitives that make the fix structural rather
than a per-control patch. Two of the three defects below were invisible to the rendering census,
because the census draws every control **enabled** — so each is pinned by a new pixel-level probe
rather than by a snapshot.

**New on `BaseWidget`:** `disabled_ink_on(ink, surface)` and `disabled_surface_near(surface, window)`.
They are deliberately two names and not one configurable weight: ink recedes by stepping **toward**
its surface's contrast colour, a fill by stepping **toward the page**. Applying the ink rule to a
fill does the opposite of what it says — measured on `masked_edit` at `8.16:1` disabled against
`1.95:1` enabled, i.e. the disabled state painted *more* prominently than the enabled one.

### 1. A disabled control could be less legible than an enabled one — and sometimes more prominent

The crate carried **three contradictory disabled mechanisms**, and the newest one's own documentation
already named the others as the defect it replaced:

| Mechanism | Behaviour | Light-appearance contrast |
|---|---|---|
| `Color::DISABLED_FOREGROUND` (`rgb(153,153,153)`) | appearance-blind | **2.48:1** |
| 20 local blend weights (`0.15`–`0.50`) | per-control | 1.41–3.88:1 |
| `dimensions::DISABLED_VEIL_ALPHA` (`0.55`) | relative to the surface | **4.57:1** |

`0.55` is not arbitrary: it is the smallest weight that clears the 4.5:1 body-text floor on **both**
appearances (`0.50` gives 3.88:1 on the light one).

Fixed, each measured on the painted picture:

| Control | Defect | Before | After |
|---|---|---|---|
| `collapsible_pane` | panel blended half-way toward its own ink | 1.03:1 / 1.65:1 | 5.03:1 / 4.58:1 |
| `app_bar` | fixed `DISABLED_FOREGROUND` for title, rule and action | 2.48:1 (light) | 4.57:1 |
| `navigation_drawer` | panel stepped toward ink | 1.13→**5.73** (more prominent) | 1.24→1.14 |
| `search_bar`, `masked_edit`, `search_box`, `tag_input` | field blended toward a fixed white | 1.95→**5.15** | 1.95→1.29 |
| `floating_label` | fixed `rgba(180,180,180)` | below the floor on light | derived from the field |
| `image_gallery` | disabled placeholder dimmed to a local `0.38` | 3.56:1 / 2.63:1 | 5.91:1 / 4.57:1 |

A further fourteen controls used the **inverted** shape (`ink.blend(&surface, w)` — stepping the ink
half-way to the surface it sits on, which is by construction the lowest-contrast point between them);
all now derive from the surface by the shared weight. `DISABLED_FOREGROUND` itself is unchanged, and
remains correct where it is used — a chart series or a progress arc is a graphic **on the window**
(measured 7.37–8.96:1), not text on a panel.

### 2. `cupertino_navigation_bar`'s large title collapsed in one frame

`large_title: bool` was a hard switch driving four things at once — bar height (`96`↔`44`), title
point size (`34`↔`18`), and the title's x and y. iOS's signature large-title collapse was a
one-frame swap. It is now a `collapse: PropertyDriver` that `draw` reads as a fraction, measured
`88 → 62 → 44` across frames, and settling at exactly `44` so a settled bar stays byte-identical to
the un-animated one.

### 3. `signature_pad` polygonised a fast stroke

`extend_stroke` accepted a point when it was far enough away **by distance alone**, which cannot tell
"moving slowly, enough points already" from "moving fast, far too few". A stroke drawn quickly was
sampled once per input event, so every segment was a long straight edge: the pad recorded where the
pointer *was*, never where it *went*. Time is now a second dimension (`min_point_interval_ms`, default
10 ms, both a schema property and a C capability), and a gap wider than the pad's own resolution is
filled by interpolation — so a fast stroke and a slow one land at the same fidelity. Measured
`5 events → 4 segments` before, `5 events → 136 segments` after.

Timestamp source: the **OS monotonic clock**. The event payloads carry position, pressure and tilt
but no time, and a drawing surface must not acquire a network dependency for a value the OS already
provides — a network timestamp answers a different question (*provenance*, "when was this signed"),
which is the caller's to attach.

### Evidence

Each fix was verified by **reverse injection** — reintroducing the defect and confirming the probe
goes red — and the probes assert the quantity that was promised rather than a proxy:

* `tests/disabled_text_contrast.rs` — disabled text clears 4.5:1 on **both** appearances, on real pixels.
* `tests/disabled_surface_probe.rs` — a disabled surface is never more prominent than the enabled one,
  and a control it cannot measure is asserted **unmeasured** rather than silently skipped.
* `tests/image_gallery_disabled_probe.rs`, `tests/image_gallery_accent_probe.rs`.
* `tests/signature_pad_smoothness_probe.rs`, `tests/camera_preview_chrome_probe.rs`.

`camera_preview`'s active-viewfinder overlay was a sixth instance of the same class: eight hard-coded
near-whites that measured **1.13–1.47:1** against the light stage the theme actually resolves, while
the census rendered only the inactive branch and so never saw it. The overlay ink is now derived from
the stage.

### The icon set grows to 68, and adding one is no longer a six-place edit

`IconName` shipped 31 tokens. It now ships **68**, covering the icons a UI actually reaches for and
was missing: the `chevron_*` family (the disclosure triangle on every menu, combo box and tree
node), `folder` / `file` / `save` / `copy` / `print`, `undo` / `redo` / `cut` / `paste` /
`attachment` / `link`, `success` / `help` / `block` / `schedule` / `hourglass`, the transport
controls (`play` / `pause` / `stop` / `skip_next` / `volume_up` / `volume_off`), `sort` /
`bar_chart` / `calendar` / `table`, and `chat` / `call` / `send` / `notifications_off`.

Two changes make that maintainable rather than merely larger:

* **The token set is declared once.** `tools/icon_tokens.txt` is the single source of truth;
  `tools/gen_icon_names.py` generates the whole `IconName` type from it (the enum, `as_str`,
  `from_name`, `ALL`, `all_tokens`, `data`). Adding an icon used to mean editing six hand-written
  places — the variant, two match arms, two arrays and three separate `31` length literals — where
  missing one is a bug the compiler cannot see. It is now a two-line edit plus a generator run.
  `tools/gen_icon_data.py` reads the same list, so the data and the enum cannot disagree.
* **A host can register its own.** `register_icon(name, paths)` (and `register_icon_on_grid` for a
  source on a 24-unit viewBox) adds an icon the crate does not ship; it draws through exactly the
  same path a bundled one does. The built-in set stays a closed, tested vocabulary — the registry is
  the open extension point beside it, not a widening of the enum.

### Notes

* No snapshot changed: every fix is on a **disabled** or **un-censused** path. The probes are the
  evidence, and `snapshots/svg/` is unchanged for all 188 controls.
* The `icon_sheet` snapshots gain the 37 new icons; `tools/icon_census.txt` is regenerated and
  asserts every token draws ink and no two draw one picture.
* `docs/plans/blue23.md` is complete and archived to `docs/plans/archive/blue23.md`; its six
  tool references, one source doc-link and one `Cargo.toml` reference were updated with it.

---

## 2.8.0 (2026-09-28) — Material Symbols icon data, a real SVG-path renderer, and a defect sweep across the theme and capability layers

Backward compatible for every public signature. The release is mostly **defects removed from paths
that were reachable but untested** — 37 planned items plus 10 found while fixing them — and one new
default. Where something was removed it had zero consumers, and each removal is listed.

**New, on by default:** `icons` bundles the Material Symbols outlines and is in `default`, so a plain
`cargo build` draws the real geometry. It is deliberately absent from every device profile (a sized
`mini`/`embedded` build should not inherit a payload the caller did not ask for), and a build without
the feature keeps the hand-drawn shapes exactly as before.

**New, default-on:** `event::key_codes` (named key codes), `TextMetrics::for_font` (the one line-box
derivation both backends now share), and `render::path::{parser, flatten}` (SVG `d` → curves →
polylines, reusing the glyph flattener).

**Removed (zero consumers, verified by grep and by a new gate):** `render/text_overflow.rs`,
`event/legacy_types.rs`, `GraphemeProcessor`, three unused queue types, and the `switch_on` snapshot
(byte-identical to `switch.svg` once animations were settled before export).

---

### 1. Every control can finally show its `:hover` / `:pressed` / `:disabled` state

`WidgetState`, `resolve_style_for_state` and `apply_active_theme` were all implemented and all
correct, and none of them was reachable: `set_enabled` / `set_hovered` / `set_pressed` wrote the flag
and stopped, so a theme author's `"button:hover"` was declared and carried nothing.

The fix is at the trait layer, once, for all 188 controls: a new
`Widget::set_state_theme_hook()` (object-safe, no `where Self: Sized`) that the three setters call, so
the state key is **re-resolved** whenever the state changes. It is called through `dyn Widget`, which
is why an object-safe hook was needed rather than a generic method: `Widget` is used as a trait object
in the routing table, so a `where Self: Sized` bound made the method uncallable from exactly the code
that had to call it.

Two controls overrode the setters and skipped the hook (`Button`, `CheckBox`); both now delegate to
the trait default, which is what makes "every control re-resolves" a property of the type rather than
a list someone maintains.

### 2. The theme's spacing reaches controls

`role_base_style` wrote `padding: Padding::all(theme.spacing.medium)` and
`margin: Margin::all(theme.spacing.small)`, and neither `merge_theme` nor `merge` copied either field
— so every themed control kept `Padding::all(0)` and the `touch_target` / spacing tokens were
write-only. This is a full-control geometry change and **moves control snapshots**
(`calendar`, `command_link`); both were reviewed line by line and regenerated.

### 3. Material Symbols icon data (`icons`, on by default)

`Icon` declared 31 names and drew 28 hand-written shapes, `Close` and `Cross` shared one method, and
the module docs admitted "visually distinct names do not always produce distinct output". There was
**no link between declaring an icon and having geometry for it**.

* `tools/vendor_material_symbols.py` vendors the per-icon SVG outlines at a **pinned commit SHA**
  (`bd8cb85b…`), with the upstream `LICENSE` copy and an `UPSTREAM_HAS_NO_NOTICE` probe.
* `tools/gen_icon_data.py` generates `src/widget/icon_data.rs` offline. It refuses to run without
  `--license=apache-2.0` (exit 2), and **refuses to emit two tokens with byte-identical outlines**, so
  the `Close == Cross` defect cannot return through an upstream rename.
* `IconName::data()` is **total** under the feature — it answers `IconData`, not `Option` — so a
  variant added without data is a compile error rather than a silent placeholder.
* `render::path::parser` reads the full SVG command set (`M/L/H/V/C/S/Q/T/A/Z` + relative forms),
  including the **bare-number repeat rule** and the **smooth-curve reflection** (`S`/`T`); arcs are
  converted to cubics at parse time (SVG F.6.5) so a `Subpath` only carries curves the flattener
  knows. `render::path::flatten` feeds them to the **existing** `raster.rs` subdivider rather than
  growing a second one.
* `tools/check_icon_data_is_opt_in.sh`, `tools/check_icon_licences.sh` and
  `tests/icon_census_test.rs` assert the gate, the Apache-2.0 chain, and that no two icons draw one
  picture.

### 4. `ROW_ROWS`, and other generated tables that were their own transcription

`event_payloads.rs` shipped a 332-entry `ROW_ROWS` table that was literally `[0, 1, 2, …, 331]`, on
the stated belief that "a macro expansion cannot do arithmetic on a `static`". That belief is false —
a `const fn` indexing a `static` compiles — so the table was a second copy of `0..n` that had to be
regenerated in lockstep and could only ever be wrong. It is gone, and the generator that emitted it
was changed too (a first attempt that edited only the output was reverted by the next `--check`).

### 5. Named key codes

19 files compared `Event::KeyPress.key` against the literals `13`, `27`, `8`, `37`, `40`. Those
numbers say nothing about what they mean and a typo (`37` for `47`) is not a compile error.
`event::key_codes` names them, and a test asserts each constant equals what
`shortcut::Key::from_key_code` maps to the matching variant — so the two tables cannot drift.

### 6. The two text backends agreed about nothing, then agreed about the line box

The software surface and the SVG backend each derived the line-box triple inline, and they had
drifted: the SVG backend applied a `.max(1.0)` to the font's leading that the software surface did
not. Because the ascent is `leading * 0.8`, a small leading made them place the **baseline**
differently while agreeing on the height — a control sized against one backend rendered a line
off-centre in the other. `TextMetrics::for_font` is now the single derivation, and a test exercises
both backends across the divergence case.

### 7. Honest capability reporting

`platform/types.rs` inferred `native_menu` from `family()` and hard-coded `low_memory_mode: true`, so
a backend that forgot to override reported a capability it did not have. Both now default to the
honest "absent" (`false` / `None`) and a backend must state what it supports.

### 8. Disabled icons no longer lose the caller's colour

`Icon::draw` pinned a grey for the disabled appearance and then restored `self.color = None` — not
the value it saved. A `set_color(red)` icon therefore **permanently** lost red the first time it was
painted disabled. A draw path may not write a control's resolved state.

### 9. Snapshot export no longer photographs mid-animation

`sample_fill` turns feature states on, which starts a transition; the exporter drew the very next
frame, so `switch.svg` was a blend of the off and on endpoint colours (`travel ≈ 0.10`) — a frame no
user rests in. The exporter now settles a transition before drawing, and draws a **perpetual**
animation (a spinner, a skeleton shimmer) at a fixed phase so its snapshot stays reproducible.

### 10. `data-loss`-class fixes in the image path

`ImageData::to_rgba8` and `decode_animation_codec` used `unreachable!()` to express "the guard above
makes this impossible". Both are now written out: the first returns the idempotent clone, the second
returns the same error its caller would have, so the guarantee lives in one place instead of a
`match` plus a panic that a later edit could turn into a crash on a draw path.

### Gates added or repaired this release

| gate | what it now prevents |
|---|---|
| `check_semantic_state_has_a_consumer.sh` | a declared `:error` key no control reads |
| `check_alias_tables_agree.sh` | `alias_factory_name` and `alias_for_name` drifting |
| `check_icon_data_is_opt_in.sh` | the icon payload entering `default` or a device profile |
| `check_icon_licences.sh` | an icon shipping without its licence copy, header or NOTICE section |
| `tests/icon_census_test.rs` | two icon names drawing one picture; a stale census |
| `check_implicit_size_uses_metrics.sh` **(repaired)** | was reporting 101 findings because 156 of its 226 `path:line` exemptions had gone stale; re-keyed on the owning type |

Nine dead `:error` theme keys were deleted, and `line_edit:error` — the only one with a consumer —
stays.

---

## 2.7.0 (2026-09-24) — From Correct Geometry to Live State: a State Channel, an Animation Bus, and a Layering Language

Backward compatible: no public signature was removed or changed. `Widget` gained three defaulted
methods (`widget_state` strengthened, `tick`, `is_animating`), `BaseWidget` gained four interaction
fields with accessors, `Colors`'s presets gained state overrides, `View` gained a defaulted
`build_with`, `Node` gained `host`/`on_mount`/`on_unmount`, and `ProgressBar` gained an indeterminate
state. One speculative API (`children_if`) was removed because it had no consumer.

---

### 1. The state channel: every control can now say what it is

`WidgetState` (12 variants), `resolve_style_for_state`, and `apply_active_theme` were all implemented
and all correct — and `fn widget_state` had exactly **one** implementation (the trait default), so a
theme author's `"button:hover"` was unreachable. One control reported hover; the other 187 reported
`Normal` forever.

The four facts every control shares now live on `BaseWidget`, recorded from the primitive input events
the runtime already routes:

| field | who writes it |
|---|---|
| `hovered` | `MouseEnter` / `MouseLeave` (synthesised by the runtime) |
| `pressed` | `MousePress` (inside, enabled) / `MouseMove` / `MouseRelease` |
| `grabbed` | the same, but `MouseMove` does not clear it |
| `focus_reason` | `FocusGained { reason }` / `FocusLost` |

`widget_state()`'s default now answers disabled > pressed > hovered > focused > resting, so a control
that overrides **nothing** gets the whole state channel. `StateOverlay` carries the states that hold at
once (a focused *and* hovered control) as a separate, non-transitioned draw layer.

The presets ship **26 state overrides** each (`:hover` 0.08 / `:pressed` 0.12 toward the ink,
`:disabled`, `:checked`, and `:error` — which gives the previously unused `error` role its first
consumer). Resting appearance is byte-identical: 376 snapshots unchanged.

### 2. The animation bus: one frame driver, and a still window costs nothing

Eleven controls implemented the same `tick(delta_ms) -> bool`; **not one production caller existed**.
The engine was written, tested, and unreachable.

`Widget::tick`/`is_animating` lift the contract onto the trait (defaulted, so 180 controls needed no
change), and `runtime::tick_animations` is the crate's **only** frame driver: it advances whatever
answers `true` to its own `is_animating()`, and returns `false` once nothing is moving — so a static
frame pays nothing. `has_animating_widgets()` lets a host decide before paying for a sweep.

`ProgressBar` gained an indeterminate sweep (a looping phase, a third-of-the-run band, no fabricated
percentage).

### 3. The layering language: seven roles, all consumed

`scrim`, `surface_container`, `surface_container_high`, `inverse_surface`/`on_inverse_surface` and
`outline_variant` all sat in the presets with at most one consumer each. `style::LayerColor` +
`layer_color()` is now the single resolution point, and each role has its consumer: modal scrims read
`Scrim`, a panel's face reads `SurfaceContainer`, a tooltip reads `InverseSurface`, and table grid lines
read `outline_variant` (visibly weaker than the focus ring's `outline`).

### 4. The declarative layer gained the dimensions it was missing

* **`portal`** — `Node::portal()` marks a node created into an engine-owned **overlay layer** while
  keeping its declared identity. A menu is a child in the tree and an overlay on screen; a pure tree
  could not express both.
* **Lifecycle** — `Node::on_mount` / `on_unmount` are *carried* and run by the engine after the patch
  batch lands, never during `build`, which keeps `build` a pure function of state.
* **Context** — `view::Context` resolves into concrete node properties at build time, so `diff` still
  compares value-settled trees and the context never becomes a comparison key.
* **Error boundary** — a node that cannot be created no longer costs the whole window: its siblings
  still mount, `ViewError` names it (`widget#key`, plus the ancestor chain), and `ApplyReport` exposes
  the failure set and ready-made placeholders. "A node cannot be expressed" no longer escalates to
  "everything disappears".
* **`children_if` removed** — it had no consumer outside its own test; a plain `if` covers the case.

### 5. Gates

Four new gates, each with a working reverse injection:

| gate | the defect it makes unrepresentable |
|---|---|
| `check_state_source_is_the_base` | a second, drifting source of hover/press/focus |
| `check_animation_has_a_driver` | an animation the bus cannot advance |
| `check_declared_tokens_have_consumers` | a colour role nothing reads |
| `check_lifecycle_hooks_are_not_build_time` | a side effect fired while a tree is described |

### 6. Text coverage is now stated

The crate draws **Latin/ASCII only** by default (an 8x8 bitmap face, no font data). This was always
true and never documented; `lib.rs` and both READMEs now say so, and a test pins that a CJK character
takes the fallback glyph — so the docs cannot silently overstate what is drawn.

### 7. The text layer: glyphs became data, and the line became Unicode-correct

The crate answered "what does this character look like?" in one place — an accessor returning
`[u8; 8]` straight from an 8x8 table. That return type was the defect: it hard-codes the cell size,
so a CJK glyph (16x16) cannot be expressed and a second face cannot be added without editing every
renderer. **Three** renderers had grown their own copy of the loop, and the GPU path even answered
`'?'` where the other two answered the box glyph — three ideas of "unsupported" that no test could
compare.

Glyphs now come from a **source**, chosen by a **stack**, resolved once:

| piece | what it is |
|---|---|
| `render::text::GlyphSource` | "do you have this character, and what are its pixels?" — 1-bit bitmap or 16x16 CJK |
| `render::text::FontStack` | "among the faces I have, which wins?" — first hit, in order |
| `render::text::for_each_cluster` | the crate's one grapheme-clustering rule |
| `render::text::estimate_cluster_advance` | the crate's one advance model |
| `render::text::bidi` | UAX #9 ordering, applied to the permutation of whole clusters |

**Coverage is opt-in data, and the default build is byte-identical.** `fonts-cjk-bitmap` adds a
generated 16x16 CJK face (2 361 glyphs, 84 996 bytes of generated array) whose data is read on
demand and never resident. Latin output does not move: a face is *appended* to the stack, and the
default stack has exactly one entry. All 376 SVG snapshots are byte-for-byte unchanged, before and
after.

**Bidirectional text is now ordered correctly** in every profile, including `mini`: an Arabic or
Hebrew run draws right to left. Reordering is applied to the cluster permutation, never to characters
— a grapheme is the unit that must not be split.

**Real shaping, from an opt-in face.** `RustybuzzShaper` reads a face's `GSUB`/`GPOS` when one of the
`fonts-*` features supplies it; the renderer honours `Font::family`, so enabling a face never changes
a label's metrics behind the caller's back. Two faces ship as generated subsets, both OFL, both
recorded in `NOTICE` with their upstream digests: a Latin face (35 896 bytes) and an Arabic face
(70 576 bytes) whose joining features turn `بيت` into the word rather than three isolated letters.

Six new gates, each with a working reverse injection:

| gate | the defect it makes unrepresentable |
|---|---|
| `check_font_data_is_opt_in` | a profile that grows a binary by enabling font data |
| `check_glyph_source_is_the_only_glyph_path` | a fourth renderer reaching a face table directly |
| `check_font_licenses` | third-party glyphs shipped without a licence record |
| `check_generated_font_table_integrity` | a table whose binary search cannot find its own glyphs |
| `check_text_model_is_single_sourced` | a second clustering loop or advance model |
| `check_text_coverage_claim_matches_features` | a coverage claim that no longer matches the features |

The second gate earned its keep immediately: it found `src/wgpu_backend/raster.rs` reading
`BASIC_FONTS` directly, the third glyph path, and the one place `'?'` was still substituted for an
uncovered character.

Still not implemented, and stated rather than implied: **antialiased outline rasterisation** and
**colour emoji**. Both need a glyph representation that can hold per-pixel coverage (which a
`&'static` 1-bit row array cannot) and an outline path for the SVG backend, so they are a redesign of
the layer's allocation contract rather than an increment on it.

---

## 2.6.1 (2026-09-23) — Controls Stop Being Their Container: a Metrics System, a Size Channel, and a Real Click Contract

Backward compatible: no public signature was removed or changed. `FocusGained` gained a payload
field, `Widget` gained two defaulted methods, `Layout` gained a defaulted method, `Colors` gained
seven roles with serde defaults, and `WidgetStyle` gained one defaulted field. The rendering changes
are geometric — a control's chrome is now its own size instead of a scaling of the rectangle it was
given.

---

### 1. The root cause: a control treated its `rect` as a drawing instruction

Every control is rendered into a rectangle by whatever placed it — a layout, a designer, a census
cell. That rectangle is the area it was **given**. The question "how big should I draw?" is a
different question, and conflating the two is why `switch.svg` was a 240x120 stadium, `radio_button`
drew `r=30`, and `progress_bar` was a solid slab:

```text
CENSUS_RECT = 240x120        // a roomy cell, so a multi-part control has room for all its parts
switch.svg   = rect 0,0,240,120 rx=60      // the control IS the cell
```

A common toolkit answers the second question with one formula, and the load-bearing term is the `max`:

```text
implicitWidth = max(implicitBackgroundWidth + leftInset + rightInset,
                    implicitContentWidth   + leftPadding + rightPadding)
```

The **background is a minimum tappable floor**, not decoration — which is why a button labelled with
5 px text is still 100x40 rather than 100x18.

`src/widget/metrics.rs` is that formula as Rust value types, plus the numeric half of the same
system: **one** `dimensions` table for every control dimension (track sizes, thumb radii, field
heights, toolbar heights, paddings, spacings, font base, density step), so "how thick is a progress
bar" has exactly one answer in the crate.

The helpers that follow from it:

| helper | the question it answers |
|---|---|
| `implicit_size(content, padding, floor)` | how big should I be? |
| `content_box(rect, padding)` | where may my content go? |
| `center_in` / `centered_square` / `centered_disc` | centre my fixed chrome, clamped never expanded |
| `centered_band` / `full_width_band` | full width, *my* height, vertically centred |
| `top_band` / `bottom_band` / `band_inset` | pin a bar to an edge and let content follow |
| `leading_box` | a fixed-size indicator at my leading edge |
| `painted_box` | a panel, at most my intrinsic size, never zero-extent |
| `FocusRing::for_control` + `focus_ring_color` | the keyboard focus ring, inset so it never overlaps a neighbour |

**174 controls** now derive their drawn box from this system instead of from `rect` directly.

### 2. The second root cause: a layout could not ask a child how big it wanted to be

`Layout::update` was `fn update(&self, rect, &mut dyn FnMut(ObjectId, Rect))` — it could **write**
child geometry but not **read** a child's wish, because it held `ObjectId`s and not widgets. The
crate's workaround made that concrete:

```rust
/// Size hints indexed by position within items (set before update).
pub fn set_child_sizes(&mut self, sizes: Vec<Size>);   // "call before update for proper sizing"
```

The caller had to work out every child's size and hand it to the layout *before* asking the layout to
lay anything out. `Wrap` and `Absolute` carried their own variants; `Flow` bypassed the protocol
entirely by storing `Box<dyn Widget>` itself.

`src/layout/hints.rs` opens that channel:

```rust
pub struct AxisHints { pub min: u32, pub pref: u32, pub max: u32 }   // normalised on construction
pub struct Hints     { pub width: AxisHints, pub height: AxisHints }
pub struct LayoutParams { pub fill: bool, pub stretch: u32, pub margins: EdgeOffsets }
pub struct ChildInfo { pub id: ObjectId, pub hints: Hints, pub params: LayoutParams }
```

Three decisions worth naming:

- **Three values per axis, not four opposite-axis functions.** A parameterised
  `getMinIntrinsicWidth(height)` makes the answer depend on the other axis, and the toolkit that
  ships it describes its own wrapper as "a speculative layout pass" that is "O(N²) in the depth of
  the tree". `min`/`pref`/`max` are computed once by the control from its own content; a layout
  reads them directly. What this gives up is width-for-height coupling, which no control here needs.
- **`min` is the value this crate was missing.** A floor ("you may squeeze me to 64x40 but no
  further") is what makes a control stay tappable, and a single `size_hint` cannot express it without
  a wrapper.
- **`fill` is declared separately from size.** A slider's `pref` and a button's `pref` can be the
  same number, yet the slider should be stretched across a form and the button should not. Size
  cannot distinguish them, so the flag is its own bit — a layout's `fillWidth`.

`Layout::arrange(rect, &[ChildInfo], out)` is the read-write entry point, and its **default
implementation forwards to `update`**, so the fifteen existing layouts keep working untouched and can
adopt hints one at a time. `FlexLayout` implements it, which is what proves the channel is real: it
lays children out from their own hints with no `set_child_sizes` call, and produces byte-identical
geometry to the legacy path given the same sizes.

`Widget::hints()` defaults to a **faithful translation** of the existing `size_hint` (the reported
size becomes both the preferred value and the floor, with no ceiling). Unconstrained-by-default
would have *loosened* every existing layout the moment a caller adopted the channel; this way the
migration is invisible until a control opts into a real floor or ceiling.

### 3. `released` was treated as `clicked`, and neither existed for the keyboard

`Button` emitted `clicked` on **any** release the host routed to it — including a drag that began
outside and merely ended on top, and including a secondary-button click, which is how a host opens a
context menu. There was no `canceled` signal, so a caller routing a destructive action could not tell
a completed activation from an abandoned one, and `MouseLeave` cleared only the hover flag while
leaving the press latch armed for an unrelated later release.

The four-segment contract (`handlePress`/`handleMove`/`handleRelease`/`handleUngrab`):

| event | result |
|---|---|
| press (primary, enabled, inside) | take the grab, `pressed = true`, emit `pressed` |
| move | `pressed` follows whether the pointer is still inside |
| release inside | `pressed = false`, emit `released` + `clicked` |
| release outside | `pressed = false`, emit `released` + **`canceled`**, **no** `clicked` |
| ungrab (focus loss / disable) | clear the grab, emit `canceled` |

Three things this required that a naive version gets wrong:

- **`pressed` and the grab are different bits.** `pressed` answers "should I paint pressed" and
  follows the pointer; the grab answers "is this gesture mine" and lasts from press to release.
  Folding them into one flag made the move arm that restores `pressed` unreachable — caught by a test
  asserting that dragging off and back re-arms the button.
- **`MouseLeave` clears `pressed` but keeps the grab**, because it fires the instant the pointer
  crosses the edge and a drag that returns should still be able to complete. The **release's landing
  point** decides the outcome.
- **`cancel_gesture()` is one place.** Focus loss, disabling and (in future) an explicit ungrab all
  route through it, so they cannot drift into three slightly different notions of cancellation.

`fab` had the identical defect one file over — the same "fixed the instance, missed the family" shape
`tools/check_click_requires_release_inside.sh` now catches.

### 4. A focus ring that appears when the user is on the keyboard, and not when they are not

"This widget has focus" and "the user is navigating with the keyboard" are different facts, and only
the second should draw a ring. The rule is
`visualFocus = activeFocus && (reason == Tab | Backtab | Shortcut)`.

`Event::FocusGained` gained a `reason: FocusReason` payload (`Pointer` / `Tab` / `BackTab` /
`Shortcut` / `Programmatic`) and `FocusReason::draws_focus_ring()` is the single predicate controls
read. The reason travels **with the event** rather than being queryable from a shared focus manager,
because a control must decide while handling the event and a draw pass happens later still — a query
would answer about whatever move happened most recently, not the one this control is handling.

`Button`, `CheckBox`, `RadioButton` and `Switch` now track focus, hover and the focus ring; the ring
is drawn **inside** the control's rectangle (nothing clips a child at this layer, so a ring outside
would overlap whatever the layout placed next to it) and follows the control's own corner radius.

The ring colour comes from the theme's new `outline` role rather than from `foreground`, because a
focused control used to look identical to a merely bordered one.

### 5. A theme palette that can grow

`Colors` had eleven roles against the forty-odd a full Material-derived palette carries and the
twenty-one a stock control set uses. Adding one was expensive for a mechanical reason: there was no
`Default for Colors`, so every `Colors { .. }` literal in the crate had to be found and extended.
Seven roles are added, all with serde defaults so
an older theme file still loads, and `impl Default for Colors` makes the next one a local change:

| role | the defect it removes |
|---|---|
| `outline` | a focus ring and a divider were the same line |
| `outline_variant` | no weaker secondary separator existed |
| `scrim` | a modal dimmed by *darkening*, which does nothing on a dark theme |
| `surface_container` / `surface_container_high` | cards and panels had nothing to step to |
| `inverse_surface` / `on_inverse_surface` | an inverting surface and the ink legible on it |

The two presets now use struct-update syntax (`..Colors::default()`), so a preset names only the
roles it deliberately overrides.

### 6. The controls that were drawing as their container

Each of these was verified by exporting the SVG and reading it, and each row is a fixed size now
rather than a scaling of the 240x120 cell:

| control | was | is |
|---|---|---|
| `button`, `toggle_button` | `rect 240x120` | `rect 0,40,240,40` |
| `label` | text at `y=0` | text at `y=53` (vertically centred) |
| `switch` | — | 52x32 track, 28 px thumb, both centred |
| `check_box` | — | 18x18 box, `r=2`, 2 px stroke |
| `radio_button` | — | `r=8` ring, 2 px stroke; the dot took the *surface* colour, so checked and unchecked were identical |
| `progress_bar` | — | 4 px centred band; a 0-value bar emitted a **zero-width** fill element |
| `slider` | 16x120 thumb slab | 20 px disc on a 4 px track |
| `scroll_bar` | 24x120 thumb | 8 px trough, 48 px minimum thumb |
| `line_edit` | `rect 240x120` | `rect 0,36,240,48` |
| `otp_input` | six 36x120 columns | six 36x48 cells, ending exactly at x=240 |
| `tool_bar` | `rect 240x120` | `rect 0,32,240,56` |
| `menu_bar` | `rect 240x120` | `rect 0,0,240,28` |
| `tooltip` | full-canvas bubble, text at `y=6` | 24 px bubble, text vertically centred |
| `pagination` | 48 px glyphs in a 120 px column | 32 px row, glyphs sized from the band |
| `avatar` | `r=60`, half-clipped at the edge | 40 px disc, centred |
| `spinner`, `rating`, `badge`, `roller`, `chip` | stretched | fixed chrome, centred |
| every `dialog` | full-canvas frame, literal text `y` | `painted_box` (at most intrinsic, centred, rounded), text on its own band |
| `table_widget`, `list_view`, `tree_view`, `data_grid`, `property_grid`, `properties_panel` | first row at `y=0`, text half a line low | inset header row, text on its band |
| `meter` | gauge hard-left at x=4, reading below the arc | figure centred, reading under the arc |
| `heatmap`, `chart`, `pie_chart`, `sparkline`, `arc` | labels at or past the canvas edge | every label constrained inside the band it labels |

### 7. Defects that only became visible once the geometry was forced to agree

- **`scroll_bar`'s two directions were not inverses.** `value_to_pixel_pos` subtracted the arrow
  cells; `pixel_pos_to_value` did not, so value 0 at x=24 read back as 111. This is the *same* defect
  class as the `slider` round-trip fixed in 2.6.0 — the earlier lesson was not scanned for siblings,
  which is why both now share one inset and carry a round-trip test.
- **`tag_input`'s chip close buttons were unclickable** — their hit circles sat 36 px above the chips.
- **Four input controls could be focused by clicking below them**, because the hit test used
  `geometry()` while the ink used the painted band.
- **`otp_input`'s last cell ran to x=241** in a 240 px row: `index * slack / boxes` truncates,
  overspending the trailing half.
- **`menu` never reserved its heading** when clamping an open position, and charged a bare separator
  the full item height while painting it a rule's height, so its reported height disagreed with its
  ink by 16 px.
- **`masked_edit::set_text` discarded its argument when no mask was set.** The loop iterated the
  mask's segments, which is empty without a mask, while `set_mask` documents "if the mask is empty,
  all input is accepted". The control painted nothing, and the legibility test passed anyway because
  an **empty `<text>` element still has a `fill`** — a test asserting on something that should not
  have existed.
- **`fab` and `floating_label`** carried the same release contract and hardcoded-duration defects as
  their better-tested siblings.
- **`mini` and `embedded` did not compile.** `Transition` and `Button::tick` named `crate::theme`
  directly, which is behind `#[cfg(device_profile)]`. Motion tokens are now read through the
  `crate::style` facade.

### 8. Gates

**Five** new source gates, each proven failable by injection and each carrying an exemption table
whose entries name a mechanism and a reason (a bare path is not an entry):

- `tools/check_click_requires_release_inside.sh` — a control that emits `clicked` and handles
  `MouseRelease` must test containment on the release, abandon the press on `MouseLeave`, or be
  listed with the mechanism its containment rests on. It found `fab` and two verified defects in
  `radar_chart` and `property_grid`.
- `tools/check_transition_durations_are_tokens.sh` — an interaction transition must be priced from
  `theme.motion`, not a literal. It found `floating_label`.
- `tools/check_implicit_size_uses_metrics.sh` — a `size_hint` must come from the metric system.
  Writing it found four controls that had made a private copy of "how big am I?": `checkbox` and
  `radiobutton` both wrote `text.len() * 8 + 24` (with a comment naming `INDICATOR_SIZE`, a constant
  living elsewhere, as a literal), `label` wrote `text.len() * 8 + 4`, and `button` wrote
  `self.text().len() as u32 * 8` with the words "the crate's usual `len * 8`" beside it. All four
  now read `ControlMetrics`, through two new pure primitives:

  ```rust
  // one cluster's advance under the renderer's own model -- 0.6 em narrow, 1.0 em wide,
  // 0.33 em blank, tracking paid on the n-1 gaps -- spelled as a pure function so a control
  // with no RenderContext can measure honestly.
  pub fn estimate_text_width(text: &str, font: &Font, scale: f32) -> u32
  pub fn estimate_line_height(font: &Font, scale: f32) -> u32
  ```

  Its exemption table is a **158-entry debt list**, and says so in its header: unlike the other two
  tables (one or two permanent entries each), this one records a backlog and shrinks as controls
  are converted. Keyed on `path:line`, so an entry cannot outlive the code it records.
- `tools/check_spacing_is_not_sibling_layout.sh` — `Style::spacing` means one thing (the gap from a
  control's **own** indicator to its **own** text); the gap between two siblings belongs to the
  layout. A control that reads the field without a `label_gap` accessor has put one number into two
  roles, and a theme can no longer change either.
- `tools/check_focus_ring_respects_reason.sh` — a ring painted from a `focused: bool` appears under
  the cursor on a click, because the pointer is the one reason that must not draw one. The gate
  requires the shared predicate (or the file's own classification of the reason) **and asserts it
  found at least one construction**, so it cannot pass vacuously.

All 376 snapshots were regenerated. **No snapshot contains a zero-width/zero-height element or an
empty text element** — both classes are now at zero, down from three and six.

---

### 9. Both backends now resolve `HorizontalAlignment` the same way

`RenderCommand::DrawText` carries a `HorizontalAlignment`, and `RenderContext::draw_text`'s
contract is that `origin` is that alignment's **anchor**. The software rasteriser implemented it
(shifting the pen before the first glyph); the **SVG backend dropped the field**. Every centred or
right-aligned label in the crate was therefore left-aligned in SVG output while appearing centred
on the raster — a wizard's `Cancel`/`Back`/`Finish` all started at their button's left edge in the
committed snapshots.

The fix resolves the alignment in the backend, using the same measurement as the rasteriser
(`estimate_cluster_advance`), so both compute one absolute `x` from one rule. A `text-anchor`
attribute was rejected: it would move the resolution into SVG's layout engine while the raster keeps
resolving in Rust, i.e. two implementations of one rule.

### 10. A composite is assembled by a layout, and the layout's own defects surfaced

`src/widget/composite.rs` adds the declare → assemble → apply chain the code path was missing while
the JSON path had it all: `CompositeBuilder` creates children through the registry, **reads each
child's own `hints()`**, hands them to `Layout::arrange`, and applies the rectangles that come back.
It contains no layout algorithm — no direction, no wrap rule, no alignment. `ActionRow` is the
§B.7 `dialog_with_actions` template on top of it, whose right-alignment is expressed as
`justify_content = FlexEnd` rather than as arithmetic.

Routing real composites through `arrange` exposed **three defects in `FlexLayout`** that are
invisible on paper and affect every layout that uses the channel:

- the leftover room was dumped on the last child, so `justify_content` could never see any —
  `FlexEnd` was unreachable and a fixed-width row came back flush left;
- the shrink branch applied each child's `min_size` and then a "cap at available" pass that cut
  straight through it, down to zero — two 100 px buttons in a 120 px band came back 57 px each;
- `consumed` added each child's leading margin on top of a size that already included it, so every
  gap was counted twice and a 240 px band's 20 px of spare room read as 8.

A fourth, narrower one: a child's floor is expressed in its own box while the solver works in outer
sizes, so a 64 px floor with a 6 px margin was laid out at an outer 64 and drawn 58 wide. The floor
now carries the margins, and the floor is honoured by **overhanging** rather than by shrinking —
CSS flexbox's `min-width: auto` and a control class's `implicitMinimumWidth` make the same choice,
because a button narrower than its label is a button whose label elides.

`min` and `fill` are separate declarations and both now survive the hand-off: `fill` is a
zero-weight request to absorb the leftover, so it is translated into a grow weight rather than
being indistinguishable from "no parameters at all".

### 11. Eleven observable defects fixed, each with a test that fails when reverted

Found by scanning for the *pattern* after the reported ones, not by fixing only what was reported:

| # | Defect | Effect |
|---|---|---|
| 1 | SVG backend dropped `HorizontalAlignment` | every centred/right-aligned label, crate-wide |
| 2 | `Button` treated `BUTTON_PADDING_H` as a left anchor | label not horizontally centred |
| 3 | `Calendar` drew day numbers at the cell's top-left plus 3 px | numbers and grid visibly disagreed |
| 4 | `Calendar`'s weekday headings spanned to the grid's right edge | based on a false "1 em per character" premise; `Mon` is 20 px, not 36 |
| 5 | `EmptyState`'s icon box overlapped its title by 28 px | caption painted across the glyph |
| 6 | `Keyboard` key caps top-anchored (comment claimed otherwise) | 124 keys, each half a line high |
| 7 | `Gantt`/`Timeline` row labels, same | every task name |
| 8 | `draw_arc_segments` used a fixed 40 samples | zero-length lines; a 115 px arc collapsed to 3 distinct pixels |
| 9–11 | the three `FlexLayout` defects above | every layout on the hint channel |

### 12. `control.md` — every control's two snapshots, grouped and gated

The repository root now carries a generated gallery: 188 controls in 17 families, each with its dark
and light snapshot. It is produced by `tools/generate_control_index.py` from the registry and the
source tree — a *view* of the snapshots, not a second copy of them — and `check_svg_snapshots.sh`
gained a step that regenerates it and requires a byte-identical match.

That step's first version was **vacuous**: it ran the generator (which rewrites `control.md` in
place) and then compared, so a hand-edit was erased before the comparison and the gate passed.
Injection proved it. It now preserves the committed copy first, and the injection fails as it
should.

---

## 2.6.0 (2026-09-22) — Every Label Sits Where It Belongs, and the Controls That Were Invisible Are Visible

Backward compatible: no public signature was removed. The additions are one rendering primitive
(`text_line`), one new source-level gate, one property on `floating_label` and one on `tab_widget`,
plus corrections inside existing controls.

---

### 1. The largest single class of visual defect this crate had: 76 labels drawn half a line low

`RenderContext`'s text origin is the glyph box's **top-left** edge, not its baseline. So the
expression every author reaches for when they mean "centre this label in its band":

```rust
y = band.y + band.height / 2;          // ← puts the box's TOP edge on the middle line
```

does the opposite of centring. Centring is `band.y + (band.height - line_height) / 2`, which needs
the renderer to have *measured* the line.

That one shape appeared **76 times across 40-odd files**: buttons, checkboxes, radio buttons, combo
boxes, date/time editors, status bars, banners, ratings, tool buttons, six dialog button rows,
`mdi_area` window titles, the properties panel, five data tables, the menu bar, the toolbar, tab bands,
keyboard key caps, tag input, popovers, toasts, chips, breadcrumbs, segmented controls, split buttons,
ribbon bars, dropdown menus and the chart empty state.

Every one of them passed every gate that existed. It is valid Rust, it compiles, the ink stays inside
the control, and the SVG is well-formed. `tools/audit_text_y.py` could *see* the result but could not
prove intent — it infers the band from neighbouring rectangles, which is why it is documented as an
audit aid rather than a check.

**The fix is one primitive plus a mechanical migration.** `RenderContext::text_line(band, font)`
returns the line box a single line occupies, centred in `band`; `draw_text_line` is the one-call form.
Both are pure additions, so the ~440 existing text call sites kept their behaviour until each was
migrated deliberately. The audit's suspicious placements fell from **38 to 6**, and the six remaining
were each examined and recorded as false positives (a decorative icon bottom-aligned in its own band,
and three controls — `label`, `ime_preedit`, `swipe_to_dismiss` — that paint only text and so have no
band of their own to centre in).

**`tools/check_text_vertically_centred.py`** now makes the class unrepresentable. It asks the exact,
source-local question — is a text origin derived by halving a height that is not the measured line
height? — and it was **reverse-injected** to prove it fails: reintroducing the shape in `chip.rs`
produced `failed: 1`, and it returned to `failed: 0` when reverted. It is wired into CI beside its
sibling `check_text_origin_is_a_top_edge.sh`, which guards the same contract from the other side.

### 2. Eight controls were invisible, and the gate that should have caught them was excused

Each of these was drawn, and the user could not see it:

| Control | Defect |
|---|---|
| `badge` | `style.background_color.or(themed_bg).unwrap_or(level.color())` — the middle arm was never `None` (the theme resolves *nowhere-to-go* as the window fill), so every severity colour was unreachable and the pill was filled with the window's own colour |
| `scroll_bar` | the thumb and the trough read the **same** field, so the two rectangles were byte-identical |
| `switch` | the ON state could never be green — the theme always writes `background_color` for this role, so the accent arm was dead |
| `drop_zone` | no visible well in the idle state |
| `signature_pad` | the pad's face *was* the window |
| `otp_input` | 5 of 6 cells had no face at all |
| `progress_bar` | the trough was the accent colour (a solid orange slab) and the percentage was hardcoded black on it |
| `group_box` | the checkable indicator's tick was pure `rgb(0,0,0)` — on a dark appearance the least readable stroke in the control, and the very part the user toggles |

**Four financial charts were also theme-blind by a single copied literal.** `candlestick_chart`,
`volume_chart`, `depth_chart` and `indicator_chart` all filled their plot pane with
`Color::rgb(18, 22, 28)`, so a light-appearance chart was a near-black slab under light-theme axis
labels. The four snapshot pairs differed by only four lines each — a fact hidden until now because all
four carried a **stale data-colour exemption** that the gate should have rejected.

The panes now resolve through one shared derivation in `finance/layout.rs`, `indicator_chart` uses the
same pane margins as the other three (its `x=52 w=180` was `x=48 w=184` for no reason, which the shared
`IndexAxis` exists to prevent), and all four draw their axes and a `No data` message in the empty state
instead of returning after the slab. The check then **reported the four stale exemptions for removal** —
the behaviour the table is designed for — and they are gone. The price-direction and
indicator-identity colours are unchanged: those really are data.

### 3. Three dialogs whose content area had height zero

`color_dialog`'s picker, `font_dialog`'s three list columns and `file_dialog`'s file list were all
collapsed to 0 px (and 8 px in the last case), because each derived its height by subtracting a
reserved band from a value that had *already* had that band removed — the minuend and the subtrahend
measured different things. `color_dialog.svg` contained two `<rect … height="0">`; `font_dialog.svg`
three. All three now stack downward from the elements actually drawn above and below them.

The same class produced the **`meter`'s tick ring being 90° out of phase with its own arc** (the tick
formula omitted the arc's `-90°` offset, so `meter.svg`'s first tick pointed 135° while the arc started
at 45°) and its ticks varying in length (each end was rounded independently).

### 4. `tab_widget` declared a feature it did not have, and one it had but never showed

- `movable` was published, stored and read by nothing — `grep drag` in the file found zero hits. It is
  **implemented**, not deleted: a press arms a drag session, moving past a neighbour reorders the tabs
  live and emits the newly published `tab_moved(from, to)` signal. `set_movable(false)` cancels a live
  drag. The signal's payload schema was generated rather than hand-written (`derive_event_payloads.py`).
- The constructor built a tab widget with **zero tabs**, so `Draw`'s `for i in 0..self.tabs.len()` ran
  zero times and the control was a bare content rectangle with no tab band at all — and because
  `TabWidget::set` had no `text`/`title` arm, the factory's `text` argument was silently dropped. Two
  tabs are now seeded (as `create_tab_bar` already did) and `text`/`title` are published, with matching
  schema rows and defaults so the schema and the contract cannot disagree.
- Tab widths were the literal `100`, so a two-character title reserved as much space as a nine-character
  one and a long one was clipped at a fixed point. Widths are measured from the titles and clamped to
  the same `[40, 200]` window `tab_bar` uses; when the tabs no longer fit, they share the strip equally
  rather than the later ones being drawn outside the control.

### 5. A control named for floating labels had no floating label

`floating_label` publishes `text`/`placeholder`/`focused` but not `label`, and the shared `label()`
helper took the **first** property that hit from `["text", "title", "message"]` — so the census's
`Sample` landed in `text` and the caption was empty in every appearance. Its snapshot showed a plain label where the
whole point of the control is the float.

`label` is now published (with schema row and default), `label()` prefers the most descriptive property
through one shared `widget_label_property_name` used by all three call sites, and a new
`floating_label_behavior` property (`auto`/`always`/`never`) actually changes what is drawn: `always`
floats the caption above the field even when unfocused, `never` keeps it inline, `auto` follows
focus-or-content.

### 6. Also corrected

- **`tool_bar`** had six hardcoded chrome colours in one loop (its background had been fixed in an
  earlier round, the items had not), including a checked fill that was *lighter* than its own hover fill,
  and a vertical divider whose colour disagreed with the horizontal one beside it.
- **`tool_bar` / `menu_bar`** item labels were positioned at the entry's **midpoint** and then drawn
  `Left`, so a label began at the middle of its button and ran off its right edge (`menu_bar`'s four-
  character titles overlapped their neighbour by 9.6 px).
- **`masked_edit`** drew its body text at `rgb(33,33,33)` on a `rgb(69,69,69)` field — **1.35:1**.
- **`mini_chart`** read its surface and ink only from its own style, never the theme, so it was
  theme-blind whenever nothing had styled it; its grid was the brightest thing in the control on the
  dark appearance.
- **`date_edit` / `time_edit` / `date_time_edit` / `shortcut_editor` / `combo_box` / `status_bar` /
  `banner` / `rating` / `action` / `breadcrumb` / `chip` / `heatmap` / `segmented_control` /
  `split_button` / `dropdown_menu` / `menu_button` / `ribbon_bar` / `collapsible_pane` /
  `masonry_layout` / `toolbox` / `line_edit` / `search_box` / `font_combo_box` / `dropdown` /
  `code_editor` / `empty_state` / `image_view`** — the rest of the half-line migration.
- **`tab_bar`** drew all three `TabShape` values identically (and its `Triangular` arm's comment
  described work that was not done); it now draws three genuinely different shapes and gives the strip
  an overflow rule so no tab escapes the control.
- **`toolbox`** reduced its page to zero height at small sizes (`rect.height - 32 * n`) and let items
  paint outside the control; the page now has a floor and the strip has a scroll offset.
- **`dial`**'s `notches_visible` / `notch_target` were fully declared and read by nothing. The tick ring
  is implemented, sharing the dial's own angle mapping so it cannot drift out of phase with the needle,
  and the `notch_target` unit is now stated (pixels of arc, the unit a dial's tick ring is defined
  in) instead of documented as degrees while doing nothing.
- **`badge`**'s dot was `min(w, h) / 2` — a 12 px dot in a 24 px cell and a 60 px disc in a 240×120 one.
  It is a fixed-size marker now, like the checkbox's indicator and the switch's track.
- **`bottom_sheet`**'s modal scrim **brightened** a dark backdrop (it blended toward the foreground, so
  `rgba(121,121,121)` was laid over `rgba(18,18,18)`); it now darkens toward black, as every platform's
  scrim does.
- **`progress_circle`**'s arc defaulted to a hardcoded literal while `progress_bar`'s came from the
  theme; both now read the same token, and the caller's explicit colour still wins.
- **`color_dialog`** filled its OK and Cancel buttons with the *same* colour.
- **`meter`**, **`otp_input`** (including an unreachable `else` branch), **`banner`**'s action labels,
  **`status_bar`**'s message blending direction, **`tool_button`**'s label, **`rating`**'s stars,
  **`command_link`**'s two-line stack, and **`bar_chart`**'s value labels.
- **A theme race in the test suite** was found and fixed: `find_replace_dialog`'s appearance test held
  the global theme guard only *inside* its render helper and then restored the appearance outside it,
  so a concurrent test rendering while reading the theme could observe a half-switched state. Guarding
  the whole test is what makes its restore atomic with respect to every other reader.

---

### Evidence

| Command | Result |
|---|---|
| `cargo test --no-default-features --features desktop` | **5605 passed, 0 failed** |
| `cargo clippy --no-default-features --features desktop --all-targets -- -D warnings` | clean |
| `cargo check` on all five profiles (`desktop`/`tablet`/`mobile`/`mini`/`embedded`) | 0 errors, 0 warnings |
| `bash tools/check_svg_snapshots.sh` | `checked=188 skipped=0 failed=0` |
| `bash tools/check_control_rendering.sh` | `checked=188 skipped=0 failed=0` (P1–P5) |
| `bash tools/check_text_vertically_centred.sh` | `failed: 0`, **reverse-injected to prove it fails** |
| `bash tools/check_text_origin_is_a_top_edge.sh` | `failed: 0` |
| `python3 tools/audit_text_y.py` | suspicious placements **38 → 6**, each remainder audited |
| `python3 tools/audit_text_contrast.py` | 28 occurrences, unchanged; **nothing below the large-text floor** |

The 376 committed snapshots were regenerated: the diff *is* the record of what moved.

## 2.5.3 (2026-09-22) — Both Backends Now Agree About Where Text Is, and an `ascent` in a Text Origin Is Now Impossible

Backward compatible: no public signature was removed. The additions are new builder methods on
`Node`, one new `Color` method, one new gate and one new audit tool, plus colour and geometry
corrections inside existing controls.

---

### 1. The two text backends disagreed, and the snapshots showed the wrong one

The root finding of this round. The software rasteriser treats `DrawText`'s `origin.y` as the glyph
box's **top** edge — `draw_bitmap_glyph` blits rows downward from it — and every call site in the
crate positions text against that contract. The SVG backend wrote the same value straight into SVG's
`y` attribute, where SVG means **baseline**.

So the two backends put the same ink in different places. A title centred with
`rect.y + (band - height) / 2` sat correctly on screen and half a line too high in every snapshot,
which means `snapshots/svg/` — the human-reviewable artifact this project added precisely to catch
what assertions cannot — was showing chrome the rasteriser never produced. Seven controls were
affected (`dock_widget`, `tab_bar`, `popup_window`, `collapsible_pane` ×2 sites, `group_box`,
`navigation_stack`), and the `P5` overflow judgement was reading the same wrong model, so it could
not see them either.

The fix is one attribute: the backend now emits `dominant-baseline="text-before-edge"`, which
restates SVG's semantics as the renderer's — `y` is the top edge of the text box. Emitting it rather
than adding an ascent to the number is what keeps this a one-place change: the ~40 call sites that
already measured and offset against the top-origin contract stay correct, and none of them has to
know which backend it is painting into.

**220 of the 376 committed snapshots changed.** That number is the measure of how long the two
backends had been disagreeing.

### 2. Chart axis chrome: the `bar_chart` half of a fix that had only been applied to `line_chart`

2.5.2 moved the cartesian axes off three fixed light-chart greys and onto a surface-to-ink
derivation (`axis_chrome`). `bar_chart` was missed: it draws its **own** axes even with the `chart`
feature on, and its value labels and category labels were still `Color::DARK_GRAY` — **1.81:1** on
the dark appearance's surface, present in the pixel census and unreadable to a person.

The same literal was also in both `not(feature = "chart")` fallbacks (`bar_chart`, `line_chart`)
and in `pie_chart`'s outline, so the tablet and mobile profiles drew near-invisible charts. The
derivation is now shared (`axis_chrome_color`), which is what stops the literal coming back in one
file at a time.

### 3. `terminal_view` was a dark slab on a light theme

The worst text ratio in the snapshot set (**1.13:1**) was not a text defect. The terminal's body was
`text_edit`'s resolved fill stepped 8 % toward the ink, and on the light appearance that lands at
`rgb(166,166,166)` — a mid-grey slab on a light theme, onto which a success-green prompt was then
drawn. The surface was wrong; the text ratio was the symptom.

A terminal body is a *field*, so it is now derived the way every other field in the crate derives
one: from the window fill, stepped toward the ink, with a caller's own colour still winning. The
prompt colour additionally goes through `nudge_apart`, which keeps a semantic token's *hue* while
rejecting a lightness that would render it invisible.

### 4. `calendar`: a header band that became mid-grey, and a `1.30:1` today-highlight

Three defects, all from a light-theme assumption written as arithmetic:

* the weekday header blended the fill **halfway toward a literal white**, so on the dark appearance
  `rgb(18,18,18)` became `rgb(137,137,137)` — a heavy band no mainstream calendar has (a standard
  calendar's `onSurfaceVariant` — whether from a palette or a native date picker — keeps the header
  within a few percent of the body). It is now a small step toward the calendar's own ink.
* the weekend columns carried a literal `rgb(180,60,60)` which measured **1.64:1** on that band. The
  weekend indicator is *semantic* (it says "not a working day"), so it now reads
  `theme.colors.error` and is nudged away from the calendar surface — 4.42:1 light, 6.53:1 dark.
* the "today" highlight was 39 %-opaque amber with a near-white day number on top: **1.30:1**, the
  worst text ratio in the set. It now reads the theme's `warning` token, and both the selected and
  today day-numbers take the contrast colour of their own composited tint.

### 5. Grouping containers that rendered as nothing

An audit of every container control against three established widget toolkits found three that failed
"an empty container must still show its structure":

* **`splitter`** guarded its divider with `pane_count() > 1`, and `Splitter::new` builds **zero**
  panes — so the default-rendered control had no handle at all, `detail = 0` in the census. The
  divider *is* the affordance; it is now always drawn, centred when there are no ratios to place it
  by.
* **`tool_box`** filled its content area with the *window* fill, so the two rectangles in its
  snapshot were byte-identical and the toolbox read as a bare border with a hole. The other four
  containers already detect exactly this case; `tool_box` now does too.
* **`image_gallery`**'s empty state hardcoded a near-white panel and a light-grey label: theme-blind
  *and* **2.02:1**. Both now resolve the theme. The gate reported the control's now-stale
  data-colour exemption itself, and it was removed.

### 6. `font_dialog`: column headers that collided with the title bar

`list_y` was a fixed `rect.y + 38` and the headers were placed 10 px above it — `rect.y + 28`,
which is exactly the title bar's bottom edge, in a 12 px strip shorter than the 14 px font it held.
The headers are now derived from the font's own line box and the columns follow them, which is what
makes the strip and the text agree by construction instead of by a tuned pair of literals.

### 7. The declarative layer gains its completeness conditions

`Node` could express a *list* (`children_of`) but not a *condition*. Conditional rendering is the
completeness condition of a declarative tree — an `if` inside a children list in any of the major
declarative UI frameworks — and without it a caller had to interrupt
the builder chain with an imperative `if`.

Four methods added, each with tests that assert the *diff* behaves correctly and not merely that the
node is built:

* `child_if(condition, child)` — include or omit one child. The `false` branch drops the node from
  the tree entirely rather than marking it hidden, so a keyed diff sees the `Insert`/`Remove` the
  state change actually is instead of matching a node that was never really there.
* `child_if_else(condition, then, else)` — the two branches are usually *different controls*, which
  is why both are required rather than one `Option`.
* `children_if(condition, make)` — a whole group, with the generator **not called** when the
  condition is false.
* `children_keyed(items, key_of, make)` — the key becomes a required argument, so a list cannot be
  built keylessly by accident. Keylessness is the precondition for BLUE18 rule #87's identity drift;
  it stays available through `children_of`, and the diff still reports positional matches when it is
  used.

### 8. A new evidence tool: `tools/audit_text_contrast.py`

Reads the committed SVGs, finds the element painted under every `<text>` origin, and reports the WCAG
contrast ratio. It is deliberately **not** a gate — a disabled label and a watermark are *supposed*
to be faint — it is the fact generator that says which of 188 controls deserve a look. This round
used it to go from **131** sub-4.5:1 occurrences to **113**, with every one of the worst cases fixed
and the remainder classified as data colours or intentional secondary text.

### 9. A text origin is a top edge, so an `ascent` term in it is always wrong

The judgement that round 61's `P5` could not make. `draw_text`'s `origin` is the glyph box's top-left
edge — the rasteriser blits downward from it and the SVG backend pairs it with
`dominant-baseline="text-before-edge"` — which makes `+ metrics.ascent` a *placement error* in every
context. It fails two ways: a centred label written `(box - height) / 2 + ascent` starts half a line
low, and a top-aligned label written `top + ascent` starts a full ascent below the edge its layout
chose.

Round 61 fixed the eight instances whose glyph box escaped its **control**, because that is what `P5`
measures. The instances that stayed inside their control were invisible to every gate, and roughly
forty survived across twenty-odd files — mis-centred labels in `app_bar`, `video_player`,
`number_picker`, `search_bar`, `bottom_navigation_bar`, `adaptive_scaffold`, `modal_bottom_sheet`,
`navigation_drawer`, `segmented_button`, `tab_view`, `image_gallery`, `cupertino/nav_bar`,
`cupertino/segmented_control`, `lottie_widget`, `rive_widget`, `animated_image`, `radar_chart`,
`swipe_to_dismiss`, `refresh_control` and `avatar`, plus `+ ascii * 0.78`-style hand-tuned baselines in
`code_editor` and `heatmap`.

All fixed, and a new source-level gate makes the class unrepresentable going forward:

* **`tools/check_text_origin_is_a_top_edge.sh`** (with `check_text_origin_is_a_top_edge.py`) scans every
  `draw_text`/`draw_text_fitted` call, resolves the identifiers in its arguments back to their `let`
  definitions, and fails if any of them mentions `ascent`. It is registered in `run_all_gates.sh`
  automatically (the runner globs `tools/check_*.sh`). It was **reverse-injected** to prove it fails: an
  early version inspected only the call's own arguments and stayed green against an injected defect,
  because the origin is usually computed one line above; resolving the definitions is the fix for that
  false green.

### 10. One shared legibility primitive, used everywhere instead of three private copies

`Color::legible_on(surface, min_ratio)` already existed — it keeps a colour's hue and pushes its
lightness away from `surface` until the ratio is met, returning `contrast_color()` in the worst case.
What this round changed is that it becomes the *rule* rather than one control's local helper: the
private `nudge_apart` in `terminal_view` is now a named call site delegating to it, and about fifteen
more sites across `markdown_editor`, `bezier_curve_editor`, `pie_chart`, `meter`, `cupertino/date_picker`,
`shortcut_editor`, `file_dialog`, `query_builder`, `cascader`, `tag_input`, `chart` and `code_editor`
now go through it instead of blending a colour a fixed fraction of the way toward something. The
repeated need *is* the evidence the abstraction removes real duplication rather than being speculative
(principle #51).

### 11. Controls that were a light-theme rectangle on a dark theme

Three controls satisfied the appearance-change judgement only because *some* pixel moved, while the
panel that dominates the render did not:

* **`chart`** painted `fill_rect(rect, rgb(255,255,255))` and a `rgb(200,200,200)` border while
  `draw_truncated_label` *did* read the theme — so on the dark appearance the axis labels were the dark
  theme's ink on a hardcoded white slab (**2.52:1**). Panel and labels now share one derivation
  (`ChartWidget::panel_colors`).
* **`emoji_picker`** was a wholesale set of light-theme literals (`252,252,254` panel, `244,245,248`
  field, `238,240,244` strip, `40,44,52` ink); its light and dark renders differed **only** in the
  window behind them. The shell now resolves through `EmojiPicker::chrome_colors`, and only the
  caller's glyphs are content.
* **`color_picker`** was the same, over its spectrum. The panel, its rails and the hex readout are now
  theme-derived; the spectrum itself is untouched, because it is the value being picked.

All three then had their now-stale data-colour exemptions **reported by the gate itself** and removed —
which is the behaviour that table wants: an exemption is only valid while the exempted thing dominates.

### 12. Selection bands, dimmed ink and fixed-direction blends

A cluster of controls derived the ink for a selected row from the *token the band was built from*
rather than from the band the glyph is painted on, and blended "secondary" ink toward a fixed colour:

| Control | Defect | Result |
|---|---|---|
| `pagination` | current-page label used the bar's own background to "invert" | 2.35:1 → `selected.contrast_color()` |
| `roller` | selection label blended 92 % back toward a *light* surface | 2.14:1 → `selected.contrast_color()` |
| `number_picker` | centre value used the picker's surface ink on the band; neighbours blended 45 % toward a fixed target | 2.94:1 / 3.73:1 → `contrast_color()` / bounded dim |
| `mobile_date_picker` | selected number was the accent *token* on an accent-derived band | 1.88:1 → `highlight.contrast_color()` |
| `cupertino_date_picker` | selected text derived from `accent` while the band is the accent over the *wheel*; wheel rows used the raw `muted` token | 2.52:1 / 2.07:1 → band composite `contrast_color()` / `legible_on` |
| `tooltip` | label chosen against the *undimmed* bubble, then drawn on the dimmed one | 2.78:1 → derived after dimming |
| `meter` | needle and ticks blended toward a literal black | 3.34:1 on dark → pushed away from the surface |
| `pie_chart` | percentages in a fixed white on the slice; slice labels kept panel ink when the box was clamped onto the slice | 1.85:1 → per-slice `contrast_color()` |
| `code_editor` | `Plain`/`Identifier`/`Operator` spans took the palette's light preset instead of the resolved ink; status bar `dim_ink` derived against the surface but painted on the chrome band | 1.38:1 / 2.99:1 → resolved ink / legible on the band |
| `tag_input` | input field, caret and close button were fixed light-theme literals | 1.25:1 → theme-derived |
| `cascader`, `shortcut_editor`, `file_dialog`, `query_builder`, `image_gallery` placeholders | fixed-fraction "secondary" blends | 2.48–3.90:1 → bounded by the text floor |

### 13. Layout arithmetic that only fitted one font size

Three controls reserved a fixed pixel height for a line box and then drew a larger font into it, which
is how a number ends up on top of the ramp it belongs under:

* **`heatmap`** reserved 10 px for the legend's endpoint numbers while drawing them at 14 px, so the
  higher endpoint's top rows landed on the ramp colour (**1.15:1**). The reservation is now the line box
  the font actually has, and the labels are positioned from the ramp's bottom edge rather than from the
  band's, so the two are adjacent by construction.
* **`color_picker`** reserved 44 px for a bottom stack that needed the readout's own 14 px line, so the
  hex readout was drawn over the spectrum's bottom edge. The stack is now derived from its parts.
* **`tag_input`**'s chip label used the chip's midpoint as its origin, and **`tooltip`**'s label added a
  full `ascent` below a top-aligned position — both are the origin-model error of §9 in a place the
  gate's `draw_text` scan does not reach (a raw arithmetic expression rather than a named local).

### 14. The text-contrast audit, measured

`tools/audit_text_contrast.py` over the committed snapshots, before and after this round:

| | occurrences below 4.5:1 | worst case |
|---|---|---|
| at the start of the round | **131** | 1.13:1 |
| after §1–§8 (round 62's first half) | 110 | 1.15:1 |
| after this half of the round | **28** | 4.04:1 |

Every remaining occurrence is between 4.04:1 and 4.5:1 and is deliberate secondary text (status bars,
empty-state hints, non-selected wheel rows, star glyphs). No occurrence is below 4.0:1, so nothing is
below the large-text floor and nothing is unreadable.

The tool itself gained one correction: its containment test is now **half-open** on the bottom and
right edges. A fill and the label below it routinely share a boundary pixel, and the closed test
attributed the label to the fill *above* it — which reported a correctly placed readout as 2.65:1
against a colour it no longer touches.

## 2.5.2 (2026-09-21) — The Fifth Rendering Judgement: Every Control Now Has to Paint Inside Itself

Backward compatible: no public signature was removed and no existing behaviour was changed. The one
file removed is a leftover diagnostic example whose imports only resolved on a device profile.

---

### 1. Why a fifth judgement was needed

The four judgements added in 2.5.1 are all measured from a **raster**, and a raster is bounded by the
surface it was rendered into. A control that paints *outside* its own rectangle therefore produces a
perfectly normal census: the escaped pixels are clipped away, the rest are counted, and nothing looks
wrong. The SVG snapshots are the opposite — absolute coordinates and no bound at all — so the same
defect appears there as drawing that leaves the picture.

**Two backends disagreeing about where the ink is, is the defect.** `group_box` shipped a title whose
text sat at `y = -8`, more than half of it above its own frame, while every raster assertion passed.

So `P5` renders each control through the SVG backend and requires every element it emits — `rect`,
`circle`, `line`, `text`, `path` — to lie inside the control's rectangle. It found **68 controls** with
real appearance defects, and they reduce to a handful of root causes:

* **Two coordinate spaces mixed.** `group_box`/`panel` built their title rectangle in *parent* space and
  painted it as *child* space, moving the glyph box half a line down (and therefore half of every title
  outside the frame). `splash_screen` placed its logo block above a box whose height it had taken from
  the wrong reference, putting the logo at `y = -20`.
* **An estimate and a measurement that disagreed.** `TabBar` laid tabs out from `text.len() * 8`
  (bytes!) but drew with a 14 pt font, so a two-character CJK title measured four times its drawn width.
  The estimate now mirrors the renderer's own advance model, and the real metrics are measured before
  the first tab is placed.
* **A line box read as a baseline.** `TextMetrics::ascent` is *inside* the line box, not above it, but
  eight controls centred text with `y + (box - height) / 2 + ascent` — which moves the glyph down by
  almost a full line. This alone pushed the last row of the date pickers, the last line of `empty_state`
  and the status rows of `code_editor`/`terminal_view` clean out of their controls.
* **Half-widths left out of the arithmetic.** A stroke is centred on its chord, a disc extends `r` in
  every direction, and a drop shadow is offset: `line`/`divider` painted outside their box by half a
  thickness, `slider`'s handle sat half outside the track at its minimum, `sparkline`'s last-point dot
  hung over the edge, and `fab`/`popover` grew past their frame once the shadow was added.
* **Text with no width bound at all.** A glyph advances `font.size()` pixels, so a 14 pt label advances
  14 px per character and a long one simply carried on past the control. Around forty call sites did
  this. They now go through one new entry point, `RenderContext::draw_text_fitted`, which takes the
  **rectangle** the text belongs in — every call site already had that rectangle in hand, and passing
  it makes "forgot to bound the text" impossible by construction.

### 2. Two defects in the judgement itself, fixed the same round

A gate that is wrong in the strict direction wastes as much time as one that passes everything, and an
earlier round had already shipped one of each. So `P5`'s first version was audited against every
violation it reported, and **25 of the 68 findings turned out to be the check's fault**:

* it estimated text width as one em per character, where the renderer charges a full em only for wide
  scalars and **0.6 em for everything else** — reporting a 106 px title as 182 px and demanding that
  forty controls truncate text that fits perfectly well;
* it treated the default `stroke-width` as a *half*-width, so **any** element flush with `x = 0` was
  reported as an escape.

Both are corrected. The advance model is now mirrored from the renderer rather than re-invented.

### 3. `map_view` stopped being exempt from the theme

`map_view` was registered in the data-colour table as `map-content`, on the assumption that its palette
*was* the map's. It draws no map at all — the control has no tile pipeline — so what it actually painted
was four hardcoded light colours, and a map inside a dark window was a white rectangle no theme could
change. Those four are chrome (they frame geographic content rather than being it) and now resolve the
theme; the parts that really are data (the grid step, the marker colours, which encode place and
selection) are unchanged. The stale exemption was removed, and the gate reported it as stale itself.

### 4. A second pass: labels that hugged an edge, and one button that was two colours

`snapshots/svg/` is only worth committing if somebody reads it, and reading it found more:

* **`wizard_dialog`.** Its three navigation buttons were laid out **twice** — once in `draw` from
  a 72 px column and once in `handle_event` from a fixed 80 px one placed 20 px and 8 px elsewhere.
  Two of the three did not respond where they appeared. There is now one `nav_button_rects`
  function for both, so a button cannot drift from its own click target. Every label was also
  hugging the top of its 36 px button instead of centring, and the filled button read
  `background_color` — which for a `Surface`-role control is *the dialog's own fill* — so it came
  out grey; on the last step a second token (`success`) took over, making one affordance look like
  two controls. It now reads the theme's `primary`, which is what an action colour is for.
* **A systematic case of the same mistake.** The renderer's text origin is the glyph's **top-left**
  and it paints *downward*, but `TextMetrics::ascent` was read as though it were space *above* the
  line box. So the idiom `centre + ascent` — and its `centre`-only cousin — pushed labels half a
  line down in **`tab_bar`, `menu`, `badge`, `snackbar`, `emoji_picker`, `dock_widget`,
  `command_palette`, `input_dialog`/`dialog`, `popup_window`, `web_engine`, `query_builder`,
  `number_picker`** and made the `heatmap` legend's reserved band one line too short. All are fixed,
  and the arithmetic is now expressed as `box.y + (box.height - line_box) / 2` so the mistake has
  nowhere to hide.
* **`heatmap`'s legend band** was `18` px while its contents (8 px ramp + 2 px gap + 10 px line)
  needed 20. The reservation is now *derived* from the parts instead of guessed, which is why the
  numbers no longer sit on the control's last row.

### 5. The cookbook had drifted two releases behind

`cookbook/` was still pinned at **2.5.0** in all three languages, and two chapters carried pins that
would not resolve at all (`version = "1.0"` and `version = "2.4"`). English also still said **179**
widget kinds where the registry has 180. All three languages are now on **2.5.2** with consistent
counts, and the web-engine chapter no longer calls `WebEngineViewEnhanced` a "full browser engine" —
it is a page model with no HTML/CSS pipeline, which is what `has_real_engine()` says. All three
mdbook builds pass.

> The cookbook had no gate watching it, which is why it drifted. The version now appears in eight
documentation files across four trees; a check that they agree would have caught this the moment
`Cargo.toml` moved.

### Measured facts

| Quantity | Value |
|---|---|
| Controls covered by the rendering census | **188** |
| Judgements asserted (`P1`–`P5`) | **5** |
| Controls with an appearance defect found by `P5` | **68** |
| Of those, findings that were the *check's* fault and were corrected | **25** |
| Root causes the 68 reduce to | **6** |
| Additional label-placement defects found by reading the snapshots | **13 controls** |
| `P5` overflow exemptions | **0** (empty by evidence, not by assumption) |
| Data-colour exemptions after removing the stale `map_view` row | **18** |
| SVG snapshots regenerated | **376** (188 × 2 appearances) |
| Cookbook version pins brought forward (2.5.0 / 1.0 / 2.4 → 2.5.2) | **48** across 3 languages |
| Library tests | **5279 passed, 0 failed** |
| Profiles built clean (`--all-targets`) | **5 / 5** (`desktop`, `tablet`, `mobile`, `mini`, `embedded`) |
| `clippy -D warnings` | **clean** |

### Upgrading

Nothing to do. `draw_text_fitted` is additive, the exemption tables only lost a row that had gone
stale, and the SVG snapshots are generated artifacts that regenerate byte-for-byte.

## 2.5.1 (2026-09-21) — A Control's Rendering Became a Verified Dimension, the WebEngine Stopped Claiming What It Could Not Do, and Three Broken Builds Were Fixed

Backward compatible: no public signature was removed and no existing behaviour changed. The one
removed item is a **private** trait (`NativeWebEngine`) that had a single implementation which never
displayed anything — see "The WebEngine became honest" below.

---

### 1. The rendering dimension — 188 controls now have their pixels checked

Every gate before this one was built on **declarations**: does the control publish a property, does
it publish an event, does it `impl Draw`. Round 58 shipped four defects the user saw with their own
eyes — a control laid out off-canvas, a window fill covering its children, a theme switch that did
nothing, `list_box` painted in the window's own colour — and **every one of them satisfied all 64
gates that existed**, because none of those gates looked at a rendered pixel.

This release adds that dimension, in five layers:

* **A rendering golden table.** All **188** controls are constructed, rendered twice (light and dark)
  into an in-memory raster, and asserted on four judgements: `P1` it painted at least one pixel
  distinguishable from its background; `P2` its dominant colour is not the surface it sits on; `P3`
  its dominant colour differs between the two appearances; `P4` each of the four semantic tokens
  (`error`/`warning`/`success`/`info`) has a real consumer and moves with the appearance. The
  traversal unit is the **canonical name**, never `WidgetKind`: 13 kinds are shared by 2–5 controls,
  so a kind sweep would have silently skipped 19 of them.
* **A data-colour exemption table.** `P3` would otherwise fail a chart whose *series* colour is
  deliberately constant. Controls whose colour **is** the data (series palettes, K-line red/green,
  the colour spectrum, meter thresholds, map tiles) are registered in
  `tools/control_color_exemptions.txt` **with a written reason**; the four controls that carry
  *semantic* colours (`banner`, `calendar`, `progress_dialog`, `message_box`) are refused entry, so
  "the theme declares four semantic tokens and nobody reads them" cannot be legalised.
* **A declaration/implementation alignment gate.** Three assertions that no existing gate could make:
  `Q1` every declared property is answered by the control that declares it (with its declared
  writability); `Q2` every `draw` body actually paints — an empty `impl Draw` is what principle #5
  forbids; `Q3` every published event list is a set and carries a payload shape.
* **376 SVG snapshots** under `snapshots/svg/`, one per control per appearance, named by canonical
  name, committed, with a regenerate-and-compare gate. A wrong-looking control is not something an
  assertion can catch; a diffable image is.
* **A WebEngine that reports what it is**, below.

### 2. Real defects this dimension found (and fixed)

These were **not** visible from any declaration, and every one of them is now covered by an
assertion with a reverse-injection record:

| Defect | How it was found |
|---|---|
| `message_box` declared `modal` as neither readable nor writable while the control had a working getter and setter | `Q1` — the designer was hiding a property that works |
| `order_book::show_spread` answered `TypeMismatch` for a non-bool write and `ReadOnlyProperty` for a bool one | `Q1` writability — a caller was told "wrong type" about a name that can never accept a write |
| The JS engine **documented arithmetic** and `1 + 2` returned `undefined` | `Q1`'s investigation — the docs promised what the code did not do |
| The SVG exporter rendered every control with the theme never applied, so `<name>.svg` and `<name>.light.svg` were byte-identical apart from a comment | `check_svg_snapshots.sh` step [4] — the snapshots existed and proved nothing |
| One example and one test used a crate-level `#![cfg]` without `required-features`, so `cargo check --all-targets` on `mini` failed with `E0601: main function not found` | `check_profiles.sh` |
| The clipboard test raced other tests on the process-wide clipboard and failed intermittently under the parallel harness | repeated `cargo test` runs |

### 3. Three build configurations that were already broken are now fixed

`cargo check --no-default-features --features mini`, `... --features embedded`, and
`--features "windows desktop-runtime controls-native controls-custom"` **failed at the previous
tag**: the widget layer referenced `crate::theme` from ~120 files, while that module is gated on
`device_profile` and those three configurations do not set it (78 errors in the last one).

Widening the theme module was not the fix — it needs `serde` and the capability registry. The fix
is a single always-available entry point in `src/style/` (`resolved_theme_style`,
`resolved_theme_style_for`, `theme_manager`, `semantic_color`, plus the type shapes), so one path
compiles in every profile and answers "no theme" where there is no theme. `mini` has no colour
model at all, and now says so instead of failing to compile.

### 4. The WebEngine became honest

`WebEngineView` models a web page; it does not render one, and now says so.

A real engine used to be reachable on Linux behind the `webkit-engine` feature: **76 lines** of
one-line forwards to `webkit2gtk`, one platform, and the `WebView` was **never added to a GTK
container** — so no user could ever have seen a page through this library, while the feature list
and the docs said otherwise. It was removed rather than completed, because completing it means
500–1500 lines per platform plus a hard dependency on system libraries, and because JavaScript
evaluation (the other half of "web support") runs on the pure-Rust `boa` engine and never went
through that trait at all.

What replaced it:

* `Platform::supports_web_engine()` — a capability question, answered `false` on every current
  backend, instead of an `Option` whose `None` conflated "no engine exists" with "the engine could
  not be constructed".
* `WebEngineViewEnhanced::has_real_engine()` — the degradation is now **queryable** from the widget
  itself. It previously was not: the constructor's doc told a caller that had to know to "query the
  platform directly", which is impossible when the *widget* is what held the engine. That made
  "this is a simulated view" undetectable — the same defect class as an event that is published but
  never emitted.
* `tools/check_web_engine_honest.sh` fails if any of the removed names returns.

### 5. Two toolchain-independent gate defects (false failures on Windows)

Four gates reported failures that were about the *host*, not the code: TOML manifests written with a
native Windows path (`\` starts an escape, so the file was unparsable), and `cargo package --list`
output compared verbatim against forward-slash paths. Also, `check_android_cross.sh` /
`check_ios_cross.sh` reported a missing toolchain as `FAIL` rather than `SKIP`, so an unavailable
target was counted as a defect in the code under test. All four now report accurately.

### Measured facts

- `cargo test --no-default-features --features desktop` → **5543 passed / 0 failed** (45 suites).
- `cargo clippy --no-default-features --features desktop --all-targets -- -D warnings` → **clean**.
- `cargo check --no-default-features --features <desktop|tablet|mobile|mini|embedded> --all-targets`
  → **0 errors, 0 warnings** on all five (three of which did not build at the previous tag).
- Rendering census: **checked=188, skipped=0, failed=0**.
- Declaration alignment: **checked=188, skipped=199 (each with a reason), failed=0**.
- SVG snapshots: **376 files** (188 controls × 2 appearances), regeneration byte-identical.
- Semantic tokens: all four have consumers; none is an empty declaration.
- WebEngine: zero residue from the removed wrapper; `supports_web_engine()` is `false` everywhere.

See [`docs/log/log-20260921-3.md`](docs/log/log-20260921-3.md) for the per-change evidence, the
reverse-injection records, and the layer-by-layer counts.

## 2.5.0 (2026-09-21) — Events Became a Typed Contract, a Project Document Becomes Rust Source, and the Designer Is Gated and Committed

Backward compatible. **No public signature was removed and no existing behaviour changed.** This
release is one coherent piece of work in three parts, all of it additive:

1. **The event side became a typed contract.** `WidgetCapability.events` changed from a name array to
   a schema carrying each event's payload kind, derived from the control's real signal declaration;
   the designer's manifest round-trips through JSON byte-identically; the JSON event path and the
   capability event table were merged into one route.
2. **A project document can now become Rust source.** `rust_widgets::designer::generate` emits a
   compilable Rust function for hardware targets that cannot run the runtime JSON loader at all.
3. **The generator is gated and its artifacts are committed and verified.** The `designer` feature is
   on for `desktop` (the profile that hosts a designer) and off elsewhere; the generated sources are
   checked in, with a regenerate-and-compare gate that makes committing them safe.

See [`docs/log/log-20260921-1.md`](docs/log/log-20260921-1.md) and
[`docs/log/log-20260921-2.md`](docs/log/log-20260921-2.md) for per-change evidence.

### Measured facts

- `cargo test --no-default-features --features desktop` → **5479 passed / 0 failed**.
- `cargo clippy --no-default-features --features desktop --all-targets -- -D warnings` → clean.
- `cargo check --no-default-features --features <desktop|tablet|mobile|mini|embedded> --all-targets`
  → **0 errors, 0 warnings** on all five. `--features desktop,no-declarative-view` and
  `--features tablet,designer` are clean as well.
- `bash tools/check_designer_feature_gate.sh` → passes; `desktop` resolves `rust_widgets::designer`,
  the other four profiles do not, and `tablet,designer` does.
- `bash tools/check_generated_sources.sh` → passes: the committed artifacts carry the generated
  marker, regeneration reproduces them byte for byte, they compile under `-D warnings`, and both
  reverse injections go red as required.
- `bash tools/check_declared_targets_ship.sh` → passes; `tools/designer_generate.rs` was added to
  the `include` list, so the target the new example declares now ships.
- `bash tools/run_all_gates.sh` → **PASS=42 FAIL=1 TIMEOUT=0 SKIP=1**. The FAIL is
  `check_profiles.sh`, which needs MSVC's `lib.exe` on an `x86_64-pc-windows-msvc` target this Linux
  host does not have — a host-tooling gap reproduced by stashing every change, so it is unrelated to
  this round. The SKIP is `check_apple_native.sh`, which needs macOS.

### The generator became a capability with a boundary, not code that is always there

The generator exists (see the section below). This release also decides **where it is allowed to
exist**, and answers that with a feature: `designer` is a **development-time** capability — a
code generator plus an artifact writer that writes Rust source into the tree — and `desktop` enables it by default, because `desktop`
is the profile a designer **host** runs on.

`tablet`, `mobile`, `mini` and `embedded` leave it off, and the reason is not tidiness. They are the
**targets** of a generation, not its hosts: a device that receives `ui_stripped.rs` never runs the
program that wrote it. Linking a code generator and `std::fs::write` into a shipping application is
exactly the weight mode 2 exists to remove, so the default is the narrow one and a caller who wants
the tool elsewhere asks for it by name — `--features tablet,designer`.

### The gate is an alias in `build.rs`, not a bare `feature = "designer"`

The condition is a **conjunction**: a real device profile, not a stripped widget set, *and* the
caller having opted in. A conjunction hand-written at more than a couple of call sites drifts, which
is what rule #47 forbids, so it is written once as the `designer_tooling` alias that `build.rs`
emits, and `src/lib.rs` reads `#[cfg(designer_tooling)]` rather than the feature. The alias is also
registered with `cargo:rustc-check-cfg`, so a typo in the `cfg` is a build error instead of a
condition that is silently false.

Folding it into the existing `full_widgets` alias would have compiled, and would have been wrong:
`full_widgets` answers "does this build have the widget tree?", which every `tablet` and `mobile`
application needs, while `designer_tooling` answers "is this build **also** a design tool?", which
none of them do.

The boundary is checked by compiling rather than by grepping. A grep for `"designer"` in
`Cargo.toml` proves nothing about what the compiler sees, so `tools/check_designer_feature_gate.sh`
compiles a probe crate whose only job is to *name* `rust_widgets::designer`. It fails in both
directions that matter: the tool leaking into a delivery profile (someone copies `"designer"` into
`tablet`, and every tablet binary silently ships a generator) and disappearing from `desktop` (the
designer can no longer be built by its own host profile). A third step keeps the gate a *default*
and not a prohibition, by requiring the explicit `tablet,designer` opt-in to resolve.

### Generated sources are committed, and that is a trade with a price

`blue19.md` §5.1.6 left this open. It is now decided: **generated sources are committed**, for
reviewability. A generated file in the tree is a diff — a reviewer sees that a control was added,
moved or had a property changed, in the same pull request as the project document that caused it. A
file generated at build time is invisible until it breaks the build.

Committing has a cost, and it is stated rather than glossed: **the tree can hold a stale file.** A
designer edits `project.json`, commits the document, and forgets to regenerate. The tree now claims
to describe a UI it does not, and nothing about the committed `.rs` file looks wrong — it is valid
Rust that compiles. That is why the decision is only safe **with** a regenerate-and-compare gate:
`regenerate → `cmp` → fail on drift`, the same shape `tools/check_abi.sh` already uses for the C
header, for the same reason. Both halves are load-bearing — committing without the gate is how a
stale artifact ships, and the gate without committing has nothing to compare against.

`tools/check_generated_sources.sh` asserts four things: that every committed artifact carries the
generated marker, that regenerating reproduces the committed bytes exactly, that the committed
artifacts compile under `-D warnings`, and that both of those can fail. The last is not decoration:
a `cmp` against a file the tool just wrote passes trivially, so the gate edits the project document
and requires different bytes to come out. It also restores the tree and re-verifies it in sync
before exiting, because a gate that corrupts the tree on its way out is worse than one that fails.

### A write is refused if the text lacks the generated marker

Every file the generator writes starts with `GENERATED_MARKER`. Two things depend on it, and the
second explains why the *writer* enforces it rather than only the gate reading it: a file without
the marker is classified by the drift gate as **not generated** and skipped, so a marker-less write
would silently disable the drift check for that file while every gate still reported green.
`write_one` therefore returns an error instead of writing, and the refusal leaves nothing behind.

`--check` in `tools/designer_generate.rs` applies the same distinction on the reading side: a file
that exists without the marker is reported as "not a generated file" rather than as a diff against a
hand-written module that happens to share the path.

### The designer calls the generator through an API and a CLI

`designer::artifact::regenerate_into` returns a per-file outcome — `created`, `updated` or
`unchanged` — so a designer's status area can distinguish "saved" from "no change" instead of
re-announcing a write that did not happen. `ArtifactOutcome::wrote()` is that distinction as a
predicate, and the gate's success criterion is the same fact: it treats a run that changed nothing
as the expected outcome.

`tools/designer_generate.rs` is the same generation from a shell, because a build script, a reviewer
checking a colleague's committed artifact, and the gate itself all need it and none of them should
re-implement the argument handling. One line per file, `created|updated|unchanged <path>`, then a
summary, so a script and a status area read the same output. `--check` detects drift without
writing, which is what lets it run on a read-only checkout; its exit statuses are distinct — `1` for
stale artifacts, which is a **finding**, and `2` for a broken tool, which is not.

### Two files, not one, because the two templates emit mutually un-compilable code

The committed artifacts are `examples/generated_project/src/generated/ui_default.rs` and
`ui_stripped.rs`. One file was never an option: the default template names `crate::view`, which a
`mini` build does not compile, and the stripped template names nothing from it, so a single file
would fail to build on every target. One file per template keeps the choice in `Cargo.toml` — which
target compiles which file — rather than in generated `cfg` attributes the generator cannot reason
about.

The names are keyed on the **profile**, not the template, because the profile is what a reader
builds: `ui_default.rs` is compiled by `--features desktop`, `ui_stripped.rs` by `--features mini`.
`desktop`, `tablet` and `mobile` share one file because they emit **identical** code, so a reader
looking for `ui_tablet.rs` is looking for something that should not exist.

### The committed artifacts are verified twice, and the second check found a defect

The sync check answers "is the file in the tree the file the generator would produce?".
`tests/generated_artifacts_are_lint_clean_test.rs` answers the other question — "is what is
committed any good?" — by compiling the **committed** files, not freshly generated text, under
`RUSTFLAGS="-D warnings"`.

That check found a real defect on its first run, which is why it is a gate and not a nicety. The
generator's `mut` placement was a guess, and it was wrong in **both directions at once**:
`warning: variable does not need to be mutable` on every child whose setters ran inside their own
block (the outer binding is read once, by `add_child`), and `error: cannot borrow root as mutable` on
the root, which does need it. Neither showed up in `tools/check_generator_output_compiles.sh`,
because that gate's fixture happened to have a child with no setters at the root level. A generated
file that warns under the host's own lints fails a downstream `-D warnings` build for a reason that
has nothing to do with the project document.

### Why the reverse injection for that step is a `mut` and not something else

The gate re-introduces exactly the defect the lint step was written for: it restores the
unconditional `mut` in the generator, regenerates, and requires the lint step to **fail**. An
injection that was caught by a different assertion in the same file (a compile error, say) would
prove the file runs, not that the step can see the class of defect it exists for — the same
distinction `tools/check_mode_consistency.sh` makes when it omits a child and requires a red gate.
The generator source is restored and the artifacts regenerated afterwards, so the tree is left
exactly as it was found; a gate that leaves drift behind makes every later gate fail for a reason
it did not cause.



#### A project document can now become Rust source, and "it compiles" is a gate

##### Measured facts

- `cargo test --no-default-features --features desktop` → **5462 passed / 0 failed**.
- `cargo clippy --no-default-features --features desktop --all-targets -- -D warnings` → clean.
- `cargo check --no-default-features --features <desktop|tablet|mobile|mini|embedded> --all-targets`
  → **0 errors, 0 warnings** on all five.
- `bash tools/check_generator_output_compiles.sh` → passes; a generated program is compiled for real
  against `desktop`, `tablet`, `mobile`, `mini` and `embedded`. The gate takes **~33s**, down from
  265s once the probe crates were made to share the workspace target directory and the injection step
  stopped re-running all four cases.
- `bash tools/check_mode_consistency.sh` → passes (6 tests), with reverse injection.
- `bash tools/check_generator_reuses_wire_rules.sh` → passes, with reverse injection.
- `bash tools/run_all_gates.sh` → **PASS=40 FAIL=1 TIMEOUT=0 SKIP=1**. The single FAIL is
  `check_profiles.sh`, which needs MSVC's `lib.exe` on an `x86_64-pc-windows-msvc` target this Linux
  host does not have — a host-tooling gap reproduced by stashing every change, so it is unrelated to
  this round. The SKIP is `check_apple_native.sh`, which needs macOS.

### A design document can now become Rust source, not only be interpreted

Mode 1 already existed: `crate::json` reads a project document and builds the UI at run time, so
editing the document costs no recompilation. That is the right shape for the design loop and the
wrong shape for shipping. `rust_widgets::designer::generate` adds the other direction — it parses the
same document (through `JsonProject::parse`, the *same* parsed-project type mode 1 reads, so the two
modes cannot disagree about what a document means) and returns Rust source plus a `GenerationReport`.

Both modes exist because they answer different questions, and for two profiles mode 2 is not an
alternative but the **only possible output**: `crate::json` and `crate::view` are both compiled out
of `mini` and `embedded`, and the `alloc_frugal` budget admits neither. A device running `mini`
cannot run the generator either — it is the **target** of one — and that is stated plainly in the
module rather than glossed: a designer runs on a desktop host, and `mini`/`embedded` receive the
generated file.

### Two templates, not one template with flags

The generator emits one of two shapes, keyed by `TargetProfile`:

| Target | Emitted shape |
|---|---|
| `desktop` / `tablet` / `mobile` | a `Node` tree plus a `ViewEngine::mount` call |
| `mini` / `embedded` | imperative construction plus `add_child`, coordinates solved at generation time |

The split is not stylistic. Sharing one template would mean every line carrying a conditional, and
`create_button` and its family are gated behind `cfg(not(alloc_frugal))` — a mistake that way leaks
`create_button` into a `mini` build, compiles fine on the desktop host, and fails only on the target.
`TargetProfile::Default` covers three profiles rather than three values because the difference
between them is device capability discovered at run time, not a difference in the API surface —
collapsing them is what keeps a designer from maintaining three copies of one template.

### A property of the output that a text assertion could not check: it compiles

BLUE19's definition of done for this task does not accept "should work": the stripped template's
output must compile for real under `--no-default-features --features mini` and `--features embedded`.
`tools/check_generator_output_compiles.sh` writes the generated text into a throwaway crate, depends
on this library with the target's feature set, and runs a real `cargo check` — for the stripped output
under `mini` and `embedded`, and for the default output under `desktop`, `tablet` and `mobile`.

This is the only check that can find the class of defect the requirement exists for, because every
member of it **compiles fine on the desktop host**. Four were found this way during the work, one per
run, and all four are now recorded in the generator and the gate:

- a reference to `crate::view` from a stripped build, where the module does not exist;
- a call into `widget::runtime` (which is `cfg(not(alloc_frugal))`) — the stripped template first
  reached for `runtime::register` to obtain a control id, when a stripped target has no registry and
  the id the control already owns is the only one to hand a parent;
- `Button::new("Go", ..)`, where the constructor takes `String` and the stripped profile has `alloc`
  but not the standard prelude, so a bare `&str` does not coerce;
- `Slider::new(text, geometry)`, where the constructor takes geometry only, producing "unexpected
  argument".

This is why the gate is a compile rather than a `grep`: a test asserting that the output contains
`add_child` would pass against code that never builds.

### Mode consistency is a gate with reverse injection

"The two modes agree" is not one testable claim, so it is asserted as three facts a user can observe,
each able to fail on its own: **structure** (the same controls in the same parent/child arrangement),
**properties** (the same names with the same values) and **declared handlers** (the same published
events reachable). `tools/check_mode_consistency.sh` runs `tests/mode_consistency_test.rs`, which
reads mode 1's tree from the loader and mode 2's from the **emitted text** — comparing the generator
against the loader directly would compare mode 1 with its own input, so mode 2 is read back from the
source it wrote.

A compile check alone cannot catch this: a generator that emitted a **smaller, still-correct** tree
would compile perfectly while losing a control the user drew. That is why the gate's second step is
reverse injection: it makes the generator omit the last child of every node and **requires the gate to
go red**, then restores the source and requires it green again. Injection is what makes the claim
falsifiable rather than decorative.

### The generator reuses the runtime's wire rules, and that has a gate too

The generator does not restate the type-compatibility rules a wire must satisfy; it consults
`WIRE_RULES`, the same table the runtime uses, exported through `is_wire_key` and
`shared_wire_rule_count`. The failure mode this guards against is **not a missing call** — it is a
*second table* that happens to agree today and drifts the first time a `PropertyValueKind` variant is
added, at which point the designer accepts a wire the generated program rejects, and nothing in the
generator's own tests shows it. `tools/check_generator_reuses_wire_rules.sh` checks that the
generator names the table, then injects a local verdict and requires the gate to fail — so a
generator that named the table and ignored it (decorative reuse) would not pass.

### Capacity and layout are resolved at generation time

Layout is solved before any control exists: the generator runs the real `crate::layout` engine at
generation time and emits the resulting coordinates as literals, so the generated program carries no
second layout engine that could drift from the runtime's. Capacity is checked the same way, and the
bound is **per target** because it is a storage fact, not a policy: `mini`'s `BaseWidget::children` is
a fixed-capacity `MiniVec` (`MINI_CHILD_CAPACITY = 64`) and exceeding it **silently drops** the extra
children. A generated program that did so would look complete and be missing controls, so a container
over capacity is **reported** in `GenerationReport::capacity_overflow`, not emitted. A heap-allocating
target has a sanity bound (`DEFAULT_CHILD_CAPACITY = 4096`) rather than a storage limit, and the test
asserts the report is empty there — reporting those would be noise.

The same "reported, not silently dropped" contract covers everything the generator cannot express:
`GenerationReport::unsupported` names each node it refused with a reason. A generator that quietly
omitted a control would produce a program that looks right and is not.


#### Events became a typed contract, and the designer manifest round-trips

##### Measured facts

- `cargo check --no-default-features --features <profile>` → **0 errors, 0 warnings** on all five
  of `desktop`, `tablet`, `mobile`, `mini`, `embedded`.
- `python3 tools/check_event_payload_types.py` → **187 controls covered, 326 published pairs**,
  every declared payload matching the Rust type of its signal.
- `bash tools/check_designer_manifest_roundtrip.sh` → **4 passed / 0 failed**.
- `bash tools/check_event_signal_dyn.sh` → passes (3 converted controls resolve every name their
  capability publishes).
- `bash tools/check_json_event_route.sh` → passes: 8 compatibility keys declared, 326 published
  events in the table, reverse injection detected.
- `bash tools/check_enabled_is_honoured_containers.sh` → passes (6 container files with ungated
  emitting mutators, 29 accepted with no emitting mutator or a written reason).

### Events became a typed contract instead of a list of strings

`WidgetCapability.events` was `&'static [&'static str]` — the library stated *that* a control
emits `value_changed` but not *what arrives with it*. A designer reading that list can draw a
wire it cannot label, and a manifest that describes a payload as a scalar when the signal
carries a tuple is asserting something the control never does.

The field is now `&'static [EventSchema]`, with the payload expressed as two orthogonal fields
rather than one wider enum:

```rust
pub struct EventSchema {
    pub name: &'static str,
    pub payload: Option<PropertyValueKind>,   // what the value is; None = no payload
    pub shape: Option<EventPayloadShape>,     // how the value is arranged; None = no payload
}
```

Splitting the two is what lets the table be honest about the cases that motivated the change.
`PaneLayoutChanged` carries `Vec<f32>`, `TabMoved` carries `(usize, usize)`, and
`RichEdit::selection_changed` carries `Option<(usize, usize)>`. Folding those into
`PropertyValueKind` leaves only two options, and both are wrong: combinatorial variants
(`Tuple2UInt`, `ListFloat`, …), or a claim that discards a component — calling a pair of
integers `UInt` loses the second half, calling it `String` loses the fact that both halves are
numbers. So `shape` carries the arity and `payload` carries the element type, and the two
never contradict each other. Across the **326** published pairs the measured distribution is
`Scalar=192`, `-=91` (no payload), `Tuple2=16`, `OptionalScalar=10`, `Mixed=8`, `ListScalar=5`,
`Tuple4=2`, `Tuple3=1`, `OptionalTuple2=1`; `payload` is `String=101`, `UInt=80`, `Bool=27`,
`Int=13`, `Float=9`, `Color=4`, `Rect=1`.

The same reasoning applies to the values that were *not* invented. Domain types (`Font`,
`DateRange`, `BarcodeResult`, `Shortcut`, and the rest) are all carried as token strings with
`payload = String`, because a designer that does not understand a domain object can still
display it and forward it, whereas inventing a JSON encoding for each one would be
manufacturing semantics the library does not have. `Color` and `Rect` are the exception: they
already have `CapabilityValue` variants and stay on that existing pipeline.

### The payload type is derived, not written down, and a gate re-derives it independently

Three hundred and twenty-six hand-written payloads would be 326 opportunities to guess wrong,
and a wrong declaration is worse than a missing one: the designer draws a connection that
cannot be made. `tools/derive_event_payloads.py` therefore reads each payload off the
**signal declaration itself** — struct name, then `pub <name>: SignalN<T>` or
`pub fn <name>_signal()`, then `T` recursed through `Option`/`Vec`/tuples — and exits with an
error if any step cannot resolve, rather than skipping. The published *names* are kept separate
in `tools/event_published_census.txt` so the deriver can never take its own previous output as
the source of names; that confusion is what let an empty table survive rounds, because "the
derivation failed" and "this control publishes nothing" look identical.

The gate `tools/check_event_payload_types.sh` is a **second independent reader**, not a
comparison against the generator — comparing the table to the generator's output would be
tautological, since any generator bug would appear on both sides. It parses the `EventSchema`
rows actually declared in `src/widget/capability/event_payloads.rs`, re-derives each pair from
the signals, and reports control, event and both answers on a mismatch, along with coverage in
both directions. Reverse injection proves the gate can fail:
`python3 tools/check_event_payload_types.py --inject=slider.value_changed` reports that the row
claims `payload=Bool/shape=Scalar` while the signal `Signal1<i32>` is `payload=Int/shape=Scalar`
and exits 1 — and the gate itself fails if an injection does **not** produce a failure, so a
comparison that only ever prints `ok` cannot pass as a check.

### The designer manifest round-trips byte-identically

`capability_manifest_json(factory, control)` exports one control's capability description and
`DesignerManifest::from_json` reads it back through an **independent parser**. Serialisation is
hand-written rather than `serde`-derived, and the reason is a measured feature fact: `serde` is
in the `desktop`, `tablet` and `mobile` feature lists but **not** in `mini` or `embedded`, so a
`derive(Serialize)` on the capability layer would make that layer's data shape depend on the
profile — and a second, `cfg`-gated description is exactly the duplication the project sets out
to avoid. The handwritten encoder gives a stable field order and a diffable document.

`tests/designer_manifest_roundtrip_test.rs` exports a control, loads it with the independent
parser, exports again, and asserts the two strings are equal — for **every one of the 187
controls, not a sample** — plus sentinels (`slider.value_changed` carries `"payload": "int"`
and `slider_pressed` carries `"payload": null`) so that "two empty strings are equal" cannot
pass. A byte-identical assertion is what makes the load half real: it is what rejected an
ingenious-looking `Color` default written as `[1,2,3,4]` when the capability table actually
stores `"#DCDCDCFF"`, and it caught a trailing comma the writer emitted before `}`.

### One call wires every published event, and "is it wired?" is queryable

`EventSignalBinder::forward_all(widget)` wires **every** event the control publishes in a single
call, instead of one `connect_event` per name. Alone that is convenience; what makes it safe is
`event_is_wired(widget, event_name)`, which answers a question `connect_event` cannot. Wiring
18 of a control's 20 events is a silent failure: every call returns success, nothing is
reported, and the two events that were missed simply never arrive.
`tests/event_wiring_test.rs` covers the lifecycle directly — one call wires a unit event and a
payload-carrying event, every published event of a converted control resolves, an unwired event
reports unwired rather than succeeding, an unknown name reports false, a detached binder
reports nothing wired, and a control with no events reports zero.

`bash tools/check_event_signal_dyn.sh` covers the converted controls independently, resolving
every published name of `button` (4), `check_box` (2) and `slider` (4) through the dynamic
signal path.

### The JSON event route was merged: one path had no gate at all

`src/json/` held a second, independent event path of **eight hard-coded `on_*` keys**
(`on_click`, `on_change`, `on_close`, `on_double_click`, `on_focus`, `on_blur`,
`on_selection_changed`, `on_value_changed`) matched by hand in the loader. The two sets did not
intersect in the way that matters: `on_click` is not a published name — `clicked` is — and
adding a published event to a control never made it declarable in JSON. Nothing covered the
path, so it could drift, and it had.

A node can now declare handlers against the **published name**, resolved against the capability
table:

```json
{ "button": { "text": "Go", "events": { "clicked": "on_go" } } }
```

The `on_*` keys are **kept**, and the reason is that they are not a second spelling of the same
thing: `on_close` means the trigger intent `Closed`, `on_selection_changed` means
`SelectionChanged`, and neither is a name the published table carries; `on_double_click`,
`on_focus` and `on_blur` exist because a pointer-driven control routes them through a value
callback the published signal cannot address. Each key now carries an explicit trigger marker
in one table (`MARKER_KEYS`) rather than being extracted by two positional functions, and
`tools/check_json_event_route.sh` asserts the boundary mechanically in both directions: every
`on_*` key the loader reads must carry a stated marker, and every `events:` name must be one the
capability table publishes. The eight keys and 326 published events are reported by the gate on
every run, with a reverse injection proving it fails when the single key source is broken.

### Mirror fields are individually classified, and containers honour `enabled`

Every `WindowState` field is now either a documented fallback or has a **named test proving it
is read** (`src/app/handle.rs`). "Written but never read" is a mirror that drifts into a false
fact, and the classification is parsed against the struct by the test itself, so adding a 14th
field fails until someone states which category it is in and names the test that backs the
claim. The 13 fields split into platform-first fallbacks (six backends return `None` from
`window_icon` and `window_min_size`; the flag mirrors use `mirrored_flag`), values read by
`center_on_screen`, and `close_callback`, which is authoritative because `close()` is its only
reader.

The handler gate covered the entry point — a control that consumes input must consult
`is_enabled()` — and had a structural blind spot at the exit: the container that owns a
*programmatic* mutator. `StackedWidget` was allowlisted as "passive: its handler only
delegates to the base", and the handler did delegate; but `set_current_index` emitted
`current_changed` while the control was disabled, so a subscriber reloaded a page the user
could not reach. `handle_event` was never on the path, so the existing gate could not see it.
`tools/check_enabled_is_honoured_containers.sh` covers the exit point the way the handler gate
covers the entry point: every programmatic signal this library emits from an allowlisted
container file must be gated by `enabled`, be absent, or carry a written reason.