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.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/** strip: Home Screen web app, iOS gives us the right-hand strip.
|
|
2
|
-
* column: Safari tab, closed portrait or a split-view pane; Safari owns the strip and we paint
|
|
3
|
-
* free space of its column (an undocumented WebKit behaviour, so only when every signal matches).
|
|
4
|
-
* inline: anything else (
|
|
5
|
-
*
|
|
2
|
+
* column: Safari tab, closed (portrait or landscape) or a split-view pane; Safari owns the strip and we paint
|
|
3
|
+
* into the free space of its column (an undocumented WebKit behaviour, so only when every signal matches).
|
|
4
|
+
* inline: anything else (open posture full width, other browsers); a right-edge strip inside
|
|
5
|
+
* the page viewport. */
|
|
6
6
|
export type Mode = "strip" | "column" | "inline";
|
|
7
7
|
/** Which screen edge the controls hug. strip: wherever iOS puts the strip (its safe-area inset). column:
|
|
8
|
-
*
|
|
9
|
-
* the screen it is on. */
|
|
8
|
+
* wherever Safari puts its column: right in portrait, the side the closed Duo is turned to in landscape.
|
|
9
|
+
* inline: the user's choice; no API tells a split-view pane which side of the screen it is on. */
|
|
10
10
|
export type Side = "left" | "right";
|
|
11
11
|
export declare const DUO: {
|
|
12
12
|
/** Strip centre line, from the right screen edge. */
|
|
@@ -49,6 +49,21 @@ export declare const SAFARI: {
|
|
|
49
49
|
readonly left: 23.5;
|
|
50
50
|
readonly right: number;
|
|
51
51
|
};
|
|
52
|
+
/** Closed landscape (Duo Lab simulator, iOS 27.1, 2026-10-26): the column is on the side the Duo is turned to,
|
|
53
|
+
* 48pt centre line from that edge as in portrait, and its buttons sit differently on each side. `top`: the back
|
|
54
|
+
* slot's top (reader under it, a step apart); `pill`: the +/tabs pill's top. Turned left (angle 90) the island
|
|
55
|
+
* heads the column and pushes the back slot down; turned right (270) it sits at the column's foot, under the
|
|
56
|
+
* pill. Measured with a bare island: how Live Activities grow it in landscape is not yet known. */
|
|
57
|
+
readonly landscape: {
|
|
58
|
+
readonly left: {
|
|
59
|
+
readonly top: 81.5;
|
|
60
|
+
readonly pill: 327;
|
|
61
|
+
};
|
|
62
|
+
readonly right: {
|
|
63
|
+
readonly top: 23.5;
|
|
64
|
+
readonly pill: 279;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
52
67
|
};
|
|
53
68
|
/** Menu island slots: a button unrolls into a pill of these (Safari's own pill items are 50; 46.5 keeps
|
|
54
69
|
* every slot above the 44pt touch target). */
|
|
@@ -84,12 +99,22 @@ export interface Signals {
|
|
|
84
99
|
* emulator's signals) restricts nothing. */
|
|
85
100
|
userAgent?: string;
|
|
86
101
|
touchPoints?: number;
|
|
102
|
+
/** screen.orientation.angle: in closed landscape, which way the Duo is turned and so which side Safari's column
|
|
103
|
+
* is on (90: left, 270: right). Unknown keeps landscape inline. */
|
|
104
|
+
angle?: number;
|
|
105
|
+
/** visualViewport.scale: above 1 under pinch zoom, when innerWidth is in zoomed px and says nothing about the
|
|
106
|
+
* column. Unknown counts as 1. */
|
|
107
|
+
scale?: number;
|
|
87
108
|
}
|
|
88
109
|
export interface Context {
|
|
89
110
|
mode: Mode;
|
|
90
111
|
side: Side;
|
|
91
112
|
width: number;
|
|
92
113
|
height: number;
|
|
114
|
+
/** The smallest `height` seen at this width and orientation, like CSS `100svh`: it ignores Safari's URL bar
|
|
115
|
+
* collapsing and expanding mid-scroll, so content sized from it does not jump. On a desktop it is `height`.
|
|
116
|
+
* Also set as `--duet-vh` (px). From `detect` alone (no history) it is `height`; `useDuet` remembers. */
|
|
117
|
+
stableHeight: number;
|
|
93
118
|
/** Screen-y minus viewport-y (status area above a standalone viewport). */
|
|
94
119
|
offsetTop: number;
|
|
95
120
|
/** strip: screen size. column: Safari window size. inline: safe-area insets top/bottom. */
|
|
@@ -99,6 +124,19 @@ export interface Context {
|
|
|
99
124
|
insetBottom: number;
|
|
100
125
|
/** In-app Safari preview (SFSafariViewController, opened from a social or mail app). */
|
|
101
126
|
preview?: boolean;
|
|
127
|
+
/** The on-screen keyboard is up (visual viewport shorter than the page). The mode stays as it was; in column
|
|
128
|
+
* mode the controls hide until it goes down. */
|
|
129
|
+
keyboard?: boolean;
|
|
130
|
+
/** From stable() only. "sure": nothing is moving. "settling": the window is still settling after an event, so
|
|
131
|
+
* the mode may still change. "held": the last committed context, kept while the page is pinch-zoomed or while a
|
|
132
|
+
* drifting signal (viewport height) alone asks for another mode before the window settles. */
|
|
133
|
+
confidence?: "sure" | "settling" | "held" | "moving";
|
|
134
|
+
/** The controls are not drawn (the page keeps its full width, no inset). "moving": a fold, unfold or turn is
|
|
135
|
+
* caught halfway (stable() only, see inTransit), so the controls hide for those frames instead of showing in a
|
|
136
|
+
* place that is right for neither posture. "preview": the in-app preview turned to landscape, whose column has no
|
|
137
|
+
* room for a button and where an inline strip would cover the page (docs/research-notes.md, "Preview placement in
|
|
138
|
+
* the gap"); the preview's own back and close buttons remain, and Duet's return in portrait or in a tab. */
|
|
139
|
+
hidden?: "moving" | "preview";
|
|
102
140
|
/** "wide" when the page has room for two panes side by side (the open Duo, full width), else "narrow"
|
|
103
141
|
* (closed, split view, closed landscape). From the window width, not the posture. */
|
|
104
142
|
layout: Layout;
|
|
@@ -118,6 +156,8 @@ export type Layout = "narrow" | "wide";
|
|
|
118
156
|
/** Page width from which two panes fit. The page spans the window, controls floating over it: the open Duo gives
|
|
119
157
|
* 951, a split-view pane 385, closed landscape 594, closed Safari 382. */
|
|
120
158
|
export declare const WIDE = 600;
|
|
159
|
+
/** Why the signals read as the in-app preview, for probes and logs; undefined when they do not. */
|
|
160
|
+
export declare function previewReason(s: Signals): string | undefined;
|
|
121
161
|
export declare const isPreview: (s: Signals) => boolean;
|
|
122
162
|
export declare const isDuo: (s: Signals) => boolean;
|
|
123
163
|
/** iOS has no model in its UA, so the sizes above say Duo; the UA says iPhone. Safari's "Request Desktop Website"
|
|
@@ -136,8 +176,18 @@ export declare function deviceOf(s: Signals): Device;
|
|
|
136
176
|
* (device probe 2026-10-25: screenX, availLeft, insets, visualViewport and touch screenX read the same in both),
|
|
137
177
|
* though Safari's column sits on the pane's outer edge. So the inline side is remembered apart for split view. */
|
|
138
178
|
export declare const isSplit: (s: Signals) => boolean;
|
|
139
|
-
/**
|
|
140
|
-
|
|
179
|
+
/** The keyboard (or anything else) covers the bottom of the page: the visual viewport is shorter than the layout one.
|
|
180
|
+
* WebKit can report a negative visual height after the preview's sheet is folded from the open screen (-78 for a
|
|
181
|
+
* 600pt page, e2e round 3, held until the next resize); no viewport is that short, so it is not a keyboard. */
|
|
182
|
+
export declare const keyboardUp: (s: Signals) => boolean;
|
|
183
|
+
/** preferred: the side the user picked for inline controls, if any. preview: the in-app preview verdict, when the
|
|
184
|
+
* caller holds a sticky one (stable()). */
|
|
185
|
+
export declare function detect(s: Signals, preferred?: Side, allowColumn?: boolean, preview?: boolean): Context;
|
|
186
|
+
/** True while a Safari page on the Duo is between postures: the page and its window disagree on orientation, the
|
|
187
|
+
* turn (screen.orientation.angle) disagrees with the page, or page and window are not a pair Safari settles in
|
|
188
|
+
* (LANDED, or the window is not a Duo window or the preview's sheet). Only asked while the window settles
|
|
189
|
+
* (signals.ts settling(), at most SETTLE.cap), so a state never measured cannot hide the controls for good. */
|
|
190
|
+
export declare function inTransit(s: Signals): boolean;
|
|
141
191
|
/** Mode flips are a feedback loop when the layout of one mode changes the signals into the other (the
|
|
142
192
|
* column layer's overflow resizing Safari's viewport). Three flips within FLIP_WINDOW pin the page to inline.
|
|
143
193
|
* Flips only count while the window keeps its size. The pin lifts when the window changes size, or after `until`: moving a split-view pane to the other side
|
|
@@ -150,9 +200,49 @@ export interface Damper {
|
|
|
150
200
|
pinned?: string;
|
|
151
201
|
until?: number;
|
|
152
202
|
ttl?: number;
|
|
203
|
+
/** Smallest viewport height per width and orientation (Context.stableHeight). */
|
|
204
|
+
heights?: Record<string, number>;
|
|
205
|
+
/** Sticky in-app preview verdict, and the window it was taken in. */
|
|
206
|
+
preview?: boolean;
|
|
207
|
+
previewKey?: string;
|
|
208
|
+
previewAt?: number;
|
|
209
|
+
/** Last context returned, the stable signals it was committed under, and its window (outer size). */
|
|
210
|
+
last?: Context;
|
|
211
|
+
stableKey?: string;
|
|
212
|
+
lastKey?: string;
|
|
213
|
+
/** A fold or turn was caught halfway, and the landing (its stable signals, first seen at landAt) must hold until
|
|
214
|
+
* landUntil before the controls show (LAND_HOLD). */
|
|
215
|
+
moving?: boolean;
|
|
216
|
+
landKey?: string;
|
|
217
|
+
landAt?: number;
|
|
218
|
+
landUntil?: number;
|
|
153
219
|
}
|
|
154
220
|
export declare const PIN_TTL = 3000;
|
|
155
|
-
|
|
221
|
+
/** Longest pin: a loop that keeps resuming doubles the wait up to this. */
|
|
222
|
+
export declare const PIN_MAX = 60000;
|
|
223
|
+
/** How long after a window first shows (stable signals or epoch changed) its verdict may still rise to preview:
|
|
224
|
+
* the real sheet's bottom inset reads 0 on the first sample and 34 ~100ms later (device, 2026-10-26). */
|
|
225
|
+
export declare const PREVIEW_RISE = 1000;
|
|
226
|
+
/** How long a landing after a fold or turn caught halfway must hold before the controls show there: unfolding from
|
|
227
|
+
* closed landscape passes through the closed portrait window (382x580 in 466x678, angle 0) for ~90ms before the
|
|
228
|
+
* open one (simulator, e2e round 2), a state that reads as landed and would flash the column for a frame. */
|
|
229
|
+
export declare const LAND_HOLD = 150;
|
|
230
|
+
/** settling: the window is still moving after a geometry event (signals.ts settling()). A fold lands the viewport
|
|
231
|
+
* first and outerWidth up to ~2s later, flipping the mode on the way at an unchanged window size, so flips then are
|
|
232
|
+
* the transition, not a loop, and do not count.
|
|
233
|
+
* epoch: signals.ts epoch(), bumped on pageshow and when the page becomes visible again.
|
|
234
|
+
*
|
|
235
|
+
* Signals come in two kinds. Stable ones (window size, page width, turn, standalone, frame, epoch) change only on
|
|
236
|
+
* load, fold, rotation or a new page show, and a mode they ask for is taken at once. Drifting ones (page height
|
|
237
|
+
* with the URL bar or find bar, the visual viewport with the keyboard, pinch zoom) move while the user scrolls or
|
|
238
|
+
* types, and never move the controls mid-gesture: a mode only they ask for waits until the window settles, and
|
|
239
|
+
* under pinch zoom the last context holds.
|
|
240
|
+
*
|
|
241
|
+
* The preview verdict is sticky the same way: a page that loaded as a tab stays a tab while it scrolls, since the
|
|
242
|
+
* URL bar collapsing changes only the page height. A tab becomes a preview only on a stable change or in the
|
|
243
|
+
* first PREVIEW_RISE ms of a window (the sheet's late inset); a preview becomes a tab as soon as the rule stops
|
|
244
|
+
* matching (Open in Safari). */
|
|
245
|
+
export declare function stable(d: Damper, s: Signals, preferred: Side | undefined, now: number, settling?: boolean, epoch?: number): Context;
|
|
156
246
|
/** Inline side drag, like a magnet: the controls resist leaving their edge (rubber band up to MAGNET.hold),
|
|
157
247
|
* then let go and follow the finger once it pulls MAGNET.release past the start. Released past the middle
|
|
158
248
|
* of the viewport after letting go, they switch sides; otherwise they snap back. `dx` is the finger's
|
|
@@ -168,6 +258,31 @@ export declare function magnet(side: Side, dx: number): {
|
|
|
168
258
|
};
|
|
169
259
|
/** Width the inline strip takes from the page (same column width as Safari's). */
|
|
170
260
|
export declare const INLINE_WIDTH = 84;
|
|
261
|
+
/** Where the page goes beside the controls, and the insets left for it to keep clear by (chrome.tsx writes them on
|
|
262
|
+
* <html>). In Safari's column without children the document is the page, and Duet owns its layout (`reserve`, the
|
|
263
|
+
* default): when the document spans a column on the left (a split pane, which spans one on each side, or a left
|
|
264
|
+
* column closed) it is scrolled one column across, so the page is moved right by that column (`shift`, margins on
|
|
265
|
+
* <html>) and lands exactly in the visible viewport, beside the controls, with nothing to keep clear (insets 0).
|
|
266
|
+
* Before, a page that ignored the insets ran under the controls on the left and left a column-wide gap on the
|
|
267
|
+
* right (RegTail, split pane, 2026-10-27). With `reserve: false` the page runs under the column and keeps clear by
|
|
268
|
+
* the insets itself. With children the scroller runs on under the column and pads by the insets; an emulated
|
|
269
|
+
* column (the site's web Duo) already frames both columns and is never scrolled across. */
|
|
270
|
+
export declare function pageInsets(o: {
|
|
271
|
+
mode: Mode;
|
|
272
|
+
side: Side;
|
|
273
|
+
split: boolean;
|
|
274
|
+
screenWidth: number;
|
|
275
|
+
width: number;
|
|
276
|
+
hiddenPreview: boolean;
|
|
277
|
+
inner: boolean;
|
|
278
|
+
emulated: boolean;
|
|
279
|
+
reserve: boolean;
|
|
280
|
+
env: (side: Side) => string;
|
|
281
|
+
}): {
|
|
282
|
+
left: string;
|
|
283
|
+
right: string;
|
|
284
|
+
shift: number;
|
|
285
|
+
};
|
|
171
286
|
export interface Spec {
|
|
172
287
|
back: boolean;
|
|
173
288
|
leading: number;
|
|
@@ -222,5 +337,42 @@ export interface Plan {
|
|
|
222
337
|
top: number;
|
|
223
338
|
height: number;
|
|
224
339
|
};
|
|
340
|
+
/** Column mode: the band clear of Safari's column buttons (columnBand). Menus crossing the column stay inside
|
|
341
|
+
* it; only leading items and the primary action may rise above columnTop, into the island's reach. */
|
|
342
|
+
columnTop?: number;
|
|
343
|
+
columnBottom?: number;
|
|
225
344
|
}
|
|
345
|
+
/** Safari's column buttons are an exclusion zone like the insets: native chrome always paints over the page, so
|
|
346
|
+
* nothing of ours shows over its back + reader rows or its +/tabs pill. `top`: the reader row's bottom plus
|
|
347
|
+
* DUO.gap, at its worst-case (island-pushed) position. `bottom`: DUO.gap above the pill. `reach`: how far the
|
|
348
|
+
* island can push the rows, so with a bare island the free band starts that much higher; Live Activities, which
|
|
349
|
+
* nothing tells the page, take it back. Column mode only; landscape rows are measured with a bare island. */
|
|
350
|
+
export declare function columnBand(c: Context): {
|
|
351
|
+
top: number;
|
|
352
|
+
bottom: number;
|
|
353
|
+
reach: number;
|
|
354
|
+
} | undefined;
|
|
355
|
+
/** Morph menu placement: the button grows into a labelled list, outer edge on the button's, opening down from
|
|
356
|
+
* its top or up from its bottom. Where it crosses Safari's column it stays inside the column band (flush with
|
|
357
|
+
* the column, as Safari's own menus open); a list too tall for that band moves wholly over the page, beside the
|
|
358
|
+
* column, where it may rise to the top; one too tall even there caps its height and scrolls. x0/y0: the button's
|
|
359
|
+
* top-left, where the morph starts. */
|
|
360
|
+
export declare function morphBox(c: Context, p: Plan, b: Box, n: number): {
|
|
361
|
+
scroll: boolean;
|
|
362
|
+
x0: number;
|
|
363
|
+
y0: number;
|
|
364
|
+
top: number;
|
|
365
|
+
height: number;
|
|
366
|
+
left: number;
|
|
367
|
+
width: 240;
|
|
368
|
+
};
|
|
369
|
+
/** Island menu placement: the button stretches sideways into a pill as tall as itself, outer edge on the
|
|
370
|
+
* button's. In the column it never rises above columnTop. */
|
|
371
|
+
export declare function islandBox(p: Plan, b: Box, n: number, side: Side): {
|
|
372
|
+
top: number;
|
|
373
|
+
left: number;
|
|
374
|
+
width: number;
|
|
375
|
+
height: 49;
|
|
376
|
+
x0: number;
|
|
377
|
+
};
|
|
226
378
|
export declare function plan(c: Context, s: Spec): Plan;
|