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.
@@ -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 into the
3
- * free space of its column (an undocumented WebKit behaviour, so only when every signal matches).
4
- * inline: anything else (landscape, open posture full width, keyboard up, other browsers); a right-edge
5
- * strip inside the page viewport. */
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
- * always right, Safari owns it. inline: the user's choice; no API tells a split-view pane which side of
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
- /** preferred: the side the user picked for inline controls, if any. */
140
- export declare function detect(s: Signals, preferred?: Side, allowColumn?: boolean): Context;
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
- export declare function stable(d: Damper, s: Signals, preferred: Side | undefined, now: number): Context;
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;