duet-ui 0.1.1 → 0.2.1

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
@@ -19,15 +19,20 @@ On the closed Duo, Safari keeps a narrow column beside the page for its own butt
19
19
  ## Usage
20
20
 
21
21
  ```tsx
22
- import { Duet } from "duet-ui";
23
- import "duet-ui/styles.css";
22
+ "use client";
23
+ import { Duet, type DuetItem } from "duet-ui";
24
+
25
+ const TABS = [
26
+ { id: "home", label: "Home", icon: <House size={24} /> },
27
+ { id: "search", label: "Search", icon: <Search size={24} /> },
28
+ ] as const satisfies readonly DuetItem[];
24
29
 
25
30
  export function App() {
26
- const [tab, setTab] = useState("home");
31
+ const [tab, setTab] = useState<(typeof TABS)[number]["id"]>("home");
27
32
  return (
28
33
  <Duet
29
34
  tabs={{ items: TABS, selected: tab, onSelect: setTab }}
30
- actions={[{ id: "share", label: "Share", icon: <Share />, onPress: share }]}
35
+ actions={[{ id: "share", label: "Share", icon: <Share size={22} />, onPress: share }]}
31
36
  minimizeOnScroll
32
37
  >
33
38
  <Page tab={tab} />
@@ -36,7 +41,9 @@ export function App() {
36
41
  }
37
42
  ```
38
43
 
39
- Add `viewport-fit=cover` to the viewport meta so the safe areas reach the page. Duet appends `shrink-to-fit=no` itself, so Safari doesn't zoom a split-view pane out to fit the column layer. 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
+
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.
40
47
 
41
48
  ## Modes
42
49
 
@@ -45,7 +52,7 @@ Duet picks a mode from the window size, safe areas and display mode. `useDuet()`
45
52
  | Mode | When | Controls |
46
53
  | --- | --- | --- |
47
54
  | `strip` | Home Screen app, closed | In the slim strip beside the page, like a native app |
48
- | `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 |
49
56
  | `inline` | Unfolded, landscape, in-app preview | An 84pt strip inside the page. Drag it to either side; the choice is remembered |
50
57
 
51
58
  ### Claimable space
@@ -60,7 +67,27 @@ Each mode has a band the controls may use, and Duet keeps every control inside i
60
67
 
61
68
  ### Only on the Duo
62
69
 
63
- `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. The server can't tell, so before hydration `duo` is true; remember the answer in a cookie if the server should render the right layout first.
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.
71
+
72
+ 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
+
74
+ ```tsx
75
+ // app/page.tsx, a server component
76
+ import { cookies } from "next/headers";
77
+ import { DuoCookie } from "duet-ui";
78
+
79
+ export default async function Page() {
80
+ const duo = (await cookies()).get("duet-duo")?.value === "1";
81
+ return (
82
+ <>
83
+ <DuoCookie rendered={duo} />
84
+ {duo ? <DuoApp /> : <DesktopApp />}
85
+ </>
86
+ );
87
+ }
88
+ ```
89
+
90
+ Pass `onChange={() => router.refresh()}` from a client component to re-render without a reload.
64
91
 
65
92
  ### In-app preview
66
93
 
@@ -74,7 +101,7 @@ Links opened from social and mail apps land in the in-app Safari preview (`SFSaf
74
101
  | `leading` | Its own group under back, like SwiftUI `.topBarLeading` |
75
102
  | `actions` | Like `.primaryAction`; collapses into "…" when it would reach the tabs. Items with `destructive: true` only ever show in "…", last, in red |
76
103
  | `primary` | The one main action (compose, new). Narrow: just above the tabs. Wide: under back, at the top. Folds into "…" last, only when the band has no room for both |
77
- | `tabs` | `{ items, selected, onSelect, search? }`. `search` adds a search tab as the last item |
104
+ | `tabs` | `{ items, selected, onSelect, search? }`. `search` adds a search tab as the last item. Each tab keeps its scroll position |
78
105
  | `searchButton` | A round search button at the bottom when there are no tabs |
79
106
  | `close` | A sheet or card is open: the strip holds only a close button until it is cleared |
80
107
  | `accessory` | A view docked by the tabs, like `.tabViewBottomAccessory` (a mini player) |
@@ -99,7 +126,7 @@ The page spans the whole window and the controls float over it, so backgrounds r
99
126
  - `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`).
100
127
  - `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).
101
128
 
102
- 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.
129
+ 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.
103
130
 
104
131
  ## Panes
105
132
 
package/dist/chrome.d.ts CHANGED
@@ -13,7 +13,11 @@ export interface DuetItem {
13
13
  /** Deletes or discards: lives only in "…", last, in red, after a separator (blueprint). */
14
14
  destructive?: boolean;
15
15
  }
16
- export interface DuetProps {
16
+ /** A tab: an item whose id is one of the app's tab ids. */
17
+ export type DuetTab<T extends string = string> = DuetItem & {
18
+ id: T;
19
+ };
20
+ export interface DuetProps<T extends string = string> {
17
21
  /** Back button, top of the strip. */
18
22
  back?: false | {
19
23
  label?: string;
@@ -27,12 +31,14 @@ export interface DuetProps {
27
31
  /** The screen's main action (New chat, Compose), a tinted round button: by the thumb above the tabs where one
28
32
  * pane shows, at the top under back where two do. */
29
33
  primary?: DuetItem;
30
- /** Tab pill at the bottom of the strip. `search` adds a `role: .search` tab as the last item. */
34
+ /** Tab pill at the bottom of the strip. `search` adds a `role: .search` tab as the last item. The ids are typed
35
+ * from `items`: declare them `as const` and `onSelect` gets the union, so `setTab` fits without a cast. Each tab
36
+ * keeps its own scroll position, and tapping the selected tab scrolls it to the top, like a native tab bar. */
31
37
  tabs?: {
32
- items: DuetItem[];
33
- selected: string;
34
- onSelect: (id: string) => void;
35
- search?: DuetItem;
38
+ items: readonly DuetTab<T>[];
39
+ selected: NoInfer<T>;
40
+ onSelect: (id: NoInfer<T>) => void;
41
+ search?: DuetTab<T>;
36
42
  };
37
43
  /** Searchable without tabs: a round search button at the bottom of the strip. */
38
44
  searchButton?: () => void;
@@ -56,4 +62,12 @@ export interface DuetProps {
56
62
  children?: ReactNode;
57
63
  }
58
64
  export declare function useDuet(): Context;
59
- export declare function Duet(p: DuetProps): import("react").JSX.Element;
65
+ /** Tells the server whether this browser is an iPhone Duo, so it can render the Duo layout from the first byte.
66
+ * The server can't tell on its own, so it renders what the `duet-duo` cookie says (`"1"` on a Duo) and passes that
67
+ * as `rendered`. When the browser disagrees, this sets the cookie and calls `onChange`, by default a reload, so a
68
+ * new device switches layout once and never again. Renders nothing; works outside `<Duet>`. */
69
+ export declare function DuoCookie({ rendered, onChange }: {
70
+ rendered: boolean;
71
+ onChange?: (duo: boolean) => void;
72
+ }): null;
73
+ export declare function Duet<T extends string = string>(props: DuetProps<T>): import("react").JSX.Element;
package/dist/chrome.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { ArrowLeftRight, ChevronLeft, Ellipsis, Search, X } from "./icons.js";
5
5
  import { DUO, INLINE_WIDTH, ISLAND, MORPH, isSplit, magnet, plan, stable } from "./geometry.js";
6
6
  import { GlassProvider } from "./glassyui/glass-provider.js";
@@ -120,6 +120,23 @@ const ssr = { mode: "column", duo: true, layout: "narrow", side: "right", width:
120
120
  export function useDuet() {
121
121
  return useSyncExternalStore(subscribe, read, () => ssr);
122
122
  }
123
+ /** Tells the server whether this browser is an iPhone Duo, so it can render the Duo layout from the first byte.
124
+ * The server can't tell on its own, so it renders what the `duet-duo` cookie says (`"1"` on a Duo) and passes that
125
+ * as `rendered`. When the browser disagrees, this sets the cookie and calls `onChange`, by default a reload, so a
126
+ * new device switches layout once and never again. Renders nothing; works outside `<Duet>`. */
127
+ export function DuoCookie({ rendered, onChange = () => location.reload() }) {
128
+ const { duo } = useDuet();
129
+ // Before hydration useDuet() answers for the server (a Duo); only the browser's own answer counts.
130
+ const hydrated = useSyncExternalStore(noop, () => true, () => false);
131
+ useEffect(() => {
132
+ if (!hydrated || duo === rendered)
133
+ return;
134
+ document.cookie = duo ? "duet-duo=1; path=/; max-age=31536000; samesite=lax" : "duet-duo=; path=/; max-age=0";
135
+ onChange(duo);
136
+ }, [hydrated, duo, rendered]); // eslint-disable-line react-hooks/exhaustive-deps -- onChange may be a fresh arrow each render
137
+ return null;
138
+ }
139
+ const noop = () => () => { };
123
140
  function useMinimized(on, scroller) {
124
141
  const [min, setMin] = useState(false);
125
142
  useEffect(() => {
@@ -152,14 +169,24 @@ function useMinimized(on, scroller) {
152
169
  * shows only its own column, and a tap there names the side (`tapSide`). */
153
170
  let tapSide = "right";
154
171
  /** Without shrink-to-fit=no Safari zooms a split pane out to fit the layer's overflow, which reads as another
155
- * window size and flips the mode. It applies even when set late, but must land before the first column paint. */
156
- function noShrink() {
157
- const meta = document.querySelector("meta[name=viewport]");
158
- if (meta && !/shrink-to-fit/.test(meta.content))
159
- meta.content += ",shrink-to-fit=no";
172
+ * window size and flips the mode. It applies even when set late, but must land before the first column paint.
173
+ * Safari reads the last viewport meta, and frameworks add their own after load (Next.js renders a second one on
174
+ * the client, device trace 2026-10-25), so every one is patched. */
175
+ function viewport(key, value) {
176
+ const metas = document.querySelectorAll("meta[name=viewport]");
177
+ if (!metas.length) {
178
+ const meta = document.createElement("meta");
179
+ meta.name = "viewport";
180
+ meta.content = "width=device-width,initial-scale=1";
181
+ document.head.appendChild(meta);
182
+ return viewport(key, value);
183
+ }
184
+ for (const meta of metas)
185
+ if (!meta.content.includes(key))
186
+ meta.content += `,${key}=${value}`;
160
187
  }
161
188
  if (typeof document !== "undefined")
162
- noShrink();
189
+ viewport("shrink-to-fit", "no");
163
190
  function useColumnLayer(on, split, column) {
164
191
  const refs = [useRef(null), useRef(null)];
165
192
  useEffect(() => {
@@ -277,7 +304,10 @@ function useSideDrag(side, nav, onStart) {
277
304
  }, [side, nav]); // eslint-disable-line react-hooks/exhaustive-deps -- onStart only resets menu and scrub state
278
305
  }
279
306
  // --- rendering ----------------------------------------------------------------------------------
280
- export function Duet(p) {
307
+ // useLayoutEffect warns during SSR on React 18.
308
+ const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
309
+ export function Duet(props) {
310
+ const p = props; // tab ids are only typed at the boundary
281
311
  const ctx = useDuet();
282
312
  const scroller = useRef(null);
283
313
  const minimized = useMinimized(!!p.minimizeOnScroll, scroller);
@@ -331,10 +361,13 @@ export function Duet(p) {
331
361
  // iOS blurs the top of a Home Screen web app (scroll-edge effect) without reporting a safe-area
332
362
  // inset, closed and unfolded; pages pad their top content by this much to stay sharp.
333
363
  r.setProperty("--duet-inset-top", ctx.mode === "strip" ? `${DUO.edgeBlur}px` : "0px");
364
+ // Safari's URL pill floats below the layout viewport (the column holds nothing above the page), so the scroller
365
+ // runs on under it like a document that scrolls, and the last content scrolls clear of it.
366
+ r.setProperty("--duet-inset-bottom", ctx.mode === "column" ? `${Math.max(0, ctx.screenHeight - ctx.height)}px` : "0px");
334
367
  document.documentElement.dataset.duetMode = ctx.mode;
335
368
  document.documentElement.toggleAttribute("data-duet-emulated", !!emulated);
336
369
  document.documentElement.toggleAttribute("data-duet-split", split);
337
- }, [ctx.mode, ctx.side, ctx.screenWidth, ctx.width, split]);
370
+ }, [ctx.mode, ctx.side, ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, split]);
338
371
  const [nav, nav2] = useColumnLayer(ctx.mode === "column", split, column);
339
372
  useEffect(() => {
340
373
  // Any touch outside the chrome closes an open menu, like a native context menu.
@@ -348,7 +381,31 @@ export function Duet(p) {
348
381
  return () => removeEventListener("pointerdown", close);
349
382
  }, [menu, nav, nav2]); // eslint-disable-line react-hooks/exhaustive-deps -- setMenu only reads menu
350
383
  useSideDrag(ctx.mode === "inline" ? ctx.side : null, nav, () => { setMenu(null); setScrub(null); });
384
+ // The safe areas only reach the page with viewport-fit=cover. Set on mount, not on import, so a page that only
385
+ // imports DuoCookie keeps its own viewport on other phones.
386
+ useLayout(() => {
387
+ const patch = () => { viewport("viewport-fit", "cover"); viewport("shrink-to-fit", "no"); };
388
+ patch();
389
+ // Keep them patched when the framework adds or rewrites a viewport meta later.
390
+ const watch = new MutationObserver(patch);
391
+ watch.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["content"] });
392
+ return () => watch.disconnect();
393
+ }, []);
351
394
  const selected = p.tabs?.selected;
395
+ // Each tab keeps its own scroll position in the inner scroller; the selected tab again scrolls to the top.
396
+ const scrolls = useRef(new Map());
397
+ const select = (id) => {
398
+ const el = scroller.current;
399
+ if (id === selected)
400
+ el?.scrollTo({ top: 0, behavior: "smooth" });
401
+ else {
402
+ if (el && selected !== undefined)
403
+ scrolls.current.set(selected, el.scrollTop);
404
+ p.tabs.onSelect(id);
405
+ }
406
+ };
407
+ useLayout(() => { if (scroller.current && selected !== undefined)
408
+ scroller.current.scrollTop = scrolls.current.get(selected) ?? 0; }, [selected]);
352
409
  const badge = (a) => a.badge !== undefined && a.badge !== "" && _jsx("span", { className: "duet-badge", children: a.badge });
353
410
  const tint = (a) => a.tint ? { "--duet-tint": a.tint } : undefined;
354
411
  const item = (a) => _jsxs("button", { className: "duet-item", "aria-label": a.label, onClick: a.onPress, children: [a.icon, badge(a)] }, a.id);
@@ -372,7 +429,7 @@ export function Duet(p) {
372
429
  const glideTo = (id) => {
373
430
  if (id !== selected)
374
431
  settle();
375
- p.tabs.onSelect(id);
432
+ select(id);
376
433
  };
377
434
  // Scrub: holding or dragging on the full pill swells it and shows labels; a lens follows the finger over the
378
435
  // tabs and the tab under it lights up; lifting selects it. A quick tap just glides the lens to the tab.
@@ -416,7 +473,7 @@ export function Duet(p) {
416
473
  setScrub(null);
417
474
  settle();
418
475
  if (t)
419
- p.tabs.onSelect(t.id);
476
+ select(t.id);
420
477
  }
421
478
  else if (tapped) {
422
479
  const t = tabItems[tabAt(e.currentTarget, e.clientY)];
@@ -448,7 +505,7 @@ export function Duet(p) {
448
505
  const close = kind === "more" && !morph, rolling = menu !== kind;
449
506
  return (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-menu", role: "menu", "data-style": morph ? "morph" : undefined, style: morph ? morphAt(anchor, items.length) : menuAt(anchor, items.length + (close ? 1 : 0)), "data-closing": rolling || undefined, onAnimationEnd: (e) => { if (rolling && (e.animationName === "duet-roll" || e.animationName === "duet-unmorph"))
450
507
  setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.12 + 0.04 * i : 0.14 + 0.06 * (items.length - i)}s` }) }, onClick: () => { setMenu(null); if (radio)
451
- p.tabs.onSelect(a.id);
508
+ select(a.id);
452
509
  else
453
510
  a.onPress?.(); }, children: [a.icon, morph && _jsx("span", { className: "duet-menu-label", children: a.label }), badge(a)] }, a.id))), close && _jsx("button", { className: "duet-menu-item", "aria-label": "Close", onClick: () => setMenu(null), children: _jsx(X, { size: 20 }) })] }));
454
511
  };
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.
@@ -118,12 +118,13 @@ export declare const isSplit: (s: Signals) => boolean;
118
118
  export declare function detect(s: Signals, preferred?: Side, allowColumn?: boolean): Context;
119
119
  /** Mode flips are a feedback loop when the layout of one mode changes the signals into the other (the
120
120
  * 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
121
+ * 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
122
  * flips the mode a few times without changing the window's size. Each re-pin for the same window doubles
123
123
  * the wait, so a real loop settles instead of flickering. */
124
124
  export interface Damper {
125
125
  mode?: Mode;
126
126
  flips: number[];
127
+ key?: string;
127
128
  pinned?: string;
128
129
  until?: number;
129
130
  ttl?: number;
package/dist/geometry.js CHANGED
@@ -63,13 +63,16 @@ export const isSplit = (s) => !s.standalone && Math.abs(s.outerWidth - 469) <= 1
63
63
  * pane on the open Duo (469x669 outer, 385x571 inner) has the same column on its outer edge, so either
64
64
  * side of the page. In-document content paints into it once the document spans both columns (84 + 385 + 84)
65
65
  * 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] };
66
+ * fit the wider document (simulator probes, 2026-10-25). Windowed Safari (floating, or beside the Home Screen)
67
+ * keeps the column at other window sizes (device 2026-10-25: 453x661 and 450x670 outer, 369x591 inner), and a
68
+ * pane with the compact URL bar has 75pt of chrome, so any portrait Safari window on the Duo with the column counts. */
69
+ const SAFARI_COLUMN = { width: [80, 88], chrome: [68, 102] };
68
70
  const within = (v, [a, b]) => v >= a && v <= b;
69
71
  /** preferred: the side the user picked for inline controls, if any. */
70
72
  export function detect(s, preferred, allowColumn = true) {
71
73
  const duo = isDuo(s);
72
- if (s.insetRight >= 60 || s.insetLeft >= 60) {
74
+ // Only a Home Screen app owns the strip: a Safari tab reports the column as an 84pt inset for a frame while folding.
75
+ if (s.standalone && (s.insetRight >= 60 || s.insetLeft >= 60)) {
73
76
  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 };
74
77
  }
75
78
  const base = { duo, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
@@ -81,7 +84,7 @@ export function detect(s, preferred, allowColumn = true) {
81
84
  // Safari's page viewport starts at screen top and ends above its address bar (382x580 of 466x678).
82
85
  if (column && isSplit(s))
83
86
  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))
87
+ if (column && duo)
85
88
  return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight };
86
89
  const preview = isPreview(s), split = isSplit(s);
87
90
  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 +93,17 @@ const FLIP_WINDOW = 2000;
90
93
  export const PIN_TTL = 3000;
91
94
  export function stable(d, s, preferred, now) {
92
95
  const key = `${s.outerWidth}x${s.outerHeight}`;
93
- if (d.pinned && d.pinned !== key)
94
- Object.assign(d, { pinned: undefined, ttl: undefined, flips: [] });
96
+ // A loop keeps the window size; a fold or rotation passes through other sizes, so only same-size flips count.
97
+ if (d.key !== key)
98
+ Object.assign(d, { key, pinned: undefined, ttl: undefined, flips: [] });
95
99
  if (d.pinned && now >= d.until)
96
100
  Object.assign(d, { pinned: undefined, flips: [] });
97
101
  let c = detect(s, preferred, !d.pinned);
98
102
  if (d.mode && c.mode !== d.mode) {
99
103
  d.flips = [...d.flips.filter((t) => now - t < FLIP_WINDOW), now];
100
104
  if (d.flips.length >= 3) {
101
- d.ttl = d.ttl ? d.ttl * 2 : PIN_TTL;
105
+ // Doubles only when the loop resumes as the last pin lifts; a later pane move starts from PIN_TTL again.
106
+ d.ttl = d.ttl && d.until !== undefined && now - d.until < FLIP_WINDOW ? d.ttl * 2 : PIN_TTL;
102
107
  Object.assign(d, { pinned: key, until: now + d.ttl });
103
108
  c = detect(s, preferred, false);
104
109
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Duet, useDuet, type DuetProps, type DuetItem } from "./chrome.tsx";
1
+ export { Duet, DuoCookie, useDuet, type DuetProps, type DuetItem, type DuetTab } from "./chrome.tsx";
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";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { Duet, useDuet } from "./chrome.js";
1
+ 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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duet-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",