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/README.md +254 -16
- package/dist/chrome.d.ts +20 -3
- package/dist/chrome.js +173 -92
- package/dist/duet.css +86 -18
- package/dist/geometry.d.ts +161 -9
- package/dist/geometry.js +349 -44
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/signals.d.ts +28 -0
- package/dist/signals.js +129 -0
- package/dist/theme.d.ts +22 -0
- package/dist/theme.js +27 -0
- package/package.json +1 -1
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 (
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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).
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 &&
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const 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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
255
|
-
const
|
|
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;
|