duet-ui 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JayFarei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,160 @@
1
- # Temporary Holding Version
1
+ # Duet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Native-feeling controls for React web apps on the foldable iPhone. Your page and its control strip, playing together.
4
+
5
+ ```sh
6
+ npm i duet-ui
7
+ ```
8
+
9
+ | Strip | Column | Inline |
10
+ | --- | --- | --- |
11
+ | ![Strip: a Home Screen app with the island menu](https://raw.githubusercontent.com/JayFarei/duet/main/docs/media/strip.webp) | ![Column: Safari's empty column holds the controls, morph menu](https://raw.githubusercontent.com/JayFarei/duet/main/docs/media/column.webp) | ![Inline: unfolded, the strip drags to either side](https://raw.githubusercontent.com/JayFarei/duet/main/docs/media/inline-drag.webp) |
12
+
13
+ Recorded on the iPhone Duo simulator, framed by [`tools/media`](https://github.com/JayFarei/duet/tree/main/tools/media).
14
+
15
+ ## Why
16
+
17
+ On the closed Duo, Safari keeps a narrow column beside the page for its own buttons, and a Home Screen app owns the whole display. iOS puts navigation in a vertical strip at the screen's edge, not a bottom tab bar. A web page that ignores this ends up with its own bar fighting Safari's, or its buttons under the camera. Duet works out where the page is running and draws back, actions and tabs where a native app would.
18
+
19
+ ## Usage
20
+
21
+ ```tsx
22
+ import { Duet } from "duet-ui";
23
+ import "duet-ui/styles.css";
24
+
25
+ export function App() {
26
+ const [tab, setTab] = useState("home");
27
+ return (
28
+ <Duet
29
+ tabs={{ items: TABS, selected: tab, onSelect: setTab }}
30
+ actions={[{ id: "share", label: "Share", icon: <Share />, onPress: share }]}
31
+ minimizeOnScroll
32
+ >
33
+ <Page tab={tab} />
34
+ </Duet>
35
+ );
36
+ }
37
+ ```
38
+
39
+ Add `viewport-fit=cover` to the viewport meta so the safe areas reach the page. Duet appends `shrink-to-fit=no` itself, so Safari doesn't zoom a split-view pane out to fit the column layer. On other phones and browsers Duet falls back to the `inline` strip.
40
+
41
+ ## Modes
42
+
43
+ Duet picks a mode from the window size, safe areas and display mode. `useDuet()` returns it.
44
+
45
+ | Mode | When | Controls |
46
+ | --- | --- | --- |
47
+ | `strip` | Home Screen app, closed | In the slim strip beside the page, like a native app |
48
+ | `column` | Safari tab, closed or a split-view pane | Painted into the column Safari leaves empty, so the page keeps its full width. In split view both columns get the controls, since a pane can't tell which side it is on, and each shows only its own |
49
+ | `inline` | Unfolded, landscape, in-app preview | An 84pt strip inside the page. Drag it to either side; the choice is remembered |
50
+
51
+ ### Claimable space
52
+
53
+ Each mode has a band the controls may use, and Duet keeps every control inside it, so you never position anything yourself:
54
+
55
+ - **Above:** the status bar and Dynamic Island, and in Safari its own back and bookmarks buttons at the top of the column (in a split-view pane, wherever that pane puts them). 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, two activities. The space they would take 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 small band as a closed tab, while the left pane uses the full column.
56
+ - **Below:** Safari's +/tabs pill, or the home indicator.
57
+ - **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.
58
+
59
+ `useDuet()` gives the mode; the band follows from it. The menus ("island" or "morph") open over the page and close back into their button.
60
+
61
+ ### Only on the Duo
62
+
63
+ `useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals. The server can't tell, so before hydration `duo` is true; remember the answer in a cookie if the server should render the right layout first.
64
+
65
+ ### In-app preview
66
+
67
+ 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`.
68
+
69
+ ## Props
70
+
71
+ | Prop | What it does |
72
+ | --- | --- |
73
+ | `back` | Back button at the top of the strip: `{ label?, onPress }` |
74
+ | `leading` | Its own group under back, like SwiftUI `.topBarLeading` |
75
+ | `actions` | Like `.primaryAction`; collapses into "…" when it would reach the tabs. Items with `destructive: true` only ever show in "…", last, in red |
76
+ | `primary` | The one main action (compose, new). Narrow: just above the tabs. Wide: under back, at the top. Folds into "…" last, only when the band has no room for both |
77
+ | `tabs` | `{ items, selected, onSelect, search? }`. `search` adds a search tab as the last item |
78
+ | `searchButton` | A round search button at the bottom when there are no tabs |
79
+ | `close` | A sheet or card is open: the strip holds only a close button until it is cleared |
80
+ | `accessory` | A view docked by the tabs, like `.tabViewBottomAccessory` (a mini player) |
81
+ | `minimizeOnScroll` | Scrolling down shrinks the tabs to one button, like `.tabBarMinimizeBehavior(.onScrollDown)` |
82
+ | `menuStyle` | How "…" and the minimized tab open: `"island"` or `"morph"` (below) |
83
+ | `dark` | Force a theme. Default: follow the system |
84
+ | `children` | Page content, in an inner scroller so the chrome stays still during momentum scroll |
85
+
86
+ Each `DuetItem` is `{ id, label, icon, onPress?, badge?, tint?, destructive? }`.
87
+
88
+ ### Menu styles
89
+
90
+ - `"island"` (default): the button stretches sideways into a pill of icons, like the Dynamic Island. Best when the icons speak for themselves.
91
+ - `"morph"`: the button grows into a labelled list, like a context menu. Best when items need their names.
92
+
93
+ Both are live on the [site](https://github.com/JayFarei/duet/tree/main/apps/site): scroll until the tabs shrink, tap the button, and switch styles in the Menus card.
94
+
95
+ ### Laying out content
96
+
97
+ 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.
98
+
99
+ - `column(maxWidth, pad)`: a centred reading column. It centres on the whole window and moves aside from the controls only as far as it must. The `duet-column` class does the same in CSS (`--duet-max`, `--duet-pad`).
100
+ - `bleed(pad)`: a full-width band (hero, sticky bar, list row). The band runs under the controls, its content is padded back beside them. `bleed(false)` runs content itself edge to edge (a map, an image).
101
+
102
+ The insets are exposed as `--duet-inset-left`, `--duet-inset-right` and `--duet-inset-top` (the strip's top blur in Home Screen apps). Inside `<DuetPanes>` each pane gets its own: only the pane next to the controls is inset.
103
+
104
+ ## Panes
105
+
106
+ Apps with a path of screens (mailboxes, inbox, message) follow the fold with `<DuetPanes>`: closed or in split view one pane shows, unfolded two adjacent panes show side by side. Going deeper slides the pair along the path instead of opening a modal. Unfolding slides the neighbouring pane in, and folding plays the same motion backwards. Every pane stays mounted, so drafts and scroll positions survive the fold.
107
+
108
+ ```tsx
109
+ import { Duet, DuetPanes, usePanes } from "duet-ui";
110
+
111
+ const PATH = ["mailboxes", "inbox", "message"];
112
+
113
+ export function Mail() {
114
+ const nav = usePanes(PATH);
115
+ return (
116
+ <Duet back={nav.back}>
117
+ <DuetPanes path={PATH} at={nav.at} panes={{
118
+ mailboxes: <Mailboxes onOpen={() => nav.go("inbox")} />,
119
+ inbox: <InboxList onOpen={() => nav.go("message")} />,
120
+ message: <Message />,
121
+ }} />
122
+ </Duet>
123
+ );
124
+ }
125
+ ```
126
+
127
+ `<DuetPaneBar>` is the pane's header pill: close, a full-screen toggle (wide only) and "…" with the pane's own actions, destructive ones last. `nav.toggle` gives the focused pane the whole window (`expanded`); back leaves full screen before it steps along the path.
128
+
129
+ `minimizeOnScroll` follows whichever pane scrolls. `useDuet().layout` is `"wide"` when the page has room for two panes (600pt beside the controls: the open Duo at full width) and `"narrow"` otherwise, from the page width rather than the posture. `nav.back` steps back along the path and is `false` when that would change nothing on screen, so the back button only shows when it does something. Try it on the [site](https://github.com/JayFarei/duet/tree/main/apps/site) at `/panes`.
130
+
131
+ ## Examples
132
+
133
+ | App | Shows | Run |
134
+ | --- | --- | --- |
135
+ | [Almanac](https://github.com/JayFarei/duet/tree/main/examples/almanac) | Reading app on `<DuetPanes>`: the list beside the story unfolded, a pane bar, tabs with search, morph menus, sticky title with `bleed` | `pnpm --filter @duet/example-almanac dev`, port 3101 |
136
+ | [Encore](https://github.com/JayFarei/duet/tree/main/examples/encore) | Music player: badges, mini player `accessory`, island menus, carousels with `bleed` | `pnpm --filter @duet/example-encore dev`, port 3103 |
137
+ | [Meridian](https://github.com/JayFarei/duet/tree/main/examples/meridian) | Transit map on Vite: `leading`, a full-screen map with `bleed(false)` | `pnpm --filter @duet/example-meridian dev`, port 3102 |
138
+
139
+ `pnpm dev` from the root builds the library and starts the site and every example.
140
+
141
+ ## Repo
142
+
143
+ | Path | What |
144
+ | --- | --- |
145
+ | [`packages/duet-ui`](https://github.com/JayFarei/duet/tree/main/packages/duet-ui) | The library |
146
+ | [`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) |
147
+ | [`examples`](https://github.com/JayFarei/duet/tree/main/examples) | Example apps |
148
+ | [`tools/simulator`](https://github.com/JayFarei/duet/tree/main/tools/simulator) | Driving the iPhone Duo simulator |
149
+ | [`tools/media`](https://github.com/JayFarei/duet/tree/main/tools/media) | Framing simulator recordings for docs |
150
+ | [`e2e`](https://github.com/JayFarei/duet/tree/main/e2e) | Playwright tests |
151
+
152
+ ```sh
153
+ pnpm install
154
+ pnpm typecheck && pnpm test && pnpm build
155
+ pnpm test:e2e
156
+ ```
157
+
158
+ ## License
159
+
160
+ MIT. Not affiliated with Apple.
@@ -0,0 +1,59 @@
1
+ import { type ReactNode } from "react";
2
+ import { type Context } from "./geometry.ts";
3
+ import "./duet.css";
4
+ export interface DuetItem {
5
+ id: string;
6
+ label: string;
7
+ icon: ReactNode;
8
+ onPress?: () => void;
9
+ /** Count or short text in a red badge on the icon's page-side corner, like a native tab item's badge. */
10
+ badge?: number | string;
11
+ /** Colour of the icon and selection when this tab is selected. Default: the system blue. */
12
+ tint?: string;
13
+ /** Deletes or discards: lives only in "…", last, in red, after a separator (blueprint). */
14
+ destructive?: boolean;
15
+ }
16
+ export interface DuetProps {
17
+ /** Back button, top of the strip. */
18
+ back?: false | {
19
+ label?: string;
20
+ onPress: () => void;
21
+ };
22
+ /** SwiftUI `.topBarLeading`: its own group under back. */
23
+ leading?: DuetItem[];
24
+ /** SwiftUI `.primaryAction`: collapses into "…" when it would hit the tab pill. Destructive items always
25
+ * go in "…". */
26
+ actions?: DuetItem[];
27
+ /** The screen's main action (New chat, Compose), a tinted round button: by the thumb above the tabs where one
28
+ * pane shows, at the top under back where two do. */
29
+ primary?: DuetItem;
30
+ /** Tab pill at the bottom of the strip. `search` adds a `role: .search` tab as the last item. */
31
+ tabs?: {
32
+ items: DuetItem[];
33
+ selected: string;
34
+ onSelect: (id: string) => void;
35
+ search?: DuetItem;
36
+ };
37
+ /** Searchable without tabs: a round search button at the bottom of the strip. */
38
+ searchButton?: () => void;
39
+ /** A sheet or card is open: the strip holds only a close button, where back goes, like an iOS sheet. Every
40
+ * other control steps aside until it is cleared. */
41
+ close?: false | {
42
+ label?: string;
43
+ onPress: () => void;
44
+ };
45
+ /** `.tabViewBottomAccessory`, e.g. a mini player. */
46
+ accessory?: ReactNode;
47
+ /** `.tabBarMinimizeBehavior(.onScrollDown)`. */
48
+ minimizeOnScroll?: boolean;
49
+ /** Force a theme. Default: follow the system light/dark setting. */
50
+ dark?: boolean;
51
+ /** How "…" and the minimized tab open their menu. "island" (default): the button stretches sideways into a
52
+ * pill of icons, like the Dynamic Island. "morph": the button grows into a labelled list, like a context menu. */
53
+ menuStyle?: "island" | "morph";
54
+ /** Page content. When given, it scrolls in an inner scroller and the document itself never moves, so
55
+ * the chrome (and in Safari the column layer) stays still during momentum scroll and rubber-banding. */
56
+ children?: ReactNode;
57
+ }
58
+ export declare function useDuet(): Context;
59
+ export declare function Duet(p: DuetProps): import("react").JSX.Element;