duet-ui 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/chrome.js +22 -8
- package/dist/duet.css +13 -5
- package/dist/geometry.d.ts +2 -1
- package/dist/geometry.js +12 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ export function App() {
|
|
|
41
41
|
}
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
That is the whole setup. The styles come with the component, and Duet adds `viewport-fit=cover` and `shrink-to-fit=no` to
|
|
44
|
+
That is the whole setup. The styles come with the component, and Duet adds `viewport-fit=cover` and `shrink-to-fit=no` to every viewport meta itself, including ones the framework adds later (the safe areas only reach the page with the first; the second keeps Safari from zooming a split-view pane out). Each tab keeps its own scroll position, and tapping the selected tab scrolls back to the top. On other phones and browsers Duet falls back to the `inline` strip.
|
|
45
45
|
|
|
46
46
|
Icons are yours, any library: 24pt in tabs, 21 to 22pt on round buttons matches the system. `as const` types the tab ids, so `onSelect` takes `setTab` without a cast.
|
|
47
47
|
|
|
@@ -52,7 +52,7 @@ Duet picks a mode from the window size, safe areas and display mode. `useDuet()`
|
|
|
52
52
|
| Mode | When | Controls |
|
|
53
53
|
| --- | --- | --- |
|
|
54
54
|
| `strip` | Home Screen app, closed | In the slim strip beside the page, like a native app |
|
|
55
|
-
| `column` | Safari tab, closed or a split-view pane | Painted into the column Safari leaves empty, so the page keeps its full width. In split view both columns get the controls, since a pane can't tell which side it is on, and each shows only its own |
|
|
55
|
+
| `column` | Safari tab, closed, windowed or a split-view pane | Painted into the column Safari leaves empty, so the page keeps its full width. In split view both columns get the controls, since a pane can't tell which side it is on, and each shows only its own |
|
|
56
56
|
| `inline` | Unfolded, landscape, in-app preview | An 84pt strip inside the page. Drag it to either side; the choice is remembered |
|
|
57
57
|
|
|
58
58
|
### Claimable space
|
|
@@ -126,7 +126,7 @@ The page spans the whole window and the controls float over it, so backgrounds r
|
|
|
126
126
|
- `column(maxWidth, pad)`: a centred reading column. It centres on the whole window and moves aside from the controls only as far as it must. The `duet-column` class does the same in CSS (`--duet-max`, `--duet-pad`).
|
|
127
127
|
- `bleed(pad)`: a full-width band (hero, sticky bar, list row). The band runs under the controls, its content is padded back beside them. `bleed(false)` runs content itself edge to edge (a map, an image).
|
|
128
128
|
|
|
129
|
-
The insets are exposed as `--duet-inset-left`, `--duet-inset-right
|
|
129
|
+
The insets are exposed as `--duet-inset-left`, `--duet-inset-right`, `--duet-inset-top` (the strip's top blur in Home Screen apps) and `--duet-inset-bottom` (Safari's floating URL pill in column mode: the page runs on under it, and the scroller ends with that much space so the last content scrolls clear). Inside `<DuetPanes>` each pane gets its own: only the pane next to the controls is inset.
|
|
130
130
|
|
|
131
131
|
## Panes
|
|
132
132
|
|
package/dist/chrome.js
CHANGED
|
@@ -169,17 +169,21 @@ function useMinimized(on, scroller) {
|
|
|
169
169
|
* shows only its own column, and a tap there names the side (`tapSide`). */
|
|
170
170
|
let tapSide = "right";
|
|
171
171
|
/** Without shrink-to-fit=no Safari zooms a split pane out to fit the layer's overflow, which reads as another
|
|
172
|
-
* window size and flips the mode. It applies even when set late, but must land before the first column paint.
|
|
172
|
+
* window size and flips the mode. It applies even when set late, but must land before the first column paint.
|
|
173
|
+
* Safari reads the last viewport meta, and frameworks add their own after load (Next.js renders a second one on
|
|
174
|
+
* the client, device trace 2026-10-25), so every one is patched. */
|
|
173
175
|
function viewport(key, value) {
|
|
174
|
-
|
|
175
|
-
if (!
|
|
176
|
-
meta = document.createElement("meta");
|
|
176
|
+
const metas = document.querySelectorAll("meta[name=viewport]");
|
|
177
|
+
if (!metas.length) {
|
|
178
|
+
const meta = document.createElement("meta");
|
|
177
179
|
meta.name = "viewport";
|
|
178
180
|
meta.content = "width=device-width,initial-scale=1";
|
|
179
181
|
document.head.appendChild(meta);
|
|
182
|
+
return viewport(key, value);
|
|
180
183
|
}
|
|
181
|
-
|
|
182
|
-
meta.content
|
|
184
|
+
for (const meta of metas)
|
|
185
|
+
if (!meta.content.includes(key))
|
|
186
|
+
meta.content += `,${key}=${value}`;
|
|
183
187
|
}
|
|
184
188
|
if (typeof document !== "undefined")
|
|
185
189
|
viewport("shrink-to-fit", "no");
|
|
@@ -357,10 +361,13 @@ export function Duet(props) {
|
|
|
357
361
|
// iOS blurs the top of a Home Screen web app (scroll-edge effect) without reporting a safe-area
|
|
358
362
|
// inset, closed and unfolded; pages pad their top content by this much to stay sharp.
|
|
359
363
|
r.setProperty("--duet-inset-top", ctx.mode === "strip" ? `${DUO.edgeBlur}px` : "0px");
|
|
364
|
+
// Safari's URL pill floats below the layout viewport (the column holds nothing above the page), so the scroller
|
|
365
|
+
// runs on under it like a document that scrolls, and the last content scrolls clear of it.
|
|
366
|
+
r.setProperty("--duet-inset-bottom", ctx.mode === "column" ? `${Math.max(0, ctx.screenHeight - ctx.height)}px` : "0px");
|
|
360
367
|
document.documentElement.dataset.duetMode = ctx.mode;
|
|
361
368
|
document.documentElement.toggleAttribute("data-duet-emulated", !!emulated);
|
|
362
369
|
document.documentElement.toggleAttribute("data-duet-split", split);
|
|
363
|
-
}, [ctx.mode, ctx.side, ctx.screenWidth, ctx.width, split]);
|
|
370
|
+
}, [ctx.mode, ctx.side, ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, split]);
|
|
364
371
|
const [nav, nav2] = useColumnLayer(ctx.mode === "column", split, column);
|
|
365
372
|
useEffect(() => {
|
|
366
373
|
// Any touch outside the chrome closes an open menu, like a native context menu.
|
|
@@ -376,7 +383,14 @@ export function Duet(props) {
|
|
|
376
383
|
useSideDrag(ctx.mode === "inline" ? ctx.side : null, nav, () => { setMenu(null); setScrub(null); });
|
|
377
384
|
// The safe areas only reach the page with viewport-fit=cover. Set on mount, not on import, so a page that only
|
|
378
385
|
// imports DuoCookie keeps its own viewport on other phones.
|
|
379
|
-
useLayout(() =>
|
|
386
|
+
useLayout(() => {
|
|
387
|
+
const patch = () => { viewport("viewport-fit", "cover"); viewport("shrink-to-fit", "no"); };
|
|
388
|
+
patch();
|
|
389
|
+
// Keep them patched when the framework adds or rewrites a viewport meta later.
|
|
390
|
+
const watch = new MutationObserver(patch);
|
|
391
|
+
watch.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["content"] });
|
|
392
|
+
return () => watch.disconnect();
|
|
393
|
+
}, []);
|
|
380
394
|
const selected = p.tabs?.selected;
|
|
381
395
|
// Each tab keeps its own scroll position in the inner scroller; the selected tab again scrolls to the top.
|
|
382
396
|
const scrolls = useRef(new Map());
|
package/dist/duet.css
CHANGED
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
/* Press lift, as iOS Liquid Glass: a separate button swells to ~1.4x and its glass turns lighter, then springs
|
|
49
49
|
back on release. Items inside a shared surface (groups, the full tab pill) lift as a bubble of their own. */
|
|
50
50
|
.duet-glass { transition: top 0.35s cubic-bezier(0.32, 0.72, 0, 1), height 0.35s cubic-bezier(0.32, 0.72, 0, 1), scale 0.5s var(--duet-spring-in); }
|
|
51
|
+
/* Minimizing on scroll, as native .onScrollDown: the pill shrinks to the one button in about 0.24s. */
|
|
52
|
+
.duet-pill[data-min] { transition-duration: 0.24s, 0.24s, 0.4s; }
|
|
51
53
|
.duet-glass::before { transition: background-color 0.25s; }
|
|
52
54
|
:is(.duet-round, .duet-pill[data-min]):has(:active) { scale: 1.4; z-index: 52; transition-duration: 0.35s, 0.35s, 0.45s; transition-timing-function: ease, ease, var(--duet-spring-out); }
|
|
53
55
|
:is(.duet-round, .duet-pill[data-min]):has(:active)::before { background-color: var(--duet-lift); }
|
|
@@ -82,8 +84,8 @@
|
|
|
82
84
|
/* Letting go eases everything back together, without overshoot, so the swell and the lens melt away as one. */
|
|
83
85
|
.duet-pill:not([data-min]) {
|
|
84
86
|
--lx: 0px; --ly: 0px; --lm: 1; --pad: 6.35px; padding: var(--pad) 3px; touch-action: none; transform-origin: 100% 50%;
|
|
85
|
-
--duet-settle: 0.42s cubic-bezier(0.32, 0.72, 0, 1);
|
|
86
|
-
transition: top var(--duet-
|
|
87
|
+
--duet-settle: 0.42s cubic-bezier(0.32, 0.72, 0, 1); --duet-grow: 0.26s cubic-bezier(0.32, 0.72, 0, 1);
|
|
88
|
+
transition: top var(--duet-grow), height var(--duet-grow), left var(--duet-grow), width var(--duet-grow), --pad var(--duet-settle), scale var(--duet-settle),
|
|
87
89
|
--li 0.4s cubic-bezier(0.32, 0.72, 0, 1), --lx var(--duet-settle), --ly var(--duet-settle), --lm var(--duet-settle);
|
|
88
90
|
}
|
|
89
91
|
.duet[data-side="left"] .duet-pill { transform-origin: 0 50%; }
|
|
@@ -123,6 +125,9 @@
|
|
|
123
125
|
-webkit-mask: linear-gradient(#000 var(--y0), transparent var(--y0), transparent var(--y1), #000 var(--y1));
|
|
124
126
|
mask: linear-gradient(#000 var(--y0), transparent var(--y0), transparent var(--y1), #000 var(--y1));
|
|
125
127
|
}
|
|
128
|
+
/* Expanding from the minimized button, the other tabs come out of a blur as the pill grows (native: about 0.2s). */
|
|
129
|
+
.duet-pill:not([data-min]) > .duet-tab:not([data-on]), .duet-pill-badges > span { animation: duet-tab-in 0.22s ease-out; }
|
|
130
|
+
@keyframes duet-tab-in { from { opacity: 0; filter: blur(6px); scale: 0.7; } }
|
|
126
131
|
.duet-pill-badges { position: absolute; z-index: 2; left: 3px; right: 3px; top: var(--pad); pointer-events: none; }
|
|
127
132
|
.duet-pill-badges > span { display: block; position: relative; height: 50px; }
|
|
128
133
|
.duet-tab > svg { transition: translate var(--duet-settle, 0.3s), scale var(--duet-settle, 0.3s); }
|
|
@@ -222,12 +227,15 @@ html:has(> body .duet-scroller), html:has(> body .duet-scroller) > body { height
|
|
|
222
227
|
column() or bleed(). Safari's column only shows in-document content and clips fixed boxes at the viewport
|
|
223
228
|
edge, so there the scroller is an in-document box that runs on under the column. The document still never
|
|
224
229
|
scrolls, so the chrome layer never moves. */
|
|
225
|
-
html[data-duet-mode="column"] .duet-scroller { position: absolute; width: calc(100% + var(--duet-inset-right)); }
|
|
230
|
+
html[data-duet-mode="column"] .duet-scroller { position: absolute; width: calc(100% + var(--duet-inset-right)); bottom: auto; height: calc(100% + var(--duet-inset-bottom)); }
|
|
231
|
+
/* Safari's URL pill floats under the page's bottom edge: the scroller runs on under it (above) and this spacer lets
|
|
232
|
+
the last content scroll clear of it. */
|
|
233
|
+
.duet-scroller::after { content: ""; display: block; height: var(--duet-inset-bottom, 0px); }
|
|
226
234
|
/* Split pane: a column on each side (the pane's own is on its outer edge, which the page cannot see); the
|
|
227
235
|
document is scrolled one column across (chrome.tsx). */
|
|
228
236
|
html[data-duet-mode="column"][data-duet-split] .duet-scroller { width: calc(100% + var(--duet-inset-left) + var(--duet-inset-right)); }
|
|
229
|
-
/* Emulated (the site's web Duo): the frame already spans the column. */
|
|
230
|
-
html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%; }
|
|
237
|
+
/* Emulated (the site's web Duo): the frame already spans the column and runs under the pill. */
|
|
238
|
+
html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%; height: 100%; }
|
|
231
239
|
|
|
232
240
|
/* Safari tab: an in-document layer at the document top (which never scrolls), so controls can paint in
|
|
233
241
|
Safari's column.
|
package/dist/geometry.d.ts
CHANGED
|
@@ -118,12 +118,13 @@ export declare const isSplit: (s: Signals) => boolean;
|
|
|
118
118
|
export declare function detect(s: Signals, preferred?: Side, allowColumn?: boolean): Context;
|
|
119
119
|
/** Mode flips are a feedback loop when the layout of one mode changes the signals into the other (the
|
|
120
120
|
* column layer's overflow resizing Safari's viewport). Three flips within FLIP_WINDOW pin the page to inline.
|
|
121
|
-
* The pin lifts when the window changes size, or after `until`: moving a split-view pane to the other side
|
|
121
|
+
* Flips only count while the window keeps its size. The pin lifts when the window changes size, or after `until`: moving a split-view pane to the other side
|
|
122
122
|
* flips the mode a few times without changing the window's size. Each re-pin for the same window doubles
|
|
123
123
|
* the wait, so a real loop settles instead of flickering. */
|
|
124
124
|
export interface Damper {
|
|
125
125
|
mode?: Mode;
|
|
126
126
|
flips: number[];
|
|
127
|
+
key?: string;
|
|
127
128
|
pinned?: string;
|
|
128
129
|
until?: number;
|
|
129
130
|
ttl?: number;
|
package/dist/geometry.js
CHANGED
|
@@ -63,13 +63,16 @@ export const isSplit = (s) => !s.standalone && Math.abs(s.outerWidth - 469) <= 1
|
|
|
63
63
|
* pane on the open Duo (469x669 outer, 385x571 inner) has the same column on its outer edge, so either
|
|
64
64
|
* side of the page. In-document content paints into it once the document spans both columns (84 + 385 + 84)
|
|
65
65
|
* and is scrolled 84 across, with `shrink-to-fit=no` in the viewport meta, or Safari zooms the pane out to
|
|
66
|
-
* fit the wider document (simulator probes, 2026-10-25).
|
|
67
|
-
|
|
66
|
+
* fit the wider document (simulator probes, 2026-10-25). Windowed Safari (floating, or beside the Home Screen)
|
|
67
|
+
* keeps the column at other window sizes (device 2026-10-25: 453x661 and 450x670 outer, 369x591 inner), and a
|
|
68
|
+
* pane with the compact URL bar has 75pt of chrome, so any portrait Safari window on the Duo with the column counts. */
|
|
69
|
+
const SAFARI_COLUMN = { width: [80, 88], chrome: [68, 102] };
|
|
68
70
|
const within = (v, [a, b]) => v >= a && v <= b;
|
|
69
71
|
/** preferred: the side the user picked for inline controls, if any. */
|
|
70
72
|
export function detect(s, preferred, allowColumn = true) {
|
|
71
73
|
const duo = isDuo(s);
|
|
72
|
-
|
|
74
|
+
// Only a Home Screen app owns the strip: a Safari tab reports the column as an 84pt inset for a frame while folding.
|
|
75
|
+
if (s.standalone && (s.insetRight >= 60 || s.insetLeft >= 60)) {
|
|
73
76
|
return { mode: "strip", duo, layout: layoutOf(s.innerWidth), side: s.insetRight >= 60 ? "right" : "left", width: s.innerWidth, height: s.innerHeight, offsetTop: Math.max(0, s.screenHeight - s.innerHeight), screenWidth: s.screenWidth, screenHeight: s.screenHeight, insetTop: s.insetTop, insetBottom: s.insetBottom };
|
|
74
77
|
}
|
|
75
78
|
const base = { duo, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
|
|
@@ -81,7 +84,7 @@ export function detect(s, preferred, allowColumn = true) {
|
|
|
81
84
|
// Safari's page viewport starts at screen top and ends above its address bar (382x580 of 466x678).
|
|
82
85
|
if (column && isSplit(s))
|
|
83
86
|
return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight, split: true };
|
|
84
|
-
if (column &&
|
|
87
|
+
if (column && duo)
|
|
85
88
|
return { ...base, mode: "column", layout: layoutOf(s.innerWidth), side: "right", screenWidth: s.outerWidth, screenHeight: s.outerHeight };
|
|
86
89
|
const preview = isPreview(s), split = isSplit(s);
|
|
87
90
|
return { ...base, mode: "inline", layout: layoutOf(s.innerWidth), side: preferred ?? (preview ? "left" : "right"), screenWidth: s.innerWidth, screenHeight: s.innerHeight, ...(preview && { preview }), ...(split && { split }) };
|
|
@@ -90,15 +93,17 @@ const FLIP_WINDOW = 2000;
|
|
|
90
93
|
export const PIN_TTL = 3000;
|
|
91
94
|
export function stable(d, s, preferred, now) {
|
|
92
95
|
const key = `${s.outerWidth}x${s.outerHeight}`;
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
// A loop keeps the window size; a fold or rotation passes through other sizes, so only same-size flips count.
|
|
97
|
+
if (d.key !== key)
|
|
98
|
+
Object.assign(d, { key, pinned: undefined, ttl: undefined, flips: [] });
|
|
95
99
|
if (d.pinned && now >= d.until)
|
|
96
100
|
Object.assign(d, { pinned: undefined, flips: [] });
|
|
97
101
|
let c = detect(s, preferred, !d.pinned);
|
|
98
102
|
if (d.mode && c.mode !== d.mode) {
|
|
99
103
|
d.flips = [...d.flips.filter((t) => now - t < FLIP_WINDOW), now];
|
|
100
104
|
if (d.flips.length >= 3) {
|
|
101
|
-
|
|
105
|
+
// Doubles only when the loop resumes as the last pin lifts; a later pane move starts from PIN_TTL again.
|
|
106
|
+
d.ttl = d.ttl && d.until !== undefined && now - d.until < FLIP_WINDOW ? d.ttl * 2 : PIN_TTL;
|
|
102
107
|
Object.assign(d, { pinned: key, until: now + d.ttl });
|
|
103
108
|
c = detect(s, preferred, false);
|
|
104
109
|
}
|
package/package.json
CHANGED