duet-ui 0.3.0 → 0.4.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/geometry.js CHANGED
@@ -31,7 +31,13 @@ export const SAFARI = { rows: 2, pill: DUO.pillPad + 2 * DUO.pillItem,
31
31
  * under the same status column as a closed tab and grows the same way (bare: 124, recording: 178, a
32
32
  * 2-slot music activity: ~224), so it takes the closed tab's worst case. The pill sits DUO.bottom above
33
33
  * the window bottom. */
34
- splitTop: { left: 23.5, right: DUO.top + DUO.island } };
34
+ splitTop: { left: 23.5, right: DUO.top + DUO.island },
35
+ /** Closed landscape (Duo Lab simulator, iOS 27.1, 2026-10-26): the column is on the side the Duo is turned to,
36
+ * 48pt centre line from that edge as in portrait, and its buttons sit differently on each side. `top`: the back
37
+ * slot's top (reader under it, a step apart); `pill`: the +/tabs pill's top. Turned left (angle 90) the island
38
+ * heads the column and pushes the back slot down; turned right (270) it sits at the column's foot, under the
39
+ * pill. Measured with a bare island: how Live Activities grow it in landscape is not yet known. */
40
+ landscape: { left: { top: 81.5, pill: 327 }, right: { top: 23.5, pill: 279 } } };
35
41
  /** Menu island slots: a button unrolls into a pill of these (Safari's own pill items are 50; 46.5 keeps
36
42
  * every slot above the 44pt touch target). */
37
43
  export const ISLAND = { pad: 3, item: 46.5 };
@@ -41,13 +47,66 @@ export const MORPH = { width: 240, row: 44, pad: 6 };
41
47
  * 951, a split-view pane 385, closed landscape 594, closed Safari 382. */
42
48
  export const WIDE = 600;
43
49
  const layoutOf = (pageWidth) => pageWidth >= WIDE ? "wide" : "narrow";
44
- /** In-app Safari preview on the closed Duo (simulator, iOS 27.1, and device screenshot 2026-10-25): the page
45
- * window is a fixed 375x667 at 3x, a size no iPhone has natively, and the preview's own buttons (close,
46
- * reader, back, share/reload/Safari) sit in the column beside it, which the page cannot paint into (it
47
- * clips at its edge). So controls go inline, on the left by default, away from the preview's column. */
48
- const PREVIEW = { width: 375, height: 667, pixelRatio: 3 };
49
- export const isPreview = (s) => !s.standalone && s.pixelRatio === PREVIEW.pixelRatio
50
- && s.outerWidth === PREVIEW.width && s.outerHeight === PREVIEW.height;
50
+ /** In-app Safari preview (SFSafariViewController) on the closed Duo. Its own buttons (close, reader, back, and a
51
+ * share/reload/Safari pill) fill a column beside the page like a tab's. In portrait Duet's controls go into the free
52
+ * gap of that column, between its reader/menu button and its back button (PREVIEW.gap, columnBand); turned, that
53
+ * gap is ~26pt, too small for a button, so they go inline on the side away from the preview's column. Two
54
+ * windows say preview (docs/research-notes.md, "In-app Safari preview"):
55
+ * - iOS 27.1 (simulator, 2026-10-26, every presentation but a sheet): the closed screen's 466x678 window with an
56
+ * 84pt column like a tab's, but only 75pt below the page (382x603) and the page reaching the screen bottom
57
+ * (safe-area-inset-bottom 34). A closed tab has 98pt (80 with the compact URL bar) and inset 0.
58
+ * * - The page sheet, the common presentation (real Duo, iOS 18.7, Messages, 2026-10-26; simulator, iOS 27.1): a
59
+ * 450x670 window inside the 466x678 screen, page 366x600 (column 84, chrome 70), inset 34. The inset lands
60
+ * ~100ms after the first sample (0, then 34), so stable() lets the verdict rise to preview at first (PREVIEW_RISE).
61
+ * Turned, the sheet is 662x458 in the 678x466 screen, page 578x388, inset 34, its column on the side the Duo
62
+ * is turned to (simulator, iOS 27.1, 2026-10-26).
63
+ * - Full screen turned (simulator, iOS 27.1, e2e round 2): the 678x466 window, page 678x391 (no column, chrome 75),
64
+ * inset bottom 34, the buttons in a safe-area inset of 84 on the side the Duo is turned to.
65
+ * No tab is smaller than the screen: closed 466x678, open 951x669, a split pane 469x669 (wider than the 466
66
+ * screen Safari keeps reporting), all inset 0 in every state measured.
67
+ * - Older builds (device screenshot 2026-10-25): a fixed 375x667 window at 3x, a size no iPhone has natively. */
68
+ /** chrome: a band around the 75pt measured, clear of a tab's 98 and 80 (compact or scrolled URL bar). A compact
69
+ * split pane (75) has another window size. stable() also keeps a tab's verdict while it scrolls, so a tab whose
70
+ * chrome drifts into the band mid-scroll is not taken for the preview. sheetInset: how far inside the screen, on
71
+ * each side, a window must be to count as a sheet (16x8 measured; a split pane is 3pt wider than the screen). */
72
+ /** gap: the free band of the preview's portrait column, in screen points (the same in the sheet and full screen,
73
+ * simulator iOS 27.1 and real Duo iOS 18.7, 2026-10-26): its reader/menu button's bottom and its back button's top.
74
+ * 146pt, room for two buttons ("…" and the selected tab) with DUO.gap around them. Measured with a bare island:
75
+ * how Live Activities move the preview's buttons is not yet known. */
76
+ const PREVIEW = { width: 375, height: 667, pixelRatio: 3, chrome: [73, 77], sheetInset: 4, gap: { top: 282, bottom: 428 } };
77
+ /** Why the signals read as the in-app preview, for probes and logs; undefined when they do not. */
78
+ export function previewReason(s) {
79
+ if (s.standalone || browserOf(s.userAgent) !== "safari")
80
+ return;
81
+ if (s.pixelRatio === PREVIEW.pixelRatio && s.outerWidth === PREVIEW.width && s.outerHeight === PREVIEW.height)
82
+ return "375x667 window at 3x";
83
+ const chrome = s.outerHeight - s.innerHeight;
84
+ // The sheet on the open screen (e2e round 3/4: 653x661 wide, 653x869 tall, no column, inset 0 or 34): no Safari
85
+ // window has these sizes.
86
+ if (UNFOLDED_SHEETS.some(([a, b]) => Math.abs(s.outerWidth - a) <= 1 && Math.abs(s.outerHeight - b) <= 1)
87
+ && s.outerWidth - s.innerWidth <= 2 && within(chrome, LANDED.chrome))
88
+ return `${s.outerWidth}x${s.outerHeight} sheet on the open screen, inset ${s.insetBottom}`;
89
+ // A tab measured inset 0 in every state (fresh, scrolled, keyboard, landscape); the preview 34.
90
+ if (s.insetBottom < 20)
91
+ return;
92
+ // Turned full screen (iOS 27.1, every presentation but a sheet): the whole 678x466 window, no page column (the
93
+ // preview's buttons sit in a safe-area inset instead, left at 90, right at 270), 75pt chrome. A turned tab has
94
+ // 98 with its column, or 126 fresh (inset 34 after a fold), so the chrome band alone tells them apart.
95
+ if (within(chrome, PREVIEW.chrome) && Math.abs(s.outerWidth - DUO_SIZES[0][1]) <= 1 && Math.abs(s.outerHeight - DUO_SIZES[0][0]) <= 1
96
+ && Math.abs(s.innerWidth - s.outerWidth) <= 1)
97
+ return `678x466 window, ${chrome}pt chrome, inset ${s.insetBottom}, turned`;
98
+ if (!hasColumn(s))
99
+ return;
100
+ if (within(chrome, PREVIEW.chrome) && Math.abs(s.outerWidth - DUO_SIZES[0][0]) <= 1 && Math.abs(s.outerHeight - DUO_SIZES[0][1]) <= 1)
101
+ return `466x678 window, ${chrome}pt chrome, inset ${s.insetBottom}`;
102
+ // The sheet: a window inside the closed screen on both sides, in either orientation.
103
+ const [w, h] = DUO_SIZES[0], m = PREVIEW.sheetInset;
104
+ const short = (a, b) => Math.min(a, b), long = (a, b) => Math.max(a, b);
105
+ if (short(s.screenWidth, s.screenHeight) === w && long(s.screenWidth, s.screenHeight) === h
106
+ && short(s.outerWidth, s.outerHeight) <= w - m && long(s.outerWidth, s.outerHeight) <= h - m)
107
+ return `${s.outerWidth}x${s.outerHeight} sheet in the ${s.screenWidth}x${s.screenHeight} screen, inset ${s.insetBottom}`;
108
+ }
109
+ export const isPreview = (s) => previewReason(s) !== undefined;
51
110
  /** Window and screen sizes only the Duo has, as [short, long] sides: the closed screen (Safari's `screen.*` keeps
52
111
  * 466x678 after unfolding, so it holds in every Safari state), the open screen, and a split-view pane's window.
53
112
  * Other phones and desktops never match, so apps can switch to a Duo layout on it alone. */
@@ -99,54 +158,201 @@ export const isSplit = (s) => !s.standalone && isIOS(s) !== false && Math.abs(s.
99
158
  * pane on the open Duo (469x669 outer, 385x571 inner) has the same column on its outer edge, so either
100
159
  * side of the page. In-document content paints into it once the document spans both columns (84 + 385 + 84)
101
160
  * and is scrolled 84 across, with `shrink-to-fit=no` in the viewport meta, or Safari zooms the pane out to
102
- * fit the wider document (simulator probes, 2026-10-25). Windowed Safari (floating, or beside the Home Screen)
103
- * keeps the column at other window sizes (device 2026-10-25: 453x661 and 450x670 outer, 369x591 inner), and a
104
- * pane with the compact URL bar has 75pt of chrome, so any portrait Safari window on the Duo with the column counts. */
105
- const SAFARI_COLUMN = { width: [80, 88], chrome: [68, 102] };
161
+ * fit the wider document (simulator probes, 2026-10-25). A device trace (2026-10-25) read the column at other
162
+ * window sizes (453x661 and 450x670 outer, 369x591 and 366x600 inner), then taken for windowed Safari; 450x670
163
+ * with inset 34 is the in-app preview's sheet (isPreview), which goes inline. A pane with the compact URL bar has
164
+ * 75pt of chrome, so any portrait Safari window on the Duo with the column counts. */
165
+ // The largest Dynamic Type grows the bottom bar to 106pt (382x572 in 466x678, e2e round 3).
166
+ const SAFARI_COLUMN = { width: [80, 88], chrome: [68, 110] };
106
167
  const within = (v, [a, b]) => v >= a && v <= b;
107
- /** preferred: the side the user picked for inline controls, if any. */
108
- export function detect(s, preferred, allowColumn = true) {
168
+ const hasColumn = (s) => within(s.outerWidth - s.innerWidth, SAFARI_COLUMN.width) && within(s.outerHeight - s.innerHeight, SAFARI_COLUMN.chrome);
169
+ /** Side inset of a Home Screen app on the Duo: 84 measured (closed portrait). iPhones have up to 59 in landscape. */
170
+ const STRIP_INSET = 76;
171
+ /** The keyboard (or anything else) covers the bottom of the page: the visual viewport is shorter than the layout one.
172
+ * WebKit can report a negative visual height after the preview's sheet is folded from the open screen (-78 for a
173
+ * 600pt page, e2e round 3, held until the next resize); no viewport is that short, so it is not a keyboard. */
174
+ export const keyboardUp = (s) => s.visualHeight > 0 && s.visualHeight < s.innerHeight - 1;
175
+ /** preferred: the side the user picked for inline controls, if any. preview: the in-app preview verdict, when the
176
+ * caller holds a sticky one (stable()). */
177
+ export function detect(s, preferred, allowColumn = true, preview = isPreview(s)) {
109
178
  const duo = isDuo(s), browser = browserOf(s.userAgent), device = deviceOf(s), standalone = s.standalone;
110
179
  // Only a Home Screen app owns the strip: a Safari tab reports the column as an 84pt inset for a frame while folding.
111
- if (s.standalone && (s.insetRight >= 60 || s.insetLeft >= 60)) {
112
- return { mode: "strip", duo, browser, device, standalone, layout: layoutOf(s.innerWidth), side: s.insetRight >= 60 ? "right" : "left", width: s.innerWidth, height: s.innerHeight, offsetTop: Math.max(0, s.screenHeight - s.innerHeight), screenWidth: s.screenWidth, screenHeight: s.screenHeight, insetTop: s.insetTop, insetBottom: s.insetBottom };
180
+ const keyboard = keyboardUp(s);
181
+ const kb = keyboard ? { keyboard } : {};
182
+ if (s.standalone && duo && (s.insetRight >= STRIP_INSET || s.insetLeft >= STRIP_INSET)) {
183
+ return { ...kb, mode: "strip", duo, browser, device, standalone, layout: layoutOf(s.innerWidth), side: s.insetRight >= STRIP_INSET ? "right" : "left", width: s.innerWidth, height: s.innerHeight, stableHeight: s.innerHeight, offsetTop: Math.max(0, s.screenHeight - s.innerHeight), screenWidth: s.screenWidth, screenHeight: s.screenHeight, insetTop: s.insetTop, insetBottom: s.insetBottom };
113
184
  }
114
- const base = { duo, browser, device, standalone, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
185
+ const base = { ...kb, duo, browser, device, standalone, width: s.innerWidth, height: s.innerHeight, stableHeight: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
186
+ // Closed landscape: the column is on the side the Duo is turned to, and the turn is the only signal for it.
187
+ const landscape = s.innerWidth > s.innerHeight;
188
+ const turned = s.angle === 90 ? "left" : s.angle === 270 ? "right" : undefined;
189
+ const closed = Math.abs(s.outerWidth - DUO_SIZES[0][1]) <= 1 && Math.abs(s.outerHeight - DUO_SIZES[0][0]) <= 1;
190
+ const columnSide = !landscape ? "right" : closed ? turned : undefined;
115
191
  // Other iOS browsers draw their own chrome where Safari has its column: inline for them.
116
- const column = allowColumn && !s.standalone && !s.framed && browser === "safari"
117
- && within(s.outerWidth - s.innerWidth, SAFARI_COLUMN.width)
118
- && within(s.outerHeight - s.innerHeight, SAFARI_COLUMN.chrome)
119
- && s.innerHeight > s.innerWidth // landscape moves Safari's column buttons around
120
- && s.visualHeight >= s.innerHeight - 1; // keyboard up
192
+ // An in-app preview has the column signature too: in portrait its column has a gap Duet's controls fit in
193
+ // (columnBand); turned, it has none, so the preview goes inline there.
194
+ // Pinch zoom puts innerWidth in zoomed px, so the column signature means nothing then (stable() holds instead).
195
+ // The keyboard leaves Safari's column in place (only the visual viewport shrinks), so it does not change the mode.
196
+ const split = isSplit(s);
197
+ const column = allowColumn && !!columnSide && !s.standalone && !s.framed && browser === "safari" && (!preview || !landscape)
198
+ && hasColumn(s) && (s.scale ?? 1) === 1;
121
199
  // Safari's page viewport starts at screen top and ends above its address bar (382x580 of 466x678).
122
- if (column && isSplit(s))
200
+ if (column && split)
123
201
  return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight, split: true };
124
202
  if (column && duo)
125
- return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight };
126
- const preview = isPreview(s), split = isSplit(s);
127
- return { ...base, mode: "inline", layout: layoutOf(s.innerWidth), side: preferred ?? (preview ? "left" : "right"), screenWidth: s.innerWidth, screenHeight: s.innerHeight, ...(preview && { preview }), ...(split && { split }) };
203
+ return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: columnSide, screenWidth: s.outerWidth, screenHeight: s.outerHeight, ...(preview && { preview }) };
204
+ // The turned full-screen preview (default and full styles, iOS 27.1): its buttons all sit in a top bar, and the 84pt
205
+ // safe-area inset on the side the Duo is turned to (left at 90, right at 270) is page area nothing native uses
206
+ // below that bar. The controls take it, like a Home Screen app's strip (e2e round 4: the sheet turned has only a
207
+ // ~26pt gap in its column, so it stays hidden).
208
+ const band = s.insetLeft >= STRIP_INSET ? "left" : s.insetRight >= STRIP_INSET ? "right" : undefined;
209
+ if (preview && landscape && closed && band && browser === "safari" && !s.standalone && !s.framed)
210
+ return { ...base, mode: "strip", layout: layoutOf(s.innerWidth - INLINE_WIDTH), side: band, screenWidth: s.innerWidth, screenHeight: s.innerHeight, preview };
211
+ // A preview inline: away from its own column (on the side the Duo is turned to, else the right).
212
+ // On the open screen the sheet has no column of its own: inline right, like a full-width tab, and never hidden.
213
+ const onClosed = Math.min(s.outerWidth, s.outerHeight) <= DUO_SIZES[0][0] + 1 && Math.max(s.outerWidth, s.outerHeight) <= DUO_SIZES[0][1] + 1;
214
+ const away = preview && onClosed ? (landscape && turned === "left" ? "right" : "left") : "right";
215
+ return { ...base, mode: "inline", layout: layoutOf(s.innerWidth), side: preferred ?? away, screenWidth: s.innerWidth, screenHeight: s.innerHeight, ...(preview && { preview }), ...(preview && onClosed && landscape && browser === "safari" && !s.standalone && { hidden: "preview" }), ...(split && { split }) };
216
+ }
217
+ /** Every window a Safari page on the Duo lands in, as measured (docs/research-notes.md): `page` is how much
218
+ * narrower the page is than the window (0, or Safari's 84pt column), `chrome` how much shorter (the address bar:
219
+ * closed 98 or 80 compact, open and fresh landscape 126, split pane 98, preview 70 to 75). A sample outside these
220
+ * is a fold, unfold or turn caught halfway: the viewport lands first and the window up to ~2s later (rotation 90 to
221
+ * 0, simulator 2026-10-26: 678x340, 466x498, 382x498, then 382x580 in 466x678). */
222
+ /** How far inside the closed screen the preview's sheet may be: 16x8 measured. */
223
+ const SHEET_SLACK = 40;
224
+ /** The in-app preview's sheet on the open screen, wide and tall posture (simulator, iOS 27.1). */
225
+ const UNFOLDED_SHEETS = [[653, 661], [653, 869]];
226
+ const LANDED = { page: [[0, 2], [83, 85]], chrome: [0, 160], columnChrome: 110 };
227
+ /** True while a Safari page on the Duo is between postures: the page and its window disagree on orientation, the
228
+ * turn (screen.orientation.angle) disagrees with the page, or page and window are not a pair Safari settles in
229
+ * (LANDED, or the window is not a Duo window or the preview's sheet). Only asked while the window settles
230
+ * (signals.ts settling(), at most SETTLE.cap), so a state never measured cannot hide the controls for good. */
231
+ export function inTransit(s) {
232
+ if (s.standalone || s.framed || browserOf(s.userAgent) !== "safari" || isIOS(s) === false || !isDuo(s))
233
+ return false;
234
+ // The preview's sheet on the open screen: a near-square window of its own, no column, its page wider than tall in
235
+ // the wide posture (e2e round 3: 653x661 wide, 653x869 tall).
236
+ if (s.outerWidth - s.innerWidth <= LANDED.page[0][1] && within(s.outerHeight - s.innerHeight, LANDED.chrome)
237
+ && UNFOLDED_SHEETS.some(([a, b]) => Math.abs(s.outerWidth - a) <= 1 && Math.abs(s.outerHeight - b) <= 1))
238
+ return false;
239
+ const landscape = s.innerWidth > s.innerHeight;
240
+ if (landscape !== s.outerWidth > s.outerHeight)
241
+ return true;
242
+ const [w, h] = DUO_SIZES[0], m = PREVIEW.sheetInset, short = Math.min(s.outerWidth, s.outerHeight), long = Math.max(s.outerWidth, s.outerHeight);
243
+ // The turn speaks for the closed screen only: the open screen is landscape at angle 0.
244
+ const closed = short <= w + 1 && long <= h + 1;
245
+ if (closed && s.angle !== undefined && s.angle % 90 === 0 && (s.angle % 180 === 90) !== landscape)
246
+ return true;
247
+ const page = s.outerWidth - s.innerWidth, chrome = s.outerHeight - s.innerHeight;
248
+ if (!LANDED.page.some((r) => within(page, r)) || !within(chrome, LANDED.chrome))
249
+ return true;
250
+ // Safari's column is 84pt in every window measured, and comes with its bottom URL bar (chrome 98, 106 at the largest
251
+ // Dynamic Type, preview 70 to 75), never with the 126pt bars of a full open window. Anything else is a stale layout
252
+ // handed a new window (e2e round 3: entering split view, 867x543 in 951x669 for ~200ms before the pane; going home
253
+ // from a split pane, the pane's 385x571 page in the closed screen's 466x678 window, an 81pt "column").
254
+ if (page >= LANDED.page[1][0] && chrome > LANDED.columnChrome)
255
+ return true;
256
+ // A Duo window, or the preview's sheet (a window a few points inside the closed screen; its inset lands ~100ms late,
257
+ // so sizes alone decide).
258
+ const sheet = short <= w - m && long <= h - m && short >= w - SHEET_SLACK && long >= h - SHEET_SLACK;
259
+ return !duoSize(s.outerWidth, s.outerHeight) && !sheet;
128
260
  }
129
261
  const FLIP_WINDOW = 2000;
130
262
  export const PIN_TTL = 3000;
131
- export function stable(d, s, preferred, now) {
263
+ /** Longest pin: a loop that keeps resuming doubles the wait up to this. */
264
+ export const PIN_MAX = 60000;
265
+ /** How long after a window first shows (stable signals or epoch changed) its verdict may still rise to preview:
266
+ * the real sheet's bottom inset reads 0 on the first sample and 34 ~100ms later (device, 2026-10-26). */
267
+ export const PREVIEW_RISE = 1000;
268
+ /** How long a landing after a fold or turn caught halfway must hold before the controls show there: unfolding from
269
+ * closed landscape passes through the closed portrait window (382x580 in 466x678, angle 0) for ~90ms before the
270
+ * open one (simulator, e2e round 2), a state that reads as landed and would flash the column for a frame. */
271
+ export const LAND_HOLD = 150;
272
+ /** settling: the window is still moving after a geometry event (signals.ts settling()). A fold lands the viewport
273
+ * first and outerWidth up to ~2s later, flipping the mode on the way at an unchanged window size, so flips then are
274
+ * the transition, not a loop, and do not count.
275
+ * epoch: signals.ts epoch(), bumped on pageshow and when the page becomes visible again.
276
+ *
277
+ * Signals come in two kinds. Stable ones (window size, page width, turn, standalone, frame, epoch) change only on
278
+ * load, fold, rotation or a new page show, and a mode they ask for is taken at once. Drifting ones (page height
279
+ * with the URL bar or find bar, the visual viewport with the keyboard, pinch zoom) move while the user scrolls or
280
+ * types, and never move the controls mid-gesture: a mode only they ask for waits until the window settles, and
281
+ * under pinch zoom the last context holds.
282
+ *
283
+ * The preview verdict is sticky the same way: a page that loaded as a tab stays a tab while it scrolls, since the
284
+ * URL bar collapsing changes only the page height. A tab becomes a preview only on a stable change or in the
285
+ * first PREVIEW_RISE ms of a window (the sheet's late inset); a preview becomes a tab as soon as the rule stops
286
+ * matching (Open in Safari). */
287
+ export function stable(d, s, preferred, now, settling = false, epoch = 0) {
132
288
  const key = `${s.outerWidth}x${s.outerHeight}`;
289
+ // Pinch zoom holds the context committed in this window. A scale off 1 in another window is WebKit's, not the
290
+ // user's: folding the open preview sheet (653 wide, its document briefly 935) onto the closed screen leaves
291
+ // visualViewport.scale at 1.0152 with the visual viewport as wide as the page (366), held until the next
292
+ // gesture (e2e round 4, pvu-open-unfolded-fold). Holding there kept the open screen's inline controls, drawn off
293
+ // the closed screen, so a window change reads the signals as unzoomed.
294
+ if ((s.scale ?? 1) !== 1 && d.last) {
295
+ if (d.lastKey === key) {
296
+ const keyboard = keyboardUp(s) || undefined;
297
+ return { ...d.last, keyboard, confidence: "held" };
298
+ }
299
+ s = { ...s, scale: 1 };
300
+ }
301
+ const stableKey = `${key} ${s.innerWidth} ${s.angle ?? 0} ${s.standalone} ${!!s.framed} ${epoch}`, raw = isPreview(s);
302
+ if (stableKey !== d.previewKey)
303
+ d.previewAt = now;
304
+ const rising = now - (d.previewAt ?? now) < PREVIEW_RISE;
305
+ if (d.preview === undefined || !raw || stableKey !== d.previewKey || rising)
306
+ d.preview = raw;
307
+ d.previewKey = stableKey;
308
+ const preview = d.preview;
133
309
  // A loop keeps the window size; a fold or rotation passes through other sizes, so only same-size flips count.
134
310
  if (d.key !== key)
135
311
  Object.assign(d, { key, pinned: undefined, ttl: undefined, flips: [] });
136
312
  if (d.pinned && now >= d.until)
137
313
  Object.assign(d, { pinned: undefined, flips: [] });
138
- let c = detect(s, preferred, !d.pinned);
139
- if (d.mode && c.mode !== d.mode) {
314
+ let c = detect(s, preferred, !d.pinned, preview);
315
+ // Halfway through a fold or turn: hidden, and nothing committed, so the landing is taken as a stable change. The
316
+ // last context's geometry stays whole: mixing its screenWidth with the half-turned width made the page's insets
317
+ // jump mid-turn (84, 0, 212, 296px on a left column turn, -485px on an unfold; final review, 2026-10-27).
318
+ const moving = () => ({ ...(d.last ?? c), keyboard: c.keyboard, hidden: "moving", confidence: "moving" });
319
+ if (settling && inTransit(s)) {
320
+ Object.assign(d, { moving: true, landKey: undefined, landUntil: undefined });
321
+ return moving();
322
+ }
323
+ // Landed after a transit: hidden until the landing holds LAND_HOLD (the caller re-reads at landUntil).
324
+ if (settling && d.moving) {
325
+ if (d.landKey !== stableKey)
326
+ Object.assign(d, { landKey: stableKey, landAt: now, landUntil: now + LAND_HOLD });
327
+ if (now < d.landUntil)
328
+ return moving();
329
+ }
330
+ Object.assign(d, { moving: undefined, landKey: undefined, landUntil: undefined });
331
+ // The preview verdict counts as stable: a preview handed to a tab (Open in Safari) moves at once.
332
+ if (d.last && c.mode !== d.last.mode && settling && stableKey === d.stableKey && !!d.last.preview === preview) {
333
+ const { width, height, insetTop, insetBottom, keyboard } = c;
334
+ return { ...d.last, width, height, insetTop, insetBottom, keyboard, confidence: "held" };
335
+ }
336
+ d.stableKey = stableKey;
337
+ if (d.mode && c.mode !== d.mode && !settling) {
140
338
  d.flips = [...d.flips.filter((t) => now - t < FLIP_WINDOW), now];
141
339
  if (d.flips.length >= 3) {
142
340
  // Doubles only when the loop resumes as the last pin lifts; a later pane move starts from PIN_TTL again.
143
- d.ttl = d.ttl && d.until !== undefined && now - d.until < FLIP_WINDOW ? d.ttl * 2 : PIN_TTL;
341
+ d.ttl = d.ttl && d.until !== undefined && now - d.until < FLIP_WINDOW ? Math.min(d.ttl * 2, PIN_MAX) : PIN_TTL;
144
342
  Object.assign(d, { pinned: key, until: now + d.ttl });
145
- c = detect(s, preferred, false);
343
+ c = detect(s, preferred, false, preview);
146
344
  }
147
345
  }
148
346
  d.mode = c.mode;
149
- return c;
347
+ c = { ...c, confidence: settling ? "settling" : "sure" };
348
+ // A mobile browser's URL bar grows and shrinks the viewport as the page scrolls, while the window keeps its
349
+ // width and orientation. A desktop window has no such bar: its height is the user's, so it stays current.
350
+ d.lastKey = key;
351
+ if (c.device === "desktop")
352
+ return d.last = c;
353
+ const h = `${s.innerWidth}${s.outerWidth > s.outerHeight ? "L" : "P"}`, heights = d.heights ??= {};
354
+ heights[h] = Math.min(heights[h] ?? Infinity, s.innerHeight);
355
+ return d.last = { ...c, stableHeight: heights[h] };
150
356
  }
151
357
  /** Inline side drag, like a magnet: the controls resist leaving their edge (rubber band up to MAGNET.hold),
152
358
  * then let go and follow the finger once it pulls MAGNET.release past the start. Released past the middle
@@ -165,18 +371,89 @@ export function magnet(side, dx) {
165
371
  }
166
372
  /** Width the inline strip takes from the page (same column width as Safari's). */
167
373
  export const INLINE_WIDTH = 84;
374
+ /** Where the page goes beside the controls, and the insets left for it to keep clear by (chrome.tsx writes them on
375
+ * <html>). In Safari's column without children the document is the page, and Duet owns its layout (`reserve`, the
376
+ * default): when the document spans a column on the left (a split pane, which spans one on each side, or a left
377
+ * column closed) it is scrolled one column across, so the page is moved right by that column (`shift`, margins on
378
+ * <html>) and lands exactly in the visible viewport, beside the controls, with nothing to keep clear (insets 0).
379
+ * Before, a page that ignored the insets ran under the controls on the left and left a column-wide gap on the
380
+ * right (RegTail, split pane, 2026-10-27). With `reserve: false` the page runs under the column and keeps clear by
381
+ * the insets itself. With children the scroller runs on under the column and pads by the insets; an emulated
382
+ * column (the site's web Duo) already frames both columns and is never scrolled across. */
383
+ export function pageInsets(o) {
384
+ const column = o.screenWidth - o.width, split = o.mode === "column" && o.split;
385
+ if (o.mode === "column" && !o.inner && !o.emulated && o.reserve)
386
+ return { left: "0px", right: "0px", shift: split || o.side === "left" ? column : 0 };
387
+ const inset = o.hiddenPreview ? "0px" : o.mode === "strip" ? o.env(o.side) : o.mode === "inline" ? `${INLINE_WIDTH}px` : `${column}px`;
388
+ const other = (side) => o.mode === "column" ? "0px" : o.env(side);
389
+ // Without children nothing runs under a right column: the document is laid out in the viewport (simulator,
390
+ // 2026-10-26: 84px of padding left a 100px gap).
391
+ const under = o.mode !== "column" || o.inner || o.emulated;
392
+ return {
393
+ right: (o.side === "right" || split) && under ? inset : other("right"),
394
+ left: o.side === "left" || split ? inset : other("left"),
395
+ shift: 0,
396
+ };
397
+ }
398
+ /** Closed landscape Safari tab: its column's buttons sit apart from portrait's (SAFARI.landscape). */
399
+ const landscapeColumn = (c) => !c.split && !c.preview && c.screenWidth > c.screenHeight;
168
400
  const groupHeight = (n) => DUO.control + (n - 1) * DUO.groupItem;
401
+ /** Safari's column buttons are an exclusion zone like the insets: native chrome always paints over the page, so
402
+ * nothing of ours shows over its back + reader rows or its +/tabs pill. `top`: the reader row's bottom plus
403
+ * DUO.gap, at its worst-case (island-pushed) position. `bottom`: DUO.gap above the pill. `reach`: how far the
404
+ * island can push the rows, so with a bare island the free band starts that much higher; Live Activities, which
405
+ * nothing tells the page, take it back. Column mode only; landscape rows are measured with a bare island. */
406
+ export function columnBand(c) {
407
+ if (c.mode !== "column")
408
+ return undefined;
409
+ const rows = SAFARI.rows * (DUO.control + DUO.gap), pill = c.screenHeight - DUO.bottom - SAFARI.pill - DUO.gap;
410
+ // In-app preview: the gap in its column, from screen points to the page's (the page reaches the screen bottom).
411
+ if (c.preview) {
412
+ const y0 = DUO_SIZES[0][1] - c.height;
413
+ return { top: PREVIEW.gap.top + DUO.gap - y0, bottom: PREVIEW.gap.bottom - DUO.gap - y0, reach: 0 };
414
+ }
415
+ if (c.split)
416
+ return { top: SAFARI.splitTop[c.side] + rows, bottom: pill, reach: c.side === "right" ? DUO.island : 0 };
417
+ if (landscapeColumn(c))
418
+ return { top: SAFARI.landscape[c.side].top + rows, bottom: SAFARI.landscape[c.side].pill - DUO.gap, reach: 0 };
419
+ return { top: DUO.top + DUO.island + rows, bottom: pill, reach: DUO.island };
420
+ }
421
+ /** Morph menu placement: the button grows into a labelled list, outer edge on the button's, opening down from
422
+ * its top or up from its bottom. Where it crosses Safari's column it stays inside the column band (flush with
423
+ * the column, as Safari's own menus open); a list too tall for that band moves wholly over the page, beside the
424
+ * column, where it may rise to the top; one too tall even there caps its height and scrolls. x0/y0: the button's
425
+ * top-left, where the morph starts. */
426
+ export function morphBox(c, p, b, n) {
427
+ const w = MORPH.width, full = 2 * MORPH.pad + n * MORPH.row, x0 = p.left, y0 = b.top + b.height / 2 - DUO.control / 2;
428
+ const place = (lo, hi) => {
429
+ const height = Math.min(full, hi - lo), down = y0 >= lo && y0 + height <= hi;
430
+ return { top: Math.min(Math.max(down ? y0 : y0 + DUO.control - height, lo), hi - height), height };
431
+ };
432
+ const flush = c.side === "left" ? p.left : p.left + DUO.control - w;
433
+ const viewport = [c.insetTop + MORPH.pad, c.height - MORPH.pad];
434
+ if (p.columnTop !== undefined && p.columnBottom !== undefined && full > p.columnBottom - p.columnTop) {
435
+ // Beside the column: its page-side edge a pad in from the column's inner edge.
436
+ const column = c.screenWidth - c.width, left = c.side === "left" ? column + MORPH.pad : c.width - MORPH.pad - w;
437
+ return { left, width: w, ...place(...viewport), scroll: full > viewport[1] - viewport[0], x0, y0 };
438
+ }
439
+ const band = p.columnTop !== undefined && p.columnBottom !== undefined ? [p.columnTop, Math.min(p.columnBottom, viewport[1])] : viewport;
440
+ return { left: flush, width: w, ...place(...band), scroll: full > band[1] - band[0], x0, y0 };
441
+ }
442
+ /** Island menu placement: the button stretches sideways into a pill as tall as itself, outer edge on the
443
+ * button's. In the column it never rises above columnTop. */
444
+ export function islandBox(p, b, n, side) {
445
+ const w = 2 * ISLAND.pad + n * ISLAND.item, top = Math.max(b.top + b.height / 2 - DUO.control / 2, p.columnTop ?? -Infinity);
446
+ return { top, left: side === "left" ? p.left : p.left + DUO.control - w, width: w, height: DUO.control, x0: p.left };
447
+ }
169
448
  export function plan(c, s) {
170
449
  // column: screen coordinates (the layer is in-document), in the band Safari's column never uses: below
171
450
  // its back + reader rows at their worst-case (island-pushed) position, above its +/tabs pill. strip and
172
451
  // inline: viewport coordinates.
173
- const top = c.mode === "strip" ? DUO.top + DUO.island - c.offsetTop
174
- : c.mode === "column" && c.split ? SAFARI.splitTop[c.side] + SAFARI.rows * (DUO.control + DUO.gap)
175
- : c.mode === "column" ? DUO.top + DUO.island + SAFARI.rows * (DUO.control + DUO.gap)
176
- : c.insetTop + DUO.gap;
177
- const bottom = c.mode === "strip" ? c.height - DUO.bottom
178
- : c.mode === "column" ? c.screenHeight - DUO.bottom - SAFARI.pill - DUO.gap
179
- : c.height - Math.max(c.insetBottom, DUO.gap);
452
+ const col = columnBand(c);
453
+ // The turned preview's strip is a page band below its native top bar, with the home indicator's inset below.
454
+ const strip = c.mode === "strip" && !c.preview;
455
+ const top = col ? col.top : strip ? DUO.top + DUO.island - c.offsetTop : c.insetTop + DUO.gap;
456
+ const bottom = col ? col.bottom : strip ? c.height - DUO.bottom : c.height - Math.max(c.insetBottom, DUO.gap);
180
457
  const centerX = c.side === "left" ? DUO.centerFromEdge : (c.mode === "column" ? c.screenWidth : c.width) - DUO.centerFromEdge;
181
458
  const step = DUO.control + DUO.gap;
182
459
  // Safari's column band (146pt) holds two separate round buttons: the selected tab and "…" (or the one
@@ -199,7 +476,9 @@ export function plan(c, s) {
199
476
  const parts = [back ? DUO.control : 0, primaryTop && primary ? DUO.control : 0, leading ? groupHeight(leading) : 0, actions ? groupHeight(actions) : 0, menuCount() ? DUO.control : 0].filter(Boolean);
200
477
  return parts.reduce((a, b) => a + b, 0) + Math.max(0, parts.length - 1) * DUO.gap;
201
478
  };
202
- while (height() > floor() - top) {
479
+ // With nothing above it, the low primary slot needs only its own room, not a gap under the stack.
480
+ const fitsFrom = (t) => height() ? height() <= floor() - t : !(primaryLow && primary) || lowTop >= t;
481
+ while (!fitsFrom(top)) {
203
482
  if (actions)
204
483
  actions--;
205
484
  else if (leading)
@@ -211,6 +490,27 @@ export function plan(c, s) {
211
490
  else
212
491
  break;
213
492
  }
493
+ // The free middle of Safari's column: before folding, the primary action and leading items may rise into the
494
+ // zone the island only takes with Live Activities, the stack packed down onto the tabs so it rises as little as
495
+ // it can. Only they do: back mirrors Safari's own, and "…" stays under the reader row so its menu opens clear of
496
+ // Safari's buttons (morphBox).
497
+ const reachTop = top - (col?.reach ?? 0);
498
+ const reaches = () => fitsFrom(reachTop) && (!menuCount() || floor() - DUO.control >= top);
499
+ if (reachTop < top && fitsFrom(top) && !back) {
500
+ if (s.primary && !primary) {
501
+ primary = true;
502
+ if (!reaches())
503
+ primary = false;
504
+ }
505
+ while (leading < s.leading) {
506
+ leading++;
507
+ if (!reaches()) {
508
+ leading--;
509
+ break;
510
+ }
511
+ }
512
+ }
513
+ const fits = () => fitsFrom(top) || (reachTop < top && reaches());
214
514
  const p = { centerX, left: centerX - DUO.control / 2, menu: { primary: !!s.primary && !primary, back: s.back && !back, leading: s.leading - leading, actions: s.actions - actions } };
215
515
  if (s.tabs)
216
516
  p.tabs = { top: bottom - bottomH, height: bottomH, min: tabsMin };
@@ -218,8 +518,11 @@ export function plan(c, s) {
218
518
  p.searchButton = { top: bottom - DUO.control, height: DUO.control };
219
519
  if (primaryLow && primary)
220
520
  p.primary = { top: lowTop, height: DUO.control };
221
- let y = top;
222
- if (height() <= floor() - top) {
521
+ if (col)
522
+ Object.assign(p, { columnTop: top, columnBottom: bottom });
523
+ // Risen into the island's reach: packed down onto the floor instead of hanging from the top.
524
+ let y = fitsFrom(top) ? top : floor() - height();
525
+ if (fits()) {
223
526
  if (back) {
224
527
  p.back = { top: y, height: DUO.control };
225
528
  y += step;
@@ -251,7 +554,9 @@ function accessory(c, s, centerX, bottom) {
251
554
  return { accessory: { left: c.screenWidth - c.width + DUO.gap, width: c.width - 2 * DUO.gap, top: c.height - DUO.gap - DUO.accessory.height, height: DUO.accessory.height } };
252
555
  const floor = c.mode === "column" ? c.height - DUO.gap : bottom;
253
556
  // Mirrored on the left: inner edge clear of the column, outer edge DUO.accessory.left from the screen edge.
254
- const left = c.side === "left" ? centerX + DUO.control / 2 + DUO.accessory.gapToColumn : DUO.accessory.left;
255
- const right = c.side === "left" ? c.width - DUO.accessory.left : c.mode === "column" ? c.width - DUO.gap : centerX - DUO.control / 2 - DUO.accessory.gapToColumn;
557
+ // In Safari's column it spans the page, a gap from its edge on the column's side.
558
+ const column = c.screenWidth - c.width;
559
+ const left = c.mode === "column" && c.side === "left" ? column + DUO.gap : c.side === "left" ? centerX + DUO.control / 2 + DUO.accessory.gapToColumn : DUO.accessory.left;
560
+ const right = c.mode === "column" && c.side === "left" ? c.screenWidth - DUO.accessory.left : c.side === "left" ? c.width - DUO.accessory.left : c.mode === "column" ? c.width - DUO.gap : centerX - DUO.control / 2 - DUO.accessory.gapToColumn;
256
561
  return { accessory: { left, width: right - left, top: floor - DUO.accessory.height, height: DUO.accessory.height } };
257
562
  }
package/dist/icons.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  const icon = (paths) => function Icon({ size = 22, strokeWidth = 2 }) {
3
- return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: paths }));
3
+ return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", "data-duet-glyph": "", "aria-hidden": true, children: paths }));
4
4
  };
5
5
  export const ChevronLeft = icon(_jsx("path", { d: "m15 18-6-6 6-6" }));
6
6
  export const Ellipsis = icon(_jsxs(_Fragment, { children: [_jsx("circle", { cx: "12", cy: "12", r: "1" }), _jsx("circle", { cx: "19", cy: "12", r: "1" }), _jsx("circle", { cx: "5", cy: "12", r: "1" })] }));
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { Duet, DuoCookie, useDuet, type DuetProps, type DuetItem, type DuetTab } from "./chrome.tsx";
2
+ export { glassTheme, themeVars, type Appearance, type DuetIcons, type DuetTheme } from "./theme.ts";
3
+ export { epoch, readSignals, settling, watchSignals } from "./signals.ts";
2
4
  export { bleed, column } from "./layout.ts";
3
5
  export { DuetPanes, DuetPaneBar, usePanes, type DuetPanesProps, type DuetPaneBarProps } from "./duet-panes.tsx";
4
6
  export { panes, backFrom, type PaneWindow } from "./panes.ts";
5
- export { browserOf, detect, deviceOf, isDuo, isIOS, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE, type Browser, type Context, type Device, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
7
+ export { browserOf, detect, deviceOf, isDuo, isIOS, isPreview, isSplit, previewReason, plan, stable, PIN_MAX, PIN_TTL, columnBand, morphBox, islandBox, magnet, pageInsets, DUO, INLINE_WIDTH, MAGNET, WIDE, type Browser, type Context, type Device, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { Duet, DuoCookie, useDuet } from "./chrome.js";
2
+ export { glassTheme, themeVars } from "./theme.js";
3
+ export { epoch, readSignals, settling, watchSignals } from "./signals.js";
2
4
  export { bleed, column } from "./layout.js";
3
5
  export { DuetPanes, DuetPaneBar, usePanes } from "./duet-panes.js";
4
6
  export { panes, backFrom } from "./panes.js";
5
- export { browserOf, detect, deviceOf, isDuo, isIOS, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
7
+ export { browserOf, detect, deviceOf, isDuo, isIOS, isPreview, isSplit, previewReason, plan, stable, PIN_MAX, PIN_TTL, columnBand, morphBox, islandBox, magnet, pageInsets, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
@@ -0,0 +1,28 @@
1
+ import type { Signals } from "./geometry.ts";
2
+ /** The signals a host page sends into this iframe, if any (see above). */
3
+ export declare const emulatedSignals: () => Signals | null;
4
+ /** The current signals, read from the window (or the emulating host page). Browser only. */
5
+ export declare function readSignals(): Signals;
6
+ /** Geometry after an event is not final: a fold moves outerWidth up to ~2s after the viewport, and nothing fires when
7
+ * it lands. So every event starts a settle: poll every `poll` ms until outer, inner and visual sizes hold for `quiet`
8
+ * ms, re-reading on each change and once at the end. A page far narrower or wider than its window (more than `lag`
9
+ * apart: Safari's column is 84) is a fold caught halfway, so it does not count as holding until `cap`. After `cap` ms
10
+ * settling() turns false even if sizes still move, but polling goes on until they hold, so a real feedback loop
11
+ * cannot keep restarting the settle. */
12
+ export declare const SETTLE: {
13
+ readonly poll: 100;
14
+ readonly quiet: 500;
15
+ readonly cap: 2000;
16
+ readonly lag: 100;
17
+ };
18
+ /** Bumped on pageshow and whenever the page becomes visible: the page may now be in another container (the in-app
19
+ * preview's "Open in Safari" hands it to a tab without reloading), so stable() decides the preview afresh. */
20
+ export declare const epoch: () => number;
21
+ export declare const settling: () => boolean;
22
+ /** Calls `cb` whenever the signals may have changed, so it can call readSignals() and detect() again: on resize,
23
+ * rotation (a turn from one landscape to the other keeps the window size, so screen.orientation too), the keyboard
24
+ * (visualViewport), and on pageshow and visibilitychange, as when an in-app preview's "Open in Safari" hands the
25
+ * page to a Safari tab without reloading it. Each event, and the call itself (a fresh tab settles with no event),
26
+ * starts a settle (SETTLE): `cb` runs at once, on every size change while it polls, and once when sizes hold.
27
+ * Returns the unsubscribe. Browser only. */
28
+ export declare function watchSignals(cb: () => void): () => void;