rust_widgets 2.9.2

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
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! BLUE20 layer 3 — export every control as an SVG snapshot.
//!
//! # What this is for
//!
//! Layers 1 and 2 are **machine** judgements: they can assert "this control painted
//! something", "its chrome moved with the theme", "this declared property is answered".
//! None of them can say "this control looks wrong". A misplaced label, a fill that hides
//! its own border, an icon that reads as a smudge — all of those satisfy every assertion
//! and are only visible to a person looking at the picture.
//!
//! So this writes one SVG per control to `snapshots/svg/`, named by `canonical_name`:
//! the 188 files become a **reviewable artifact** rather than a number. Because they are
//! committed and regenerable, any later visual change shows up as a diff.
//!
//! # Why the geometry is fixed rather than per-control
//!
//! A size chosen per control would make each image pretty and cross-control comparison
//! impossible. A single canvas (240x120, the same `CENSUS_RECT` the census measures over)
//! means two PNGs of two controls can be laid side by side and judged against each other,
//! and it makes "does this control fill its box" a question with a visible answer.
//!
//! # Why two appearances, and why some controls have a third
//!
//! `<name>.svg` is the dark appearance (the demo's default) and `<name>.light.svg` is the
//! light one. The pair is what makes "does this control respond to the theme" checkable by
//! eye rather than only by the P3 assertion: a control whose two files are identical is
//! theme-blind in a way a person can *see*, and `tools/check_svg_snapshots.sh` requires
//! both files to exist so one cannot be quietly dropped.
//!
//! A few controls have a **state** that no static appearance can show and that a user can put
//! them into: `group_box`'s tick is the one part of it a user toggles, and its `checkable` flag
//! defaults to `false`, so an unchecked `group_box.svg` is the only picture the pair carries.
//! BLUE23 §4.1 asks for that state to enter the snapshot set as `group_box_checked` — a third
//! file, produced by [`EXTRA_APPEARANCES`], so the tick is reviewable rather than merely present
//! in the source. Extras are declared here rather than invented per control so the set is one
//! list, and `tools/check_svg_snapshots.sh` derives its expectations from the same list.
//!
//! # Regenerating
//!
//! ```text
//! cargo run --no-default-features --features desktop --example export_control_svgs
//! ```
//!
//! `tools/check_svg_snapshots.sh` runs exactly that and requires the result to match the
//! committed files byte for byte, so a change to any control's drawing appears as a diff
//! in review (rule #106).

#![cfg(all(not(feature = "mini"), not(target_arch = "wasm32")))]

use rust_widgets::core::{Color, Point, Rect, Size};
use rust_widgets::render::{PaintBackend, RenderContext, SvgPaintBackend};
use rust_widgets::theme::{theme_test_guard, AppearanceMode};
use rust_widgets::widget::census::{install_preset_appearances, CENSUS_RECT, CENSUS_TEXT};
use rust_widgets::widget::svg::render_widget_to_svg_on;
use rust_widgets::widget::{draw_bridge::draw_of, Draw, Widget, WidgetFactory};
use std::fs;
use std::path::Path;

/// Where the snapshots live, relative to the crate root.
const OUTPUT_DIR: &str = "snapshots/svg";

/// The marker every generated SVG carries.
///
/// Rule #106 asks for snapshots that a gate can recognise as **generated**: without it a
/// "skip non-generated files" check would treat these as hand-written artifacts and could
/// legitimately skip them, which is the silent-coverage loss the rule is about.
/// The `tools/check_generated_sources.sh` machinery looks for this shape.
const GENERATED_MARKER: &str = "<!-- GENERATED by examples/export_control_svgs.rs -->";

/// How long one settle tick advances a control's transition, in milliseconds.
///
/// 16 ms is one frame at 60 Hz, so settling a transition costs about as many ticks as a user would
/// see frames — and the loop exits as soon as the control reports it has stopped.
const SETTLE_TICK_MS: u32 = 16;

/// The most settle ticks the exporter will spend on one control.
///
/// A bound rather than an unbounded `while`: a perpetual animation (a spinner, a skeleton loader)
/// never reports settled, and an unbounded loop would hang the export. Perpetual animations are
/// drawn at this phase, which is what makes their snapshots reproducible.
const MAX_SETTLE_TICKS: usize = 64;

/// The controls that have a reviewable **interactive state** beyond the two appearances.
///
/// # Why this is a list and not per-control code
///
/// The snapshot set is derived from this table, so "which files exist" and "which states were
/// exercised" cannot disagree. A per-control `if` in the loop below would put the two facts in
/// different places, and the file's existence would stop being evidence that anything looked at
/// the state.
///
/// Each entry is `(control, suffix, describe)`:
///
/// * `control` — the registry name, i.e. the `<name>` half of the file name;
/// * `suffix` — appended to the file name, so `group_box` + `_checked` is `group_box_checked.svg`;
/// * `describe` — the `<!-- control: … -->` marker's state word, so a reader of one file can tell
///   which state it shows without comparing it against its siblings.
const EXTRA_APPEARANCES: &[(&str, &str, &str)] = &[
    // The default icon is a single-contour token. Keep a counter-bearing icon as a visual baseline
    // so compound filling remains reviewable in both appearances.
    ("icon", "_info", "info"),
    // BLUE23 §4.1: the tick is the only part of a group box a user toggles, and `create_group_box`
    // leaves `checkable` false — so without this the checked appearance is absent from the set.
    ("group_box", "_checked", "checked"),
    // BLUE24 §10A.6 criterion 6 ("every declared face is reviewable"): `frame`'s default shape is
    // `Box`, which paints an outline and **no fill at all**, so the three shapes that *do* paint a
    // face — `styled_panel`, `win_panel`, and the 3D `panel` edge — had no snapshot anywhere in the
    // set. A control with three declaring draw paths and one exercised path is the coverage loss
    // the criterion is about: a defect inside `draw_styled_panel_frame` was invisible to every
    // automated check this crate has.
    //
    // The shape is a factory property (`FRAME_PROPERTIES`), so the extra appearances are the three
    // values of it that are otherwise unreachable from the default construction.
    ("frame", "_styled_panel", "styled_panel"),
    ("frame", "_win_panel", "win_panel"),
    ("frame", "_panel", "panel"),
    // A toggle's *on* state is the half a user actually wants to check, and it was in no snapshot.
    //
    // `switch`, `radio_button`, `check_box` and `toggle_button` all publish `set_checked`, and the
    // default construction of the first two leaves them **off** — so `radio_button.svg` showed an
    // empty ring, and the appearance a user sees when the control is on had never been rendered by
    // anything. `check_box` was the accidental exception (its constructor starts checked), which is
    // exactly the kind of accident that hides a gap: one control in the family happened to exercise
    // the state, so "the family is covered" looked true.
    //
    // This is BLUE24 §10A.6 criterion 6 — every declared state must be reviewable — applied to
    // a state a user toggles rather than to a drawing path a theme selects.
    //
    // # Why `switch` is not in this list
    //
    // It was, as `switch_on`, and the file turned out to be **byte-identical** to `switch.svg` once
    // the exporter settled animations before drawing (see `settle`): `sample_fill` already turns a
    // switch on, so the default file already shows the on state and the extra showed the same
    // picture under a different name. That is the defect this list exists to avoid — an artifact
    // that adds a file without adding information — so the entry was removed rather than kept.
    // A switch's *off* state is the one the default file cannot show, and that gap is real; it is
    // recorded here rather than papered over with a duplicate.
    ("radio_button", "_checked", "checked"),
    ("toggle_button", "_checked", "checked"),
    // A tooltip's **shown** state is the only state a user ever sees, and it was in no snapshot.
    //
    // `create_tooltip` starts hidden, and the default file exports that rest state — where the
    // bubble is composited fully toward the window by the fade, so `tooltip.svg`'s bubble fill
    // *equals* the window fill and the picture says nothing about the control. That hid a real
    // defect for several rounds: the bubble colour was taken from a field whose default is
    // `rgba(40,40,40,220)` rather than the window, so the theme's `inverse_surface` arm below it
    // was unreachable and the shown bubble painted the same near-black in **both** appearances
    // (1.27:1 against the dark window — a smudge, not a tooltip). Measured before the fix with
    // `tests/tooltip_paint_probe.rs`. The defect was invisible to the snapshot set precisely
    // because the snapshot set had no shown tooltip in it.
    ("tooltip", "_shown", "shown"),
    // A skeleton's `Rect` shape is the constructor's default, so the other two declared
    // shapes (`Circle`, `TextLine`) had no snapshot at all even though `set_shape`
    // publishes them (SNAP §16.2-3). They are shapes, not states, so they are exported
    // as extra appearances rather than as `sample_fill` arms.
    ("skeleton_loader", "_circle", "circle"),
    ("skeleton_loader", "_textline", "textline"),
];

/// The primary/companion pairings this gallery exports.
///
/// Each entry is `(stem, primary, companion)`:
///
/// * `stem` is the primary's registry name, so the files are `pair_<stem>.svg` (dark) and
///   `pair_<stem>.light.svg` (light);
/// * `primary` is the registry name the exporter constructs through the factory;
/// * `companion` is the type name the scene builder constructs directly. It is documentation
///   for a reader — the builder below is the single place the real constructor is chosen.
///
/// The table is data rather than a hand-written list of file names so the two facts a reader
/// cares about — which pairs exist, and which primary each depicts — cannot disagree, and so
/// `tools/check_svg_snapshots.sh` and `tools/generate_control_index.py` can derive their own
/// pair counts from the same source.
const PAIRS: &[(&str, &str, &str)] = &[
    ("combo_box", "combo_box", "SuggestionPopup"),
    ("line_edit", "line_edit", "ValidationBubble"),
    ("auto_complete_edit", "auto_complete_edit", "RecentValuesPopup"),
    ("fab", "fab", "SpeedDial"),
    ("swipe_to_dismiss", "swipe_to_dismiss", "SwipeActions"),
    ("navigation_drawer", "navigation_drawer", "DrawerSectionHeader"),
    ("line_chart", "line_chart", "ChartCrosshair"),
    ("data_grid", "data_grid", "GridFindBar"),
    ("menu_button", "menu_button", "MenuPanel"),
    ("canvas", "canvas", "LayerPanel"),
    ("media_player", "media_player", "MediaTransportBar"),
    ("video_player", "video_player", "SubtitleBar"),
    ("list_box", "list_box", "Scrollbar"),
    ("number_picker", "number_picker", "StepButtons"),
    ("date_range_picker", "date_range_picker", "PresetChipBar"),
    ("mobile_date_picker", "mobile_date_picker", "DrumColumn"),
    ("ribbon_bar", "ribbon_bar", "KeyTipBadge"),
    ("tag_input", "tag_input", "Chip"),
    ("dialog", "dialog", "ModalHost"),
    ("tag", "tag", "TagGroup"),
    ("image_gallery", "image_gallery", "ColumnChooser"),
];

/// Applies the state an extra appearance depicts. Returns `false` when the control does not have
/// the state, which is reported rather than silently skipped.
fn apply_extra_state(
    control: &str,
    suffix: &str,
    widget: &mut dyn rust_widgets::widget::Widget,
) -> bool {
    match (control, suffix) {
        ("icon", "_info") => {
            use rust_widgets::widget::{Icon, IconName};
            match rust_widgets::widget::capability::coercion::widget_as_mut::<Icon>(widget) {
                Some(icon) => {
                    icon.set_icon_enum(IconName::Info);
                    true
                }
                None => false,
            }
        }
        ("group_box", "_checked") => {
            use rust_widgets::widget::container_widgets::groupbox::GroupBox;
            match rust_widgets::widget::capability::coercion::widget_as_mut::<GroupBox>(widget) {
                Some(group_box) => {
                    group_box.set_checkable(true);
                    group_box.set_checked(true);
                    true
                }
                None => false,
            }
        }
        // The toggles, each through its own `set_checked`. The string in `EXTRA_APPEARANCES` and the
        // state that is drawn cannot disagree, because the arm names the state and the control is
        // asked for it by its published name — a mismatch would produce a snapshot of the *off* state
        // under a name that says `on`, which is the one failure this has to rule out.
        ("radio_button", "_checked") => {
            use rust_widgets::widget::base_widgets::radiobutton::RadioButton;
            match rust_widgets::widget::capability::coercion::widget_as_mut::<RadioButton>(widget) {
                Some(radio) => {
                    radio.set_checked(true);
                    true
                }
                None => false,
            }
        }
        ("toggle_button", "_checked") => {
            use rust_widgets::widget::base_widgets::toggle_button::ToggleButton;
            match rust_widgets::widget::capability::coercion::widget_as_mut::<ToggleButton>(widget)
            {
                Some(button) => {
                    button.set_checked(true);
                    true
                }
                None => false,
            }
        }
        // The three frame shapes that paint a fill, each reached through the control's own
        // `from_name`, so the string in `EXTRA_APPEARANCES` and the shape that is drawn cannot
        // disagree — a mismatch would produce a snapshot of the *default* shape under a name that
        // claims otherwise, which is the one failure mode this arm has to rule out.
        ("frame", "_styled_panel" | "_win_panel" | "_panel") => {
            use rust_widgets::widget::base_widgets::frame::{Frame, FrameShape};
            let shape = FrameShape::from_name(&suffix[1..]).expect("a name from EXTRA_APPEARANCES");
            match rust_widgets::widget::capability::coercion::widget_as_mut::<Frame>(widget) {
                Some(frame) => {
                    frame.set_frame_shape(shape);
                    true
                }
                None => false,
            }
        }
        // A shown tooltip is the state a user sees, and it is reached through the control's own
        // `show`, so the string in `EXTRA_APPEARANCES` and the state drawn cannot disagree.
        ("tooltip", "_shown") => {
            use rust_widgets::widget::dialog::tooltip::Tooltip;
            match rust_widgets::widget::capability::coercion::widget_as_mut::<Tooltip>(widget) {
                Some(tooltip) => {
                    tooltip.show();
                    true
                }
                None => false,
            }
        }
        ("skeleton_loader", "_circle") => {
            use rust_widgets::widget::display_widgets::skeleton_loader::{
                SkeletonLoader, SkeletonShape,
            };
            match rust_widgets::widget::capability::coercion::widget_as_mut::<SkeletonLoader>(
                widget,
            ) {
                Some(skeleton) => {
                    skeleton.set_shape(SkeletonShape::Circle(18));
                    true
                }
                None => false,
            }
        }
        ("skeleton_loader", "_textline") => {
            use rust_widgets::widget::display_widgets::skeleton_loader::{
                SkeletonLoader, SkeletonShape,
            };
            match rust_widgets::widget::capability::coercion::widget_as_mut::<SkeletonLoader>(
                widget,
            ) {
                Some(skeleton) => {
                    skeleton.set_shape(SkeletonShape::TextLine(160));
                    true
                }
                None => false,
            }
        }
        _ => false,
    }
}

/// One companion in a pair scene.
///
/// Most companions are full `Widget`s, so the scene advances and themes them the way the
/// runtime does. The four model-only input companions (`Chip`, `StepButtons`, `Scrollbar`,
/// `FormatToolbar`) paint through their own `Draw` impl and own no `BaseWidget`, so they carry
/// only that paint entry.
enum Companion {
    Widget(Box<dyn Widget>),
    Painted(Box<dyn Draw>),
}

impl Companion {
    /// Applies the active theme and settles a companion that owns animation state.
    fn prepare(&mut self) {
        if let Companion::Widget(widget) = self {
            rust_widgets::theme::apply_theme_to_widget(widget.as_mut());
            for _ in 0..MAX_SETTLE_TICKS {
                if !widget.is_animating() {
                    break;
                }
                let _ = widget.tick(SETTLE_TICK_MS);
            }
        }
    }

    /// Paints the companion into the scene's shared context.
    fn draw(&mut self, context: &mut RenderContext) {
        match self {
            Companion::Widget(widget) => {
                if let Some(drawable) = draw_of(widget.as_mut()) {
                    drawable.draw(context);
                }
            }
            Companion::Painted(drawable) => drawable.draw(context),
        }
    }
}

/// A primary control and the companion that belongs beside it, plus the paint order.
struct PairScene {
    primary: Box<dyn Widget>,
    companion: Companion,
    /// Whether the companion paints *behind* the primary. A modal scrim and a swipe-action
    /// background are the two cases: both are chrome the primary sits on top of.
    companion_behind: bool,
}

impl PairScene {
    fn new(primary: Box<dyn Widget>, companion: Companion, companion_behind: bool) -> Self {
        Self { primary, companion, companion_behind }
    }
}

/// Builds the scene one pair entry describes.
///
/// Every arm uses the companion's own public constructor and the geometry it documents (an
/// anchor for a popup, a track for a scrollbar, a bounds for a stepper pair), so the picture
/// and the control's real hit geometry are one thing rather than two.
fn build_pair_scene(factory: &WidgetFactory, stem: &str) -> Option<PairScene> {
    let create = |rect: Rect| factory.create(stem, rect, CENSUS_TEXT);
    match stem {
        "combo_box" => {
            use rust_widgets::widget::input_widgets::suggestion_popup::SuggestionPopup;
            let field = Rect::new(8, 8, 224, 28);
            let primary = create(field)?;
            let mut popup = SuggestionPopup::new(Rect::new(8, 36, 224, 80));
            popup.set_anchor(field);
            popup.set_viewport(Some(CENSUS_RECT));
            popup.set_metrics(4, 20);
            popup.set_items(vec![
                "Apple".to_string(),
                "Apricot".to_string(),
                "Avocado".to_string(),
                "Banana".to_string(),
            ]);
            popup.set_selected_index(Some(0));
            popup.show();
            Some(PairScene::new(primary, Companion::Widget(Box::new(popup)), false))
        }
        "line_edit" => {
            use rust_widgets::widget::input_widgets::validation_bubble::{
                ValidationBubble, ValidationSeverity,
            };
            let field = Rect::new(8, 8, 224, 28);
            let primary = create(field)?;
            let mut bubble = ValidationBubble::new(Rect::new(8, 44, 224, 24));
            bubble.set_anchor(field);
            bubble.show_message("This field is required.", ValidationSeverity::Error);
            Some(PairScene::new(primary, Companion::Widget(Box::new(bubble)), false))
        }
        "auto_complete_edit" => {
            use rust_widgets::widget::input_widgets::recent_values_popup::RecentValuesPopup;
            let field = Rect::new(8, 8, 224, 28);
            let primary = create(field)?;
            let mut popup = RecentValuesPopup::new(Rect::new(8, 36, 224, 60));
            popup.set_anchor(field);
            popup.remember("Montreal");
            popup.remember("Manchester");
            popup.remember("Marseille");
            popup.show();
            Some(PairScene::new(primary, Companion::Widget(Box::new(popup)), false))
        }
        "fab" => {
            use rust_widgets::widget::companions::{
                SpeedDial, SpeedDialAction, SpeedDialDirection,
            };
            let main = Rect::new(192, 80, 40, 40);
            let primary = create(main)?;
            let mut dial = SpeedDial::new(Rect::new(8, 0, 224, 120));
            // Upwards is the arrangement the family documents and the one where every caption has
            // room beside its circle; a horizontal dial would stack the captions over each other.
            dial.set_direction(SpeedDialDirection::Up);
            // The census canvas is 120 px tall: two 40 px buttons plus the primary fill it exactly,
            // so the sample states zero gap rather than pushing an action out of the picture.
            dial.set_spacing(0);
            dial.set_actions(vec![
                SpeedDialAction::new("Upload", "^"),
                SpeedDialAction::new("Share", ">"),
            ]);
            // The open state is the one a user can put the dial into; `prepare` settles the
            // expansion driver before the frame is drawn.
            dial.open();
            Some(PairScene::new(primary, Companion::Widget(Box::new(dial)), false))
        }
        "swipe_to_dismiss" => {
            use rust_widgets::widget::companions::{SwipeAction, SwipeActions};
            // The row is narrower than the action background on purpose: the companion paints
            // the revealed trailing band, which is the part a partial swipe uncovers.
            let row = Rect::new(8, 36, 160, 48);
            let primary = create(row)?;
            let mut actions = SwipeActions::new();
            actions.set_geometry(Rect::new(8, 36, 224, 48));
            actions.set_max_reveal_width(160);
            actions.set_trailing_actions(vec![
                SwipeAction::new("Archive"),
                SwipeAction::new("Delete"),
            ]);
            actions.set_offset(-64.0);
            Some(PairScene::new(primary, Companion::Widget(Box::new(actions)), true))
        }
        "navigation_drawer" => {
            use rust_widgets::widget::companions::DrawerSectionHeader;
            let primary = create(Rect::new(8, 8, 224, 104))?;
            // Below the drawer's own 60 px title band. At y = 36 the header was painted *over* the
            // drawer's title, so the pair showed two labels on top of each other.
            let mut header = DrawerSectionHeader::new(Rect::new(12, 72, 216, 24));
            header.set_label("Library");
            header.set_uppercase(true);
            Some(PairScene::new(primary, Companion::Widget(Box::new(header)), false))
        }
        "line_chart" => {
            use rust_widgets::widget::chart_widgets::chart_crosshair::{
                ChartCrosshair, CrosshairMode,
            };
            let chart = Rect::new(8, 8, 224, 80);
            let primary = create(chart)?;
            let mut crosshair = ChartCrosshair::new(chart);
            crosshair.set_mode(CrosshairMode::Both);
            crosshair.set_show_axis_values(true);
            crosshair.show_at_data(Point::new(120, 48), (3.5, 12.0));
            Some(PairScene::new(primary, Companion::Widget(Box::new(crosshair)), false))
        }
        "data_grid" => {
            use rust_widgets::widget::view_widgets::grid_find_bar::GridFindBar;
            let primary = create(Rect::new(8, 36, 224, 76))?;
            let mut find = GridFindBar::new(Rect::new(8, 4, 224, 28));
            find.open();
            find.set_query("alp");
            Some(PairScene::new(primary, Companion::Widget(Box::new(find)), false))
        }
        "menu_button" => {
            use rust_widgets::widget::companions::{MenuPanel, MenuPanelItem};
            let primary = create(Rect::new(96, 8, 128, 28))?;
            let mut menu = MenuPanel::new(Rect::new(96, 40, 128, 72));
            menu.set_items(vec![
                MenuPanelItem::new("New"),
                MenuPanelItem::new("Open"),
                MenuPanelItem::new("Save"),
            ]);
            menu.set_max_visible_rows(3);
            menu.set_highlighted_index(Some(0));
            menu.open();
            Some(PairScene::new(primary, Companion::Widget(Box::new(menu)), false))
        }
        "canvas" => {
            use rust_widgets::widget::companions::{LayerEntry, LayerPanel};
            let primary = create(Rect::new(8, 8, 224, 104))?;
            let mut layers = LayerPanel::new(Rect::new(130, 8, 102, 104));
            layers.set_entries(vec![
                LayerEntry::new(1, "Foreground"),
                LayerEntry::new(2, "Sketch"),
                LayerEntry::new(3, "Background"),
            ]);
            layers.select_layer(1);
            Some(PairScene::new(primary, Companion::Widget(Box::new(layers)), false))
        }
        "media_player" => {
            use rust_widgets::widget::media_widgets::media_transport::MediaTransportBar;
            let primary = create(Rect::new(8, 8, 224, 104))?;
            let mut transport = MediaTransportBar::new(Rect::new(8, 76, 224, 36));
            transport.set_position(42_000, 180_000);
            transport.set_playing(true);
            transport.set_show_timecode(true);
            Some(PairScene::new(primary, Companion::Widget(Box::new(transport)), false))
        }
        "video_player" => {
            use rust_widgets::widget::media_widgets::subtitle_bar::SubtitleBar;
            let primary = create(Rect::new(8, 8, 224, 104))?;
            let mut subtitle = SubtitleBar::new(Rect::new(8, 80, 224, 32));
            subtitle.set_text("The quick brown fox jumps over the lazy dog.");
            subtitle.set_max_lines(2);
            Some(PairScene::new(primary, Companion::Widget(Box::new(subtitle)), false))
        }
        "list_box" => {
            use rust_widgets::widget::input_widgets::input_companions::{
                ScrollOrientation, Scrollbar,
            };
            let primary = create(Rect::new(8, 8, 224, 104))?;
            let mut bar = Scrollbar::new();
            bar.set_orientation(ScrollOrientation::Vertical, 6);
            bar.set_track(Rect::new(228, 10, 6, 100));
            bar.set_metrics(320, 100, 96);
            bar.set_auto_hide(false);
            Some(PairScene::new(primary, Companion::Painted(Box::new(bar)), false))
        }
        "number_picker" => {
            use rust_widgets::widget::input_widgets::input_companions::{
                StepButtons, StepOrientation,
            };
            let primary = create(Rect::new(8, 40, 120, 28))?;
            let mut steps = StepButtons::new(Rect::new(132, 40, 24, 28));
            steps.set_orientation(StepOrientation::Vertical);
            Some(PairScene::new(primary, Companion::Painted(Box::new(steps)), false))
        }
        "date_range_picker" => {
            use rust_widgets::widget::misc_widgets::preset_chip_bar::PresetChipBar;
            let primary = create(Rect::new(8, 8, 224, 76))?;
            let mut presets = PresetChipBar::new(Rect::new(8, 88, 224, 24));
            let today = presets.add_preset("Today");
            presets.add_preset("7 days");
            presets.add_preset("30 days");
            presets.add_preset("YTD");
            presets.set_selected(today);
            Some(PairScene::new(primary, Companion::Widget(Box::new(presets)), false))
        }
        "mobile_date_picker" => {
            use rust_widgets::widget::misc_widgets::drum_column::DrumColumn;
            let primary = create(Rect::new(8, 8, 140, 104))?;
            let mut drum = DrumColumn::new(Rect::new(156, 16, 64, 88));
            drum.set_items(vec![
                "Jan".to_string(),
                "Feb".to_string(),
                "Mar".to_string(),
                "Apr".to_string(),
                "May".to_string(),
                "Jun".to_string(),
            ]);
            drum.set_visible_rows(5);
            drum.set_selected_index(2);
            Some(PairScene::new(primary, Companion::Widget(Box::new(drum)), false))
        }
        "ribbon_bar" => {
            use rust_widgets::widget::advanced_widgets::key_tip_badge::KeyTipBadge;
            let primary = create(Rect::new(8, 8, 224, 56))?;
            let mut badge = KeyTipBadge::new("F");
            let badge_rect = badge.rect_for(Rect::new(18, 18, 44, 28));
            badge.set_geometry(badge_rect);
            Some(PairScene::new(primary, Companion::Widget(Box::new(badge)), false))
        }
        "tag_input" => {
            use rust_widgets::widget::input_widgets::input_companions::Chip;
            let primary = create(Rect::new(8, 8, 224, 40))?;
            let mut chip = Chip::new("urgent");
            chip.set_bounds(Rect::new(8, 56, 112, 24));
            chip.set_selected(true);
            Some(PairScene::new(primary, Companion::Painted(Box::new(chip)), false))
        }
        "dialog" => {
            use rust_widgets::widget::dialog::modal_host::ModalHost;
            let primary = create(Rect::new(20, 20, 200, 80))?;
            let mut host = ModalHost::new(CENSUS_RECT);
            host.push(primary.id());
            Some(PairScene::new(primary, Companion::Widget(Box::new(host)), true))
        }
        "tag" => {
            use rust_widgets::widget::display_widgets::tag_group::{TagGroup, TagSelectionMode};
            let primary = create(Rect::new(8, 8, 80, 28))?;
            let mut group = TagGroup::new(Rect::new(8, 44, 224, 64));
            group.set_selection_mode(TagSelectionMode::Multiple);
            group.set_tags(vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
            Some(PairScene::new(primary, Companion::Widget(Box::new(group)), false))
        }
        "image_gallery" => {
            use rust_widgets::widget::view_widgets::column_chooser::ColumnChooser;
            use rust_widgets::widget::view_widgets::data_grid::ColumnSpec;
            let primary = create(Rect::new(8, 8, 110, 104))?;
            let mut chooser = ColumnChooser::new(Rect::new(126, 8, 106, 104));
            let columns = vec![
                ColumnSpec::new("Name", 90),
                ColumnSpec::new("Camera", 70),
                ColumnSpec::new("Email", 120),
                ColumnSpec::new("Status", 80),
            ];
            let hidden = columns[2].to_token();
            chooser.set_columns(columns);
            chooser.set_column_visible(&hidden, false);
            Some(PairScene::new(primary, Companion::Widget(Box::new(chooser)), false))
        }
        _ => None,
    }
}

/// Draws a pair scene through the same SVG backend the single-control snapshots use.
///
/// One backend for both controls is what makes the pair a *picture* rather than two documents
/// stacked: the primary and its companion share one canvas, one clip and one palette state.
fn render_pair_scene(scene: &mut PairScene, backdrop: Color) -> String {
    let mut backend = SvgPaintBackend::new(Size::new(CENSUS_RECT.width, CENSUS_RECT.height));
    backend.begin_frame(backdrop);
    {
        let mut context = RenderContext::new(&mut backend);
        if scene.companion_behind {
            scene.companion.draw(&mut context);
        }
        if let Some(drawable) = draw_of(scene.primary.as_mut()) {
            drawable.draw(&mut context);
        }
        if !scene.companion_behind {
            scene.companion.draw(&mut context);
        }
    }
    backend.end_frame();
    backend.finish()
}

/// Wraps a pair rendering with its provenance comment.
fn decorate_pair(
    body: &str,
    stem: &str,
    primary: &str,
    companion: &str,
    appearance: AppearanceMode,
) -> String {
    let label = match appearance {
        AppearanceMode::Light => "light",
        AppearanceMode::Dark => "dark",
    };
    format!(
        "{GENERATED_MARKER}\n<!-- pair: pair_{stem} | primary: {primary} | companion: {companion} | appearance: {label} -->\n{body}\n"
    )
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialises against any other test or probe that switches the process-wide theme, so
    // this cannot observe a half-switched palette.
    let _guard = theme_test_guard();
    install_preset_appearances();

    // The menu family reads a process-wide, hardware-adaptive effect policy. A committed SVG
    // must not depend on the GPU or battery of the machine that regenerated it, so the
    // exporter installs the deterministic verification policy before any control exists.
    #[cfg(feature = "advanced-widgets")]
    rust_widgets::menu_config::use_verification_policy();

    // The snapshots are user-facing pictures, so the widget layer must see the same catalogue a
    // running application has. Without this the action buttons drew their catalogue **keys**
    // (`common.button.ok`), and the 80 px row elided that to `common....` — the defect a reviewer
    // sees immediately in `message_box.svg`. `App::init` does this in an application; the exporter
    // has no `App`, so it does it here, before any control is constructed.
    #[cfg(feature = "i18n")]
    rust_widgets::i18n::init();

    let dir = Path::new(OUTPUT_DIR);
    fs::create_dir_all(dir)?;

    let factory = WidgetFactory::new_with_defaults();
    let names = factory.widget_names();

    let mut written = 0usize;
    let mut failed: Vec<String> = Vec::new();
    // Counted so the number of controls that received sample data is *reported* rather than assumed. A
    // control whose fill silently stopped matching its name would otherwise be indistinguishable from one
    // that never had data to begin with.
    let mut filled = 0usize;

    for name in &names {
        for (appearance, suffix) in [(AppearanceMode::Dark, ""), (AppearanceMode::Light, ".light")]
        {
            rust_widgets::theme::global_theme_manager().set_appearance(appearance);
            let Some(mut widget) = factory.create(name, CENSUS_RECT, CENSUS_TEXT) else {
                failed
                    .push(format!("{name}: the registry publishes it but `create` returned None"));
                continue;
            };
            // Give the data-bearing controls their content.
            //
            // # Why here and not in the factory
            //
            // `create` is the production path, so a host that asked for an empty table must get an empty
            // one; the sample data exists for the picture. Applying it after construction and before the
            // first draw is what keeps those two requirements apart. See
            // `widget::sample_fill`'s module docs.
            if rust_widgets::widget::sample_fill::apply(name, widget.as_mut()) {
                filled += 1;
            }

            // The theme the control renders under is applied the way the runtime applies
            // it, so the snapshot shows the colours a user would actually get. Without
            // this the two appearances produced identical drawings — the files existed and
            // proved nothing, which is precisely the failure rule #106 is about.
            //
            // # Why this runs **after** the sample fill, and not before
            //
            // `apply_active_theme` looks the control up by `"<kind>:<state>"`, and the state it
            // asks for is `widget.widget_state()`. Applying the theme first therefore resolved the
            // **constructor's** state, so every `:checked` key in the presets was invisible to the
            // snapshot: `check_box.svg` was drawn checked but painted the *resting* fill, because at
            // theme time the box was still unchecked. A state override the picture cannot show is
            // the same defect as a state the control never reports — the key is declared and
            // carries nothing.
            //
            // The runtime has the same ordering requirement, and for the same reason: a style is
            // resolved *for the state the control is in*. A host that re-applies the theme when a
            // control's state changes (which is what `request_redraw`'s callers do) reaches the same
            // answer this ordering reaches once.
            rust_widgets::theme::apply_theme_to_widget(widget.as_mut());

            // Settle any animation the sample data started; see `settle` for the defect this
            // removes (`switch.svg` captured a mid-travel frame).
            settle(widget.as_mut());

            write_one(
                dir,
                &mut written,
                name,
                suffix,
                "",
                "",
                widget.as_mut(),
                appearance,
                &mut failed,
            )?;
        }

        // The interactive states, one file each. Rendered under the dark appearance: these exist to
        // show a *shape* the default file cannot, and the light/dark question is already answered by
        // the pair above.
        for (control, state_suffix, state_word) in EXTRA_APPEARANCES {
            if control != name {
                continue;
            }
            rust_widgets::theme::global_theme_manager().set_appearance(AppearanceMode::Dark);
            let Some(mut widget) = factory.create(name, CENSUS_RECT, CENSUS_TEXT) else {
                continue;
            };
            rust_widgets::widget::sample_fill::apply(name, widget.as_mut());
            if !apply_extra_state(control, state_suffix, widget.as_mut()) {
                failed.push(format!(
                    "{name}{state_suffix}: the extra appearance names a state this control does not have"
                ));
                continue;
            }
            // After the state, for the same reason the two-appearance loop above applies it there:
            // the state is what the lookup key is built from, so theming first would resolve the
            // constructor's state and this file would show the state's *shape* in the resting
            // colours — which is exactly what it is not for.
            rust_widgets::theme::apply_theme_to_widget(widget.as_mut());
            settle(widget.as_mut());
            write_one(
                dir,
                &mut written,
                name,
                state_suffix,
                &format!(" ({state_word})"),
                state_word,
                widget.as_mut(),
                AppearanceMode::Dark,
                &mut failed,
            )?;
            // ... and the same state under the light appearance.
            //
            // The pair above settles "does this control respond to an appearance switch" for the
            // *default* shape only. A shape reachable solely through a factory property needs its
            // own pair, or the light rendering of `draw_win_panel_frame`'s white/grey bevel — whose
            // whole purpose is an illusion of relief — would never have been looked at.
            rust_widgets::theme::global_theme_manager().set_appearance(AppearanceMode::Light);
            let Some(mut widget) = factory.create(name, CENSUS_RECT, CENSUS_TEXT) else {
                continue;
            };
            rust_widgets::widget::sample_fill::apply(name, widget.as_mut());
            if !apply_extra_state(control, state_suffix, widget.as_mut()) {
                continue;
            }
            rust_widgets::theme::apply_theme_to_widget(widget.as_mut());
            settle(widget.as_mut());
            write_one(
                dir,
                &mut written,
                name,
                &format!("{state_suffix}.light"),
                &format!(" ({state_word}, light)"),
                state_word,
                widget.as_mut(),
                AppearanceMode::Light,
                &mut failed,
            )?;
        }
    }

    // Companion pairings: a primary control together with the companion that belongs beside
    // it, drawn into one scene rather than two separate pictures. Each pair gets the same two
    // appearances as a control, for the same reason: a pair whose files are byte-identical is
    // a pair that does not respond to the theme.
    let mut pairs_written = 0usize;
    for (stem, primary_name, companion_name) in PAIRS {
        for (appearance, suffix) in [(AppearanceMode::Dark, ""), (AppearanceMode::Light, ".light")]
        {
            rust_widgets::theme::global_theme_manager().set_appearance(appearance);
            let Some(mut scene) = build_pair_scene(&factory, stem) else {
                failed.push(format!("pair_{stem}: the factory could not create `{primary_name}`"));
                continue;
            };
            // The primary is filled and themed exactly as its single-control snapshot is; the
            // companion is themed and settled by `Companion::prepare`.
            rust_widgets::widget::sample_fill::apply(primary_name, scene.primary.as_mut());
            rust_widgets::theme::apply_theme_to_widget(scene.primary.as_mut());
            settle(scene.primary.as_mut());
            scene.companion.prepare();

            let backdrop = rust_widgets::theme::global_theme_manager()
                .current_theme()
                .map(|active| active.colors.background)
                .unwrap_or(Color::WHITE);
            let body = render_pair_scene(&mut scene, backdrop);
            let document = decorate_pair(&body, stem, primary_name, companion_name, appearance);
            fs::write(dir.join(format!("pair_{stem}{suffix}.svg")), document)?;
            pairs_written += 1;
        }
    }

    // The index is generated too, so the list of controls cannot drift from the files.
    fs::write(dir.join("README.md"), index(&names))?;

    println!("wrote {written} SVG snapshots for {} controls into {OUTPUT_DIR}/", names.len());
    println!("wrote {pairs_written} companion pair snapshots for {} pairs", PAIRS.len());
    println!(
        "checked={} skipped=0 failed={} sample-filled={}",
        names.len(),
        failed.len(),
        // Two appearances per control, so the counter is per-export; halved for the control count.
        filled / 2
    );
    if !failed.is_empty() {
        for entry in &failed {
            eprintln!("  {entry}");
        }
        return Err(format!("{} control(s) could not be exported", failed.len()).into());
    }
    Ok(())
}

/// Advances a control's transition a bounded number of times, so a settleable animation reaches its
/// rest state before the picture is taken.
///
/// # The defect this removes
///
/// `sample_fill` turns feature states *on* — `switch.set_checked(true)`, for one — and a control
/// with an animated transition therefore starts one. Drawing the very next frame captured
/// `switch.svg` **mid-travel**: measured, the track was a blend of the off and on endpoint colours
/// (travel ≈ 0.10), which is a picture of a frame no user rests in. It is neither the off state nor
/// the on state, and it moved for a reason — the A-2 fix changed the *ON endpoint* to the theme's
/// declared `switch:checked` fill — that has nothing to do with the state the file claims to show.
///
/// # Why a perpetual animation is not a failure
///
/// Some controls animate **forever** by design: a spinner, a skeleton loader's shimmer, a code
/// editor's caret, a terminal cursor. Their animation has no rest state to settle to — a spinner
/// that stopped would be a broken spinner — so "still animating after the bound" is the *correct*
/// answer for them, not a defect. The loop is therefore bounded and reaching the bound is
/// **accepted**: those controls are drawn at a defined, repeatable phase (`MAX_SETTLE_TICKS *
/// SETTLE_TICK_MS` into the animation), which is exactly what a snapshot of a perpetual animation
/// must be for the file to be regenerable byte-for-byte.
///
/// # Why the bound is the same for every control
///
/// A per-control bound would need a table of "how long is long enough", and a wrong entry would
/// silently re-introduce the mid-travel defect for one control. One bound generous enough for every
/// settleable transition (measured: the slowest is the switch's travel, well under 100 ms) is the
/// whole rule, and it needs no maintenance when a control is added.
fn settle(widget: &mut dyn rust_widgets::widget::Widget) {
    for _ in 0..MAX_SETTLE_TICKS {
        if !widget.is_animating() {
            return;
        }
        let _ = widget.tick(SETTLE_TICK_MS);
    }
}

/// Renders one widget and writes one snapshot file.
///
/// # Why this is extracted
///
/// The two-appearance loop and the extra-appearance loop both need the same four steps: attach
/// the `Draw` view, pick the backdrop, render, write. Keeping one copy is what makes an extra
/// appearance impossible to render *differently* from a default one — the failure mode a second
/// inline copy would invite.
#[allow(clippy::too_many_arguments)]
fn write_one(
    dir: &Path,
    written: &mut usize,
    name: &str,
    suffix: &str,
    caption: &str,
    state_word: &str,
    widget: &mut dyn rust_widgets::widget::Widget,
    appearance: AppearanceMode,
    failed: &mut Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let Some(drawable) = draw_of(widget) else {
        failed.push(format!("{name}: the widget has no `Draw` implementation"));
        return Ok(());
    };

    // A control that paints no background of its own (a `Label`, a `Separator`) is
    // composited over the surface it would really sit on — the active theme's own
    // background. Filling the frame with a fixed white made exactly those controls
    // unreadable in the snapshot: the dark theme's near-white ink on white showed as a
    // blank rectangle while the light snapshot of the same control looked fine. The
    // difference between the two files was an artefact of the exporter, not of the
    // control, and the snapshots exist to show the control.
    let backdrop = rust_widgets::theme::global_theme_manager()
        .current_theme()
        .map(|active| active.colors.background)
        .unwrap_or(rust_widgets::core::Color::WHITE);
    let body = render_widget_to_svg_on(drawable, CENSUS_RECT, backdrop);
    let document = decorate(&body, name, appearance, caption, state_word);
    fs::write(dir.join(format!("{name}{suffix}.svg")), document)?;
    *written += 1;
    Ok(())
}

/// Wraps the renderer's `<svg …>` body with the provenance comment and a theme label.
///
/// The renderer's own output is left byte-for-byte intact after the opening tag, so a
/// diff of two snapshots is a diff of the drawing rather than of this wrapper.
///
/// # Why the caption and the state word are two arguments
///
/// They are two different readers' spellings of one fact, and collapsing them cost a snapshot.
/// `state_word` is what a **gate** reads out of the provenance line, and its contract is the bare
/// token the extra appearance was declared with — `checked`. `caption` is what a **person** reads
/// in `group_box_checked.svg` to know what it shows without diffing it against its siblings, and it
/// is free to be prose. Passing one string for both meant adding a caption to the frame extras
/// rewrote `group_box_checked.svg`'s provenance line to `state:  (checked)`, i.e. a committed
/// snapshot changed for a reason that had nothing to do with any control's drawing.
fn decorate(
    body: &str,
    name: &str,
    appearance: AppearanceMode,
    caption: &str,
    state_word: &str,
) -> String {
    let label = match appearance {
        AppearanceMode::Light => "light",
        AppearanceMode::Dark => "dark",
    };
    if state_word.is_empty() {
        format!("{GENERATED_MARKER}\n<!-- control: {name} | appearance: {label} -->\n{body}\n")
    } else {
        format!(
            "{GENERATED_MARKER}\n<!-- control: {name} | appearance: {label} | state: {state_word}{caption} -->\n{body}\n"
        )
    }
}

/// Builds `snapshots/svg/README.md`, listing every control with both of its files.
///
/// Generated rather than hand-written so a control added to the registry cannot leave the
/// index describing an incomplete set — which is exactly the drift this file would
/// otherwise acquire.
fn index(names: &[&'static str]) -> String {
    let mut out = String::new();
    out.push_str("<!-- GENERATED by examples/export_control_svgs.rs -->\n");
    out.push_str("# Control SVG snapshots (BLUE20 layer 3)\n\n");
    out.push_str(
        "One SVG per control, named by its **canonical name**. Each control appears twice:\n\
         `<name>.svg` is the dark appearance, `<name>.light.svg` the light one.\n\n",
    );
    out.push_str(
        "## Why the pair matters\n\n\
         Two files that are byte-identical mean the control renders the same in both\n\
         appearances, so it does not respond to the theme. The files exist so that is\n\
         visible to a person, not only to the `P3` assertion in\n\
         `tools/check_control_rendering.sh`.\n\n",
    );
    out.push_str(
        "## Regenerating\n\n\
         ```bash\n\
         cargo run --no-default-features --features desktop --example export_control_svgs\n\
         ```\n\n\
         `tools/check_svg_snapshots.sh` regenerates the set and requires it to match the\n\
         committed files byte for byte, so **any** change to a control's drawing shows up as\n\
         a diff. Do not hand-edit these files: a hand-edit is reverted by the next run and\n\
         fails the gate in between.\n\n",
    );
    out.push_str(
        "## Adding a control\n\n\
         1. Register it in `src/widget/capability/registration.rs` (capability + constructor).\n\
         2. Re-run the exporter above; the new control's two files and its index row are\n\
            generated automatically — this list is derived from the registry, never typed.\n\
         3. Run `bash tools/check_svg_snapshots.sh`.\n\n",
    );
    // The pair pictures are a second kind of artifact with the same provenance: one scene,
    // two controls, two appearances. Listing them here keeps the README a complete index of
    // the exported set instead of a control-only view that silently omits them.
    out.push_str(&format!("## Companion pairings ({})\n\n", PAIRS.len()));
    out.push_str(
        "Each `pair_<name>.svg` (dark) and `pair_<name>.light.svg` (light) depicts a primary\n\
         control together with the companion that belongs beside it. `<name>` is the primary's\n\
         registry name, so the pair can be found from the control it extends.\n\n",
    );
    out.push_str("| # | Pair | Primary | Companion | Dark | Light |\n|---|---|---|---|---|---|\n");
    for (index, (stem, primary, companion)) in PAIRS.iter().enumerate() {
        out.push_str(&format!(
            "| {} | `pair_{stem}` | `{primary}` | `{companion}` | `pair_{stem}.svg` | `pair_{stem}.light.svg` |\n",
            index + 1
        ));
    }
    out.push('\n');
    out.push_str(&format!("## Controls ({})\n\n", names.len()));
    out.push_str("| # | Control | Dark | Light |\n|---|---|---|---|\n");
    for (index, name) in names.iter().enumerate() {
        out.push_str(&format!(
            "| {} | `{name}` | `{name}.svg` | `{name}.light.svg` |\n",
            index + 1
        ));
    }
    out
}