duet-ui 0.4.0-rc.1 → 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 CHANGED
@@ -99,15 +99,37 @@ How a new reading is committed:
99
99
 
100
100
  Column mode infers from the window geometry that Safari's column is there; it never sees Safari's buttons. The 80 to 88px width difference is Safari's column (84px measured), the height difference is Safari's chrome below the page.
101
101
 
102
- - `position: fixed` content is clipped to the page viewport, but WebKit paints in-document content that overflows the layout viewport into Safari's column. Duet renders the controls in an absolute layer at the top of the document, widens the document by the column's width, keeps the document itself from scrolling (the page scrolls in the inner scroller) and sets `touch-action: pan-y` on `<html>` so the page does not pan sideways. This is observed WebKit behaviour, not a supported API.
102
+ - `position: fixed` content stops at the page viewport (and a fixed box covering the middle of the viewport edge has its colour painted over the column, see [Modals and sheets in column mode](#modals-and-sheets-in-column-mode)), but WebKit paints in-document content that overflows the layout viewport into Safari's column. Duet renders the controls in an absolute layer at the top of the document, widens the document by the column's width, keeps the document itself from scrolling (the page scrolls in the inner scroller) and sets `touch-action: pan-y` on `<html>` so the page does not pan sideways. This is observed WebKit behaviour, not a supported API.
103
103
  - Without `children` the document itself scrolls, and the layer follows it on a CSS scroll timeline (`animation-timeline: scroll(root)`). Safari runs the timeline with the scroll, so the controls hold through momentum and through smooth programmatic scrolls (`scrollIntoView({ behavior: "smooth" })`, `scrollTo({ behavior: "smooth" })`): you need no scroll listener and no `behavior: "auto"` workaround. Measured on the simulator: within 1px through flings and at rest, and one frame of up to 7pt at the end of a smooth scroll (a transform set on scroll ran up to 300pt off there). The keyframe ends at `scrollHeight - innerHeight`, Safari's own timeline range, which follows its URL bar as it collapses. Without scroll-timeline support, or when a page reset like `* { animation: none !important }` removes it, Duet falls back to a transform set on scroll, which lags in momentum. Children mode stays the steadier choice.
104
104
  - The widened document can pan sideways when a swipe runs off the end of a horizontal scroller (a chip row), since `touch-action` stops at that scroller. Duet pins `scrollX` (0, or the column width when the document spans a left column) on every scroll and sets `overscroll-behavior-x: contain` on all elements at zero specificity (`:where()`), so your own value wins.
105
105
  - Touches in the column reach the page but are not hit-tested to Duet's buttons, and Safari usually keeps the touch end. Duet listens for `pointerdown`, finds the button under the touch's coordinates and clicks it, so a column button fires on touch down.
106
106
  - Placement comes from constants measured on the iPhone Duo simulator (iOS 27.1) and checked on a device: Safari's back and reader rows at the top of the column, its +/tabs pill at the bottom. A Safari update that moves its buttons or changes this painting behaviour needs new measurements.
107
107
 
108
+ ### Modals and sheets in column mode
109
+
110
+ A modal or sheet on the web usually sits on a full-screen scrim: `position: fixed; inset: 0`. In Safari's column on the closed Duo that used to hide Duet's whole column layer, the close button included (#79).
111
+
112
+ - **What you see:** nothing to handle. While the overlay is open, the controls stay in the column and keep working. Safari's own buttons there are unaffected either way.
113
+ - **Cause:** Safari 26 hit-tests the middle of each viewport edge, 4px in, for a fixed or sticky box (WebKit's `LocalFrameView::fixedContainerEdges`). When one covers that point, Safari paints the box's colour, or plain chrome when the box has a `backdrop-filter`, over everything beyond that edge, Safari's column included. Duet's column layer is in-document content, so it disappears under that fill. Measured on the simulator: a scrim 3px short of the edge still hides the column, 6px short does not, and a scrim of 90% width or 40% height does not.
114
+ - **What Duet does:** in column mode it renders a small fixed guard (`.duet-guard`, 16x64px, transparent, `pointer-events: none`, top `z-index`) straddling that point on each edge that faces a column. Safari's hit test lands on the guard, which is too small to count as a container, so it finds none and keeps painting the column normally. The guard takes no taps and paints nothing.
115
+ - **What you must do:** nothing for visibility. For a native feel, pass `close` to `<Duet>` while the sheet is open, so the controls hold only its close button, and hide the sheet's own X in column mode:
116
+
117
+ ```tsx
118
+ <Duet tabs={tabs} close={open ? { label: "Close", onPress: () => setOpen(false) } : undefined}>
119
+ ```
120
+
121
+ ```css
122
+ html[data-duet-mode="column"] .sheet-x { display: none; }
123
+ ```
124
+
125
+ The old workaround, making the scrim `position: absolute` in column mode, still works and is no longer needed.
126
+ - **Not covered:** `<dialog>.showModal()` puts its `::backdrop` in the browser's top layer, above any `z-index`, so it still hides the column (checked on the simulator), and the page outside a modal dialog is inert anyway, so Duet's buttons could not take taps. Use a fixed `<div>` scrim (or `dialog.show()` with your own scrim) when you want the column controls. A page box of your own with `z-index: 2147483647` over the middle of the column edge also beats the guard.
127
+
128
+ Try it on the [site](https://getduet.dev/sheet).
129
+
108
130
  ### Split view
109
131
 
110
- A Safari split-view pane (469x669 window, 385x571 page) has Safari's column on the pane's outer edge, but no web signal says whether the pane is on the left or the right. In column mode Duet widens the document by a column on each side and draws the controls in both; each pane only shows its own column, and a tap there tells Duet which side it was. When a split pane falls back to `inline`, its side is remembered separately from the full-window side, so the user can move the controls next to Safari's column once. `useDuet().split` is true in a split pane.
132
+ A Safari split-view pane (469x669 window, 385x571 page) has Safari's column on the pane's outer edge, but no web signal says whether the pane is on the left or the right. In column mode Duet widens the document by a column on each side and draws the controls in both; each pane only shows its own column, and a tap there tells Duet which side it was. The document is kept scrolled across the left column, so a pane shows document x 84 to 469. When a split pane falls back to `inline`, its side is remembered separately from the full-window side, so the user can move the controls next to Safari's column once. `useDuet().split` is true in a split pane.
111
133
 
112
134
  ### Side
113
135
 
@@ -256,7 +278,17 @@ The page spans the whole window and the controls float over it, so backgrounds r
256
278
  - `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`).
257
279
  - `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). The `duet-bleed` class does the same in CSS.
258
280
 
259
- Duet sets the insets on `<html>` as `--duet-inset-left`, `--duet-inset-right` (the controls' width on their edge; in `column` mode without children the page does not run under a right-hand column, so that inset is 0), `--duet-inset-top` (28px in `strip` mode, for iOS's top blur in Home Screen apps, else 0) and `--duet-inset-bottom` (in `column` mode, Safari's chrome below the page: the page runs on under Safari's floating URL bar, and the scroller ends with that much space so the last content scrolls clear) and `--duet-safe-top-column` (in `column` mode, the top of the part of Safari's column clear of its back and reader buttons: keep popovers that cross the column below it). `--duet-vh` is `stableHeight` in px: size full-screen sections with `min-height: var(--duet-vh, 100svh)` so they keep their height while the URL bar collapses and expands. Inside `<DuetPanes>` each pane gets its own left and right insets: only a pane on the controls' edge is inset. `<html data-duet-mode>` holds the current mode for CSS.
281
+ Duet sets the insets on `<html>` as `--duet-inset-left`, `--duet-inset-right` (the controls' width on their edge; 0 in `column` mode without children, see below), `--duet-inset-top` (28px in `strip` mode, for iOS's top blur in Home Screen apps, else 0) and `--duet-inset-bottom` (in `column` mode, Safari's chrome below the page: the page runs on under Safari's floating URL bar, and the scroller ends with that much space so the last content scrolls clear) and `--duet-safe-top-column` (in `column` mode, the top of the part of Safari's column clear of its back and reader buttons: keep popovers that cross the column below it). `--duet-vh` is `stableHeight` in px: size full-screen sections with `min-height: var(--duet-vh, 100svh)` so they keep their height while the URL bar collapses and expands. Inside `<DuetPanes>` each pane gets its own left and right insets: only a pane on the controls' edge is inset. `<html data-duet-mode>` holds the current mode for CSS.
282
+
283
+ ### Column mode without children: Duet places the page
284
+
285
+ In `column` mode the controls sit in Safari's column, outside the layout viewport, so a page has nothing to keep clear of. Without `children` Duet places the page itself, so any page, including one that never calls `column()` or `bleed()`, starts just clear of the controls, keeps centred content centred in the space left and is never clipped:
286
+
287
+ - The insets are 0 and the page's layout viewport is exactly the part of the window beside Safari's column.
288
+ - Where the document spans a left column (split view, the closed Duo turned to 90), Duet keeps it scrolled across and gives `<html>` `margin-left` of the column width and the matching negative `margin-right`, so the page lands in the visible part of the window instead of under the column. `<html data-duet-reserve>` marks this. Do not set `margin` on `<html>` yourself, and do not reset `scrollX`: Duet owns it.
289
+ - `<Duet reserve={false}>` opts out: the page runs under the column as before, and `--duet-inset-left` / `--duet-inset-right` hold the column's width for `column()`, `bleed()` or your own padding.
290
+
291
+ With `children`, in `inline` and `strip` mode and in the emulated column, the page still runs under the controls and keeps clear through the insets above.
260
292
 
261
293
  ## Panes
262
294
 
@@ -293,6 +325,146 @@ export function Mail() {
293
325
 
294
326
  Try it on the [site](https://getduet.dev/panes).
295
327
 
328
+ ## Edge cases
329
+
330
+ Every iOS and Safari quirk Duet handles, what you would see without the handling, and what is left for you to do. Most need nothing from you. "Without children" means a page that renders `<Duet>` without `children`, so the document itself scrolls.
331
+
332
+ ### Split view pane, or the closed Duo turned left
333
+
334
+ - **You see:** in column mode without children, the left 84px of the page is clipped, the controls draw over the content and an empty band runs down the right.
335
+ - **Cause:** Safari's column is on the pane's left edge (or on the left when the closed Duo is turned to 90). Duet keeps the document scrolled one column across so the left column can paint, but the page content still starts at x 0, under the column.
336
+ - **Duet does:** places the page itself: `margin-left` of the column width and the matching negative `margin-right` on `<html>`, marked `<html data-duet-reserve>`, with both insets 0. Centred content stays centred, nothing is clipped.
337
+ - **You do:** nothing. Don't set `margin` on `<html>` or reset `scrollX`, Duet owns both. `<Duet reserve={false}>` opts out: the page runs under the column and `--duet-inset-left` / `--duet-inset-right` hold its width for `column()`, `bleed()` or your own padding.
338
+ - **Status:** [#77](https://github.com/JayFarei/duet/issues/77), [PR #78](https://github.com/JayFarei/duet/pull/78), 0.4.0-rc.2. See [Split view](#split-view).
339
+
340
+ ### Rubber band at the top or bottom of the page
341
+
342
+ - **You see:** in column mode without children, the controls leave their place as a group while the page bounces at either end (sliding up to 160pt over Safari's own column buttons, or out of the column), then come back once it settles.
343
+ - **Cause:** the controls follow the document on a CSS scroll timeline, which is clamped to the scroll range. The iOS rubber band pushes `scrollY` past that range, and the in-document layer moves with the bounce.
344
+ - **Duet does:** while the layer follows the document, `<html>` gets `overscroll-behavior-y: none`, so the document does not rubber-band, like a page with children. Only the Duo column is affected: other modes and other devices keep the bounce.
345
+ - **You do:** nothing. The rule has zero specificity (`:where()`), so a page's own `overscroll-behavior` still wins, and with it the controls ride the bounce again.
346
+ - **Status:** [#76](https://github.com/JayFarei/duet/issues/76), [PR #80](https://github.com/JayFarei/duet/pull/80), 0.4.0-rc.2.
347
+
348
+ ### Full-screen `position: fixed` overlay or modal backdrop
349
+
350
+ - **You see:** without the handling, in column mode, while a modal or sheet scrim (`position: fixed; inset: 0`) is open, every Duet control in Safari's column disappears, the close button included, so the user has no way to close the sheet from the column.
351
+ - **Cause:** Safari 26 hit-tests a point 4px in from the middle of each viewport edge for a fixed or sticky box and, when one covers it, paints that box's colour over the inset beyond that edge, Safari's column included, instead of the page's in-document content.
352
+ - **Duet does:** in column mode renders `.duet-guard`, a small invisible fixed box (16x64px, `pointer-events: none`, top `z-index`) on each column edge, so that hit test lands on it and the column keeps showing the controls. Your scrim stays `position: fixed`.
353
+ - **You do:** nothing for a fixed `<div>` scrim. `<dialog>.showModal()` is not covered: its `::backdrop` sits in the top layer, above any `z-index`, so use a fixed `<div>` scrim instead. For one X, in the column, pass `close` to `<Duet>` while the sheet is open and hide the sheet's own X in column mode (`html[data-duet-mode="column"] .sheet-close { display: none; }`). See [Modals and sheets in column mode](#modals-and-sheets-in-column-mode).
354
+ - **Status:** [#79](https://github.com/JayFarei/duet/issues/79), [PR #83](https://github.com/JayFarei/duet/pull/83), 0.4.0-rc.2.
355
+
356
+ ### Sideways swipe pans the page into the column
357
+
358
+ - **You see:** a sideways swipe moves the whole page, controls included, into Safari's column, typically when a swipe runs off the end of a carousel or code block.
359
+ - **Cause:** the controls widen the document by the column's width, so the document can scroll sideways, and `touch-action` on `<html>` stops at a horizontal scroller, whose overscroll chains to the document.
360
+ - **Duet does:** sets `touch-action: pan-y` on `<html>` (and on the inner scroller with children), `overscroll-behavior-x: contain` on every element at zero specificity, and pins `scrollX` (0, or the column width for a left column) on every scroll, instantly, so a page's `scroll-behavior: smooth` can't turn it into a slow creep. Leaving the column resets `scrollX` to 0. Carousels and code blocks still scroll sideways.
361
+ - **You do:** nothing. Your own `overscroll-behavior-x` wins over Duet's.
362
+ - **Status:** 0.2.5 ([PR #26](https://github.com/JayFarei/duet/pull/26)). See [Column mode](#column-mode-how-it-works).
363
+
364
+ ### Back/forward cache restore
365
+
366
+ - **You see:** after going back to a page (`pageshow` with `persisted`), the controls start in the page and slide into the column over 2 to 3s, seen in a split left pane and on the closed Duo turned left.
367
+ - **Cause:** a back/forward cache restore keeps the page's old state but not the old window, and the `scrollX` pin was a plain `scrollTo`, which a page's `html { scroll-behavior: smooth }` animated and each scroll event restarted.
368
+ - **Duet does:** re-reads the signals on `pageshow`, re-pins `scrollX` with `behavior: "instant"`, and turns transitions off for 1.1s after a restore, as at first mount, so the controls land in place. Normal mode changes keep their ~250ms spring.
369
+ - **You do:** nothing.
370
+ - **Status:** [#66](https://github.com/JayFarei/duet/issues/66), [PR #67](https://github.com/JayFarei/duet/pull/67).
371
+
372
+ ### In-app preview sheet
373
+
374
+ - **You see:** a link opened from a social or mail app in the in-app Safari preview's page sheet got Safari-tab placement on a real Duo.
375
+ - **Cause:** the sheet is a window inside the closed screen (450x670 on a real Duo), and its bottom safe-area inset reads 0 on the first sample and 34 about 100ms later, so the first reading looks like a tab.
376
+ - **Duet does:** recognises a window inside the closed screen with the column signature and a bottom inset as the preview, and lets a tab verdict rise to preview during a window's first second. Turned sideways in the sheet, where the free gap is about 26pt, it hides the controls (`useDuet().hidden` is `"preview"`) so the preview's own buttons stay usable.
377
+ - **You do:** nothing. Check `useDuet().preview` and `hidden` if your page lays out differently there.
378
+ - **Status:** [#66](https://github.com/JayFarei/duet/issues/66), [PR #67](https://github.com/JayFarei/duet/pull/67), [#69](https://github.com/JayFarei/duet/issues/69). See [In-app preview](#in-app-preview).
379
+
380
+ ### Turning or folding mid-transition
381
+
382
+ - **You see:** without the handling, the controls flash at the wrong edge during a turn, fold, unfold or split move, the page insets jump (up to 296px on a turn, -485px on an unfold), or Safari zooms the page to fit (scale 1.14).
383
+ - **Cause:** there is no fold signal, and mid-transition the window passes through sizes no landed window has (inner and outer orientations disagree). The window size can land up to ~2s after the viewport, with no event. The column layer still carries the old posture's positions, which widen the document past the new window.
384
+ - **Duet does:** keeps the last placement and hides the controls (`hidden` and `confidence` are `"moving"`) until the window lands and holds for 150ms (`LAND_HOLD`). The insets keep their last values, and the column layer is clipped out of the document width, so it adds no width until the landing. After each event it polls every 100ms until the sizes hold for 500ms, for at most 2s.
385
+ - **You do:** nothing. If your own code reacts to `useDuet()`, ignore readings with `confidence: "moving"`.
386
+ - **Status:** [#68](https://github.com/JayFarei/duet/issues/68), [#69](https://github.com/JayFarei/duet/issues/69), [PR #70](https://github.com/JayFarei/duet/pull/70), 0.4.0-rc.1. See [Which mode](#which-mode).
387
+
388
+ ### Text field focus and the on-screen keyboard
389
+
390
+ - **You see:** without the handling, focusing an input in column mode makes Safari zoom out (to about 0.94), and the keyboard covers the lower column and the controls in it.
391
+ - **Cause:** the column layer widens the document, and Safari zooms out to that width when a text field takes focus. The keyboard leaves Safari's column in place and only shrinks the visual viewport.
392
+ - **Duet does:** from the moment a field (`:read-write:focus`, so inputs, textareas and `contenteditable`) takes focus, clips the column layer out of the document width and hides it, before the zoom and the keyboard. The keyboard never changes the mode. `useDuet().keyboard` is true while it is up, and `<Duet>` sets `data-keyboard`. In column mode the controls come back when focus leaves.
393
+ - **You do:** nothing. Don't rely on Duet's column controls while the user types.
394
+ - **Status:** [#61](https://github.com/JayFarei/duet/issues/61), [PR #63](https://github.com/JayFarei/duet/pull/63).
395
+
396
+ ### Pinch zoom
397
+
398
+ - **You see:** nothing changes while the page is zoomed: the mode and placement stay put.
399
+ - **Cause:** with `visualViewport.scale` not 1, `innerWidth` is in zoomed px, so the window signature is unreadable.
400
+ - **Duet does:** keeps the last context (`confidence: "held"`) until the scale returns to 1. Column mode also requires scale 1, so a page that loads zoomed starts `inline`.
401
+ - **You do:** nothing.
402
+ - **Status:** see [Which mode](#which-mode).
403
+
404
+ ### Safari's URL bar collapses
405
+
406
+ - **You see:** `100vh` and `innerHeight` grow and shrink by 18px while the user scrolls, so full-screen sections jump.
407
+ - **Cause:** Safari collapses its URL bar on scroll, which changes the page height.
408
+ - **Duet does:** reports `useDuet().stableHeight`, the smallest page height seen at this window width and orientation, and sets it as `--duet-vh`. In column mode the page runs on under the floating URL bar, and `--duet-inset-bottom` reserves that space at the end of the scroller.
409
+ - **You do:** size full-screen sections with `min-height: var(--duet-vh, 100svh)`.
410
+ - **Status:** see [Laying out content](#laying-out-content).
411
+
412
+ ### Live Activities and the top band
413
+
414
+ - **You see:** an empty gap at the top of the strip or column, even when no Live Activity runs.
415
+ - **Cause:** Live Activities grow the Dynamic Island down the strip and push Safari's buttons with it, and a page can't tell whether any are running.
416
+ - **Duet does:** always reserves the worst case measured on device (two activities). `leading` and `primary` may rise into that space before they fold, back, actions and "…" never do. Closed landscape and the in-app preview were measured with a bare island only.
417
+ - **You do:** nothing.
418
+ - **Status:** see [Claimable space](#claimable-space).
419
+
420
+ ### Menus and popovers under Safari's buttons
421
+
422
+ - **You see:** without the handling, a menu that reaches into the column is drawn under Safari's back, reader or +/tabs buttons.
423
+ - **Cause:** Safari's native chrome always paints over the page, so `z-index` can't help.
424
+ - **Duet does:** keeps its own menus between `columnTop` and Safari's pill, or opens them wholly over the page when they are too tall.
425
+ - **You do:** keep your own popovers that cross the column below `--duet-safe-top-column` (0 outside column mode).
426
+ - **Status:** see [Claimable space](#claimable-space).
427
+
428
+ ### Column buttons fire on touch down
429
+
430
+ - **You see:** buttons in Safari's column fire as the finger lands and show no native press state.
431
+ - **Cause:** touches in the column reach the page but are not hit-tested to elements outside the viewport, and Safari usually keeps the touch end.
432
+ - **Duet does:** listens for `pointerdown`, finds the button under the touch's coordinates and clicks it. In a split pane, the tap also tells Duet which side the pane is on.
433
+ - **You do:** nothing. Don't put anything that needs a long press or a drag in the column.
434
+ - **Status:** see [Column mode](#column-mode-how-it-works).
435
+
436
+ ### Animations removed by a page reset
437
+
438
+ - **You see:** in column mode without children, the controls lag behind momentum scroll.
439
+ - **Cause:** a reset like `* { animation: none !important }` (common under reduced motion) removes the scroll timeline the layer follows the document on, and so does a browser without scroll-timeline support.
440
+ - **Duet does:** falls back to a transform set on every scroll event, which lags in momentum.
441
+ - **You do:** exclude `.duet` from the reset, or pass the page as `children` (the steadier choice in any case).
442
+ - **Status:** [PR #56](https://github.com/JayFarei/duet/pull/56). See [Column mode](#column-mode-how-it-works).
443
+
444
+ ### Viewport meta and `viewport-fit=cover`
445
+
446
+ - **You see:** without the handling, Safari zooms a split pane out to fit the widened document, and `strip` mode is never detected.
447
+ - **Cause:** Safari shrinks the page to fit wide content unless the viewport meta says `shrink-to-fit=no`, and safe-area insets only reach the page with `viewport-fit=cover`. Frameworks add and rewrite viewport metas after load.
448
+ - **Duet does:** appends `shrink-to-fit=no` on import and `viewport-fit=cover` when `<Duet>` mounts, to every viewport meta, and keeps them on metas added or rewritten later. With no viewport meta it adds `width=device-width,initial-scale=1` first.
449
+ - **You do:** nothing, but don't set `viewport-fit=contain` or `shrink-to-fit=yes`: Duet only appends absent keys and leaves your values as they are.
450
+ - **Status:** 0.2.0, 0.2.1. See [Viewport meta](#viewport-meta).
451
+
452
+ ### Mode flips back and forth
453
+
454
+ - **You see:** without the handling, the controls jump between the column and the page several times a second.
455
+ - **Cause:** column mode can feed back into the signals, for example when Safari zooms the page out to fit the wider document, the column signature disappears, `inline` removes the overflow, and the signature returns.
456
+ - **Duet does:** after 3 mode changes within 2s at the same window size, pauses column mode for 3s, doubling each time the loop resumes right after the pause, up to 60s. Changes while the window is still settling are not counted.
457
+ - **You do:** nothing.
458
+ - **Status:** 0.2.1. See [Oscillation guard](#oscillation-guard).
459
+
460
+ ### Mode detection limits
461
+
462
+ - **You see:** `inline` where you expected the column or the strip, or a Duo layout on a device that is not a Duo.
463
+ - **Cause:** iOS gives a web page no API for the device, the fold or Safari's buttons. Duet infers everything from window and screen sizes measured on the Duo, safe-area insets, display mode and the user agent.
464
+ - **Duet does:** uses the column only in Safari (not Chrome, Firefox, Edge or other apps' browsers), never in an iframe, and gives in-app web views (a user agent without `Safari/`) `inline`. A Duo whose reported sizes differ (display zoom, a future iOS release) gets `inline`, and an iPad or another device with matching sizes may read as a Duo.
465
+ - **You do:** use `useDuet().duo` to switch to a Duo layout, not as proof of the device. Check `useDuet().browser` to tell why a page is `inline`. For server-rendered layouts, see [Server rendering](#server-rendering-and-hydration).
466
+ - **Status:** 0.2.2. See [Is this an iPhone Duo?](#is-this-an-iphone-duo) and [Which mode](#which-mode).
467
+
296
468
  ## Limitations
297
469
 
298
470
  - **Duo detection is a heuristic** from screen and window sizes plus the user agent (see above). It can misfire on devices with matching sizes and miss a Duo whose reported sizes differ.
package/dist/chrome.d.ts CHANGED
@@ -71,6 +71,12 @@ export interface DuetProps<T extends string = string> {
71
71
  /** Page content. When given, it scrolls in an inner scroller and the document itself never moves, so
72
72
  * the chrome (and in Safari the column layer) stays still during momentum scroll and rubber-banding. */
73
73
  children?: ReactNode;
74
+ /** Without children, in Safari's column: Duet places the page beside the controls (default `true`). When the
75
+ * document spans a column on the left (a split pane, or the closed Duo turned left) the page is moved into the
76
+ * visible viewport with margins on `<html>`, and the insets are 0, so the page needs no padding of its own.
77
+ * `false` lets the page run under the column instead: it keeps clear by `--duet-inset-left/right`, through
78
+ * column() and bleed(). */
79
+ reserve?: boolean;
74
80
  }
75
81
  export declare function useDuet(): Context;
76
82
  /** Tells the server whether this browser is an iPhone Duo, so it can render the Duo layout from the first byte.
package/dist/chrome.js CHANGED
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { ArrowLeftRight, ChevronLeft, Ellipsis, Search, X } from "./icons.js";
5
5
  import { glassTheme, themeVars } from "./theme.js";
6
- import { DUO, INLINE_WIDTH, columnBand, isSplit, islandBox, magnet, morphBox, plan, stable } from "./geometry.js";
6
+ import { DUO, columnBand, isSplit, pageInsets, islandBox, magnet, morphBox, plan, stable } from "./geometry.js";
7
7
  import { emulatedSignals, epoch, readSignals, settling, watchSignals } from "./signals.js";
8
8
  import { GlassProvider } from "./glassyui/glass-provider.js";
9
9
  import { GlassSurface } from "./glassyui/glass-surface.js";
@@ -201,7 +201,24 @@ function useColumnLayer(on, split, left, column, inColumn) {
201
201
  sizes.observe(root);
202
202
  sizes.observe(document.body);
203
203
  }
204
- const follow = () => {
204
+ // The layer's positions are document coordinates, from an absolute box at the document's left edge. A page whose
205
+ // <body> (or <html>) is positioned makes that its containing block, which body margins, or the margins that move
206
+ // the page beside a left column (pageInsets), move off the document's edge: measured, and undone with
207
+ // `translate` (the scroll timeline animates `transform`, which composes with it).
208
+ const align = () => {
209
+ refs.forEach((ref, i) => {
210
+ const el = ref.current;
211
+ if (!el)
212
+ return;
213
+ el.style.translate = "";
214
+ const off = Math.round(el.getBoundingClientRect().left + scrollX - (split && i ? column : 0));
215
+ if (off)
216
+ el.style.translate = `${-off}px 0`;
217
+ });
218
+ };
219
+ const follow = (e) => {
220
+ if (e?.type !== "scroll")
221
+ align();
205
222
  // The layer widens the document, and a swipe that runs off the end of a sideways scroller chains to it
206
223
  // (touch-action stops at the scroller), leaving page and controls panned off their rails: hold it.
207
224
  // An emulating host already frames both columns; pinning there loops in iOS Safari, whose frame overflows a few px.
@@ -243,6 +260,7 @@ function useColumnLayer(on, split, left, column, inColumn) {
243
260
  sizes?.disconnect();
244
261
  for (const el of els) {
245
262
  el.style.transform = "";
263
+ el.style.translate = "";
246
264
  el.style.removeProperty("--duet-scroll-max");
247
265
  delete el.dataset.follow;
248
266
  }
@@ -385,20 +403,26 @@ export function Duet(props) {
385
403
  // The copy whose column opened the menu draws it; the other copy's column is in the other pane.
386
404
  const [menuSide, setMenuSide] = useState(ctx.side);
387
405
  const inner = p.children !== undefined; // the inner scroller, which runs on under Safari's column
406
+ const reserve = p.reserve !== false;
388
407
  useEffect(() => {
389
408
  // The page spans the window and the controls float over it; these insets are how far text and buttons keep
390
409
  // clear on each edge (layout.ts). In Safari's column the scroller runs on under the column (an emulated
391
- // column's frame already spans it), so either way the inset there is the column's width; a tab has no safe
392
- // areas, so the other edge is 0. Without children the document is the page: it is laid out in the viewport
393
- // and nothing runs under a right column (simulator, 2026-10-26: 84px of padding left a 100px gap), while a
394
- // left column has the document scrolled one column across, so the page runs under it.
410
+ // column's frame already spans it), so the inset there is the column's width; a tab has no safe areas, so the
411
+ // other edge is 0. Without children the document is the page, and Duet places it beside the column itself
412
+ // (pageInsets): nothing for the page to do.
395
413
  const r = document.documentElement.style;
396
414
  // Controls hidden for good in this window (the turned preview) take no room; hidden while moving keeps the
397
415
  // last insets, so the page does not reflow twice in one turn.
398
- const inset = ctx.hidden === "preview" ? "0px" : ctx.mode === "strip" ? envInset(ctx.side) : ctx.mode === "inline" ? `${INLINE_WIDTH}px` : `${ctx.screenWidth - ctx.width}px`;
399
- const other = (side) => ctx.mode === "column" ? "0px" : envInset(side);
400
- const under = ctx.mode !== "column" || inner || !!emulatedSignals();
401
- const right = (ctx.side === "right" || split) && under ? inset : other("right"), left = ctx.side === "left" || split ? inset : other("left");
416
+ const { left, right, shift } = pageInsets({
417
+ mode: ctx.mode, side: ctx.side, split, screenWidth: ctx.screenWidth, width: ctx.width, hiddenPreview: ctx.hidden === "preview",
418
+ inner, emulated: !!emulatedSignals(), reserve, env: envInset,
419
+ });
420
+ // The document is scrolled one column across (useColumnLayer): margins on <html> move the page into the visible
421
+ // viewport. <html>'s own margins, so the page's body margins, widths and max-widths are untouched, centred
422
+ // content centres in the visible pane, and position: fixed (already viewport relative) is unaffected.
423
+ r.marginLeft = shift ? `${shift}px` : "";
424
+ r.marginRight = shift ? `${-shift}px` : "";
425
+ document.documentElement.toggleAttribute("data-duet-reserve", !!shift);
402
426
  // --duet-edge-*: the window's insets, kept for panes, which set --duet-inset-* to their own.
403
427
  for (const [k, v] of [["right", right], ["left", left]]) {
404
428
  r.setProperty(`--duet-inset-${k}`, v);
@@ -419,7 +443,13 @@ export function Duet(props) {
419
443
  document.documentElement.dataset.duetMode = ctx.mode;
420
444
  document.documentElement.toggleAttribute("data-duet-emulated", !!emulatedSignals());
421
445
  document.documentElement.toggleAttribute("data-duet-split", split);
422
- }, [ctx.mode, ctx.side, ctx.hidden === "preview", ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, ctx.stableHeight, split, inner]);
446
+ }, [ctx.mode, ctx.side, ctx.hidden === "preview", ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, ctx.stableHeight, split, inner, reserve]);
447
+ useEffect(() => () => {
448
+ const h = document.documentElement;
449
+ h.style.marginLeft = "";
450
+ h.style.marginRight = "";
451
+ h.removeAttribute("data-duet-reserve");
452
+ }, []);
423
453
  // Mid-turn the layer is clipped out of the document (duet.css) and lets go of the sideways scroll; the landing
424
454
  // takes it up again, scrolled one column across for a left column.
425
455
  const [nav, nav2] = useColumnLayer(ctx.mode === "column" && ctx.hidden !== "moving", split, ctx.side === "left", column, ctx.mode === "column");
@@ -600,5 +630,5 @@ export function Duet(props) {
600
630
  // are masked out under the lens (CSS), so only the enlarged copy shows through it.
601
631
  _jsx("span", { className: "duet-lens", "aria-hidden": true, children: _jsx("span", { className: "duet-lens-view", children: _jsx("span", { className: "duet-lens-col", children: tabItems.map((t, i) => _jsx("span", { className: "duet-tab", "data-on": i === lensAt || undefined, style: tint(t), children: t.icon }, t.id)) }) }) })), !pl.tabs.min && _jsx("span", { className: "duet-pill-badges", "aria-hidden": true, children: tabItems.map((t) => _jsx("span", { children: badge(t) }, t.id)) }), pl.tabs.min ? tabItems.filter((t) => t.id === selected).map((t) => tabButton(t, true, 0)) : tabItems.map((t, i) => tabButton(t, false, i))] })), pl.searchButton && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "search", style: at(pl.searchButton), children: searchButton }), (!split || side === menuSide) && menuEl("more", anchor.more, more, false, menuAt, morphAt), p.tabs && (!split || side === menuSide) && menuEl("tabs", anchor.tabs, tabItems, true, menuAt, morphAt), p.accessory && pl.accessory && !i && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-accessory", "data-duet-slot": "accessory", style: pl.accessory, "data-covered": menu === "tabs" || undefined, children: p.accessory }))] }, side));
602
632
  };
603
- return (_jsxs(GlassProvider, { defaultTheme: glassTheme(p.appearance, p.dark), children: [p.children !== undefined && _jsx("div", { ref: scroller, className: "duet-scroller", children: p.children }), sides.map(bar)] }));
633
+ return (_jsxs(GlassProvider, { defaultTheme: glassTheme(p.appearance, p.dark), children: [p.children !== undefined && _jsx("div", { ref: scroller, className: "duet-scroller", children: p.children }), sides.map(bar), ctx.mode === "column" && !emulatedSignals() && sides.map((side) => _jsx("span", { className: "duet-guard", "data-side": side, "aria-hidden": true }, side))] }));
604
634
  }
package/dist/duet.css CHANGED
@@ -254,9 +254,10 @@ html:has(> body .duet-scroller), html:has(> body .duet-scroller) > body { height
254
254
  .duet-scroller { position: fixed; inset: 0; overflow-y: auto; overscroll-behavior: contain; box-sizing: border-box; }
255
255
  /* The page spans the whole window and the controls float over it (blueprint rules 14/15): backgrounds run on
256
256
  under the controls, and only text and buttons keep clear, by the insets (--duet-inset-left/right), through
257
- column() or bleed(). Safari's column only shows in-document content and clips fixed boxes at the viewport
258
- edge, so there the scroller is an in-document box that runs on under the column. The document still never
259
- scrolls, so the chrome layer never moves. */
257
+ column() or bleed(). Safari's column paints in-document content that overflows the layout viewport, but no
258
+ fixed box reaches it: a fixed box stops at the viewport edge, and one covering the middle of that edge has its
259
+ colour painted over the column instead (the guard below). So there the scroller is an in-document box that runs
260
+ on under the column. The document still never scrolls, so the chrome layer never moves. */
260
261
  html[data-duet-mode="column"] .duet-scroller { position: absolute; width: calc(100% + var(--duet-inset-left) + var(--duet-inset-right)); bottom: auto; height: calc(100% + var(--duet-inset-bottom)); }
261
262
  /* That overflow widens the document, and touch-action on <html> stops at this scroll container, so without its own
262
263
  a sideways swipe pans the whole page, chrome and all, into the column. Carousels inside are scrollers of their own
@@ -283,6 +284,22 @@ html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%;
283
284
  So from focus, before the zoom and the keyboard, the layer is clipped out of the document width. */
284
285
  html:has(:read-write:focus) .duet[data-mode="column"] { overflow: clip; visibility: hidden; }
285
286
  .duet[data-mode="column"] { position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 50; }
287
+ /* A page's full-screen fixed overlay (a modal scrim, `position: fixed; inset: 0`) would hide the whole column,
288
+ controls included: Safari 26 hit-tests the middle of each viewport edge, 4px in, for a fixed or sticky box
289
+ (LocalFrameView::fixedContainerEdges) and, when one covers it, paints that box's colour, or plain chrome behind a
290
+ backdrop-filter, over the inset beyond that edge, Safari's column included, instead of the page's in-document
291
+ content. The guard is a small fixed box straddling that point on the column's edge, above every page layer, so
292
+ the hit test lands on it instead: too small to count as a container itself, it has none above it, and the column
293
+ keeps showing the controls. That hit test skips layers with nothing fixed or sticky in them, so the guard is fixed
294
+ and outside the layer; it keeps a (transparent) background, since a box with no background, no backdrop and no
295
+ children counts as transparent and the hit test then retries without it, honouring pointer-events. It takes no
296
+ taps (simulator, 2026-10-27, #79). */
297
+ .duet-guard {
298
+ position: fixed; z-index: 2147483647; top: 50%; width: 16px; height: 64px; margin-top: -32px; pointer-events: none;
299
+ background-image: linear-gradient(transparent, transparent);
300
+ }
301
+ .duet-guard[data-side="right"] { right: -8px; }
302
+ .duet-guard[data-side="left"] { left: -8px; }
286
303
  /* Mid-turn or mid-fold the layer still carries the last posture's positions (the old screen's column), which would
287
304
  widen the document past the new window, and Safari then zooms the page to fit it (scale 1.14 unfolding from
288
305
  closed landscape into the tall window, e2e round 2). Clipped, it adds no width until the window lands. */
@@ -292,6 +309,11 @@ html:has(:read-write:focus) .duet[data-mode="column"] { overflow: clip; visibili
292
309
  data-follow and --duet-scroll-max, the document's scroll range, or a transform where this is unsupported). */
293
310
  .duet[data-mode="column"][data-follow] { animation: duet-follow linear both; animation-timeline: scroll(root block); }
294
311
  @keyframes duet-follow { from { transform: translateY(0); } to { transform: translateY(var(--duet-scroll-max, 0px)); } }
312
+ /* The rubber band at either end moves the document past its scroll range, where the timeline (clamped to it) cannot
313
+ follow, so the layer rode the bounce: up to 160pt over Safari's own column buttons, or out of the column (#76). The
314
+ document there does not rubber-band, like a page with children, whose document never scrolls. Zero specificity, so
315
+ a page's own overscroll-behavior wins. */
316
+ :where(html[data-duet-mode="column"]:has(> body .duet[data-follow])) { overscroll-behavior-y: none; }
295
317
  /* A swipe that runs off the end of a sideways scroller chains to the widened document and pans it, chrome and
296
318
  all, into the column. Zero specificity, so a page's own overscroll-behavior wins. */
297
319
  :where(html[data-duet-mode="column"]) :where(*) { overscroll-behavior-x: contain; }
@@ -258,6 +258,31 @@ export declare function magnet(side: Side, dx: number): {
258
258
  };
259
259
  /** Width the inline strip takes from the page (same column width as Safari's). */
260
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
+ };
261
286
  export interface Spec {
262
287
  back: boolean;
263
288
  leading: number;
package/dist/geometry.js CHANGED
@@ -371,6 +371,30 @@ export function magnet(side, dx) {
371
371
  }
372
372
  /** Width the inline strip takes from the page (same column width as Safari's). */
373
373
  export const INLINE_WIDTH = 84;
374
+ /** Where the page goes beside the controls, and the insets left for it to keep clear by (chrome.tsx writes them on
375
+ * <html>). In Safari's column without children the document is the page, and Duet owns its layout (`reserve`, the
376
+ * default): when the document spans a column on the left (a split pane, which spans one on each side, or a left
377
+ * column closed) it is scrolled one column across, so the page is moved right by that column (`shift`, margins on
378
+ * <html>) and lands exactly in the visible viewport, beside the controls, with nothing to keep clear (insets 0).
379
+ * Before, a page that ignored the insets ran under the controls on the left and left a column-wide gap on the
380
+ * right (RegTail, split pane, 2026-10-27). With `reserve: false` the page runs under the column and keeps clear by
381
+ * the insets itself. With children the scroller runs on under the column and pads by the insets; an emulated
382
+ * column (the site's web Duo) already frames both columns and is never scrolled across. */
383
+ export function pageInsets(o) {
384
+ const column = o.screenWidth - o.width, split = o.mode === "column" && o.split;
385
+ if (o.mode === "column" && !o.inner && !o.emulated && o.reserve)
386
+ return { left: "0px", right: "0px", shift: split || o.side === "left" ? column : 0 };
387
+ const inset = o.hiddenPreview ? "0px" : o.mode === "strip" ? o.env(o.side) : o.mode === "inline" ? `${INLINE_WIDTH}px` : `${column}px`;
388
+ const other = (side) => o.mode === "column" ? "0px" : o.env(side);
389
+ // Without children nothing runs under a right column: the document is laid out in the viewport (simulator,
390
+ // 2026-10-26: 84px of padding left a 100px gap).
391
+ const under = o.mode !== "column" || o.inner || o.emulated;
392
+ return {
393
+ right: (o.side === "right" || split) && under ? inset : other("right"),
394
+ left: o.side === "left" || split ? inset : other("left"),
395
+ shift: 0,
396
+ };
397
+ }
374
398
  /** Closed landscape Safari tab: its column's buttons sit apart from portrait's (SAFARI.landscape). */
375
399
  const landscapeColumn = (c) => !c.split && !c.preview && c.screenWidth > c.screenHeight;
376
400
  const groupHeight = (n) => DUO.control + (n - 1) * DUO.groupItem;
package/dist/index.d.ts CHANGED
@@ -4,4 +4,4 @@ export { epoch, readSignals, settling, watchSignals } from "./signals.ts";
4
4
  export { bleed, column } from "./layout.ts";
5
5
  export { DuetPanes, DuetPaneBar, usePanes, type DuetPanesProps, type DuetPaneBarProps } from "./duet-panes.tsx";
6
6
  export { panes, backFrom, type PaneWindow } from "./panes.ts";
7
- export { browserOf, detect, deviceOf, isDuo, isIOS, isPreview, isSplit, previewReason, plan, stable, PIN_MAX, PIN_TTL, columnBand, morphBox, islandBox, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE, type Browser, type Context, type Device, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
7
+ export { browserOf, detect, deviceOf, isDuo, isIOS, isPreview, isSplit, previewReason, plan, stable, PIN_MAX, PIN_TTL, columnBand, morphBox, islandBox, magnet, pageInsets, DUO, INLINE_WIDTH, MAGNET, WIDE, type Browser, type Context, type Device, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
package/dist/index.js CHANGED
@@ -4,4 +4,4 @@ export { epoch, readSignals, settling, watchSignals } from "./signals.js";
4
4
  export { bleed, column } from "./layout.js";
5
5
  export { DuetPanes, DuetPaneBar, usePanes } from "./duet-panes.js";
6
6
  export { panes, backFrom } from "./panes.js";
7
- export { browserOf, detect, deviceOf, isDuo, isIOS, isPreview, isSplit, previewReason, plan, stable, PIN_MAX, PIN_TTL, columnBand, morphBox, islandBox, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
7
+ export { browserOf, detect, deviceOf, isDuo, isIOS, isPreview, isSplit, previewReason, plan, stable, PIN_MAX, PIN_TTL, columnBand, morphBox, islandBox, magnet, pageInsets, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duet-ui",
3
- "version": "0.4.0-rc.1",
3
+ "version": "0.4.0-rc.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",