day-core 0.6.2

Declarative app development API using native UI toolkits
Documentation
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
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

//! Secondary windows (docs/windows.md): the app-facing `open_window` API, the per-window
//! registry, and the fallback that presents window content as a fullscreen cover where the
//! toolkit cannot open windows (`Cap::MultiWindow` = `Unsupported`).
//!
//! Windows live in the one thread-local tree as additional boundary roots (the same
//! "wrap an externally-owned handle" record as the primary root and the list cell
//! anchors), so bindings, `find_by_id`, and dayscript work across windows unchanged.
//! Close is asynchronous everywhere: the platform (or the cover's hide transition)
//! confirms, and teardown runs then: one path for native and programmatic closes.

use std::cell::{Cell, OnceCell, RefCell};
use std::rc::Rc;

use day_reactive::{Scope, Signal};
use day_spec::props::{CoverPatch, CoverProps};
use day_spec::{
    Attention, Event, NodeId, Point, Rect, Size, WindowChange, WindowKind, WindowLevel,
    WindowOptions, WindowRole, WindowState, WindowTabbing, kinds,
};

use crate::AnyPiece;
use crate::build::{Boundary, BuildCx, Piece};
use crate::tree::{RNode, WindowRootReply, id_to_rnode, rnode_to_id, with_tree};

fn focus_revision() -> Signal<u64> {
    FOCUS_REVISION.with(|revision| *revision.get_or_init(|| Signal::global(0)))
}

/// How a window record is realized.
enum Tier {
    /// A live native OS window; the root's handle is its content container.
    Native { closing: Cell<bool> },
    /// The toolkit answered `Pending`: native creation is in flight; the content builds
    /// when the backend calls [`finish_window_open`].
    PendingNative {
        build: Option<Box<dyn FnOnce() -> AnyPiece>>,
        title: String,
    },
    /// No native window: the content presents as a fullscreen cover in the primary
    /// window; `cover` is the cover node, `closing` gates the dismiss transition.
    Cover {
        cover: RNode,
        closing: Rc<Cell<bool>>,
    },
}

/// Close callbacks, shared so they outlive the registry entry during teardown.
type OnCloseList = Rc<RefCell<Vec<Box<dyn Fn()>>>>;

struct WindowRecord {
    root: RNode,
    key: Option<String>,
    kind: WindowKind,
    /// Whether this window keeps the app alive (docs/windows.md close policy). Derived from
    /// `kind` today; a per-window override is the natural next step.
    role: WindowRole,
    scope: Scope,
    content_scope: Option<Scope>,
    tier: Tier,
    focused: bool,
    on_close: OnCloseList,
    close_request: Option<Rc<dyn Fn()>>,
    /// What the window's `state()` signal holds: written by the app to ask, and by
    /// `Event::WindowStateChanged` to report.
    state: Signal<WindowState>,
    /// The last state the platform reported, so a report written into `state` is not sent back
    /// to the platform as a request.
    reported: Rc<Cell<WindowState>>,
    /// The `WindowOptions::remember_frame` key this window saves its frame under.
    remember: Option<String>,
}

impl WindowRecord {
    /// Whether dismissal has been requested and not yet confirmed by the platform.
    fn closing(&self) -> bool {
        match &self.tier {
            Tier::Native { closing } => closing.get(),
            Tier::Cover { closing, .. } => closing.get(),
            Tier::PendingNative { .. } => false,
        }
    }
}

day_reactive::tls_slots! {
    windows;
    static WINDOWS: RefCell<Vec<WindowRecord>> = const { RefCell::new(Vec::new()) };

    /// Invalidate reactive focused ambient lookups after command routing changes.
    // Keep the core TLS initializer independent of the reactive runtime. Bitmap cleanup
    // can touch an empty core root while reactive TLS is being destroyed (Windows GNU
    // can even recreate the root after its destructor has finished).
    static FOCUS_REVISION: OnceCell<Signal<u64>> = const { OnceCell::new() };

    /// The app's first window, so [`initial_window`] can name it. Nothing about the close
    /// policy consults this: that window is an ordinary registry record and counts exactly
    /// like the ones opened after it.
    static INITIAL_WINDOW: Cell<Option<RNode>> = const { Cell::new(None) };

    /// Set while [`quit`] disposes the windows, so the last one closing does not start a second
    /// quit.
    static QUITTING: Cell<bool> = const { Cell::new(false) };

    static PREFS: RefCell<Option<PrefsRegistration>> = const { RefCell::new(None) };
    static PREFS_ACTION: Cell<u64> = const { Cell::new(0) };
    /// What About does (docs/settings.md): opens a Day window, or the toolkit's own dialog.
    static ABOUT: RefCell<Option<Rc<dyn Fn()>>> = const { RefCell::new(None) };
    static ABOUT_ACTION: Cell<u64> = const { Cell::new(0) };
    /// The updater's Check for Updates… command (docs/settings.md).
    static UPDATES: RefCell<Option<Rc<dyn Fn()>>> = const { RefCell::new(None) };
    static UPDATES_ACTION: Cell<u64> = const { Cell::new(0) };
    static NEW_WINDOW: RefCell<Option<Rc<dyn Fn() -> AnyPiece>>> = const { RefCell::new(None) };
    /// Builders behind the macOS tab bar's "+" for windows of one tab group
    /// (`register_new_window_for`, docs/windows.md).
    static TAB_NEW: RefCell<Vec<(String, Rc<dyn Fn()>)>> = const { RefCell::new(Vec::new()) };
    static GROUP_WINDOWS: RefCell<Vec<(String, Rc<dyn Fn() -> AnyPiece>)>> = const { RefCell::new(Vec::new()) };
    static NEW_WINDOW_ACTION: Cell<u64> = const { Cell::new(0) };

    /// What the app asked `launch` for, so a window opened later can describe itself the same
    /// way (docs/windows.md). Title above all: every platform's automatic window management
    /// keys on it (the macOS Window menu and tab bar, the iPad app switcher, the Android
    /// recents card), and an untitled window is absent from all of them.
    static LAUNCH_OPTIONS: RefCell<Option<WindowOptions>> = const { RefCell::new(None) };
}

/// Record what the app handed `launch`, for [`open_new_window`] to inherit. Called once by
/// `launch_with` with the options it is about to open the primary window with, already
/// title-decorated, which is idempotent (`tag_title`).
pub fn set_launch_options(options: &WindowOptions) {
    LAUNCH_OPTIONS.with(|o| *o.borrow_mut() = Some(options.clone()));
}

/// What the app handed `launch` (title, app name, version), for surfaces that describe the
/// app (an About page). `None` before launch.
pub fn launch_options() -> Option<WindowOptions> {
    LAUNCH_OPTIONS.with(|o| o.borrow().clone())
}

/// A live secondary window (docs/windows.md). Cheap to clone; inert after close.
#[derive(Clone)]
pub struct WindowHandle {
    root: RNode,
}

impl WindowHandle {
    /// Bind command/ambient lookup to retained document state without transferring scope ownership.
    pub fn set_content_scope(&self, scope: Scope) {
        WINDOWS.with(|w| {
            if let Some(r) = w.borrow_mut().iter_mut().find(|r| r.root == self.root) {
                r.content_scope = Some(scope);
            }
        });
        focus_revision().update(|n| *n += 1);
    }

    /// Route native user close requests to the document owner. `close()` still closes
    /// unconditionally after the owner has accepted and removed its content.
    pub fn on_close_request(&self, f: impl Fn() + 'static) {
        WINDOWS.with(|w| {
            if let Some(r) = w.borrow_mut().iter_mut().find(|r| r.root == self.root) {
                r.close_request = Some(Rc::new(f));
            }
        });
    }
    /// Observe native activation (including selection of an OS window tab).
    pub fn on_focus(&self, f: impl Fn() + 'static) {
        with_tree(|t| {
            t.on_event(
                self.root,
                Rc::new(move |ev| {
                    if matches!(ev, Event::WindowFocused(true)) {
                        f();
                    }
                }),
            )
        });
    }
    /// Native order of this window's tab group. Empty where unsupported.
    pub fn tab_order(&self) -> Vec<NodeId> {
        with_tree(|t| t.window_tab_order(self.root))
    }

    /// Close the window. Asynchronous: the platform (or the cover's hide transition)
    /// confirms, then the content is disposed and `on_close` callbacks run. Focused-window
    /// lookup stops targeting this window as soon as close is requested. Idempotent.
    pub fn close(&self) {
        let root = self.root;
        let action = WINDOWS.with(|w| {
            w.borrow()
                .iter()
                .find(|r| r.root == root)
                .map(|r| match &r.tier {
                    Tier::Native { closing } => {
                        if closing.replace(true) {
                            CloseAction::None
                        } else {
                            CloseAction::Native
                        }
                    }
                    Tier::PendingNative { .. } => CloseAction::Immediate,
                    Tier::Cover { cover, closing } => {
                        if closing.get() {
                            CloseAction::None
                        } else {
                            closing.set(true);
                            CloseAction::Cover(*cover)
                        }
                    }
                })
        });
        focus_revision().update(|n| *n += 1);
        match action {
            Some(CloseAction::Native) => with_tree(|t| t.close_native_window(root)),
            // Parked pending: nothing native to wait for, so tear down now; the backend's
            // later `finish_window_open` finds the record gone and drops its window.
            Some(CloseAction::Immediate) => teardown(root),
            Some(CloseAction::Cover(cover)) => {
                with_tree(|t| t.patch(cover, Box::new(CoverPatch::Dismiss), false));
            }
            Some(CloseAction::None) | None => {}
        }
    }

    /// Bring the window to front and make it key. No-op on the cover tier (the cover is
    /// already frontmost in the primary window) and while a `Pending` open is in flight.
    pub fn focus(&self) {
        let root = self.root;
        if WINDOWS.with(|w| {
            w.borrow()
                .iter()
                .any(|r| r.root == root && !r.closing() && matches!(r.tier, Tier::Native { .. }))
        }) {
            // Record explicit activation as well as native notifications. An inactive
            // AppKit application can accept makeKeyAndOrderFront without sending a key
            // notification; commands must still follow the window we just selected.
            WINDOWS.with(|windows| {
                for window in windows.borrow_mut().iter_mut() {
                    window.focused = window.root == root;
                }
            });
            focus_revision().update(|n| *n += 1);
            with_tree(|t| t.focus_native_window(root));
        }
    }

    /// Retitle the window. Applies to live native windows; a `Pending` window picks the
    /// new title up at completion; the cover tier has no title bar (no-op).
    pub fn set_title(&self, title: &str) {
        let root = self.root;
        let title = &crate::decorate_window_title(title);
        let live = WINDOWS.with(|w| {
            let mut windows = w.borrow_mut();
            match windows.iter_mut().find(|r| r.root == root) {
                Some(r) => match &mut r.tier {
                    Tier::Native { .. } => true,
                    Tier::PendingNative { title: t, .. } => {
                        *t = title.to_string();
                        false
                    }
                    Tier::Cover { .. } => false,
                },
                None => false,
            }
        });
        if live {
            let title = title.to_string();
            with_tree(|t| t.set_native_window_title(root, &title));
        }
    }

    /// A frame source tied to this window, usable outside a piece build.
    pub fn frame_clock(&self) -> crate::frame::FrameClock {
        crate::frame::FrameClock::for_root(self.root)
    }

    /// Whether the window is still open (its record exists; a `Pending` open counts).
    pub fn is_open(&self) -> bool {
        let root = self.root;
        WINDOWS.with(|w| w.borrow().iter().any(|r| r.root == root))
    }

    /// Run `f` after the window has closed and its content is disposed, on any close path
    /// (title-bar, platform gesture, [`WindowHandle::close`]). No-op if already closed.
    pub fn on_close(&self, f: impl Fn() + 'static) {
        let root = self.root;
        WINDOWS.with(|w| {
            if let Some(r) = w.borrow().iter().find(|r| r.root == root) {
                r.on_close.borrow_mut().push(Box::new(f));
            }
        });
    }

    /// The window's display state, two ways (docs/windows.md "Window properties"): read it to
    /// follow what the user does (minimize, zoom, fullscreen), write it to ask for a state.
    ///
    /// The platform has the last word. A write is a request; the signal settles on whatever the
    /// window actually became once the platform reports it, so a request the platform declines
    /// (a fullscreen the browser refuses outside a user gesture) reads back as the state the
    /// window kept. Probe `Cap::WindowStates` and `Cap::WindowFullscreen` before offering the
    /// controls. A cover-tier window (`Cap::MultiWindow` = `Unsupported`) stays `Normal`.
    pub fn state(&self) -> Signal<WindowState> {
        let root = self.root;
        WINDOWS
            .with(|w| w.borrow().iter().find(|r| r.root == root).map(|r| r.state))
            .unwrap_or_else(|| Signal::new(WindowState::Normal))
    }

    /// This window's safe-area insets, in points (tracked): what lies under the window's own
    /// chrome. Nonzero for a [`WindowChrome::Overlay`] window, whose title bar sits over the
    /// content (docs/window-chrome.md), and on a phone running edge-to-edge. Unlike
    /// [`crate::safe_area`], which resolves the window being built and otherwise the primary,
    /// this keeps answering for this window when a binding re-runs later.
    ///
    /// [`WindowChrome::Overlay`]: day_spec::WindowChrome::Overlay
    pub fn safe_area(&self) -> day_geometry::Insets {
        crate::ambient::window_safe_area(self.root)
    }

    /// Ask for a display state: `state().set(s)`, spelled as an action.
    pub fn set_state(&self, state: WindowState) {
        self.state().set(state);
    }

    /// Keep this window's content out of screenshots, screen recordings and screen sharing
    /// (`Cap::ContentProtection`, docs/windows.md): for passwords, payment details, private
    /// messages. On iOS the content is covered while the screen is recorded or mirrored, but a
    /// screenshot still captures it.
    pub fn set_content_protected(&self, protected: bool) {
        self.apply(WindowChange::ContentProtected(protected));
    }

    /// Move and/or resize the window (docs/windows.md "Window properties"): `origin` is the outer
    /// frame's top-left corner on the desktop (`Cap::WindowPosition`), `size` the content size
    /// (`Cap::WindowGeometry`). `None` leaves that part alone.
    pub fn set_frame(&self, origin: Option<Point>, size: Option<Size>) {
        self.apply(WindowChange::Frame { origin, size });
    }

    /// The window's outer frame on the desktop, in points, where the platform says
    /// (`None` on a phone, under Wayland, and for a cover-tier window).
    pub fn frame(&self) -> Option<Rect> {
        if !self.is_native() {
            return None;
        }
        let root = self.root;
        with_tree(|t| t.native_window_frame(root))
    }

    /// The content sizes the user can resize the window between (`Cap::WindowGeometry`).
    pub fn set_limits(&self, min: Option<Size>, max: Option<Size>) {
        self.apply(WindowChange::Limits { min, max });
    }

    /// Whether the user can resize the window (`Cap::WindowGeometry`).
    pub fn set_resizable(&self, resizable: bool) {
        self.apply(WindowChange::Resizable(resizable));
    }

    /// Keep the window above other apps' windows, or let it stack normally (`Cap::WindowLevel`).
    pub fn set_level(&self, level: WindowLevel) {
        self.apply(WindowChange::Level(level));
    }

    /// Show the window on every virtual desktop / Space (`Cap::WindowLevel`).
    pub fn set_on_all_workspaces(&self, on: bool) {
        self.apply(WindowChange::OnAllWorkspaces(on));
    }

    /// Leave the window out of the taskbar and the window switcher (`Cap::WindowLevel`).
    pub fn set_skip_taskbar(&self, skip: bool) {
        self.apply(WindowChange::SkipTaskbar(skip));
    }

    /// Offer or withhold the window's minimize control (`Cap::WindowStates`).
    pub fn set_minimizable(&self, on: bool) {
        self.apply(WindowChange::Minimizable(on));
    }

    /// Offer or withhold the window's maximize / zoom control (`Cap::WindowStates`).
    pub fn set_maximizable(&self, on: bool) {
        self.apply(WindowChange::Maximizable(on));
    }

    /// Offer or withhold the window's close control (`Cap::WindowStates`). The app can still
    /// close it with [`close`](Self::close).
    pub fn set_closable(&self, on: bool) {
        self.apply(WindowChange::Closable(on));
    }

    /// Hide the window without closing it, or show it again (`Cap::WindowStates`): a palette
    /// that comes and goes, a window a status item toggles.
    pub fn set_visible(&self, visible: bool) {
        self.apply(WindowChange::Visible(visible));
    }

    /// Give this window its own light (`Some(false)`) or dark (`Some(true)`) appearance, or
    /// follow the app's again (`None`). `Cap::WindowAppearance`; where unsupported the app-wide
    /// `day::set_appearance` still applies.
    pub fn set_appearance(&self, dark: Option<bool>) {
        self.apply(WindowChange::Appearance(dark));
    }

    /// Ask for the user's attention: a Dock bounce, a flashing taskbar button
    /// (`Cap::RequestAttention`). The platform does nothing while the app is already in front.
    pub fn request_attention(&self, attention: Attention) {
        self.apply(WindowChange::RequestAttention(attention));
    }

    /// Hand one change to the toolkit, for a window that has a native side. A cover-tier window
    /// lives inside the primary one and has nothing of its own to change.
    fn apply(&self, change: WindowChange) {
        if self.is_native() {
            let root = self.root;
            with_tree(|t| t.apply_window(root, &change));
        }
    }

    fn is_native(&self) -> bool {
        let root = self.root;
        WINDOWS.with(|w| {
            w.borrow()
                .iter()
                .any(|r| r.root == root && matches!(r.tier, Tier::Native { .. }))
        })
    }
}

enum CloseAction {
    Native,
    Immediate,
    Cover(RNode),
    None,
}

/// Open a secondary window (docs/windows.md): `open_window(res::window_options().key("city").size(…), build)`.
/// `options.key` names the logical window: when a window with this key is already open it is
/// focused and returned instead of duplicated (the preferences pattern); no key always opens a
/// new one. `options.kind` says whether it is a regular window or a preferences/About panel.
/// Where the toolkit cannot open windows (`Cap::MultiWindow` = `Unsupported`; probe it to adapt
/// chrome) the content presents as a fullscreen cover in the primary window instead, closable
/// the same way.
pub fn open_window<P: Piece>(
    mut options: WindowOptions,
    build: impl FnOnce() -> P + 'static,
) -> WindowHandle {
    let key = options.key.take();
    let key = key.as_deref();
    let kind = options.kind;
    if let Some(k) = key
        && let Some(existing) = window_by_key(k)
    {
        revive_if_dismissing(existing.root);
        existing.focus();
        return existing;
    }
    // A secondary window carries the same debug tag as the primary: with several windows of
    // several builds open, that is what tells them apart.
    options.title = crate::decorate_window_title(&options.title);

    // The window's lifetime is app-owned, not caller-owned: a window opened from a button
    // must survive that page popping, so its scope hangs off the root scope.
    let scope = Scope::root().enter(Scope::child);

    match with_tree(|t| t.open_window_root(&options, kind)) {
        WindowRootReply::Open(root) => {
            register(
                root,
                key,
                kind,
                scope,
                Tier::Native {
                    closing: Cell::new(false),
                },
            );
            wire_window_events(root);
            if let Some(k) = &options.remember_frame {
                remember_frame(root, k);
            }
            scope.enter(|| {
                // Name this window for the duration of its build, so a `toolbar(...)` inside a
                // shared window builder installs on this window (docs/toolbars.md).
                crate::toolbar::with_window(root, || {
                    let piece = build();
                    let mut cx = BuildCx::new(root);
                    let _ = piece.build(&mut cx);
                })
            });
            with_tree(|t| {
                t.mark_layout_dirty();
                t.layout_if_needed();
            });
            if options.size_to_fit {
                // Measured after the first layout, because that is the only point where the
                // content's real height is known; it depends on the user's text size, their
                // language, and which rows the app decided to show.
                //
                // The content root has exactly one child (the piece the builder returned), and
                // its laid-out frame is the content's natural height.
                let fitted = with_tree(|t| {
                    t.first_child(root)
                        .and_then(|c| t.node_frame(c))
                        .map(|f| f.origin.y + f.size.height)
                        .unwrap_or(0.0)
                });
                // Never grow past what the caller asked for: `size` is the ceiling, so a panel
                // with more content than fits scrolls rather than running off the screen.
                if fitted > 0.0 && fitted < options.size.height {
                    with_tree(|t| {
                        t.fit_window(root, Size::new(options.size.width, fitted));
                        t.mark_layout_dirty();
                        t.layout_if_needed();
                    });
                }
            }
            focus_revision().update(|n| *n += 1);
            WindowHandle { root }
        }
        WindowRootReply::Pending(root) => {
            register(
                root,
                key,
                kind,
                scope,
                Tier::PendingNative {
                    build: Some(Box::new(move || AnyPiece::new(build()))),
                    title: options.title.clone(),
                },
            );
            wire_window_events(root);
            WindowHandle { root }
        }
        WindowRootReply::Unsupported => {
            open_as_cover(key, kind, scope, Box::new(move || AnyPiece::new(build())))
        }
    }
}

/// The open window registered under `key`, if any (the dayscript `screenshot` step's
/// `window:` target resolves here). Native windows being closed are excluded; a dismissing
/// fallback cover remains available so reopening its key can reverse the transition.
pub fn window_by_key(key: &str) -> Option<WindowHandle> {
    WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .find(|r| {
                r.key.as_deref() == Some(key)
                    && !matches!(&r.tier, Tier::Native { closing } if closing.get())
            })
            .map(|r| WindowHandle { root: r.root })
    })
}

/// The most recently focused open secondary window, if any. `None` ⇒ the primary window
/// is key (or no secondary window exists).
pub fn focused_window() -> Option<WindowHandle> {
    WINDOWS.with(|w| {
        w.borrow()
            .iter()
            // The same rule as `focused_scope`: a dismissing window is already behind us.
            .find(|r| r.focused && !r.closing())
            .map(|r| WindowHandle { root: r.root })
    })
}

pub(crate) fn focused_root() -> RNode {
    focused_window()
        .map(|w| w.root)
        .unwrap_or_else(|| with_tree(|t| t.root_node()))
}

/// The scope owning the content of the window that currently has focus: the scope an
/// app-wide command should resolve per-window state through (docs/state.md).
///
/// Falls back to the app's primary window, which is the same rule [`focused_window`] states:
/// a backend reports focus for the windows it opened, and "no record is focused" means the
/// primary is key (on AppKit the primary's delegate carries no node and never emits
/// `WindowFocused` at all, so that IS the primary's steady state). `None` only before boot
/// and after the last window is gone.
pub fn focused_scope() -> Option<Scope> {
    focus_revision().get();
    WINDOWS.with(|w| {
        let windows = w.borrow();
        // A dismissing window is no longer the command target, even while its content stays
        // registered until native confirmation. GTK dialogs animate their close just as
        // cover sheets do; resolving through Settings during that gap loses document state.
        let focused = windows.iter().find(|r| r.focused && !r.closing());
        let initial = INITIAL_WINDOW.with(|c| c.get());
        focused
            .or_else(|| windows.iter().find(|r| Some(r.root) == initial))
            .map(|r| r.content_scope.unwrap_or(r.scope))
    })
}

/// The scopes a focused-window lookup consults, in order: the focused window's own, then,
/// when that window is a cover, the primary window's. A cover renders inside the primary
/// window, so state the cover does not carry — an editor's per-window `Scene` while its
/// settings cover is up — is still the primary's: a handler of the content underneath that
/// fires during the presentation (a focus change, a redraw) resolves to the window it belongs
/// to rather than to a sheet that never had one (docs/state.md).
pub(crate) fn focused_scope_chain() -> Vec<Scope> {
    focus_revision().get();
    WINDOWS.with(|w| {
        let windows = w.borrow();
        let initial = INITIAL_WINDOW.with(|c| c.get());
        let primary = || {
            windows
                .iter()
                .find(|r| Some(r.root) == initial)
                .map(|r| r.content_scope.unwrap_or(r.scope))
        };
        match windows.iter().find(|r| r.focused && !r.closing()) {
            Some(front) => {
                let own = front.content_scope.unwrap_or(front.scope);
                let mut chain = vec![own];
                if matches!(front.tier, Tier::Cover { .. }) {
                    chain.extend(primary().filter(|scope| *scope != own));
                }
                chain
            }
            None => primary().into_iter().collect(),
        }
    })
}

/// The tree root of the window registered under `key`: day-script's snapshot target.
pub fn window_root_by_key(key: &str) -> Option<RNode> {
    WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .find(|r| {
                r.key.as_deref() == Some(key)
                    && !matches!(&r.tier, Tier::Native { closing } if closing.get())
            })
            .map(|r| match &r.tier {
                // The cover renders inside the primary window: its pixels are the
                // primary's pixels.
                Tier::Cover { .. } => with_tree(|t| t.root_node()),
                _ => r.root,
            })
    })
}

/// Backend-facing: complete a `Pending` window open (docs/windows.md). `id` is the node
/// the backend's `open_window` received; `raw` is the new window's content container;
/// `size` its content size in points. `false` ⇒ the window was closed before completion,
/// and the backend should drop the native window it just created.
pub fn finish_window_open(id: NodeId, raw: day_spec::RawHandle, size: Size) -> bool {
    let root = id_to_rnode(id);
    let pending = WINDOWS.with(|w| {
        let mut windows = w.borrow_mut();
        let r = windows.iter_mut().find(|r| r.root == root)?;
        match &mut r.tier {
            Tier::PendingNative { build, title } => {
                let b = build.take();
                let title = title.clone();
                r.tier = Tier::Native {
                    closing: Cell::new(false),
                };
                Some((b, title, r.scope))
            }
            _ => None,
        }
    });
    let Some((build, title, scope)) = pending else {
        return false;
    };
    if !with_tree(|t| t.adopt_window_root(root, raw, size)) {
        return false;
    }
    with_tree(|t| t.set_native_window_title(root, &title));
    // This window's size class, before its content builds (docs/size-classes.md): a second
    // window can sit in a different class from the first, which is why the signal is per-window.
    crate::ambient::set_window_size_class(
        root,
        day_spec::SizeClass::from_size(size.width, size.height),
    );
    if let Some(build) = build {
        scope.enter(|| {
            crate::toolbar::with_window(root, || {
                let piece = build();
                let mut cx = BuildCx::new(root);
                let _ = piece.build(&mut cx);
            })
        });
    }
    with_tree(|t| {
        t.mark_layout_dirty();
        t.layout_if_needed();
    });
    // The window has a native root only now, so a toolbar edit composed before this could not
    // reach it (docs/toolbars.md). Owe the windows a recompose: the bar goes out whole.
    crate::toolbar::chrome_changed();
    focus_revision().update(|n| *n += 1);
    true
}

fn register(root: RNode, key: Option<&str>, kind: WindowKind, scope: Scope, tier: Tier) {
    WINDOWS.with(|w| {
        let mut windows = w.borrow_mut();
        // A window that just opened is the key window; every platform orders it front. Seeding
        // that here rather than waiting for `Event::WindowFocused` is what makes it true for the
        // first one: the toolkit makes the window key while creating it, which on AppKit fires
        // `windowDidBecomeKey` before `wire_window_events` has installed a handler to hear it.
        // Without this, a window opened by File â–¸ New Window is never marked focused, and
        // `focused_scope` (how an app-wide menu command finds the front window's state,
        // docs/state.md) resolves to the primary until the user clicks away and back.
        for r in windows.iter_mut() {
            r.focused = false;
        }
        let (state, reported) = state_rail(root, scope);
        windows.push(WindowRecord {
            root,
            key: key.map(str::to_string),
            kind,
            role: WindowRole::from(kind),
            scope,
            content_scope: None,
            tier,
            focused: true,
            on_close: Rc::default(),
            close_request: None,
            state,
            reported,
            remember: None,
        })
    });
    focus_revision().update(|n| *n += 1);
}

/// Start remembering `root`'s frame under `key`, and put the window where it was last time
/// (docs/windows.md "Remembered frames"). A saved position no attached display shows any more is
/// dropped; the size is kept.
fn remember_frame(root: RNode, key: &str) {
    WINDOWS.with(|w| {
        if let Some(r) = w.borrow_mut().iter_mut().find(|r| r.root == root) {
            r.remember = Some(key.to_owned());
        }
    });
    let Some(saved) = crate::frames::load(key) else {
        return;
    };
    let areas: Vec<Rect> = monitors().iter().map(|m| m.work_area).collect();
    let origin = crate::frames::on_screen(saved.frame, &areas).then_some(saved.frame.origin);
    let handle = WindowHandle { root };
    handle.set_frame(origin, Some(saved.frame.size));
    if saved.maximized {
        handle.set_state(WindowState::Maximized);
    }
}

/// [`save_frame`] for a window already out of the registry (its state signal is still alive:
/// the scope is disposed after this).
fn save_frame_of_record(root: RNode, key: &str, state: WindowState) {
    if matches!(state, WindowState::Minimized | WindowState::Fullscreen) {
        return;
    }
    if let Some(frame) = remembered_frame(root) {
        crate::frames::save(
            key,
            crate::frames::Remembered {
                frame,
                maximized: state == WindowState::Maximized,
            },
        );
    }
}

/// Save the frame of every window that remembers one (window close, app quit).
pub(crate) fn save_remembered_frames() {
    let keyed: Vec<(RNode, String)> = WINDOWS
        .try_with(|w| {
            w.borrow()
                .iter()
                .filter(|r| matches!(r.tier, Tier::Native { .. }))
                .filter_map(|r| r.remember.clone().map(|k| (r.root, k)))
                .collect()
        })
        .unwrap_or_default();
    for (root, key) in keyed {
        save_frame(root, &key);
    }
}

fn save_frame(root: RNode, key: &str) {
    let handle = WindowHandle { root };
    let state = handle.state().get_untracked();
    // A minimized or fullscreen window's frame is not one to reopen at; keep the last normal
    // one and only note the zoom.
    if matches!(state, WindowState::Minimized | WindowState::Fullscreen) {
        return;
    }
    if let Some(frame) = remembered_frame(root) {
        crate::frames::save(
            key,
            crate::frames::Remembered {
                frame,
                maximized: state == WindowState::Maximized,
            },
        );
    }
}

/// What a remembered frame records: the outer frame's position, and the size Day lays the
/// content out at, which is the size `WindowChange::Frame` restores. Recording the outer size
/// would grow the window by its title bar on every launch.
fn remembered_frame(root: RNode) -> Option<Rect> {
    let outer = with_tree(|t| t.native_window_frame(root))?;
    let laid_out = with_tree(|t| t.node_frame(root)).map_or(outer.size, |f| f.size);
    Some(Rect::new(
        outer.origin.x,
        outer.origin.y,
        laid_out.width,
        laid_out.height,
    ))
}

/// The window's two-way state signal: an app write becomes a [`WindowChange::State`] request,
/// a platform report (`Event::WindowStateChanged`, handled in `wire_window_events`) is written
/// into the signal without being sent back. Owned by the window's scope, so it goes with the
/// window.
fn state_rail(root: RNode, scope: Scope) -> (Signal<WindowState>, Rc<Cell<WindowState>>) {
    let reported: Rc<Cell<WindowState>> = Rc::default();
    let state = scope.enter(|| {
        let state = Signal::new(WindowState::Normal);
        let rep = reported.clone();
        day_reactive::bind_seeded(
            WindowState::Normal,
            move || state.get(),
            move |s| {
                if *s != rep.get() {
                    WindowHandle { root }.apply(WindowChange::State(*s));
                }
            },
        );
        state
    });
    (state, reported)
}

/// The per-window event rail (native + pending tiers): resize relayouts that window,
/// focus updates the registry, close tears down, deferred one main-loop hop so the
/// disposal never runs inside the platform's close callback (a released view inside
/// `windowWillClose`/`close-request`/`closeEvent` is the top reentrancy hazard).
fn wire_window_events(root: RNode) {
    with_tree(|t| {
        t.on_event(
            root,
            Rc::new(move |ev| match ev {
                Event::WindowResized(size) => {
                    let s = *size;
                    with_tree(|t| t.set_root_size(root, s));
                    // …and re-bucket this window (docs/size-classes.md). The primary's rail does
                    // the same in `launch_with`; without it here a secondary window relayouts at
                    // its new size but keeps the class it opened at, so dragging the second
                    // window from narrow to wide never re-presented its navigation. Invisible
                    // until a window could be resized at all, which on the phones it now can.
                    crate::ambient::set_window_size_class(
                        root,
                        day_spec::SizeClass::from_size(s.width, s.height),
                    );
                }
                Event::WindowFocused(f) => {
                    let f = *f;
                    WINDOWS.with(|w| {
                        for r in w.borrow_mut().iter_mut() {
                            if r.root == root {
                                r.focused = f;
                            } else if f {
                                r.focused = false;
                            }
                        }
                    });
                    focus_revision().update(|n| *n += 1);
                }
                Event::WindowClosed => {
                    day_reactive::on_main(move || teardown(root));
                }
                Event::WindowStateChanged(s) => {
                    let s = *s;
                    let signal = WINDOWS.with(|w| {
                        w.borrow().iter().find(|r| r.root == root).map(|r| {
                            r.reported.set(s);
                            r.state
                        })
                    });
                    if let Some(signal) = signal {
                        signal.set_if_changed(s);
                    }
                }
                _ => {}
            }),
        );
    });
}

/// How many non-closing primary windows keep the app alive (docs/windows.md close policy).
/// This includes the initial window while it remains registered and open.
pub fn primary_window_count() -> usize {
    WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .filter(|r| r.role == WindowRole::Primary)
            .count()
    })
}

/// Whether any window is still holding the app open.
fn app_has_primary_window() -> bool {
    primary_window_count() > 0
}

/// Whether closing the last primary window ends the process on this platform.
///
/// macOS says no, and means it: an app with no windows stays running with its menu bar live,
/// and ⌘N reopens one; `applicationShouldTerminateAfterLastWindowClosed` defaults to false
/// for exactly this. Quitting there would be the framework overriding a platform convention
/// its users rely on. Every other desktop treats the last window as the app.
fn last_primary_close_quits() -> bool {
    match crate::status::keep_running_policy() {
        // A status item (Automatic) or the app's own choice keeps the process up.
        _ if crate::status::keeps_running() => false,
        crate::status::KeepRunning::Never => true,
        _ => !cfg!(target_os = "macos"),
    }
}

/// End the app now (docs/windows.md "Keeping the app running"): the Quit item of a status
/// item's menu, a background helper's own decision. Every window is disposed and
/// `Lifecycle::WillTerminate` delivered first, as when the last window closes.
pub fn quit() {
    let remaining: Vec<RNode> = WINDOWS.with(|w| w.borrow().iter().map(|r| r.root).collect());
    for root in remaining {
        teardown_quietly(root);
    }
    crate::lifecycle::dispatch_lifecycle(day_spec::Lifecycle::WillTerminate);
    with_tree(|t| t.quit_app());
}

/// [`teardown`] without the "was that the last primary?" check: the caller is ending the app
/// itself.
fn teardown_quietly(root: RNode) {
    QUITTING.with(|q| q.set(true));
    teardown(root);
    QUITTING.with(|q| q.set(false));
}

/// End the app because its last [`WindowRole::Primary`] window has closed.
///
/// One place, for every backend: dispose whatever is still open (a settings panel does not
/// keep an app alive, however long it has been up), deliver `WillTerminate` once, and then
/// ask the toolkit for the platform's exit.
fn quit_after_last_primary() {
    if !last_primary_close_quits() {
        return;
    }
    // Secondary windows go with the app. Collected first: teardown mutates the registry.
    let remaining: Vec<RNode> = WINDOWS.with(|w| w.borrow().iter().map(|r| r.root).collect());
    for root in remaining {
        teardown(root);
    }
    crate::lifecycle::dispatch_lifecycle(day_spec::Lifecycle::WillTerminate);
    with_tree(|t| t.quit_app());
}

/// The single teardown path (any close route lands here). Idempotent via registry
/// membership: registry-remove → scope dispose → content removal → root removal →
/// `on_close` callbacks.
fn teardown(root: RNode) {
    let Some(record) = WINDOWS.with(|w| {
        let mut windows = w.borrow_mut();
        windows
            .iter()
            .position(|r| r.root == root)
            .map(|i| windows.remove(i))
    }) else {
        return;
    };
    focus_revision().update(|n| *n += 1);
    // The toolbar forgets the window FIRST: its contributions' cleanups run inside the
    // dispose below and would otherwise re-compose a bar through gates whose signals the same
    // dispose has already dropped (a contained panic that left the next New Window blank).
    // The frame, while the native window still answers for it.
    if let (Some(key), Tier::Native { .. }) = (&record.remember, &record.tier) {
        save_frame_of_record(root, key, record.state.get_untracked());
    }
    crate::frame::forget_window(root);
    crate::toolbar::forget_window(root);
    record.scope.dispose();
    crate::ambient::forget_window(root);
    match record.tier {
        Tier::Native { .. } | Tier::PendingNative { .. } => {
            while let Some(c) = with_tree(|t| t.first_child(root)) {
                with_tree(|t| t.remove_subtree(c));
            }
            with_tree(|t| t.remove_window_root(root));
        }
        Tier::Cover { cover, .. } => {
            // The cover node lives under the primary root: drop its independent layout
            // entry, then the ordinary subtree removal takes its content with it.
            with_tree(|t| {
                t.drop_extra_layout_root(cover);
                t.remove_subtree(cover);
            });
        }
    }
    with_tree(|t| {
        t.mark_layout_dirty();
        t.layout_if_needed();
    });
    for f in record.on_close.borrow().iter() {
        f();
    }
    // The app's life is the life of its primary windows, not of the first one opened. Checked
    // after the callbacks so a handler that opens a replacement window is counted.
    if record.role == WindowRole::Primary
        && !app_has_primary_window()
        && !QUITTING.with(|q| q.get())
    {
        quit_after_last_primary();
    }
}

/// Reverse a cover dismissal that has not yet been confirmed. Close is asynchronous on this
/// tier (the hide transition runs for a quarter second, and the record stays registered until
/// `CoverHidden` comes back), so a keyed window reopened inside that window is the same window
/// arriving again, not a second one. Without this the reopen returns a handle to content the
/// pending confirmation is about to dispose, and the surface the caller asked for goes blank
/// (docs/windows.md). Clearing `closing` is also what makes that confirmation a no-op when it
/// lands: the cover's handler already gates on it.
fn revive_if_dismissing(root: RNode) {
    let cover = WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .find(|r| r.root == root)
            .and_then(|r| match &r.tier {
                Tier::Cover { cover, closing } if closing.get() => {
                    closing.set(false);
                    Some(*cover)
                }
                _ => None,
            })
    });
    let Some(cover) = cover else { return };
    with_tree(|t| {
        t.patch(
            cover,
            Box::new(CoverPatch::Present {
                background: None,
                dismiss_disabled: false,
            }),
            false,
        )
    });
}

/// The fallback tier: present window content modally in the primary window. Preferences
/// prefer a sheet on regular-width windows and remain fullscreen on compact windows.
/// NavBack dismisses; `Event::CoverHidden` confirms (docs/cover.md).
fn open_as_cover(
    key: Option<&str>,
    kind: WindowKind,
    scope: Scope,
    build: Box<dyn FnOnce() -> AnyPiece>,
) -> WindowHandle {
    let size: Rc<RefCell<Option<Size>>> = Rc::default();
    let primary = with_tree(|t| t.root_node());
    // A settings or About panel is a sheet where there is room for one; content windows cover.
    let sheet = matches!(kind, WindowKind::Preferences | WindowKind::About)
        && crate::window_size_class_untracked(primary)
            .is_some_and(|size| size.width != day_spec::WidthClass::Compact);
    let composed_sheet =
        sheet && crate::capability(day_spec::Cap::CoverSheet) == day_spec::Support::Unsupported;
    let cover = {
        let mut cx = BuildCx::new(primary);
        cx.native(
            kinds::COVER,
            &CoverProps { sheet, half: false },
            Rc::new(crate::CoverLayout { size: size.clone() }),
            crate::Flex::default(),
            Boundary::Yes,
        )
    };
    with_tree(|t| t.add_extra_layout_root(cover, Size::new(0.0, 0.0)));
    let closing: Rc<Cell<bool>> = Rc::default();
    // The window root is the cover node on this tier: one id for close/teardown.
    register(
        cover,
        key,
        kind,
        scope,
        Tier::Cover {
            cover,
            closing: closing.clone(),
        },
    );

    {
        let (size, closing) = (size.clone(), closing.clone());
        with_tree(|t| {
            t.on_event(
                cover,
                Rc::new(move |ev| match ev {
                    Event::FrameChanged(sz) => {
                        if *size.borrow() != Some(*sz) {
                            *size.borrow_mut() = Some(*sz);
                            with_tree(|t| {
                                t.set_root_size(cover, *sz);
                                t.mark_layout_dirty();
                                t.layout_if_needed();
                            });
                        }
                    }
                    // Native dismissal request (Android system back): a fallback "window"
                    // closes like a window, not like a page.
                    Event::NavBack { .. } => {
                        if !closing.get() {
                            closing.set(true);
                            with_tree(|t| t.patch(cover, Box::new(CoverPatch::Dismiss), false));
                        }
                    }
                    // The hide transition finished; now the content can go.
                    Event::CoverHidden if closing.get() => {
                        day_reactive::on_main(move || teardown(cover));
                    }
                    _ => {}
                }),
            );
        });
    }

    scope.enter(|| {
        crate::toolbar::with_window(cover, || {
            let piece = build();
            let mut cx = BuildCx::new(cover);
            if composed_sheet {
                let _ = crate::ComposedSheet { content: piece }.build(&mut cx);
            } else {
                let _ = piece.build(&mut cx);
            }
        })
    });
    with_tree(|t| {
        t.patch(
            cover,
            Box::new(CoverPatch::Present {
                background: composed_sheet.then_some(day_spec::Color::CLEAR),
                dismiss_disabled: false,
            }),
            false,
        );
        t.mark_needs_measure(cover);
        t.mark_layout_dirty();
        t.layout_if_needed();
    });
    focus_revision().update(|n| *n += 1);
    WindowHandle { root: cover }
}

// ---------------------------------------------------------------------------
// Preferences + New Window registration (docs/windows.md)
// ---------------------------------------------------------------------------

/// The registered preferences piece: its builder plus the window options it opens with.
type PrefsRegistration = (Rc<dyn Fn() -> AnyPiece>, WindowOptions);

/// The singleton key every preferences window opens under.
pub const PREFERENCES_KEY: &str = "day.preferences";

/// Declare the app's preferences piece (docs/windows.md): once, in `root()`, ideally
/// before `app_menu`. Enables the desktop Preferences window (singleton, primary+`,`),
/// the auto Settings…/Preferences menu item, and [`open_preferences`] everywhere (cover
/// fallback where the toolkit cannot open windows). The window titles itself with
/// `options.title`; use [`register_preferences_with`] to localize it or change the size.
pub fn register_preferences<P: Piece>(build: impl Fn() -> P + 'static) {
    register_preferences_with(
        WindowOptions {
            title: day_localization::t(day_localization::key::day_settings),
            version: None,
            // The width a settings panel wants, and a ceiling for the height rather than the
            // height itself: `size_to_fit` shrinks the window to whatever the rows actually
            // measure. A fixed 640 either clips the last row or leaves a band of empty panel
            // under it, and which one depends on the user's text size, so nobody can pick a
            // number that is right for everyone.
            size: Size::new(520.0, 640.0),
            min_size: None,
            size_to_fit: true,
            app_name: None,
            // Secondary windows: the app-launch ceremony belongs to `launch` alone.
            locales: None,
            title_fn: None,
            // A settings panel never becomes a tab; `WindowKind::Preferences` says so already.
            tabbing: WindowTabbing::Automatic,
            ..WindowOptions::default()
        },
        build,
    );
}

/// [`register_preferences`] with explicit window options (localized title, size).
pub fn register_preferences_with<P: Piece>(
    options: WindowOptions,
    build: impl Fn() -> P + 'static,
) {
    PREFS.with(|p| *p.borrow_mut() = Some((Rc::new(move || AnyPiece::new(build())), options)));
    if PREFS_ACTION.with(|c| c.get()) == 0 {
        PREFS_ACTION.with(|c| {
            c.set(crate::menu::register_menu_action(Rc::new(|| {
                open_preferences();
            })))
        });
    }
    // Self-heal for registration after `app_menu`: re-forward the retained model so the
    // injection pass sees the now-registered action.
    crate::menu::reinstall_app_menu();
}

/// Open-or-focus the preferences surface (docs/windows.md): a `WindowKind::Preferences`
/// singleton window on desktop, the cover fallback elsewhere: one call for menu items and
/// toolbar gears alike. `false` = no preferences piece is registered (logged).
pub fn open_preferences() -> bool {
    let Some((build, options)) = PREFS.with(|p| p.borrow().clone()) else {
        log::warn!("open_preferences without register_preferences — ignored");
        return false;
    };
    open_window(
        options.key(PREFERENCES_KEY).kind(WindowKind::Preferences),
        move || build(),
    );
    true
}

// ---------------------------------------------------------------------------
// About + Check for Updates registration (docs/settings.md)
// ---------------------------------------------------------------------------

/// The singleton key the About window opens under.
pub const ABOUT_KEY: &str = "day.about";

/// Declare what the app's About command does (docs/settings.md): once, in `root()`. Enables
/// the About menu item everywhere a desktop has one (the macOS App menu, Help elsewhere; both
/// the default menu and an installed `app_menu`) and [`open_about`]. The action is the whole
/// contract, so a toolkit-native About dialog (libadwaita's) and a Day window
/// ([`register_about_window`]) register the same way.
pub fn register_about(open: impl Fn() + 'static) {
    ABOUT.with(|a| *a.borrow_mut() = Some(Rc::new(open)));
    if ABOUT_ACTION.with(|c| c.get()) == 0 {
        ABOUT_ACTION.with(|c| {
            c.set(crate::menu::register_menu_action(Rc::new(|| {
                open_about();
            })))
        });
    }
    crate::menu::reinstall_app_menu();
}

/// [`register_about`] with a Day-rendered About window: opened under the singleton key
/// [`ABOUT_KEY`] as a `WindowKind::About` panel (fixed, centered, out of the Window menu),
/// the cover fallback where the toolkit cannot open windows. The window titles itself with
/// `options.title`.
pub fn register_about_window<P: Piece>(options: WindowOptions, build: impl Fn() -> P + 'static) {
    let build: Rc<dyn Fn() -> AnyPiece> = Rc::new(move || AnyPiece::new(build()));
    register_about(move || {
        let build = build.clone();
        open_window(
            options.clone().key(ABOUT_KEY).kind(WindowKind::About),
            move || build(),
        );
    });
}

/// Run the registered About action (docs/settings.md): the menu item's dispatch, and what a
/// toolbar button or a settings row calls. `false` = nothing registered (logged).
pub fn open_about() -> bool {
    let Some(open) = ABOUT.with(|a| a.borrow().clone()) else {
        log::warn!("open_about without register_about — ignored");
        return false;
    };
    open();
    true
}

/// The dispatch id of the About menu action (0 = unregistered). Backends wire their stock
/// About item to it; `MenuRole::About` lowering and the injection pass use it.
pub fn about_action_id() -> u64 {
    ABOUT_ACTION.with(|c| c.get())
}

/// Declare the updater's Check for Updates… command (docs/settings.md): the item appears
/// under About in the macOS App menu and in Help elsewhere, and `MenuRole::CheckForUpdates`
/// items lower to it. Registered by an update provider, not by the app.
pub fn register_check_for_updates(check: impl Fn() + 'static) {
    UPDATES.with(|u| *u.borrow_mut() = Some(Rc::new(check)));
    if UPDATES_ACTION.with(|c| c.get()) == 0 {
        UPDATES_ACTION.with(|c| {
            c.set(crate::menu::register_menu_action(Rc::new(|| {
                check_for_updates();
            })))
        });
    }
    crate::menu::reinstall_app_menu();
}

/// Run the registered Check for Updates… command. `false` = none registered.
pub fn check_for_updates() -> bool {
    let Some(check) = UPDATES.with(|u| u.borrow().clone()) else {
        return false;
    };
    check();
    true
}

/// The dispatch id of the Check for Updates… action (0 = unregistered).
pub fn check_for_updates_action_id() -> u64 {
    UPDATES_ACTION.with(|c| c.get())
}

/// Register the builder behind File â–¸ New Window and the macOS tab-bar "+" (docs/windows.md):
/// each call opens another `WindowKind::Normal` window. A `menu_role(MenuRole::NewWindow)`
/// item lowers to this action; without a registration it lowers disabled.
pub fn register_new_window<P: Piece>(build: impl Fn() -> P + 'static) {
    NEW_WINDOW.with(|p| *p.borrow_mut() = Some(Rc::new(move || AnyPiece::new(build()))));
    if NEW_WINDOW_ACTION.with(|c| c.get()) == 0 {
        NEW_WINDOW_ACTION.with(|c| {
            c.set(crate::menu::register_menu_action(Rc::new(|| {
                open_new_window();
            })))
        });
    }
    crate::menu::reinstall_app_menu();
}

/// Open a window through the registered new-window builder (the `newWindowForTab:` /
/// File â–¸ New Window path). `None` = no builder registered.
pub fn open_new_window() -> Option<WindowHandle> {
    let build = NEW_WINDOW.with(|p| p.borrow().clone())?;
    // Another window of this app, so it describes itself the way the app described its first
    // one: same title, same minimum size, same display name. A window with no title is missing
    // from the macOS Window menu and shows a blank tab, so inheriting is what makes File â–¸ New
    // Window produce something the platform can manage (docs/windows.md). Content that wants a
    // title of its own says so with `window_title` from inside the window.
    let launch = LAUNCH_OPTIONS.with(|o| o.borrow().clone());
    // Size mirrors the primary's current content size so a "duplicate window" lands familiar.
    let size = with_tree(|t| {
        let root = t.root_node();
        t.node_frame(root).map(|f| f.size)
    })
    .unwrap_or(Size::new(800.0, 600.0));
    Some(open_window(
        WindowOptions {
            title: launch.as_ref().map(|o| o.title.clone()).unwrap_or_default(),
            size,
            min_size: launch.as_ref().and_then(|o| o.min_size),
            size_to_fit: false,
            app_name: launch.as_ref().and_then(|o| o.app_name.clone()),
            version: launch.as_ref().and_then(|o| o.version.clone()),
            // Secondary windows: the app-launch ceremony belongs to `launch` alone.
            locales: None,
            // Already resolved into `title` above; calling it again would re-run app code
            // outside the launch sequence it was written for.
            title_fn: None,
            tabbing: WindowTabbing::Automatic,
            ..WindowOptions::default()
        },
        move || build(),
    ))
}

/// Register the builder behind the macOS tab bar's "+" for windows whose
/// [`WindowOptions::tabbing`] names `group` (docs/windows.md): each call opens another window of
/// that group, as a tab of the window the user clicked "+" in. Groups without a registration
/// fall back to [`register_new_window`]'s builder.
pub fn register_new_window_for<P: Piece>(group: &str, build: impl Fn() -> P + 'static) {
    let build: Rc<dyn Fn() -> AnyPiece> = Rc::new(move || AnyPiece::new(build()));
    GROUP_WINDOWS.with(|g| {
        let mut groups = g.borrow_mut();
        groups.retain(|(name, _)| name != group);
        groups.push((group.to_string(), build));
    });
}

/// Open another window of tab group `group` (the backend's `newWindowForTab:` path for a window
/// whose tabbing names a group). Uses the group's registered builder, else the app's New Window
/// builder; `None` when neither exists.
pub fn open_new_window_for_group(group: &str) -> Option<WindowHandle> {
    let action = TAB_NEW.with(|g| {
        g.borrow()
            .iter()
            .find(|(name, _)| name == group)
            .map(|(_, f)| f.clone())
    });
    if action.is_some() {
        let group = group.to_string();
        day_reactive::on_main(move || {
            let action = TAB_NEW.with(|g| {
                g.borrow()
                    .iter()
                    .find(|(name, _)| name == &group)
                    .map(|(_, f)| f.clone())
            });
            if let Some(f) = action {
                f();
            }
        });
        return None;
    }

    let Some(build) = GROUP_WINDOWS.with(|g| {
        g.borrow()
            .iter()
            .find(|(name, _)| name == group)
            .map(|(_, b)| b.clone())
    }) else {
        return open_new_window();
    };
    let launch = LAUNCH_OPTIONS.with(|o| o.borrow().clone());
    let size = with_tree(|t| {
        let root = t.root_node();
        t.node_frame(root).map(|f| f.size)
    })
    .unwrap_or(Size::new(800.0, 600.0));
    Some(open_window(
        WindowOptions {
            title: launch.as_ref().map(|o| o.title.clone()).unwrap_or_default(),
            size,
            min_size: launch.as_ref().and_then(|o| o.min_size),
            app_name: launch.as_ref().and_then(|o| o.app_name.clone()),
            version: launch.as_ref().and_then(|o| o.version.clone()),
            tabbing: WindowTabbing::Group(group.to_string()),
            ..WindowOptions::default()
        },
        move || build(),
    ))
}

/// The window the caller belongs to (docs/windows.md): the one whose content is being built
/// right now, else the focused window, else the app's first window. What a piece uses to change
/// "its" window (`current_window()?.set_content_protected(true)`) without threading a handle
/// through. `None` before boot.
pub fn current_window() -> Option<WindowHandle> {
    let building = crate::toolbar::window_being_built();
    let known = WINDOWS.with(|w| w.borrow().iter().any(|r| r.root == building));
    if known {
        return Some(WindowHandle { root: building });
    }
    focused_window().or_else(initial_window)
}

/// Bind the title of the window this piece is building into to a reactive closure
/// (docs/windows.md). This is how a window comes to be named after what it shows.
///
/// The window-level counterpart to a navigation title. It matters more than it looks: the macOS
/// Window menu, the tab bar, Mission Control, the iPad app switcher and the Android recents card
/// all label a window by its title, so two windows that share one title are two windows the user
/// cannot tell apart anywhere the system lists them.
///
/// ```ignore
/// // inside a window's shell, so each window titles itself:
/// day::window_title(move || match scene.selected.get() {
///     Some(id) => scene.name_of(id),
///     None => app_title(),
/// });
/// ```
///
/// Reactive like any binding: the title follows what the closure reads. Which window it targets
/// is resolved once, here, for the same reason `toolbar_reactive` captures it: the binding
/// re-runs long after this build, when "the window being built" is no longer this one.
pub fn window_title(f: impl Fn() -> String + 'static) {
    let root = crate::toolbar::window_being_built();
    day_reactive::bind(f, move |title| {
        let title = crate::decorate_window_title(title);
        with_tree(|t| t.set_native_window_title(root, &title));
    });
}

/// The dispatch id of the auto Preferences menu action (0 = unregistered). Backends use it
/// to wire their default-menu Settings item; the injection pass uses it for `app_menu`.
pub fn preferences_action_id() -> u64 {
    PREFS_ACTION.with(|c| c.get())
}

/// The dispatch id of the New Window action (0 = unregistered), used by `MenuRole::NewWindow`
/// lowering and the backends' tab-bar "+" wiring.
pub fn new_window_action_id() -> u64 {
    NEW_WINDOW_ACTION.with(|c| c.get())
}

/// Test/diagnostic surface: the number of open secondary windows.
pub fn open_window_count() -> usize {
    WINDOWS.with(|w| w.borrow().len())
}

/// The `WindowKind` of the open window at `root`, for backends and tests.
pub fn window_kind_of(handle: &WindowHandle) -> Option<WindowKind> {
    WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .find(|r| r.root == handle.root)
            .map(|r| r.kind)
    })
}

/// Reset the registry + registrations (tests; pairs with `uninstall_tree`).
pub fn reset_windows() {
    // Does not dispose each record's content scope, which is a change that was
    // made and then reverted after it broke navigation (docs/settings.md "Appearance on Android").
    //
    // Disposing looks right: the reactive graph a window built otherwise outlives it, and on an
    // Android re-mount that showed up as the previous window's navigation host re-registering
    // over the new one's ("two routed one-of-N surfaces … at the same navigation level"). But
    // the disposal cascade runs app cleanup that reads signals the same cascade has already
    // disposed; the panic is contained, the disposal stops half-way, and the rebuild that
    // follows registers no routes at all. The symptom is an app whose tab bar draws and whose
    // tabs do nothing.
    //
    // Leaking the old graph is the lesser fault: it warns, and the app works. Disposing safely
    // needs the cascade to tear down in dependency order, which is its own piece of work.
    WINDOWS.with(|w| w.borrow_mut().clear());
    INITIAL_WINDOW.with(|c| c.set(None));
    crate::toolbar::reset_toolbars();
    PREFS.with(|p| *p.borrow_mut() = None);
    ABOUT.with(|a| *a.borrow_mut() = None);
    UPDATES.with(|u| *u.borrow_mut() = None);
    NEW_WINDOW.with(|p| *p.borrow_mut() = None);
    GROUP_WINDOWS.with(|g| g.borrow_mut().clear());
    // Action ids stay registered (the closures are inert without a builder); that is cheap, and
    // re-registration reuses them.
}

/// Adopt the app's first window into the registry, so it is an ordinary primary window rather
/// than a privileged one (docs/windows.md close policy).
///
/// Called once at boot with the root container the backend handed back. `scope` owns the root
/// content, so closing this window disposes exactly its tree and nothing else, which is
/// why the caller builds that content in a child of the root scope rather than in the root
/// scope itself. State an app wants to outlive its windows still does: `Signal::global` lives
/// on the root scope, above this one.
pub fn adopt_initial_window(root: RNode, scope: Scope) {
    register(
        root,
        None,
        WindowKind::Normal,
        scope,
        Tier::Native {
            closing: Cell::new(false),
        },
    );
    wire_window_events(root);
    INITIAL_WINDOW.with(|c| c.set(Some(root)));
    let key = LAUNCH_OPTIONS.with(|o| o.borrow().as_ref().and_then(|o| o.remember_frame.clone()));
    if let Some(key) = key {
        remember_frame(root, &key);
    }
}

/// The displays attached now (docs/windows.md "Monitors"). A toolkit that cannot list them
/// (`Cap::Monitors` unsupported) answers with one display the size of the primary window, so a
/// placement computed from the answer stays on screen.
pub fn monitors() -> Vec<day_spec::Monitor> {
    let listed = with_tree(|t| t.monitors());
    if !listed.is_empty() {
        return listed;
    }
    let size = with_tree(|t| {
        let root = t.root_node();
        t.node_frame(root).map(|f| f.size)
    })
    .unwrap_or(Size::new(0.0, 0.0));
    let frame = Rect::new(0.0, 0.0, size.width, size.height);
    vec![day_spec::Monitor {
        id: "0".into(),
        name: String::new(),
        frame,
        work_area: frame,
        scale: 1.0,
        primary: true,
    }]
}

/// A handle to the app's first window, once adopted. `None` before boot completes, and after
/// that window closes; it is an ordinary window and can be closed like any other.
pub fn initial_window() -> Option<WindowHandle> {
    let root = INITIAL_WINDOW.with(|c| c.get())?;
    WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .any(|r| r.root == root)
            .then_some(WindowHandle { root })
    })
}

/// The window root's spec-boundary id (backends key their per-window maps by it).
pub fn window_node_id(handle: &WindowHandle) -> NodeId {
    rnode_to_id(handle.root)
}

/// Whether this backend can group actual OS windows as tabs.
pub fn native_window_tabs() -> bool {
    with_tree(|t| t.native_window_tabs())
}

/// Explicit grouping, independent of the user's automatic-tabbing preference.
pub fn group_windows(windows: &[WindowHandle]) {
    with_tree(|t| t.group_windows(&windows.iter().map(|w| w.root).collect::<Vec<_>>()));
}

/// Native close delegate hook. Queue document code outside the toolkit callback.
/// Returns false when the owner will decide whether to close.
pub fn native_close_requested(id: NodeId) -> bool {
    let handler = WINDOWS.with(|w| {
        w.borrow()
            .iter()
            .find(|r| r.root == id_to_rnode(id))
            .and_then(|r| r.close_request.clone())
    });
    if handler.is_some() {
        day_reactive::on_main(move || {
            let handler = WINDOWS.with(|w| {
                w.borrow()
                    .iter()
                    .find(|r| r.root == id_to_rnode(id))
                    .and_then(|r| r.close_request.clone())
            });
            if let Some(f) = handler {
                f();
            }
        });
        false
    } else {
        true
    }
}

/// Route the native group add button to a document collection rather than a window builder.
/// Registration belongs to the current scope.
pub fn register_tab_new(group: String, action: Rc<dyn Fn()>) {
    TAB_NEW.with(|g| g.borrow_mut().push((group.clone(), action)));
    Scope::current()
        .on_cleanup(move || TAB_NEW.with(|g| g.borrow_mut().retain(|(name, _)| *name != group)));
}