duet-ui 0.2.0 → 0.2.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 CHANGED
@@ -41,7 +41,7 @@ export function App() {
41
41
  }
42
42
  ```
43
43
 
44
- That is the whole setup. The styles come with the component, and Duet adds `viewport-fit=cover` and `shrink-to-fit=no` to the viewport meta itself (the safe areas only reach the page with the first; the second keeps Safari from zooming a split-view pane out). Each tab keeps its own scroll position, and tapping the selected tab scrolls back to the top. On other phones and browsers Duet falls back to the `inline` strip.
44
+ That is the whole setup. The styles come with the component, and Duet adds `viewport-fit=cover` and `shrink-to-fit=no` to every viewport meta itself, including ones the framework adds later (the safe areas only reach the page with the first; the second keeps Safari from zooming a split-view pane out). Each tab keeps its own scroll position, and tapping the selected tab scrolls back to the top. On other phones and browsers Duet falls back to the `inline` strip.
45
45
 
46
46
  Icons are yours, any library: 24pt in tabs, 21 to 22pt on round buttons matches the system. `as const` types the tab ids, so `onSelect` takes `setTab` without a cast.
47
47
 
@@ -52,7 +52,7 @@ Duet picks a mode from the window size, safe areas and display mode. `useDuet()`
52
52
  | Mode | When | Controls |
53
53
  | --- | --- | --- |
54
54
  | `strip` | Home Screen app, closed | In the slim strip beside the page, like a native app |
55
- | `column` | Safari tab, closed or a split-view pane | Painted into the column Safari leaves empty, so the page keeps its full width. In split view both columns get the controls, since a pane can't tell which side it is on, and each shows only its own |
55
+ | `column` | Safari tab, closed, windowed or a split-view pane | Painted into the column Safari leaves empty, so the page keeps its full width. In split view both columns get the controls, since a pane can't tell which side it is on, and each shows only its own |
56
56
  | `inline` | Unfolded, landscape, in-app preview | An 84pt strip inside the page. Drag it to either side; the choice is remembered |
57
57
 
58
58
  ### Claimable space
@@ -67,7 +67,9 @@ Each mode has a band the controls may use, and Duet keeps every control inside i
67
67
 
68
68
  ### Only on the Duo
69
69
 
70
- `useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals.
70
+ `useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. iOS never names the model, so it takes two checks: an iPhone user agent (or Safari's desktop-site Mac agent with touch), and a screen or window size only the Duo has. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals.
71
+
72
+ `useDuet().browser` is `"safari"`, `"chrome"`, `"firefox"`, `"edge"`, `"webview"` (an in-app browser such as Instagram's) or `"other"`. Only Safari gets `column`: the others draw their own chrome where Safari leaves its column, so they get `inline`.
71
73
 
72
74
  The server can't tell, so `<DuoCookie>` tells it: it keeps a `duet-duo` cookie in step with the browser, and a new device reloads once into the right layout. In the Next.js App Router:
73
75
 
@@ -126,7 +128,7 @@ The page spans the whole window and the controls float over it, so backgrounds r
126
128
  - `column(maxWidth, pad)`: a centred reading column. It centres on the whole window and moves aside from the controls only as far as it must. The `duet-column` class does the same in CSS (`--duet-max`, `--duet-pad`).
127
129
  - `bleed(pad)`: a full-width band (hero, sticky bar, list row). The band runs under the controls, its content is padded back beside them. `bleed(false)` runs content itself edge to edge (a map, an image).
128
130
 
129
- The insets are exposed as `--duet-inset-left`, `--duet-inset-right` and `--duet-inset-top` (the strip's top blur in Home Screen apps). Inside `<DuetPanes>` each pane gets its own: only the pane next to the controls is inset.
131
+ The insets are exposed as `--duet-inset-left`, `--duet-inset-right`, `--duet-inset-top` (the strip's top blur in Home Screen apps) and `--duet-inset-bottom` (Safari's floating URL pill in column mode: the page runs on under it, and the scroller ends with that much space so the last content scrolls clear). Inside `<DuetPanes>` each pane gets its own: only the pane next to the controls is inset.
130
132
 
131
133
  ## Panes
132
134
 
package/dist/chrome.js CHANGED
@@ -47,6 +47,8 @@ function signals() {
47
47
  insetLeft: parseFloat(pad.paddingLeft) || 0,
48
48
  framed: window.top !== window,
49
49
  pixelRatio: devicePixelRatio,
50
+ userAgent: navigator.userAgent,
51
+ touchPoints: navigator.maxTouchPoints,
50
52
  standalone: navigator.standalone === true || matchMedia("(display-mode: standalone)").matches,
51
53
  };
52
54
  }
@@ -116,7 +118,7 @@ function subscribe(cb) {
116
118
  visualViewport?.removeEventListener("resize", on);
117
119
  };
118
120
  }
119
- const ssr = { mode: "column", duo: true, layout: "narrow", side: "right", width: 382, height: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
121
+ const ssr = { mode: "column", duo: true, browser: "safari", layout: "narrow", side: "right", width: 382, height: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
120
122
  export function useDuet() {
121
123
  return useSyncExternalStore(subscribe, read, () => ssr);
122
124
  }
@@ -169,17 +171,21 @@ function useMinimized(on, scroller) {
169
171
  * shows only its own column, and a tap there names the side (`tapSide`). */
170
172
  let tapSide = "right";
171
173
  /** Without shrink-to-fit=no Safari zooms a split pane out to fit the layer's overflow, which reads as another
172
- * window size and flips the mode. It applies even when set late, but must land before the first column paint. */
174
+ * window size and flips the mode. It applies even when set late, but must land before the first column paint.
175
+ * Safari reads the last viewport meta, and frameworks add their own after load (Next.js renders a second one on
176
+ * the client, device trace 2026-10-25), so every one is patched. */
173
177
  function viewport(key, value) {
174
- let meta = document.querySelector("meta[name=viewport]");
175
- if (!meta) {
176
- meta = document.createElement("meta");
178
+ const metas = document.querySelectorAll("meta[name=viewport]");
179
+ if (!metas.length) {
180
+ const meta = document.createElement("meta");
177
181
  meta.name = "viewport";
178
182
  meta.content = "width=device-width,initial-scale=1";
179
183
  document.head.appendChild(meta);
184
+ return viewport(key, value);
180
185
  }
181
- if (!meta.content.includes(key))
182
- meta.content += `,${key}=${value}`;
186
+ for (const meta of metas)
187
+ if (!meta.content.includes(key))
188
+ meta.content += `,${key}=${value}`;
183
189
  }
184
190
  if (typeof document !== "undefined")
185
191
  viewport("shrink-to-fit", "no");
@@ -357,10 +363,13 @@ export function Duet(props) {
357
363
  // iOS blurs the top of a Home Screen web app (scroll-edge effect) without reporting a safe-area
358
364
  // inset, closed and unfolded; pages pad their top content by this much to stay sharp.
359
365
  r.setProperty("--duet-inset-top", ctx.mode === "strip" ? `${DUO.edgeBlur}px` : "0px");
366
+ // Safari's URL pill floats below the layout viewport (the column holds nothing above the page), so the scroller
367
+ // runs on under it like a document that scrolls, and the last content scrolls clear of it.
368
+ r.setProperty("--duet-inset-bottom", ctx.mode === "column" ? `${Math.max(0, ctx.screenHeight - ctx.height)}px` : "0px");
360
369
  document.documentElement.dataset.duetMode = ctx.mode;
361
370
  document.documentElement.toggleAttribute("data-duet-emulated", !!emulated);
362
371
  document.documentElement.toggleAttribute("data-duet-split", split);
363
- }, [ctx.mode, ctx.side, ctx.screenWidth, ctx.width, split]);
372
+ }, [ctx.mode, ctx.side, ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, split]);
364
373
  const [nav, nav2] = useColumnLayer(ctx.mode === "column", split, column);
365
374
  useEffect(() => {
366
375
  // Any touch outside the chrome closes an open menu, like a native context menu.
@@ -376,7 +385,14 @@ export function Duet(props) {
376
385
  useSideDrag(ctx.mode === "inline" ? ctx.side : null, nav, () => { setMenu(null); setScrub(null); });
377
386
  // The safe areas only reach the page with viewport-fit=cover. Set on mount, not on import, so a page that only
378
387
  // imports DuoCookie keeps its own viewport on other phones.
379
- useLayout(() => viewport("viewport-fit", "cover"), []);
388
+ useLayout(() => {
389
+ const patch = () => { viewport("viewport-fit", "cover"); viewport("shrink-to-fit", "no"); };
390
+ patch();
391
+ // Keep them patched when the framework adds or rewrites a viewport meta later.
392
+ const watch = new MutationObserver(patch);
393
+ watch.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["content"] });
394
+ return () => watch.disconnect();
395
+ }, []);
380
396
  const selected = p.tabs?.selected;
381
397
  // Each tab keeps its own scroll position in the inner scroller; the selected tab again scrolls to the top.
382
398
  const scrolls = useRef(new Map());
package/dist/duet.css CHANGED
@@ -48,6 +48,8 @@
48
48
  /* Press lift, as iOS Liquid Glass: a separate button swells to ~1.4x and its glass turns lighter, then springs
49
49
  back on release. Items inside a shared surface (groups, the full tab pill) lift as a bubble of their own. */
50
50
  .duet-glass { transition: top 0.35s cubic-bezier(0.32, 0.72, 0, 1), height 0.35s cubic-bezier(0.32, 0.72, 0, 1), scale 0.5s var(--duet-spring-in); }
51
+ /* Minimizing on scroll, as native .onScrollDown: the pill shrinks to the one button in about 0.24s. */
52
+ .duet-pill[data-min] { transition-duration: 0.24s, 0.24s, 0.4s; }
51
53
  .duet-glass::before { transition: background-color 0.25s; }
52
54
  :is(.duet-round, .duet-pill[data-min]):has(:active) { scale: 1.4; z-index: 52; transition-duration: 0.35s, 0.35s, 0.45s; transition-timing-function: ease, ease, var(--duet-spring-out); }
53
55
  :is(.duet-round, .duet-pill[data-min]):has(:active)::before { background-color: var(--duet-lift); }
@@ -82,8 +84,8 @@
82
84
  /* Letting go eases everything back together, without overshoot, so the swell and the lens melt away as one. */
83
85
  .duet-pill:not([data-min]) {
84
86
  --lx: 0px; --ly: 0px; --lm: 1; --pad: 6.35px; padding: var(--pad) 3px; touch-action: none; transform-origin: 100% 50%;
85
- --duet-settle: 0.42s cubic-bezier(0.32, 0.72, 0, 1);
86
- transition: top var(--duet-settle), height var(--duet-settle), left var(--duet-settle), width var(--duet-settle), --pad var(--duet-settle), scale var(--duet-settle),
87
+ --duet-settle: 0.42s cubic-bezier(0.32, 0.72, 0, 1); --duet-grow: 0.26s cubic-bezier(0.32, 0.72, 0, 1);
88
+ transition: top var(--duet-grow), height var(--duet-grow), left var(--duet-grow), width var(--duet-grow), --pad var(--duet-settle), scale var(--duet-settle),
87
89
  --li 0.4s cubic-bezier(0.32, 0.72, 0, 1), --lx var(--duet-settle), --ly var(--duet-settle), --lm var(--duet-settle);
88
90
  }
89
91
  .duet[data-side="left"] .duet-pill { transform-origin: 0 50%; }
@@ -123,6 +125,9 @@
123
125
  -webkit-mask: linear-gradient(#000 var(--y0), transparent var(--y0), transparent var(--y1), #000 var(--y1));
124
126
  mask: linear-gradient(#000 var(--y0), transparent var(--y0), transparent var(--y1), #000 var(--y1));
125
127
  }
128
+ /* Expanding from the minimized button, the other tabs come out of a blur as the pill grows (native: about 0.2s). */
129
+ .duet-pill:not([data-min]) > .duet-tab:not([data-on]), .duet-pill-badges > span { animation: duet-tab-in 0.22s ease-out; }
130
+ @keyframes duet-tab-in { from { opacity: 0; filter: blur(6px); scale: 0.7; } }
126
131
  .duet-pill-badges { position: absolute; z-index: 2; left: 3px; right: 3px; top: var(--pad); pointer-events: none; }
127
132
  .duet-pill-badges > span { display: block; position: relative; height: 50px; }
128
133
  .duet-tab > svg { transition: translate var(--duet-settle, 0.3s), scale var(--duet-settle, 0.3s); }
@@ -222,12 +227,15 @@ html:has(> body .duet-scroller), html:has(> body .duet-scroller) > body { height
222
227
  column() or bleed(). Safari's column only shows in-document content and clips fixed boxes at the viewport
223
228
  edge, so there the scroller is an in-document box that runs on under the column. The document still never
224
229
  scrolls, so the chrome layer never moves. */
225
- html[data-duet-mode="column"] .duet-scroller { position: absolute; width: calc(100% + var(--duet-inset-right)); }
230
+ html[data-duet-mode="column"] .duet-scroller { position: absolute; width: calc(100% + var(--duet-inset-right)); bottom: auto; height: calc(100% + var(--duet-inset-bottom)); }
231
+ /* Safari's URL pill floats under the page's bottom edge: the scroller runs on under it (above) and this spacer lets
232
+ the last content scroll clear of it. */
233
+ .duet-scroller::after { content: ""; display: block; height: var(--duet-inset-bottom, 0px); }
226
234
  /* Split pane: a column on each side (the pane's own is on its outer edge, which the page cannot see); the
227
235
  document is scrolled one column across (chrome.tsx). */
228
236
  html[data-duet-mode="column"][data-duet-split] .duet-scroller { width: calc(100% + var(--duet-inset-left) + var(--duet-inset-right)); }
229
- /* Emulated (the site's web Duo): the frame already spans the column. */
230
- html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%; }
237
+ /* Emulated (the site's web Duo): the frame already spans the column and runs under the pill. */
238
+ html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%; height: 100%; }
231
239
 
232
240
  /* Safari tab: an in-document layer at the document top (which never scrolls), so controls can paint in
233
241
  Safari's column.
@@ -80,6 +80,10 @@ export interface Signals {
80
80
  framed?: boolean;
81
81
  /** devicePixelRatio: tells the in-app preview's 375x667 window from an iPhone SE's (2x). */
82
82
  pixelRatio?: number;
83
+ /** navigator.userAgent and maxTouchPoints: tell iOS and Safari apart (see isIOS, browserOf). Unknown (an
84
+ * emulator's signals) restricts nothing. */
85
+ userAgent?: string;
86
+ touchPoints?: number;
83
87
  }
84
88
  export interface Context {
85
89
  mode: Mode;
@@ -103,6 +107,8 @@ export interface Context {
103
107
  /** A Safari split-view pane on the open Duo (see isSplit). In column mode nothing tells which pane it is, so
104
108
  * the controls are drawn in both columns, each pane showing only its own (chrome.tsx); `side` is moot. */
105
109
  split?: boolean;
110
+ /** The browser the page runs in (see browserOf). Only Safari gets the column. */
111
+ browser: Browser;
106
112
  }
107
113
  export type Layout = "narrow" | "wide";
108
114
  /** Page width from which two panes fit. The page spans the window, controls floating over it: the open Duo gives
@@ -110,6 +116,13 @@ export type Layout = "narrow" | "wide";
110
116
  export declare const WIDE = 600;
111
117
  export declare const isPreview: (s: Signals) => boolean;
112
118
  export declare const isDuo: (s: Signals) => boolean;
119
+ /** iOS has no model in its UA, so the sizes above say Duo; the UA says iPhone. Safari's "Request Desktop Website"
120
+ * sends a Mac UA, told from a Mac by touch. undefined when the signals carry no UA. */
121
+ export declare const isIOS: (s: Signals) => boolean | undefined;
122
+ export type Browser = "safari" | "chrome" | "firefox" | "edge" | "webview" | "other";
123
+ /** Every iOS browser is WebKit; the others name themselves in the UA. An in-app web view (Instagram, Facebook)
124
+ * drops the "Safari/" token; the in-app Safari preview keeps it, and is Safari. */
125
+ export declare function browserOf(ua: string | undefined): Browser;
113
126
  /** A Safari split-view pane: its window is 469x669 in either pane. Nothing tells the left pane from the right
114
127
  * (device probe 2026-10-25: screenX, availLeft, insets, visualViewport and touch screenX read the same in both),
115
128
  * though Safari's column sits on the pane's outer edge. So the inline side is remembered apart for split view. */
@@ -118,12 +131,13 @@ export declare const isSplit: (s: Signals) => boolean;
118
131
  export declare function detect(s: Signals, preferred?: Side, allowColumn?: boolean): Context;
119
132
  /** Mode flips are a feedback loop when the layout of one mode changes the signals into the other (the
120
133
  * column layer's overflow resizing Safari's viewport). Three flips within FLIP_WINDOW pin the page to inline.
121
- * The pin lifts when the window changes size, or after `until`: moving a split-view pane to the other side
134
+ * 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
122
135
  * flips the mode a few times without changing the window's size. Each re-pin for the same window doubles
123
136
  * the wait, so a real loop settles instead of flickering. */
124
137
  export interface Damper {
125
138
  mode?: Mode;
126
139
  flips: number[];
140
+ key?: string;
127
141
  pinned?: string;
128
142
  until?: number;
129
143
  ttl?: number;
package/dist/geometry.js CHANGED
@@ -53,27 +53,52 @@ export const isPreview = (s) => !s.standalone && s.pixelRatio === PREVIEW.pixelR
53
53
  * Other phones and desktops never match, so apps can switch to a Duo layout on it alone. */
54
54
  const DUO_SIZES = [[466, 678], [669, 951], [469, 669]];
55
55
  const duoSize = (w, h) => DUO_SIZES.some(([a, b]) => Math.abs(Math.min(w, h) - a) <= 1 && Math.abs(Math.max(w, h) - b) <= 1);
56
- export const isDuo = (s) => duoSize(s.screenWidth, s.screenHeight) || duoSize(s.outerWidth, s.outerHeight);
56
+ export const isDuo = (s) => isIOS(s) !== false && (duoSize(s.screenWidth, s.screenHeight) || duoSize(s.outerWidth, s.outerHeight));
57
+ /** iOS has no model in its UA, so the sizes above say Duo; the UA says iPhone. Safari's "Request Desktop Website"
58
+ * sends a Mac UA, told from a Mac by touch. undefined when the signals carry no UA. */
59
+ export const isIOS = (s) => s.userAgent === undefined ? undefined
60
+ : /iPhone|iPod/.test(s.userAgent) || (/Macintosh/.test(s.userAgent) && (s.touchPoints ?? 0) > 1);
61
+ /** Every iOS browser is WebKit; the others name themselves in the UA. An in-app web view (Instagram, Facebook)
62
+ * drops the "Safari/" token; the in-app Safari preview keeps it, and is Safari. */
63
+ export function browserOf(ua) {
64
+ if (ua === undefined)
65
+ return "safari";
66
+ if (/EdgiOS|Edg\//.test(ua))
67
+ return "edge";
68
+ if (/CriOS|Chrome\//.test(ua))
69
+ return "chrome";
70
+ if (/FxiOS|Firefox\//.test(ua))
71
+ return "firefox";
72
+ if (/AppleWebKit/.test(ua) && !/Safari\//.test(ua))
73
+ return "webview";
74
+ if (/Version\/[\d.]+.*Safari\//.test(ua) && !/OPiOS|OPT\/|GSA\/|Ddg\//.test(ua))
75
+ return "safari";
76
+ return "other";
77
+ }
57
78
  /** A Safari split-view pane: its window is 469x669 in either pane. Nothing tells the left pane from the right
58
79
  * (device probe 2026-10-25: screenX, availLeft, insets, visualViewport and touch screenX read the same in both),
59
80
  * though Safari's column sits on the pane's outer edge. So the inline side is remembered apart for split view. */
60
- export const isSplit = (s) => !s.standalone && Math.abs(s.outerWidth - 469) <= 1 && Math.abs(s.outerHeight - 669) <= 1;
81
+ export const isSplit = (s) => !s.standalone && isIOS(s) !== false && Math.abs(s.outerWidth - 469) <= 1 && Math.abs(s.outerHeight - 669) <= 1;
61
82
  /** Measured on the Duo simulator: Safari's column is 84pt wide (outer − inner), the chrome below the
62
83
  * page is 98pt (80 with the compact URL bar), the window is the closed screen's 678pt tall. A split-view
63
84
  * pane on the open Duo (469x669 outer, 385x571 inner) has the same column on its outer edge, so either
64
85
  * side of the page. In-document content paints into it once the document spans both columns (84 + 385 + 84)
65
86
  * and is scrolled 84 across, with `shrink-to-fit=no` in the viewport meta, or Safari zooms the pane out to
66
- * fit the wider document (simulator probes, 2026-10-25). */
67
- const SAFARI_COLUMN = { width: [80, 88], chrome: [76, 102], height: [675, 681] };
87
+ * fit the wider document (simulator probes, 2026-10-25). Windowed Safari (floating, or beside the Home Screen)
88
+ * keeps the column at other window sizes (device 2026-10-25: 453x661 and 450x670 outer, 369x591 inner), and a
89
+ * pane with the compact URL bar has 75pt of chrome, so any portrait Safari window on the Duo with the column counts. */
90
+ const SAFARI_COLUMN = { width: [80, 88], chrome: [68, 102] };
68
91
  const within = (v, [a, b]) => v >= a && v <= b;
69
92
  /** preferred: the side the user picked for inline controls, if any. */
70
93
  export function detect(s, preferred, allowColumn = true) {
71
- const duo = isDuo(s);
72
- if (s.insetRight >= 60 || s.insetLeft >= 60) {
73
- return { mode: "strip", duo, 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 };
94
+ const duo = isDuo(s), browser = browserOf(s.userAgent);
95
+ // Only a Home Screen app owns the strip: a Safari tab reports the column as an 84pt inset for a frame while folding.
96
+ if (s.standalone && (s.insetRight >= 60 || s.insetLeft >= 60)) {
97
+ return { mode: "strip", duo, browser, 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 };
74
98
  }
75
- const base = { duo, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
76
- const column = allowColumn && !s.standalone && !s.framed
99
+ const base = { duo, browser, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
100
+ // Other iOS browsers draw their own chrome where Safari has its column: inline for them.
101
+ const column = allowColumn && !s.standalone && !s.framed && browser === "safari"
77
102
  && within(s.outerWidth - s.innerWidth, SAFARI_COLUMN.width)
78
103
  && within(s.outerHeight - s.innerHeight, SAFARI_COLUMN.chrome)
79
104
  && s.innerHeight > s.innerWidth // landscape moves Safari's column buttons around
@@ -81,7 +106,7 @@ export function detect(s, preferred, allowColumn = true) {
81
106
  // Safari's page viewport starts at screen top and ends above its address bar (382x580 of 466x678).
82
107
  if (column && isSplit(s))
83
108
  return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight, split: true };
84
- if (column && within(s.outerHeight, SAFARI_COLUMN.height))
109
+ if (column && duo)
85
110
  return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight };
86
111
  const preview = isPreview(s), split = isSplit(s);
87
112
  return { ...base, mode: "inline", layout: layoutOf(s.innerWidth), side: preferred ?? (preview ? "left" : "right"), screenWidth: s.innerWidth, screenHeight: s.innerHeight, ...(preview && { preview }), ...(split && { split }) };
@@ -90,15 +115,17 @@ const FLIP_WINDOW = 2000;
90
115
  export const PIN_TTL = 3000;
91
116
  export function stable(d, s, preferred, now) {
92
117
  const key = `${s.outerWidth}x${s.outerHeight}`;
93
- if (d.pinned && d.pinned !== key)
94
- Object.assign(d, { pinned: undefined, ttl: undefined, flips: [] });
118
+ // A loop keeps the window size; a fold or rotation passes through other sizes, so only same-size flips count.
119
+ if (d.key !== key)
120
+ Object.assign(d, { key, pinned: undefined, ttl: undefined, flips: [] });
95
121
  if (d.pinned && now >= d.until)
96
122
  Object.assign(d, { pinned: undefined, flips: [] });
97
123
  let c = detect(s, preferred, !d.pinned);
98
124
  if (d.mode && c.mode !== d.mode) {
99
125
  d.flips = [...d.flips.filter((t) => now - t < FLIP_WINDOW), now];
100
126
  if (d.flips.length >= 3) {
101
- d.ttl = d.ttl ? d.ttl * 2 : PIN_TTL;
127
+ // Doubles only when the loop resumes as the last pin lifts; a later pane move starts from PIN_TTL again.
128
+ d.ttl = d.ttl && d.until !== undefined && now - d.until < FLIP_WINDOW ? d.ttl * 2 : PIN_TTL;
102
129
  Object.assign(d, { pinned: key, until: now + d.ttl });
103
130
  c = detect(s, preferred, false);
104
131
  }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export { Duet, DuoCookie, useDuet, type DuetProps, type DuetItem, type DuetTab }
2
2
  export { bleed, column } from "./layout.ts";
3
3
  export { DuetPanes, DuetPaneBar, usePanes, type DuetPanesProps, type DuetPaneBarProps } from "./duet-panes.tsx";
4
4
  export { panes, backFrom, type PaneWindow } from "./panes.ts";
5
- export { detect, isDuo, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE, type Context, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
5
+ export { browserOf, detect, isDuo, isIOS, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE, type Browser, type Context, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { Duet, DuoCookie, useDuet } from "./chrome.js";
2
2
  export { bleed, column } from "./layout.js";
3
3
  export { DuetPanes, DuetPaneBar, usePanes } from "./duet-panes.js";
4
4
  export { panes, backFrom } from "./panes.js";
5
- export { detect, isDuo, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
5
+ export { browserOf, detect, isDuo, isIOS, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duet-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Native-feeling controls for React web apps on the foldable iPhone: the side strip, Safari's column, split view and Home Screen apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",