duet-ui 0.2.2 → 0.2.4
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 +144 -62
- package/dist/chrome.js +1 -1
- package/dist/duet.css +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Duet
|
|
2
2
|
|
|
3
|
-
Native-feeling controls for React web apps on the foldable iPhone. Your page and its control strip, playing together.
|
|
3
|
+
Native-feeling controls for React web apps on the foldable iPhone (the "iPhone Duo"). Your page and its control strip, playing together.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm i duet-ui
|
|
@@ -12,69 +12,125 @@ npm i duet-ui
|
|
|
12
12
|
|
|
13
13
|
Recorded on the iPhone Duo simulator, framed by [`tools/media`](https://github.com/JayFarei/duet/tree/main/tools/media).
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## What it does
|
|
16
16
|
|
|
17
|
-
On the closed Duo,
|
|
17
|
+
On the closed Duo, iOS puts navigation in a vertical strip at the screen's edge, not a bottom tab bar. A Home Screen web app gets that strip as a safe-area inset. In a Safari tab, Safari owns the strip for its own buttons and leaves part of it empty. A web page that ignores this ends up with its own bar fighting Safari's, or its buttons under the camera.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
`<Duet>` draws your back button, actions, primary action, tabs, search and accessory where a native app would, picks one of three layouts (`strip`, `column`, `inline`) from the window geometry, keeps every control inside the space it may use, and folds what does not fit into a "…" menu. `<DuetPanes>` shows one screen of a path when the page is narrow and two side by side when it is wide. `useDuet()` tells your own code what Duet decided, including whether this looks like an iPhone Duo.
|
|
20
|
+
|
|
21
|
+
Everything is inferred from web signals (window and screen sizes, safe-area insets, display mode, user agent). iOS gives a web page no API for the device model, the fold, Safari's buttons or the Dynamic Island, so detection is a heuristic built from measurements, and column mode relies on WebKit behaviour that is not a supported API. See [Limitations](#limitations).
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- React and React DOM 18.3 or newer (peer dependencies).
|
|
26
|
+
- A bundler that handles CSS imported from a package (Next.js, Vite): the components import their own stylesheet. It is also exported as `duet-ui/styles.css`.
|
|
27
|
+
- Duet's components and hooks are client components (`"use client"` in the Next.js App Router).
|
|
28
|
+
- The type definitions use `NoInfer`, so TypeScript 5.4 or newer.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
20
31
|
|
|
21
32
|
```tsx
|
|
22
33
|
"use client";
|
|
23
|
-
import {
|
|
34
|
+
import { useState } from "react";
|
|
35
|
+
import { House, Link, Search } from "lucide-react";
|
|
36
|
+
import { Duet, column, type DuetTab } from "duet-ui";
|
|
24
37
|
|
|
25
38
|
const TABS = [
|
|
26
39
|
{ id: "home", label: "Home", icon: <House size={24} /> },
|
|
27
40
|
{ id: "search", label: "Search", icon: <Search size={24} /> },
|
|
28
|
-
] as const satisfies readonly
|
|
41
|
+
] as const satisfies readonly DuetTab[];
|
|
42
|
+
|
|
43
|
+
type Tab = (typeof TABS)[number]["id"];
|
|
29
44
|
|
|
30
45
|
export function App() {
|
|
31
|
-
const [tab, setTab] = useState<
|
|
46
|
+
const [tab, setTab] = useState<Tab>("home");
|
|
32
47
|
return (
|
|
33
48
|
<Duet
|
|
34
49
|
tabs={{ items: TABS, selected: tab, onSelect: setTab }}
|
|
35
|
-
actions={[{ id: "
|
|
50
|
+
actions={[{ id: "copy", label: "Copy link", icon: <Link size={22} />, onPress: () => navigator.clipboard.writeText(location.href) }]}
|
|
36
51
|
minimizeOnScroll
|
|
37
52
|
>
|
|
38
|
-
<
|
|
53
|
+
<main style={column(680)}>
|
|
54
|
+
<h1>{tab === "home" ? "Home" : "Search"}</h1>
|
|
55
|
+
</main>
|
|
39
56
|
</Duet>
|
|
40
57
|
);
|
|
41
58
|
}
|
|
42
59
|
```
|
|
43
60
|
|
|
44
|
-
|
|
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.
|
|
45
62
|
|
|
46
|
-
|
|
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)).
|
|
47
64
|
|
|
48
|
-
##
|
|
65
|
+
## How Duet decides
|
|
49
66
|
|
|
50
|
-
|
|
67
|
+
### Is this an iPhone Duo?
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
`useDuet().duo` (and `isDuo(signals)`) is a heuristic. It is true when both hold:
|
|
70
|
+
|
|
71
|
+
1. The screen size (`screen.width` x `screen.height`) or the window size (`outerWidth` x `outerHeight`) matches, within 1px and in either orientation, one of the sizes measured on the Duo: 466x678 (closed screen; Safari keeps reporting it as `screen.*` after unfolding), 669x951 (open screen) or 469x669 (a Safari split-view pane).
|
|
72
|
+
2. The user agent says iOS: it contains `iPhone` or `iPod`, or it contains `Macintosh` and `navigator.maxTouchPoints` is greater than 1 (Safari's "Request Desktop Website"). Signals without a user agent, such as ones you build yourself for `isDuo()`, skip this check.
|
|
73
|
+
|
|
74
|
+
Possible false positives: an iPad (which also sends a Mac user agent with touch) or another iOS device whose screen or window happens to match one of these sizes. Possible false negatives: anything that changes the reported sizes, such as a different display zoom setting, a future iOS release, or a window size that was not measured. Use it to switch to a Duo layout, not as proof of the device.
|
|
75
|
+
|
|
76
|
+
### Which mode
|
|
77
|
+
|
|
78
|
+
Duet re-reads the signals on resize, orientation change and visual viewport resize (again 300 and 600ms later, since the window size and insets settle late), and picks the first mode that matches:
|
|
79
|
+
|
|
80
|
+
| Order | Mode | Conditions (all must hold) | Controls |
|
|
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 60px. The right side wins if both are. Does not check for a Duo. | In the strip iOS gives the app, beside the page |
|
|
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`); the keyboard is down (visual viewport height at least `innerHeight - 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 its own buttons leave empty, so the page keeps its full width |
|
|
84
|
+
| 3 | `inline` | Everything else: unfolded at full width, landscape, keyboard up, other browsers, in-app web views, the in-app Safari preview, other phones, desktops | An 84px strip inside the page, on the side the user picks |
|
|
85
|
+
|
|
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
|
+
|
|
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
|
+
|
|
90
|
+
### Column mode: how it works
|
|
91
|
+
|
|
92
|
+
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
|
+
|
|
94
|
+
- `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.
|
|
95
|
+
- 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
|
+
- 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
|
+
|
|
98
|
+
### Split view
|
|
99
|
+
|
|
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.
|
|
101
|
+
|
|
102
|
+
### Side
|
|
103
|
+
|
|
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 always `"right"`, Safari's column on the closed Duo, and 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"`, or `"left"` in the in-app preview.
|
|
105
|
+
|
|
106
|
+
### In-app preview
|
|
107
|
+
|
|
108
|
+
Links opened from social and mail apps can open in the in-app Safari preview (`SFSafariViewController`). Duet takes a window that is not standalone, exactly 375x667 (`outerWidth` x `outerHeight`) at `devicePixelRatio` 3 to be that preview, a size no iPhone has natively, measured on the closed Duo. The preview's own buttons sit beside the page and the page cannot paint there, so Duet uses `inline`, on the left unless the user picked a side before, and sets `useDuet().preview`. This is a heuristic too: another window at that size and density would match.
|
|
109
|
+
|
|
110
|
+
### Oscillation guard
|
|
111
|
+
|
|
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.
|
|
113
|
+
|
|
114
|
+
### Viewport meta
|
|
115
|
+
|
|
116
|
+
Duet appends `shrink-to-fit=no` to every `<meta name="viewport">` when `duet-ui` is imported, and `viewport-fit=cover` when `<Duet>` mounts, and keeps both on metas the framework adds or rewrites later. If there is no viewport meta it adds `width=device-width,initial-scale=1` first. It only appends a key that is absent: an existing `viewport-fit` or `shrink-to-fit` value is left as it is. Safe-area insets only reach the page with `viewport-fit=cover`, so with `viewport-fit=contain` `strip` mode cannot be detected. Without `shrink-to-fit=no`, Safari zooms a split pane out to fit the widened document.
|
|
57
117
|
|
|
58
118
|
### Claimable space
|
|
59
119
|
|
|
60
120
|
Each mode has a band the controls may use, and Duet keeps every control inside it, so you never position anything yourself:
|
|
61
121
|
|
|
62
|
-
- **Above:** the status bar and Dynamic Island, and in Safari its own back and
|
|
63
|
-
- **Below:** Safari's +/tabs pill, or the
|
|
122
|
+
- **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
|
+
- **Below:** Safari's +/tabs pill, or the bottom edge.
|
|
64
124
|
- **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.
|
|
65
125
|
|
|
66
|
-
|
|
126
|
+
## Server rendering and hydration
|
|
67
127
|
|
|
68
|
-
|
|
128
|
+
The server can't measure anything, so on the server and during hydration `useDuet()` returns a fixed snapshot: `mode: "column"`, `duo: true`, `browser: "safari"`, `layout: "narrow"`, `side: "right"` (a closed Safari tab). The real values arrive right after hydration. So `useDuet().duo` is `true` in the server HTML on every device: a page that renders a Duo layout or a desktop layout from it switches once after hydration on non-Duo devices.
|
|
69
129
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`useDuet().browser` is `"safari"`, `"chrome"`, `"firefox"`, `"edge"`, `"webview"` (an in-app browser such as Instagram's) or `"other"`. Only Safari gets `column`: the others draw their own chrome where Safari leaves its column, so they get `inline`.
|
|
73
|
-
|
|
74
|
-
The server can't tell, so `<DuoCookie>` tells it: it keeps a `duet-duo` cookie in step with the browser, and a new device reloads once into the right layout. In the Next.js App Router:
|
|
130
|
+
To choose the layout on the server, `<DuoCookie>` keeps a `duet-duo` cookie (`"1"` on a Duo) in step with the browser. Read the cookie on the server, render from it, and pass what you rendered as `rendered`. After hydration, if the browser's answer differs, `<DuoCookie>` updates the cookie and calls `onChange` (default: `location.reload()`), so a new device switches layout once. It renders nothing and works outside `<Duet>`. In the Next.js App Router:
|
|
75
131
|
|
|
76
132
|
```tsx
|
|
77
|
-
// app/page.tsx, a server component
|
|
133
|
+
// app/page.tsx, a server component. DuoApp and DesktopApp are your own components.
|
|
78
134
|
import { cookies } from "next/headers";
|
|
79
135
|
import { DuoCookie } from "duet-ui";
|
|
80
136
|
|
|
@@ -91,51 +147,58 @@ export default async function Page() {
|
|
|
91
147
|
|
|
92
148
|
Pass `onChange={() => router.refresh()}` from a client component to re-render without a reload.
|
|
93
149
|
|
|
94
|
-
### In-app preview
|
|
95
|
-
|
|
96
|
-
Links opened from social and mail apps land in the in-app Safari preview (`SFSafariViewController`). On the closed Duo its window is a fixed 375x667 at 3x, a size no iPhone has natively, and its own close and share buttons sit where the column would. Duet detects it, switches to `inline` on the left, and sets `useDuet().preview`.
|
|
97
|
-
|
|
98
150
|
## Props
|
|
99
151
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
|
|
115
|
-
|
|
152
|
+
### `<Duet>`
|
|
153
|
+
|
|
154
|
+
| Prop | Type | What it does |
|
|
155
|
+
| --- | --- | --- |
|
|
156
|
+
| `back` | `false \| { label?, onPress }` | Back button at the top of the strip |
|
|
157
|
+
| `leading` | `DuetItem[]` | Its own group under back, like SwiftUI `.topBarLeading` |
|
|
158
|
+
| `actions` | `DuetItem[]` | Like `.primaryAction`; folds into "…" when it would reach the tabs. Items with `destructive: true` only show in "…", last, in red |
|
|
159
|
+
| `primary` | `DuetItem` | The screen's main action (compose, new), a tinted round button. Narrow: just above the tabs. Wide: under back. Folds into "…" last |
|
|
160
|
+
| `tabs` | `{ items, selected, onSelect, search? }` | Tab pill at the bottom. `search` is a `DuetTab` added as the last tab. Each tab keeps its scroll position |
|
|
161
|
+
| `searchButton` | `() => void` | A round search button at the bottom, when there are no tabs |
|
|
162
|
+
| `close` | `false \| { label?, onPress }` | A sheet or card is open: the strip holds only a close button, where back goes, until it is cleared |
|
|
163
|
+
| `accessory` | `ReactNode` | A view docked by the tabs, like `.tabViewBottomAccessory` (a mini player) |
|
|
164
|
+
| `minimizeOnScroll` | `boolean` | Scrolling down shrinks the tabs to the selected one, like `.tabBarMinimizeBehavior(.onScrollDown)`. Follows the inner scroller and any pane |
|
|
165
|
+
| `menuStyle` | `"island" \| "morph"` | How "…" and the minimized tab open their menu. Default `"island"` |
|
|
166
|
+
| `dark` | `boolean` | Force a theme. Default: follow the system |
|
|
167
|
+
| `children` | `ReactNode` | Page content, in an inner scroller so the document never scrolls and the controls stay still |
|
|
168
|
+
|
|
169
|
+
`DuetItem` is `{ id, label, icon, onPress?, badge?, tint?, destructive? }`: `badge` is a number or short text in a red badge, `tint` the colour of a selected tab or a primary button. `DuetTab<T>` is a `DuetItem` whose `id` is `T`.
|
|
116
170
|
|
|
117
171
|
### Menu styles
|
|
118
172
|
|
|
119
173
|
- `"island"` (default): the button stretches sideways into a pill of icons, like the Dynamic Island. Best when the icons speak for themselves.
|
|
120
174
|
- `"morph"`: the button grows into a labelled list, like a context menu. Best when items need their names.
|
|
121
175
|
|
|
122
|
-
Both are live on the [site](https://
|
|
176
|
+
Both are live on the [site](https://getduet.dev): scroll until the tabs shrink, tap the button, and switch styles in the Menus card.
|
|
177
|
+
|
|
178
|
+
### `useDuet()`
|
|
179
|
+
|
|
180
|
+
Returns the current `Context`: `mode`, `side`, `duo`, `browser`, `layout` (`"wide"` when `innerWidth` is at least 600px, else `"narrow"`), `split` and `preview` (when true), `width` and `height` (the page viewport), plus the screen size and insets the placement uses. It works outside `<Duet>`.
|
|
123
181
|
|
|
124
|
-
###
|
|
182
|
+
### Lower-level exports
|
|
183
|
+
|
|
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. `duet-ui/geometry` exports the geometry module alone, without React.
|
|
185
|
+
|
|
186
|
+
## Laying out content
|
|
125
187
|
|
|
126
188
|
The page spans the whole window and the controls float over it, so backgrounds run on under them and the layout is asymmetric, like a native app. Only text and buttons keep clear.
|
|
127
189
|
|
|
128
190
|
- `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`).
|
|
129
|
-
- `bleed(pad)`: a full-width band (hero, sticky bar, list row). The band runs under the controls, its content is padded back beside them. `bleed(false)` runs content itself edge to edge (a map, an image).
|
|
191
|
+
- `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.
|
|
130
192
|
|
|
131
|
-
|
|
193
|
+
Duet sets the insets on `<html>` as `--duet-inset-left`, `--duet-inset-right`, `--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). 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.
|
|
132
194
|
|
|
133
195
|
## Panes
|
|
134
196
|
|
|
135
|
-
Apps with a path of screens (mailboxes, inbox, message) follow the fold with `<DuetPanes>`:
|
|
197
|
+
Apps with a path of screens (mailboxes, inbox, message) follow the fold with `<DuetPanes>`: when `layout` is `"narrow"` one pane shows, when it is `"wide"` the focused pane and the one before it show side by side. Going deeper slides the pair along the path instead of opening a modal. Every pane stays mounted (off-screen panes are inert), so drafts and scroll positions survive the fold.
|
|
136
198
|
|
|
137
199
|
```tsx
|
|
138
|
-
|
|
200
|
+
"use client";
|
|
201
|
+
import { Duet, DuetPaneBar, DuetPanes, usePanes } from "duet-ui";
|
|
139
202
|
|
|
140
203
|
const PATH = ["mailboxes", "inbox", "message"];
|
|
141
204
|
|
|
@@ -143,19 +206,37 @@ export function Mail() {
|
|
|
143
206
|
const nav = usePanes(PATH);
|
|
144
207
|
return (
|
|
145
208
|
<Duet back={nav.back}>
|
|
146
|
-
<DuetPanes path={PATH} at={nav.at} panes={{
|
|
147
|
-
mailboxes: <
|
|
148
|
-
inbox: <
|
|
149
|
-
message:
|
|
209
|
+
<DuetPanes path={PATH} at={nav.at} expanded={nav.expanded} panes={{
|
|
210
|
+
mailboxes: <button onClick={() => nav.go("inbox")}>Inbox</button>,
|
|
211
|
+
inbox: <button onClick={() => nav.go("message")}>First message</button>,
|
|
212
|
+
message: (
|
|
213
|
+
<article>
|
|
214
|
+
<DuetPaneBar onClose={nav.close} onToggle={nav.toggle} expanded={nav.expanded} />
|
|
215
|
+
<p>Hello from the message pane.</p>
|
|
216
|
+
</article>
|
|
217
|
+
),
|
|
150
218
|
}} />
|
|
151
219
|
</Duet>
|
|
152
220
|
);
|
|
153
221
|
}
|
|
154
222
|
```
|
|
155
223
|
|
|
156
|
-
`<
|
|
224
|
+
- `<DuetPanes path at expanded? panes>`: `panes` maps each id in `path` to its content. `expanded` gives the focused pane the whole window.
|
|
225
|
+
- `usePanes(path, initial?)` returns `at`, `go(id)` (focus a pane, leaving full screen), `layout`, `expanded`, `toggle()` (full screen on and off), `close` (focus the previous pane, `undefined` at the root) and `back`, ready for `<Duet back>`: it leaves full screen first, then steps back along the path, and is `false` when that would change nothing on screen.
|
|
226
|
+
- `<DuetPaneBar onClose? onToggle? expanded? more?>` is the pane's header pill: close, a full-screen toggle (only when `layout` is `"wide"`) and "…" with the pane's own `DuetItem`s, destructive ones last. It renders nothing when it has no buttons.
|
|
227
|
+
|
|
228
|
+
Try it on the [site](https://getduet.dev/panes).
|
|
229
|
+
|
|
230
|
+
## Limitations
|
|
157
231
|
|
|
158
|
-
|
|
232
|
+
- **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.
|
|
233
|
+
- **Column mode depends on unsupported WebKit behaviour** (painting in-document content outside the layout viewport) and on measured positions of Safari's buttons. A Safari or iOS update can break or shift it. Duet falls back to `inline` whenever any column check fails, but it cannot detect a Safari layout that still passes the checks while its buttons moved.
|
|
234
|
+
- **Column taps are routed by coordinates** on `pointerdown`: they fire on touch down and show no native press state.
|
|
235
|
+
- **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
|
+
- **Split-view panes can't tell their side**: column mode draws both edges, inline mode relies on the user's choice.
|
|
237
|
+
- **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, the open screen at full width and the keyboard all get `inline`.
|
|
239
|
+
- Measured on the iPhone Duo simulator (iOS 27.1) and checked on a device in October 2026.
|
|
159
240
|
|
|
160
241
|
## Examples
|
|
161
242
|
|
|
@@ -171,12 +252,13 @@ export function Mail() {
|
|
|
171
252
|
|
|
172
253
|
| Path | What |
|
|
173
254
|
| --- | --- |
|
|
174
|
-
| [`packages/duet-ui`](https://github.com/JayFarei/duet/tree/main/packages/duet-ui) | The library |
|
|
175
|
-
| [`apps/site`](https://github.com/JayFarei/duet/tree/main/apps/site) | Docs site. On a desktop it runs itself inside a web Duo you can fold, rotate and scrub through the fold (with ¼ speed) |
|
|
255
|
+
| [`packages/duet-ui`](https://github.com/JayFarei/duet/tree/main/packages/duet-ui) | The library ([changelog](https://github.com/JayFarei/duet/tree/main/packages/duet-ui/CHANGELOG.md)) |
|
|
256
|
+
| [`apps/site`](https://github.com/JayFarei/duet/tree/main/apps/site) | Docs site at [getduet.dev](https://getduet.dev). On a desktop it runs itself inside a web Duo you can fold, rotate and scrub through the fold (with ¼ speed), sending the page emulated signals through `postMessage` |
|
|
176
257
|
| [`examples`](https://github.com/JayFarei/duet/tree/main/examples) | Example apps |
|
|
177
258
|
| [`tools/simulator`](https://github.com/JayFarei/duet/tree/main/tools/simulator) | Driving the iPhone Duo simulator |
|
|
178
259
|
| [`tools/media`](https://github.com/JayFarei/duet/tree/main/tools/media) | Framing simulator recordings for docs |
|
|
179
260
|
| [`e2e`](https://github.com/JayFarei/duet/tree/main/e2e) | Playwright tests |
|
|
261
|
+
| [`docs`](https://github.com/JayFarei/duet/tree/main/docs) | [Releasing](https://github.com/JayFarei/duet/tree/main/docs/releasing.md), and the dated [research notes](https://github.com/JayFarei/duet/tree/main/docs/research-notes.md) behind column mode |
|
|
180
262
|
|
|
181
263
|
```sh
|
|
182
264
|
pnpm install
|
package/dist/chrome.js
CHANGED
|
@@ -506,7 +506,7 @@ export function Duet(props) {
|
|
|
506
506
|
return null;
|
|
507
507
|
const close = kind === "more" && !morph, rolling = menu !== kind;
|
|
508
508
|
return (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-menu", role: "menu", "data-style": morph ? "morph" : undefined, style: morph ? morphAt(anchor, items.length) : menuAt(anchor, items.length + (close ? 1 : 0)), "data-closing": rolling || undefined, onAnimationEnd: (e) => { if (rolling && (e.animationName === "duet-roll" || e.animationName === "duet-unmorph"))
|
|
509
|
-
setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.
|
|
509
|
+
setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.06 + 0.025 * i : 0.05 + 0.035 * (items.length - i)}s` }) }, onClick: () => { setMenu(null); if (radio)
|
|
510
510
|
select(a.id);
|
|
511
511
|
else
|
|
512
512
|
a.onPress?.(); }, children: [a.icon, morph && _jsx("span", { className: "duet-menu-label", children: a.label }), badge(a)] }, a.id))), close && _jsx("button", { className: "duet-menu-item", "aria-label": "Close", onClick: () => setMenu(null), children: _jsx(X, { size: 20 }) })] }));
|
package/dist/duet.css
CHANGED
|
@@ -163,9 +163,9 @@
|
|
|
163
163
|
.duet-menu {
|
|
164
164
|
position: fixed; z-index: 51; display: flex; flex-direction: row; align-items: center; justify-content: flex-end;
|
|
165
165
|
padding: 0 3px; overflow: hidden; transition: none;
|
|
166
|
-
animation: duet-unroll 0.
|
|
166
|
+
animation: duet-unroll 0.36s var(--duet-spring-out) both, duet-thin 0.36s ease-out both;
|
|
167
167
|
}
|
|
168
|
-
.duet-menu[data-closing] { animation: duet-roll 0.
|
|
168
|
+
.duet-menu[data-closing] { animation: duet-roll 0.22s cubic-bezier(0.32, 0.72, 0, 1) both, duet-thin 0.22s ease-out both; pointer-events: none; }
|
|
169
169
|
/* A touch denser than the controls, so the strip under it does not compete with the menu icons. */
|
|
170
170
|
.duet-menu::before { background-color: var(--duet-menu); }
|
|
171
171
|
.duet-menu > * { animation: duet-fade 0.16s 0.03s var(--duet-spring-out) both; }
|
|
@@ -202,9 +202,9 @@
|
|
|
202
202
|
circle (--x0/--y0 is its top-left) and springs out to the list; closing shrinks it back into the button. */
|
|
203
203
|
.duet-menu[data-style="morph"] {
|
|
204
204
|
flex-direction: column; align-items: stretch; justify-content: flex-start; padding: 6px; border-radius: 22px;
|
|
205
|
-
animation: duet-morph 0.
|
|
205
|
+
animation: duet-morph 0.36s var(--duet-spring-out) both;
|
|
206
206
|
}
|
|
207
|
-
.duet-menu[data-style="morph"][data-closing] { animation: duet-unmorph 0.
|
|
207
|
+
.duet-menu[data-style="morph"][data-closing] { animation: duet-unmorph 0.2s cubic-bezier(0.32, 0.72, 0, 1) both; }
|
|
208
208
|
.duet[data-side="left"] .duet-menu[data-style="morph"] { flex-direction: column; }
|
|
209
209
|
.duet-menu[data-style="morph"] > .duet-menu-item {
|
|
210
210
|
width: auto; height: 44px; border-radius: 16px; display: flex; align-items: center; justify-content: flex-start; gap: 12px; padding: 0 12px;
|
package/package.json
CHANGED