duet-ui 0.3.0 → 0.4.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +254 -16
- package/dist/chrome.d.ts +20 -3
- package/dist/chrome.js +173 -92
- package/dist/duet.css +86 -18
- package/dist/geometry.d.ts +161 -9
- package/dist/geometry.js +349 -44
- package/dist/icons.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/signals.d.ts +28 -0
- package/dist/signals.js +129 -0
- package/dist/theme.d.ts +22 -0
- package/dist/theme.js +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ export function App() {
|
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Icons are yours, from any library (this example uses `lucide-react`): 24pt in tabs and 21 to 22pt on round buttons match the system. `as const` types the tab ids, so `onSelect` takes `setTab` without a cast. Each tab keeps its own scroll position, and tapping the selected tab scrolls back to the top. Pass the page as `children`: it then scrolls in an inner scroller, which keeps the controls still during momentum scroll.
|
|
61
|
+
Icons are yours, from any library (this example uses `lucide-react`): 24pt in tabs and 21 to 22pt on round buttons match the system. `as const` types the tab ids, so `onSelect` takes `setTab` without a cast. Each tab keeps its own scroll position, and tapping the selected tab scrolls back to the top. Pass the page as `children`: it then scrolls in an inner scroller, which keeps the controls still during momentum scroll. Without `children` the document scrolls and the controls follow it (see [Column mode](#column-mode-how-it-works)).
|
|
62
62
|
|
|
63
63
|
On phones and browsers that are not an iPhone Duo in Safari or a Home Screen app, the same code renders the `inline` layout, so you may want to render `<Duet>` only where `useDuet().duo` is true (see [Server rendering](#server-rendering-and-hydration)).
|
|
64
64
|
|
|
@@ -75,41 +75,85 @@ Possible false positives: an iPad (which also sends a Mac user agent with touch)
|
|
|
75
75
|
|
|
76
76
|
### Which mode
|
|
77
77
|
|
|
78
|
-
Duet re-reads the signals on resize, orientation change
|
|
78
|
+
Duet re-reads the signals on resize, orientation change (including `screen.orientation` change: turning from one landscape to the other keeps the window size), visual viewport resize, `pageshow` and `visibilitychange` (so a page handed from the in-app preview to a Safari tab re-reads). The window size and insets settle late (after a fold `outerWidth` can land up to ~2s after the viewport, with no event), so after each event Duet polls every 100ms until the sizes hold for 500ms, re-reading on every change, for at most 2s. It picks the first mode that matches:
|
|
79
79
|
|
|
80
80
|
| Order | Mode | Conditions (all must hold) | Controls |
|
|
81
81
|
| --- | --- | --- | --- |
|
|
82
|
-
| 1 | `strip` | Running as a Home Screen app (`navigator.standalone` or `display-mode: standalone`), and the left or right safe-area inset is at least
|
|
83
|
-
| 2 | `column` | Not a Home Screen app; not inside an iframe; the browser is Safari (below); `outerWidth - innerWidth` is 80 to 88px; `outerHeight - innerHeight` is 68 to 102px; portrait (`innerHeight > innerWidth`)
|
|
84
|
-
| 3 | `inline` | Everything else: unfolded at full width, landscape
|
|
82
|
+
| 1 | `strip` | Running as a Home Screen app (`navigator.standalone` or `display-mode: standalone`) on a Duo (`duo` true), and the left or right safe-area inset is at least 76px (the Duo's strip is 84; an iPhone's landscape sensor inset is 59, an Android cutout 62). The right side wins if both are. Also the in-app Safari preview turned sideways in its default or full style (below): closed screen, 678x466 window, an 84px safe-area inset on the turned side. | In the strip iOS gives the app, beside the page (in the preview, in the inset below its top bar) |
|
|
83
|
+
| 2 | `column` | Not a Home Screen app; not inside an iframe; not the in-app Safari preview turned sideways (below); the browser is Safari (below); `outerWidth - innerWidth` is 80 to 88px; `outerHeight - innerHeight` is 68 to 102px; portrait (`innerHeight > innerWidth`), or landscape in a window the size of the closed screen (678x466 within 1px) with `screen.orientation.angle` 90 or 270; the page is not zoomed (`visualViewport.scale` 1); the oscillation guard has not paused column mode; and either the window is a split-view pane (`outerWidth` x `outerHeight` 469x669 within 1px, iOS user agent) or `duo` is true | Painted into the part of Safari's column (or the in-app preview's) its own buttons leave empty, so the page keeps its full width |
|
|
84
|
+
| 3 | `inline` | Everything else: unfolded at full width, landscape of unknown turn, other browsers, in-app web views, the in-app Safari preview unfolded, other phones, desktops | An 84px strip inside the page, on the side the user picks |
|
|
85
85
|
|
|
86
86
|
"Safari" means the user agent has `Version/x Safari/` and none of the tokens of other iOS browsers and apps (`CriOS`, `FxiOS`, `EdgiOS`, `OPiOS`, `GSA/`, `Ddg/`); an in-app web view drops the `Safari/` token. `useDuet().browser` reports the result: `"safari"`, `"chrome"`, `"firefox"`, `"edge"`, `"webview"` or `"other"`. Other browsers draw their own chrome where Safari has its column, so they always get `inline`.
|
|
87
87
|
|
|
88
88
|
There is no fold signal. The mode follows the geometry: folding or unfolding changes the window size, and the mode follows on the next read.
|
|
89
89
|
|
|
90
|
+
How a new reading is committed:
|
|
91
|
+
|
|
92
|
+
- **Keyboard.** The keyboard does not change the mode. `useDuet().keyboard` is true while the visual viewport is shorter than the page, and `<Duet>` sets `data-keyboard`; in `column` mode the controls hide until it closes (it covers the lower column).
|
|
93
|
+
- **Zoom.** While the page is zoomed (`visualViewport.scale` not 1, for example Safari zooming out when an input takes focus) the window signature is unreadable, so Duet keeps the last context.
|
|
94
|
+
- **Drift.** While the window is still settling, a mode change at the same window size, page width and turn is held until the sizes stop moving; a change of window size, page width or turn commits at once.
|
|
95
|
+
- **Loops.** A mode that flips twice in 2s at a stable window is pinned for 3s, doubling on each repeat up to 60s.
|
|
96
|
+
- `useDuet().confidence` says how the context was reached: `"sure"` (settled signals), `"settling"` (the window is still moving) `"held"` (the last context, kept through zoom or drift) or `"moving"` (a turn or fold caught halfway, controls hidden).
|
|
97
|
+
|
|
90
98
|
### Column mode: how it works
|
|
91
99
|
|
|
92
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.
|
|
93
101
|
|
|
94
|
-
- `position: fixed` content
|
|
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
|
+
- 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
|
+
- 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.
|
|
95
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.
|
|
96
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.
|
|
97
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
|
+
|
|
98
130
|
### Split view
|
|
99
131
|
|
|
100
|
-
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.
|
|
101
133
|
|
|
102
134
|
### Side
|
|
103
135
|
|
|
104
|
-
`useDuet().side` is the edge the controls are on. It is only detected in `strip` mode (from which safe-area inset is large). In `column` mode it is
|
|
136
|
+
`useDuet().side` is the edge the controls are on. It is only detected in `strip` mode (from which safe-area inset is large). In `column` mode it is Safari's column on the closed Duo: `"right"` in portrait; turned sideways, the side the camera is on, `"left"` at angle 90 (turned anticlockwise) and `"right"` at 270. A left column widens the document on the left and keeps it scrolled across, as in split view. In a split pane it is nominal, since both edges are drawn. In `inline` mode it is the user's choice: drag the controls across the middle of the page, or use "Move controls" in "…". The choice is stored in `localStorage` per site (`duet-side`, or `duet-side-split` in split view). Without one, it is `"right"`. In the in-app preview turned sideways (`strip`) it is the side of the safe-area inset: `"left"` at angle 90, `"right"` at 270.
|
|
105
137
|
|
|
106
138
|
### In-app preview
|
|
107
139
|
|
|
108
|
-
Links opened from social and mail apps can open in the in-app Safari preview (`SFSafariViewController`).
|
|
140
|
+
Links opened from social and mail apps can open in the in-app Safari preview (`SFSafariViewController`). On the closed Duo the preview has a column like a Safari tab's, holding its own buttons: close and reader/menu at the top, back and a share/reload/Safari pill below. In portrait Duet uses `column` and puts its controls into the free gap between the reader/menu button and the back button (146pt, screen y 282 to 428, the same in the sheet and full screen on the simulator and a real Duo), so the page keeps its full width. The gap holds two buttons: "…" and the selected tab (the tab pill minimized); everything else folds into "…". Turned sideways, the preview's layout depends on its style. The sheet keeps a column on the side the Duo is turned to, with a gap of about 26pt, too small for a button: there Duet hides its controls (`useDuet().hidden` is `"preview"`, the controls get `visibility: hidden` and no inset) instead of drawing an inline strip across the page, so the page keeps its full width and the preview's own close, back, share and "Open in Safari" stay usable. The default and full styles move every native button into a top bar (window y 24 to 68) and give the page an 84px safe-area inset on the turned side (left at 90, right at 270) that nothing draws in: Duet uses `strip` there, its controls in that inset from below the top bar to above the home indicator, and the page keeps its width. Turning back to portrait, or opening the page in Safari, brings the column back. A strip across page content was flagged in review and is never used. Duet sets `useDuet().preview` in both. The gap was measured with a bare Dynamic Island; how Live Activities move the preview's buttons is not yet known. Duet takes a Safari window that is not standalone to be the preview when it is either:
|
|
141
|
+
|
|
142
|
+
- the closed screen's 466x678 window with the column signature, at most 77px of chrome below the page (the preview has 75, a Safari tab 98, or 80 with the compact URL bar) and a bottom safe-area inset (34 in the preview, 0 in a tab), or
|
|
143
|
+
- a page sheet: a window inside the closed 466x678 screen, in either orientation (450x670 on a real Duo, 662x458 turned) with the column signature and a bottom safe-area inset, or
|
|
144
|
+
- exactly 375x667 at `devicePixelRatio` 3, the size earlier builds gave the preview.
|
|
145
|
+
|
|
146
|
+
No Safari tab on the Duo is smaller than the screen (a split pane is 469 wide, the screen 466), and iOS has no windowed Safari or Stage Manager, so a window inside the screen is the sheet.
|
|
147
|
+
|
|
148
|
+
The verdict is sticky: a page that loaded as a tab stays a tab while its page height and insets move (scrolling collapses the URL bar to 80px of chrome), and is only re-decided when the window size, page width, turn, standalone or framing changes, or on `pageshow` or when the page becomes visible again. In a window's first second the verdict may still rise to preview, since the sheet's inset reads 0 on the first sample and 34 about 100ms later. A preview that stops matching becomes a tab at once.
|
|
149
|
+
|
|
150
|
+
While the Duo turns or folds, the window passes through sizes no landed window has (inner and outer orientations disagree, or the chrome around the page is not Safari's). Duet keeps the last placement and hides the controls (`hidden` is `"moving"`) until the window lands and holds there for 150ms, so nothing flashes at the wrong edge mid-turn, not even the closed portrait window an unfold from landscape passes through for a frame or two.
|
|
151
|
+
|
|
152
|
+
Unfolded, the preview is a sheet centred on the open screen (653x661 wide, 653x869 tall) with its bars above and below the page and no column: Duet reads it as the preview (`preview` true) and goes `inline` on the user's side, as in full width Safari. Limits: When "Open in Safari" hands the page to a tab, the `pageshow`/`visibilitychange` re-read switches to `column`. The [/probe page](https://getduet.dev/probe) logs every signal the rule reads, every 100ms. Measurements are in [docs/research-notes.md](https://github.com/JayFarei/duet/tree/main/docs/research-notes.md).
|
|
109
153
|
|
|
110
154
|
### Oscillation guard
|
|
111
155
|
|
|
112
|
-
Column mode can feed back into the signals (for example if Safari zooms the page out to fit the wider document, the column signature disappears, `inline` removes the overflow, and the signature returns). If the mode changes 3 times within 2 seconds while the outer window size stays the same, Duet pauses column mode for 3 seconds, then tries again. If the loop resumes as soon as the pause lifts, the next pause doubles. A change of window size resets the guard.
|
|
156
|
+
Column mode can feed back into the signals (for example if Safari zooms the page out to fit the wider document, the column signature disappears, `inline` removes the overflow, and the signature returns). If the mode changes 3 times within 2 seconds while the outer window size stays the same, Duet pauses column mode for 3 seconds, then tries again. If the loop resumes as soon as the pause lifts, the next pause doubles. A change of window size resets the guard, and changes while the window is still settling after an event (the 2s polling above, as during a fold) are not counted: they are the transition, not a loop.
|
|
113
157
|
|
|
114
158
|
### Viewport meta
|
|
115
159
|
|
|
@@ -121,7 +165,10 @@ Each mode has a band the controls may use, and Duet keeps every control inside i
|
|
|
121
165
|
|
|
122
166
|
- **Above:** the status bar and Dynamic Island, and in Safari its own back and reader buttons at the top of the column. Live Activities grow the island down the strip and push Safari's buttons with it, and a page can't tell, so the band always assumes the worst case measured on device (two activities). That space stays empty even when none are running. A split-view pane on the right sits under the same status column, so it gets the same band as a closed tab, while the left pane uses the full column.
|
|
123
167
|
- **Below:** Safari's +/tabs pill, or the bottom edge.
|
|
168
|
+
- **Closed landscape:** Safari moves its back and reader rows and its pill on each side (left column: back slot top 81.5pt, pill top 327pt; right column: 23.5pt and 279pt), and Duet takes the gap between. Measured with a bare island; how Live Activities grow it sideways is not yet known.
|
|
124
169
|
- **What does not fit folds into "…"**, in this order: actions, then `leading`, then back. Tabs shrink to the selected one first. "…" always gets a slot: where the band holds only two buttons (a closed Safari tab), `primary` folds too, as the menu's first item.
|
|
170
|
+
- **The free middle of Safari's column:** before `leading` or `primary` fold, they may rise into the space the island only takes with Live Activities (up to 91.7pt above the band, in a closed portrait tab and the right split pane), and the stack packs down onto the tabs so it rises as little as it can. Back, actions and "…" never do. With no activity running the item sits clear of Safari's reader button; with two activities the reader can cover part of it.
|
|
171
|
+
- **Safari's column buttons are an exclusion zone for menus too.** Native chrome always paints over the page, so z-index can't help. A menu that reaches into the column keeps its top at or below `columnTop` (the reader button's worst-case bottom plus 11pt) and its bottom above Safari's pill, opening flush with the column like Safari's own menus. A morph list too tall for that band opens wholly over the page, beside the column, where it may rise to the top of the page. One too tall even there caps its height and scrolls inside. `plan()` returns `columnTop` and `columnBottom` in `column` mode, and Duet sets `--duet-safe-top-column` on `<html>` for your own popovers (0 outside `column` mode).
|
|
125
172
|
|
|
126
173
|
## Server rendering and hydration
|
|
127
174
|
|
|
@@ -163,10 +210,44 @@ Pass `onChange={() => router.refresh()}` from a client component to re-render wi
|
|
|
163
210
|
| `accessory` | `ReactNode` | A view docked by the tabs, like `.tabViewBottomAccessory` (a mini player) |
|
|
164
211
|
| `minimizeOnScroll` | `boolean` | Scrolling down shrinks the tabs to the selected one, like `.tabBarMinimizeBehavior(.onScrollDown)`. Follows the inner scroller and any pane |
|
|
165
212
|
| `menuStyle` | `"island" \| "morph"` | How "…" and the minimized tab open their menu. Default `"island"` |
|
|
166
|
-
| `
|
|
213
|
+
| `appearance` | `"auto" \| "light" \| "dark"` | Light or dark, like `overrideUserInterfaceStyle`. Default `"auto"`: follow the system. `dark` (boolean) still works but is deprecated |
|
|
214
|
+
| `tint` | `string` | Accent for the selected tab, the active menu item and the primary button, like iOS's `tintColor`. Default: system blue |
|
|
215
|
+
| `icons` | `{ size?, tabSize?, weight?, color? }` | How your icons are drawn. See [Theming](#theming) |
|
|
167
216
|
| `children` | `ReactNode` | Page content, in an inner scroller so the document never scrolls and the controls stay still |
|
|
168
217
|
|
|
169
|
-
`DuetItem` is `{ id, label, icon, onPress?, badge?, tint?, destructive? }`: `badge` is a number or short text in a red
|
|
218
|
+
`DuetItem` is `{ id, label, icon, onPress?, badge?, badgeTone?, tint?, destructive? }`: `badge` is a number or short text in a badge, red by default; `badgeTone: "neutral"` makes it grey, for plain counts like a balance. `tint` overrides the strip's tint for that tab or primary button. `DuetTab<T>` is a `DuetItem` whose `id` is `T`.
|
|
219
|
+
|
|
220
|
+
### Theming
|
|
221
|
+
|
|
222
|
+
Deliberately small, like iOS: an accent, an appearance and your icons. The glass itself stays native.
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
<Duet appearance="auto" tint="#ff2d55" icons={{ size: 21, tabSize: 24, weight: 1.75 }} tabs={...}>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- `tint` colours only what is active: the selected tab, the checked menu item, the primary button. Everything else stays ink (black in light, white in dark).
|
|
229
|
+
- `appearance` sets the page theme. Menus follow it. In Safari's column the buttons keep following the system, so they match Safari's own; menus open over the page, so they keep `appearance`.
|
|
230
|
+
- `icons` sizes and weighs your icons, the way the system sizes SF Symbols in a native bar: `size` (round buttons, groups, menus, default 22px), `tabSize` (tab pill, default 24px), `weight` (stroke width of SVG line icons such as Lucide, default 2) and `color` (unselected icons, default ink). SVGs are resized directly; font icons take the size as `font-size`, so give them `font-size: 1em` or leave it unset. The chrome's own glyphs (back, close, "…", search) keep their native sizes.
|
|
231
|
+
|
|
232
|
+
Every prop writes a CSS custom property on `.duet`, so plain CSS can set the same tokens instead, globally or per appearance. These are stable:
|
|
233
|
+
|
|
234
|
+
| Token | What it is | Default |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| `--duet-tint` | Accent: selected tab, active menu item, primary button | `#0088ff` |
|
|
237
|
+
| `--duet-ink` | Text and icon colour | black / white |
|
|
238
|
+
| `--duet-icon` | Unselected icon colour | `--duet-ink` |
|
|
239
|
+
| `--duet-icon-size` | Icons in round buttons, groups and menus | `22px` |
|
|
240
|
+
| `--duet-tab-icon-size` | Icons in the tab pill | `24px` |
|
|
241
|
+
| `--duet-icon-weight` | Stroke width of SVG line icons | `2` |
|
|
242
|
+
| `--duet-badge`, `--duet-badge-neutral`, `--duet-badge-ink` | Badge fills (alert, neutral) and text | red, grey, white |
|
|
243
|
+
| `--duet-solid` | Opaque surface: menus in Safari's column, the badge ring | near white / near black |
|
|
244
|
+
| `--duet-frost`, `--duet-menu` | Glass veil of controls and of menus | sampled from iOS |
|
|
245
|
+
| `--duet-sel`, `--duet-lift` | Selection and press highlight | sampled from iOS |
|
|
246
|
+
|
|
247
|
+
```css
|
|
248
|
+
:root .duet { --duet-tint: #ff2d55; --duet-icon-size: 21px; }
|
|
249
|
+
:root[data-lg-theme="dark"] .duet { --duet-tint: #ff6482; }
|
|
250
|
+
```
|
|
170
251
|
|
|
171
252
|
### Menu styles
|
|
172
253
|
|
|
@@ -177,11 +258,18 @@ Both are live on the [site](https://getduet.dev): scroll until the tabs shrink,
|
|
|
177
258
|
|
|
178
259
|
### `useDuet()`
|
|
179
260
|
|
|
180
|
-
Returns the current `Context`: `mode`, `side`, `duo`, `browser`, `layout` (`"wide"` when `innerWidth` is at least 600px, else `"narrow"`), `split` and `
|
|
261
|
+
Returns the current `Context`: `mode`, `side`, `duo`, `browser`, `layout` (`"wide"` when `innerWidth` is at least 600px, else `"narrow"`), `split`, `preview` and `keyboard` (when true), `hidden` (`"moving"` or `"preview"` when the controls are not drawn), `confidence`, `width` and `height` (the page viewport), `stableHeight` (the smallest page height seen at this window width and orientation, like CSS `svh`: it does not grow when Safari's URL bar collapses), plus the screen size and insets the placement uses. It works outside `<Duet>`.
|
|
181
262
|
|
|
182
263
|
### Lower-level exports
|
|
183
264
|
|
|
184
|
-
`isDuo`, `isIOS`, `isSplit`, `browserOf`, `detect`, `plan`, `magnet`, `panes`, `backFrom` and the measured constants (`DUO`, `INLINE_WIDTH`, `MAGNET`, `WIDE`) are exported for your own signals and tests
|
|
265
|
+
`isDuo`, `isIOS`, `isSplit`, `browserOf`, `detect`, `plan`, `columnBand`, `morphBox`, `islandBox`, `magnet`, `panes`, `backFrom` and the measured constants (`DUO`, `INLINE_WIDTH`, `MAGNET`, `WIDE`) are exported for your own signals and tests, along with `isPreview`. `duet-ui/geometry` exports the geometry module alone, without React.
|
|
266
|
+
|
|
267
|
+
`readSignals()` returns the current `Signals` from the window, the same reading the chrome uses (browser only). `watchSignals(cb)` calls `cb` whenever they may have changed, with the triggers and settle polling described in [Which mode](#which-mode), and returns the unsubscribe. Together with `detect` they let a page act on the mode before React mounts:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { readSignals, watchSignals, detect } from "duet-ui";
|
|
271
|
+
const off = watchSignals(() => { document.documentElement.dataset.mode = detect(readSignals()).mode; });
|
|
272
|
+
```
|
|
185
273
|
|
|
186
274
|
## Laying out content
|
|
187
275
|
|
|
@@ -190,7 +278,17 @@ The page spans the whole window and the controls float over it, so backgrounds r
|
|
|
190
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`).
|
|
191
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.
|
|
192
280
|
|
|
193
|
-
Duet sets the insets on `<html>` as `--duet-inset-left`, `--duet-inset-right
|
|
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.
|
|
194
292
|
|
|
195
293
|
## Panes
|
|
196
294
|
|
|
@@ -227,6 +325,146 @@ export function Mail() {
|
|
|
227
325
|
|
|
228
326
|
Try it on the [site](https://getduet.dev/panes).
|
|
229
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
|
+
|
|
230
468
|
## Limitations
|
|
231
469
|
|
|
232
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.
|
|
@@ -235,7 +473,7 @@ Try it on the [site](https://getduet.dev/panes).
|
|
|
235
473
|
- **Live Activities and the Dynamic Island are invisible to the page**, so the top band always reserves the worst case, which leaves empty space when no activity runs.
|
|
236
474
|
- **Split-view panes can't tell their side**: column mode draws both edges, inline mode relies on the user's choice.
|
|
237
475
|
- **Server rendering** starts from a fixed Duo snapshot (see [Server rendering](#server-rendering-and-hydration)).
|
|
238
|
-
- Other iOS browsers, in-app web views, iframes, landscape
|
|
476
|
+
- Other iOS browsers, in-app web views, iframes, landscape other than the closed Safari tab and the open screen at full width all get `inline`.
|
|
239
477
|
- Measured on the iPhone Duo simulator (iOS 27.1) and checked on a device in October 2026.
|
|
240
478
|
|
|
241
479
|
## Examples
|
package/dist/chrome.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import { type Appearance, type DuetIcons } from "./theme.ts";
|
|
2
3
|
import { type Context } from "./geometry.ts";
|
|
3
4
|
import "./duet.css";
|
|
4
5
|
export interface DuetItem {
|
|
@@ -6,9 +7,11 @@ export interface DuetItem {
|
|
|
6
7
|
label: string;
|
|
7
8
|
icon: ReactNode;
|
|
8
9
|
onPress?: () => void;
|
|
9
|
-
/** Count or short text in a
|
|
10
|
+
/** Count or short text in a badge on the icon's page-side corner, like a native tab item's badge. */
|
|
10
11
|
badge?: number | string;
|
|
11
|
-
/**
|
|
12
|
+
/** "alert" (default): red, for things that want attention. "neutral": grey, for plain counts like a balance. */
|
|
13
|
+
badgeTone?: "alert" | "neutral";
|
|
14
|
+
/** This item's accent when selected (a tab) or as the primary button. Default: the strip's `tint`. */
|
|
12
15
|
tint?: string;
|
|
13
16
|
/** Deletes or discards: lives only in "…", last, in red, after a separator (blueprint). */
|
|
14
17
|
destructive?: boolean;
|
|
@@ -52,14 +55,28 @@ export interface DuetProps<T extends string = string> {
|
|
|
52
55
|
accessory?: ReactNode;
|
|
53
56
|
/** `.tabBarMinimizeBehavior(.onScrollDown)`. */
|
|
54
57
|
minimizeOnScroll?: boolean;
|
|
55
|
-
/**
|
|
58
|
+
/** Light or dark, like overrideUserInterfaceStyle. Default "auto": follow the system. Menus follow it too; in
|
|
59
|
+
* Safari's column the buttons keep following the system, so they match Safari's own. */
|
|
60
|
+
appearance?: Appearance;
|
|
61
|
+
/** @deprecated Use `appearance`. */
|
|
56
62
|
dark?: boolean;
|
|
63
|
+
/** Accent for the selected tab, the active menu item and the primary button, like iOS's tintColor. Default:
|
|
64
|
+
* the system blue. Same as `--duet-tint` in CSS. */
|
|
65
|
+
tint?: string;
|
|
66
|
+
/** Size, stroke weight and colour of your icons. Same as the `--duet-icon*` vars in CSS. */
|
|
67
|
+
icons?: DuetIcons;
|
|
57
68
|
/** How "…" and the minimized tab open their menu. "island" (default): the button stretches sideways into a
|
|
58
69
|
* pill of icons, like the Dynamic Island. "morph": the button grows into a labelled list, like a context menu. */
|
|
59
70
|
menuStyle?: "island" | "morph";
|
|
60
71
|
/** Page content. When given, it scrolls in an inner scroller and the document itself never moves, so
|
|
61
72
|
* the chrome (and in Safari the column layer) stays still during momentum scroll and rubber-banding. */
|
|
62
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;
|
|
63
80
|
}
|
|
64
81
|
export declare function useDuet(): Context;
|
|
65
82
|
/** Tells the server whether this browser is an iPhone Duo, so it can render the Duo layout from the first byte.
|