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/dist/chrome.js
CHANGED
|
@@ -2,56 +2,15 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { ArrowLeftRight, ChevronLeft, Ellipsis, Search, X } from "./icons.js";
|
|
5
|
-
import {
|
|
5
|
+
import { glassTheme, themeVars } from "./theme.js";
|
|
6
|
+
import { DUO, columnBand, isSplit, pageInsets, islandBox, magnet, morphBox, plan, stable } from "./geometry.js";
|
|
7
|
+
import { emulatedSignals, epoch, readSignals, settling, watchSignals } from "./signals.js";
|
|
6
8
|
import { GlassProvider } from "./glassyui/glass-provider.js";
|
|
7
9
|
import { GlassSurface } from "./glassyui/glass-surface.js";
|
|
8
10
|
import "./duet.css";
|
|
9
11
|
// --- context ------------------------------------------------------------------------------------
|
|
10
|
-
// A desktop page that draws a Duo around this page in an iframe (the site's web Duo) sends the signals the
|
|
11
|
-
// real device would give: `postMessage({ type: "duet:signals", signals }, origin)`. Same origin only. The page
|
|
12
|
-
// says `duet:ready` once it listens, so the host answers then instead of guessing when the page has loaded.
|
|
13
|
-
let emulated = null;
|
|
14
|
-
if (typeof window !== "undefined" && window.top !== window) {
|
|
15
|
-
addEventListener("message", (e) => {
|
|
16
|
-
if (e.source !== parent || e.origin !== location.origin || e.data?.type !== "duet:signals")
|
|
17
|
-
return;
|
|
18
|
-
emulated = e.data.signals;
|
|
19
|
-
listeners.forEach((f) => f());
|
|
20
|
-
});
|
|
21
|
-
parent.postMessage({ type: "duet:ready" }, location.origin);
|
|
22
|
-
}
|
|
23
12
|
/** The safe-area inset on one side as CSS: the real env() value, or the emulated one. */
|
|
24
|
-
const envInset = (side) =>
|
|
25
|
-
let probe = null;
|
|
26
|
-
function signals() {
|
|
27
|
-
if (emulated)
|
|
28
|
-
return emulated;
|
|
29
|
-
if (!probe) {
|
|
30
|
-
probe = document.createElement("div");
|
|
31
|
-
probe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;"
|
|
32
|
-
+ "padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)";
|
|
33
|
-
document.body.appendChild(probe);
|
|
34
|
-
}
|
|
35
|
-
const pad = getComputedStyle(probe);
|
|
36
|
-
return {
|
|
37
|
-
innerWidth: innerWidth,
|
|
38
|
-
innerHeight: innerHeight,
|
|
39
|
-
outerWidth: outerWidth,
|
|
40
|
-
outerHeight: outerHeight,
|
|
41
|
-
visualHeight: visualViewport?.height ?? innerHeight,
|
|
42
|
-
insetTop: parseFloat(pad.paddingTop) || 0,
|
|
43
|
-
insetBottom: parseFloat(pad.paddingBottom) || 0,
|
|
44
|
-
screenWidth: innerWidth > innerHeight ? Math.max(screen.width, screen.height) : Math.min(screen.width, screen.height),
|
|
45
|
-
screenHeight: innerWidth > innerHeight ? Math.min(screen.width, screen.height) : Math.max(screen.width, screen.height),
|
|
46
|
-
insetRight: parseFloat(pad.paddingRight) || 0,
|
|
47
|
-
insetLeft: parseFloat(pad.paddingLeft) || 0,
|
|
48
|
-
framed: window.top !== window,
|
|
49
|
-
pixelRatio: devicePixelRatio,
|
|
50
|
-
userAgent: navigator.userAgent,
|
|
51
|
-
touchPoints: navigator.maxTouchPoints,
|
|
52
|
-
standalone: navigator.standalone === true || matchMedia("(display-mode: standalone)").matches,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
13
|
+
const envInset = (side) => { const e = emulatedSignals(); return e ? `${side === "right" ? e.insetRight : e.insetLeft}px` : `env(safe-area-inset-${side})`; };
|
|
55
14
|
// Inline: the side is the user's, remembered per site, and apart for split view (Safari's column is on the pane's
|
|
56
15
|
// outer edge, which the page cannot see, so the controls follow it once moved there).
|
|
57
16
|
const sideKey = (split) => split ? "duet-side-split" : "duet-side";
|
|
@@ -90,35 +49,33 @@ function switchSide(el, side) {
|
|
|
90
49
|
}
|
|
91
50
|
let snap = null, snapKey = "";
|
|
92
51
|
const damper = { flips: [] };
|
|
93
|
-
let pinTimer;
|
|
94
|
-
|
|
95
|
-
|
|
52
|
+
let pinTimer, landTimer;
|
|
53
|
+
// The damper keeps history, so it is fed once per signal change (refresh, from the watcher), never from React's
|
|
54
|
+
// getSnapshot (read), which React may call several times per render.
|
|
55
|
+
function refresh() {
|
|
56
|
+
const s = readSignals(), c = stable(damper, s, preferredSide(isSplit(s)), performance.now(), settling(), epoch());
|
|
96
57
|
// A pin lifts on its own time, with no resize to re-read on.
|
|
97
58
|
if (damper.pinned && !pinTimer)
|
|
98
59
|
pinTimer = setTimeout(() => { pinTimer = undefined; listeners.forEach((f) => f()); }, damper.until - performance.now() + 20);
|
|
60
|
+
// So is a landing after a fold or turn (LAND_HOLD), which no resize may follow.
|
|
61
|
+
if (damper.landUntil !== undefined && !landTimer)
|
|
62
|
+
landTimer = setTimeout(() => { landTimer = undefined; listeners.forEach((f) => f()); }, damper.landUntil - performance.now() + 10);
|
|
99
63
|
const k = JSON.stringify(c);
|
|
100
64
|
if (k !== snapKey)
|
|
101
65
|
[snapKey, snap] = [k, c];
|
|
66
|
+
}
|
|
67
|
+
function read() {
|
|
68
|
+
if (!snap)
|
|
69
|
+
refresh();
|
|
102
70
|
return snap;
|
|
103
71
|
}
|
|
104
72
|
function subscribe(cb) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
visualViewport?.addEventListener("resize", on);
|
|
110
|
-
// A fresh Safari tab can hydrate before its window geometry settles, with no resize afterwards.
|
|
111
|
-
const t = [0, 300, 1000].map((ms) => setTimeout(cb, ms));
|
|
112
|
-
listeners.add(cb);
|
|
113
|
-
return () => {
|
|
114
|
-
listeners.delete(cb);
|
|
115
|
-
t.forEach(clearTimeout);
|
|
116
|
-
removeEventListener("resize", on);
|
|
117
|
-
removeEventListener("orientationchange", on);
|
|
118
|
-
visualViewport?.removeEventListener("resize", on);
|
|
119
|
-
};
|
|
73
|
+
const f = () => { refresh(); cb(); };
|
|
74
|
+
listeners.add(f);
|
|
75
|
+
const unwatch = watchSignals(f);
|
|
76
|
+
return () => { listeners.delete(f); unwatch(); };
|
|
120
77
|
}
|
|
121
|
-
const ssr = { mode: "column", duo: true, browser: "safari", device: "duo", standalone: false, layout: "narrow", side: "right", width: 382, height: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
|
|
78
|
+
const ssr = { mode: "column", duo: true, browser: "safari", device: "duo", standalone: false, layout: "narrow", side: "right", width: 382, height: 580, stableHeight: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
|
|
122
79
|
export function useDuet() {
|
|
123
80
|
return useSyncExternalStore(subscribe, read, () => ssr);
|
|
124
81
|
}
|
|
@@ -162,13 +119,18 @@ function useMinimized(on, scroller) {
|
|
|
162
119
|
}
|
|
163
120
|
/** Safari tab: fixed content is clipped to the page viewport (so is sticky), but in-document content paints
|
|
164
121
|
* into Safari's column. So the chrome is an absolute layer at the document top, and the inner scroller keeps
|
|
165
|
-
* the document itself from scrolling, so the layer never moves.
|
|
166
|
-
*
|
|
122
|
+
* the document itself from scrolling, so the layer never moves. Without children the document scrolls, and the
|
|
123
|
+
* layer rides it on a CSS scroll timeline (duet.css, duet-follow): Safari runs that with the scroll itself, so
|
|
124
|
+
* it holds through momentum and smooth programmatic scrolls, where a transform set from scroll events lags and
|
|
125
|
+
* drifts (#55; simulator, 2026-10-26: up to 300pt during scrollIntoView, under 2pt on the timeline).
|
|
126
|
+
* The keyframe ends at the timeline's scroll range (--duet-scroll-max); a fixed 100000px range drifted ~12pt in
|
|
127
|
+
* momentum. Browsers without scroll timelines get the transform. Touches there reach the page
|
|
167
128
|
* but are not hit-tested to our elements, and Safari keeps the touch end (no pointerup, no click), so we route
|
|
168
129
|
* them by coordinates on pointerdown. Nothing in the column scrolls, so a touch there is always a tap.
|
|
169
130
|
* Split pane: the column is on the pane's outer edge, which the page cannot see, so the document spans a column
|
|
170
131
|
* on each side, scrolled 84 across, and the controls are drawn in both (refs[0] left, refs[1] right). Each pane
|
|
171
|
-
* shows only its own column, and a tap there names the side (`tapSide`).
|
|
132
|
+
* shows only its own column, and a tap there names the side (`tapSide`). A left-hand column (closed landscape,
|
|
133
|
+
* turned left) works the same way, with the document spanning that column alone. */
|
|
172
134
|
let tapSide = "right";
|
|
173
135
|
/** Without shrink-to-fit=no Safari zooms a split pane out to fit the layer's overflow, which reads as another
|
|
174
136
|
* window size and flips the mode. It applies even when set late, but must land before the first column paint.
|
|
@@ -189,19 +151,87 @@ function viewport(key, value) {
|
|
|
189
151
|
}
|
|
190
152
|
if (typeof document !== "undefined")
|
|
191
153
|
viewport("shrink-to-fit", "no");
|
|
192
|
-
|
|
154
|
+
// Leaving the column, the sideways scroll the layer pinned stays behind: from a split left pane turned into the tall
|
|
155
|
+
// window (inline), the document kept scrollX -84 with nothing to scroll, and Safari then painted the pill's glass but
|
|
156
|
+
// not its unselected tab icons (e2e round 4, splitturn-left-to90). So when the mode leaves the column (not while the
|
|
157
|
+
// column is only hidden mid-turn), the scroll goes back to 0, again as Safari restores the old offset on the resize.
|
|
158
|
+
function useColumnLayer(on, split, left, column, inColumn) {
|
|
193
159
|
const refs = [useRef(null), useRef(null)];
|
|
160
|
+
const was = useRef(inColumn);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const left = was.current && !inColumn;
|
|
163
|
+
was.current = inColumn;
|
|
164
|
+
if (!left || emulatedSignals())
|
|
165
|
+
return;
|
|
166
|
+
const reset = () => { if (Math.round(scrollX) !== 0)
|
|
167
|
+
scrollTo({ left: 0, top: scrollY, behavior: "instant" }); };
|
|
168
|
+
const t = [0, 300, 1000].map((ms) => setTimeout(reset, ms));
|
|
169
|
+
return () => t.forEach(clearTimeout);
|
|
170
|
+
}, [inColumn]);
|
|
194
171
|
useEffect(() => {
|
|
195
172
|
const els = refs.map((r) => r.current).filter((el) => !!el);
|
|
196
173
|
if (!on || !els.length)
|
|
197
174
|
return;
|
|
198
175
|
const html = document.documentElement.style, prev = html.touchAction;
|
|
199
176
|
html.touchAction = "pan-y"; // the layer widens the document; keep the page from panning sideways
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
177
|
+
const root = document.documentElement;
|
|
178
|
+
for (const el of els)
|
|
179
|
+
el.dataset.follow = "";
|
|
180
|
+
// A page reset such as `* { animation: none !important }` (common under reduced motion) drops the timeline.
|
|
181
|
+
const timeline = typeof CSS !== "undefined" && CSS.supports("animation-timeline: scroll()") && getComputedStyle(els[0]).animationName === "duet-follow";
|
|
182
|
+
if (!timeline)
|
|
203
183
|
for (const el of els)
|
|
204
|
-
el.
|
|
184
|
+
delete el.dataset.follow;
|
|
185
|
+
// The keyframe ends at the timeline's own scroll range, which in Safari is scrollHeight - innerHeight: when the
|
|
186
|
+
// URL bar collapses innerHeight grows 18px but clientHeight does not, and scrollHeight - clientHeight drifted
|
|
187
|
+
// 6pt 1000pt down (simulator, 2026-10-26). Content can outgrow the body (absolute, overflowing), so every
|
|
188
|
+
// scroll re-measures as well as resizes.
|
|
189
|
+
let max = "";
|
|
190
|
+
const range = () => {
|
|
191
|
+
const m = `${Math.max(0, root.scrollHeight - innerHeight)}px`;
|
|
192
|
+
if (m !== max) {
|
|
193
|
+
max = m;
|
|
194
|
+
for (const el of els)
|
|
195
|
+
el.style.setProperty("--duet-scroll-max", m);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const sizes = timeline ? new ResizeObserver(range) : undefined;
|
|
199
|
+
if (sizes) {
|
|
200
|
+
range();
|
|
201
|
+
sizes.observe(root);
|
|
202
|
+
sizes.observe(document.body);
|
|
203
|
+
}
|
|
204
|
+
// The layer's positions are document coordinates, from an absolute box at the document's left edge. A page whose
|
|
205
|
+
// <body> (or <html>) is positioned makes that its containing block, which body margins, or the margins that move
|
|
206
|
+
// the page beside a left column (pageInsets), move off the document's edge: measured, and undone with
|
|
207
|
+
// `translate` (the scroll timeline animates `transform`, which composes with it).
|
|
208
|
+
const align = () => {
|
|
209
|
+
refs.forEach((ref, i) => {
|
|
210
|
+
const el = ref.current;
|
|
211
|
+
if (!el)
|
|
212
|
+
return;
|
|
213
|
+
el.style.translate = "";
|
|
214
|
+
const off = Math.round(el.getBoundingClientRect().left + scrollX - (split && i ? column : 0));
|
|
215
|
+
if (off)
|
|
216
|
+
el.style.translate = `${-off}px 0`;
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
const follow = (e) => {
|
|
220
|
+
if (e?.type !== "scroll")
|
|
221
|
+
align();
|
|
222
|
+
// The layer widens the document, and a swipe that runs off the end of a sideways scroller chains to it
|
|
223
|
+
// (touch-action stops at the scroller), leaving page and controls panned off their rails: hold it.
|
|
224
|
+
// An emulating host already frames both columns; pinning there loops in iOS Safari, whose frame overflows a few px.
|
|
225
|
+
const x = split || left ? column : 0;
|
|
226
|
+
// Instant: under a page's `scroll-behavior: smooth` a plain scrollTo animates, and each scroll event restarted
|
|
227
|
+
// it, so the controls crept into the column over seconds (#66, back from /probe in a split left pane).
|
|
228
|
+
if (!emulatedSignals() && Math.round(scrollX) !== x)
|
|
229
|
+
scrollTo({ left: x, top: scrollY, behavior: "instant" });
|
|
230
|
+
if (timeline)
|
|
231
|
+
range();
|
|
232
|
+
else
|
|
233
|
+
for (const el of els)
|
|
234
|
+
el.style.transform = scrollY ? `translateY(${scrollY}px)` : "";
|
|
205
235
|
};
|
|
206
236
|
const tap = (e) => {
|
|
207
237
|
if (e.clientX >= 0 && e.clientX < innerWidth)
|
|
@@ -219,21 +249,28 @@ function useColumnLayer(on, split, column) {
|
|
|
219
249
|
}
|
|
220
250
|
};
|
|
221
251
|
follow();
|
|
222
|
-
// Safari restores or resets the sideways scroll on load and
|
|
252
|
+
// Safari restores or resets the sideways scroll on load, resize and a back/forward cache restore.
|
|
223
253
|
const t = [0, 300, 1000].map((ms) => setTimeout(follow, ms));
|
|
224
254
|
addEventListener("scroll", follow, { passive: true });
|
|
225
255
|
addEventListener("resize", follow);
|
|
256
|
+
addEventListener("pageshow", follow);
|
|
226
257
|
addEventListener("pointerdown", tap);
|
|
227
258
|
return () => {
|
|
228
259
|
html.touchAction = prev;
|
|
229
|
-
|
|
260
|
+
sizes?.disconnect();
|
|
261
|
+
for (const el of els) {
|
|
230
262
|
el.style.transform = "";
|
|
263
|
+
el.style.translate = "";
|
|
264
|
+
el.style.removeProperty("--duet-scroll-max");
|
|
265
|
+
delete el.dataset.follow;
|
|
266
|
+
}
|
|
231
267
|
t.forEach(clearTimeout);
|
|
232
268
|
removeEventListener("scroll", follow);
|
|
233
269
|
removeEventListener("resize", follow);
|
|
270
|
+
removeEventListener("pageshow", follow);
|
|
234
271
|
removeEventListener("pointerdown", tap);
|
|
235
272
|
};
|
|
236
|
-
}, [on, split, column]); // eslint-disable-line react-hooks/exhaustive-deps -- refs are stable
|
|
273
|
+
}, [on, split, left, column]); // eslint-disable-line react-hooks/exhaustive-deps -- refs are stable
|
|
237
274
|
return refs;
|
|
238
275
|
}
|
|
239
276
|
/** Inline: drag any control across the middle of the page to move the strip to that side. The controls
|
|
@@ -322,6 +359,23 @@ export function Duet(props) {
|
|
|
322
359
|
const press = useRef(null);
|
|
323
360
|
// A tap glides the lens to the new tab as a clear magnifier, without swelling the pill, like Apple News.
|
|
324
361
|
const [glide, setGlide] = useState(false);
|
|
362
|
+
// The first render is the server's guess (ssr) and the window geometry settles over the next second (subscribe):
|
|
363
|
+
// until then the controls jump to where they belong instead of gliding in from the guess.
|
|
364
|
+
const [settled, setSettled] = useState(false);
|
|
365
|
+
// A back/forward cache restore keeps the old state but not the old window (a split pane, a turn), so it settles
|
|
366
|
+
// afresh: the controls land where they belong instead of gliding there.
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
let t = setTimeout(() => setSettled(true), 1100);
|
|
369
|
+
const restored = (e) => {
|
|
370
|
+
if (!e.persisted)
|
|
371
|
+
return;
|
|
372
|
+
clearTimeout(t);
|
|
373
|
+
setSettled(false);
|
|
374
|
+
t = setTimeout(() => setSettled(true), 1100);
|
|
375
|
+
};
|
|
376
|
+
addEventListener("pageshow", restored);
|
|
377
|
+
return () => { clearTimeout(t); removeEventListener("pageshow", restored); };
|
|
378
|
+
}, []);
|
|
325
379
|
const glideTimer = useRef(0);
|
|
326
380
|
useEffect(() => () => { clearTimeout(glideTimer.current); if (press.current)
|
|
327
381
|
clearTimeout(press.current.timer); }, []);
|
|
@@ -348,13 +402,27 @@ export function Duet(props) {
|
|
|
348
402
|
const planFor = (side) => plan(side === ctx.side ? ctx : { ...ctx, side }, spec);
|
|
349
403
|
// The copy whose column opened the menu draws it; the other copy's column is in the other pane.
|
|
350
404
|
const [menuSide, setMenuSide] = useState(ctx.side);
|
|
405
|
+
const inner = p.children !== undefined; // the inner scroller, which runs on under Safari's column
|
|
406
|
+
const reserve = p.reserve !== false;
|
|
351
407
|
useEffect(() => {
|
|
352
408
|
// The page spans the window and the controls float over it; these insets are how far text and buttons keep
|
|
353
409
|
// clear on each edge (layout.ts). In Safari's column the scroller runs on under the column (an emulated
|
|
354
|
-
// column's frame already spans it), so
|
|
410
|
+
// column's frame already spans it), so the inset there is the column's width; a tab has no safe areas, so the
|
|
411
|
+
// other edge is 0. Without children the document is the page, and Duet places it beside the column itself
|
|
412
|
+
// (pageInsets): nothing for the page to do.
|
|
355
413
|
const r = document.documentElement.style;
|
|
356
|
-
|
|
357
|
-
|
|
414
|
+
// Controls hidden for good in this window (the turned preview) take no room; hidden while moving keeps the
|
|
415
|
+
// last insets, so the page does not reflow twice in one turn.
|
|
416
|
+
const { left, right, shift } = pageInsets({
|
|
417
|
+
mode: ctx.mode, side: ctx.side, split, screenWidth: ctx.screenWidth, width: ctx.width, hiddenPreview: ctx.hidden === "preview",
|
|
418
|
+
inner, emulated: !!emulatedSignals(), reserve, env: envInset,
|
|
419
|
+
});
|
|
420
|
+
// The document is scrolled one column across (useColumnLayer): margins on <html> move the page into the visible
|
|
421
|
+
// viewport. <html>'s own margins, so the page's body margins, widths and max-widths are untouched, centred
|
|
422
|
+
// content centres in the visible pane, and position: fixed (already viewport relative) is unaffected.
|
|
423
|
+
r.marginLeft = shift ? `${shift}px` : "";
|
|
424
|
+
r.marginRight = shift ? `${-shift}px` : "";
|
|
425
|
+
document.documentElement.toggleAttribute("data-duet-reserve", !!shift);
|
|
358
426
|
// --duet-edge-*: the window's insets, kept for panes, which set --duet-inset-* to their own.
|
|
359
427
|
for (const [k, v] of [["right", right], ["left", left]]) {
|
|
360
428
|
r.setProperty(`--duet-inset-${k}`, v);
|
|
@@ -362,15 +430,29 @@ export function Duet(props) {
|
|
|
362
430
|
}
|
|
363
431
|
// iOS blurs the top of a Home Screen web app (scroll-edge effect) without reporting a safe-area
|
|
364
432
|
// inset, closed and unfolded; pages pad their top content by this much to stay sharp.
|
|
365
|
-
r.setProperty("--duet-inset-top", ctx.mode === "strip" ? `${DUO.edgeBlur}px` : "0px");
|
|
433
|
+
r.setProperty("--duet-inset-top", ctx.mode === "strip" && ctx.standalone ? `${DUO.edgeBlur}px` : "0px");
|
|
366
434
|
// Safari's URL pill floats below the layout viewport (the column holds nothing above the page), so the scroller
|
|
367
435
|
// runs on under it like a document that scrolls, and the last content scrolls clear of it.
|
|
368
436
|
r.setProperty("--duet-inset-bottom", ctx.mode === "column" ? `${Math.max(0, ctx.screenHeight - ctx.height)}px` : "0px");
|
|
437
|
+
// Safari's column buttons paint over the page: a popover that reaches into the column keeps its top at or below
|
|
438
|
+
// this (columnBand). Split view takes the lower of the two panes' limits, since nothing tells which pane it is.
|
|
439
|
+
const bands = sides.map((side) => columnBand({ ...ctx, side }));
|
|
440
|
+
r.setProperty("--duet-safe-top-column", `${Math.max(0, ...bands.map((b) => b?.top ?? 0))}px`);
|
|
441
|
+
// The page's height without Safari's URL bar bouncing it mid-scroll (Context.stableHeight), like 100svh.
|
|
442
|
+
r.setProperty("--duet-vh", `${ctx.stableHeight}px`);
|
|
369
443
|
document.documentElement.dataset.duetMode = ctx.mode;
|
|
370
|
-
document.documentElement.toggleAttribute("data-duet-emulated", !!
|
|
444
|
+
document.documentElement.toggleAttribute("data-duet-emulated", !!emulatedSignals());
|
|
371
445
|
document.documentElement.toggleAttribute("data-duet-split", split);
|
|
372
|
-
}, [ctx.mode, ctx.side, ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, split]);
|
|
373
|
-
|
|
446
|
+
}, [ctx.mode, ctx.side, ctx.hidden === "preview", ctx.screenWidth, ctx.screenHeight, ctx.width, ctx.height, ctx.stableHeight, split, inner, reserve]);
|
|
447
|
+
useEffect(() => () => {
|
|
448
|
+
const h = document.documentElement;
|
|
449
|
+
h.style.marginLeft = "";
|
|
450
|
+
h.style.marginRight = "";
|
|
451
|
+
h.removeAttribute("data-duet-reserve");
|
|
452
|
+
}, []);
|
|
453
|
+
// Mid-turn the layer is clipped out of the document (duet.css) and lets go of the sideways scroll; the landing
|
|
454
|
+
// takes it up again, scrolled one column across for a left column.
|
|
455
|
+
const [nav, nav2] = useColumnLayer(ctx.mode === "column" && ctx.hidden !== "moving", split, ctx.side === "left", column, ctx.mode === "column");
|
|
374
456
|
useEffect(() => {
|
|
375
457
|
// Any touch outside the chrome closes an open menu, like a native context menu.
|
|
376
458
|
if (!menu)
|
|
@@ -408,7 +490,8 @@ export function Duet(props) {
|
|
|
408
490
|
};
|
|
409
491
|
useLayout(() => { if (scroller.current && selected !== undefined)
|
|
410
492
|
scroller.current.scrollTop = scrolls.current.get(selected) ?? 0; }, [selected]);
|
|
411
|
-
const
|
|
493
|
+
const theme = themeVars(p);
|
|
494
|
+
const badge = (a) => a.badge !== undefined && a.badge !== "" && _jsx("span", { className: "duet-badge", "data-tone": a.badgeTone === "neutral" ? "neutral" : undefined, children: a.badge });
|
|
412
495
|
const tint = (a) => a.tint ? { "--duet-tint": a.tint } : undefined;
|
|
413
496
|
const item = (a) => _jsxs("button", { className: "duet-item", "aria-label": a.label, onClick: a.onPress, children: [a.icon, badge(a)] }, a.id);
|
|
414
497
|
const toggle = (m) => () => { if (split)
|
|
@@ -517,15 +600,13 @@ export function Duet(props) {
|
|
|
517
600
|
// A menu is the button that opens it unrolling sideways, like the Dynamic Island: a pill of icon slots
|
|
518
601
|
// as tall as the button, outer edge on the button's, widening towards the page centre only.
|
|
519
602
|
const menuAt = (b, n) => {
|
|
520
|
-
const
|
|
521
|
-
return {
|
|
603
|
+
const { x0, ...box } = islandBox(pl, b, n, side);
|
|
604
|
+
return { ...box, "--x0": `${x0}px` };
|
|
522
605
|
};
|
|
523
|
-
// Morph: the button grows into a labelled list,
|
|
524
|
-
// instead when the list would run off the bottom of the screen.
|
|
606
|
+
// Morph: the button grows into a labelled list, clear of Safari's column buttons (morphBox).
|
|
525
607
|
const morphAt = (b, n) => {
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
return { top, left: left ? pl.left : pl.left + DUO.control - w, width: w, height: h, "--x0": `${pl.left}px`, "--y0": `${y0}px` };
|
|
608
|
+
const { x0, y0, scroll, ...box } = morphBox(side === ctx.side ? ctx : { ...ctx, side }, pl, b, n);
|
|
609
|
+
return { ...box, "--x0": `${x0}px`, "--y0": `${y0}px`, ...(scroll && { overflowY: "auto", overscrollBehavior: "contain" }) };
|
|
529
610
|
};
|
|
530
611
|
const shownLeading = leading.slice(0, leading.length - pl.menu.leading);
|
|
531
612
|
const shownActions = actions.slice(0, actions.length - pl.menu.actions);
|
|
@@ -544,10 +625,10 @@ export function Duet(props) {
|
|
|
544
625
|
} }] : []),
|
|
545
626
|
...destructive,
|
|
546
627
|
];
|
|
547
|
-
return (_jsxs("nav", { ref: i ? nav2 : nav, className: "duet", "data-mode": ctx.mode, "data-side": side, style: split && i ? { left: column } : undefined, children: [p.close && pl.back && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round duet-close", "data-duet-slot": "close", style: at(pl.back), children: _jsx("button", { className: "duet-item", "aria-label": p.close.label ?? "Close", onClick: p.close.onPress, children: _jsx(X, { size: 22, strokeWidth: 2.2 }) }) })), !p.close && p.back && pl.back && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "back", style: at(pl.back), children: backButton }), !p.close && p.primary && pl.primary && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round duet-primary", "data-duet-slot": "primary", style: { ...at(pl.primary), ...tint(p.primary) }, children: item(p.primary) })), pl.leading && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-group", "data-duet-slot": "leading", style: at(pl.leading), children: shownLeading.map(item) }), pl.actions && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-group", "data-duet-slot": "actions", style: at(pl.actions), children: shownActions.map(item) }), !p.close && pl.more && more.length > 0 && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "more", style: at(pl.more), "data-unrolled": menu === "more" || closing === "more" || undefined, children: moreButton }), p.tabs && pl.tabs && (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-pill", "data-duet-slot": "tabs", role: "tablist", style: { ...swell(at(pl.tabs), scrub !== null && !pl.tabs.min, side), "--li": lensAt }, "data-min": pl.tabs.min || undefined, "data-scrub": scrub !== null || undefined, "data-glide": (glide && scrub === null) || undefined, "data-unrolled": menu === "tabs" || closing === "tabs" || undefined, ...(pl.tabs.min ? {} : scrubbing), children: [!pl.tabs.min && (
|
|
628
|
+
return (_jsxs("nav", { ref: i ? nav2 : nav, className: "duet", "data-mode": ctx.mode, "data-side": side, "data-settling": !settled || ctx.confidence !== "sure" || undefined, "data-hidden": ctx.hidden, "data-confidence": ctx.confidence, "data-keyboard": ctx.keyboard || undefined, style: { ...theme, ...(split && i ? { left: column } : undefined) }, children: [p.close && pl.back && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round duet-close", "data-duet-slot": "close", style: at(pl.back), children: _jsx("button", { className: "duet-item", "aria-label": p.close.label ?? "Close", onClick: p.close.onPress, children: _jsx(X, { size: 22, strokeWidth: 2.2 }) }) })), !p.close && p.back && pl.back && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "back", style: at(pl.back), children: backButton }), !p.close && p.primary && pl.primary && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round duet-primary", "data-duet-slot": "primary", style: { ...at(pl.primary), ...tint(p.primary) }, children: item(p.primary) })), pl.leading && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-group", "data-duet-slot": "leading", style: at(pl.leading), children: shownLeading.map(item) }), pl.actions && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-group", "data-duet-slot": "actions", style: at(pl.actions), children: shownActions.map(item) }), !p.close && pl.more && more.length > 0 && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "more", style: at(pl.more), "data-unrolled": menu === "more" || closing === "more" || undefined, children: moreButton }), p.tabs && pl.tabs && (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-pill", "data-duet-slot": "tabs", role: "tablist", style: { ...swell(at(pl.tabs), scrub !== null && !pl.tabs.min, side), "--li": lensAt }, "data-min": pl.tabs.min || undefined, "data-scrub": scrub !== null || undefined, "data-glide": (glide && scrub === null) || undefined, "data-unrolled": menu === "tabs" || closing === "tabs" || undefined, ...(pl.tabs.min ? {} : scrubbing), children: [!pl.tabs.min && (
|
|
548
629
|
// The lens magnifies a copy of the tab column, slid so it lines up with the real one; the real tabs
|
|
549
630
|
// are masked out under the lens (CSS), so only the enlarged copy shows through it.
|
|
550
631
|
_jsx("span", { className: "duet-lens", "aria-hidden": true, children: _jsx("span", { className: "duet-lens-view", children: _jsx("span", { className: "duet-lens-col", children: tabItems.map((t, i) => _jsx("span", { className: "duet-tab", "data-on": i === lensAt || undefined, style: tint(t), children: t.icon }, t.id)) }) }) })), !pl.tabs.min && _jsx("span", { className: "duet-pill-badges", "aria-hidden": true, children: tabItems.map((t) => _jsx("span", { children: badge(t) }, t.id)) }), pl.tabs.min ? tabItems.filter((t) => t.id === selected).map((t) => tabButton(t, true, 0)) : tabItems.map((t, i) => tabButton(t, false, i))] })), pl.searchButton && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "search", style: at(pl.searchButton), children: searchButton }), (!split || side === menuSide) && menuEl("more", anchor.more, more, false, menuAt, morphAt), p.tabs && (!split || side === menuSide) && menuEl("tabs", anchor.tabs, tabItems, true, menuAt, morphAt), p.accessory && pl.accessory && !i && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-accessory", "data-duet-slot": "accessory", style: pl.accessory, "data-covered": menu === "tabs" || undefined, children: p.accessory }))] }, side));
|
|
551
632
|
};
|
|
552
|
-
return (_jsxs(GlassProvider, { defaultTheme: p.
|
|
633
|
+
return (_jsxs(GlassProvider, { defaultTheme: glassTheme(p.appearance, p.dark), children: [p.children !== undefined && _jsx("div", { ref: scroller, className: "duet-scroller", children: p.children }), sides.map(bar), ctx.mode === "column" && !emulatedSignals() && sides.map((side) => _jsx("span", { className: "duet-guard", "data-side": side, "aria-hidden": true }, side))] }));
|
|
553
634
|
}
|
package/dist/duet.css
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
/* iOS 27 Duo strip controls on GlassyUI surfaces. Colours sampled from the native lab captures.
|
|
2
2
|
Theme follows GlassyUI's [data-lg-theme] on <html> (system light/dark unless forced), with the media
|
|
3
|
-
query as the fallback before hydration.
|
|
3
|
+
query as the fallback before hydration.
|
|
4
|
+
|
|
5
|
+
Theme tokens, a stable surface: set them on .duet (or any ancestor) in your own CSS, or through the
|
|
6
|
+
tint, appearance and icons props, which write the same vars.
|
|
7
|
+
--duet-tint accent: selected tab, active menu item, primary button (system blue)
|
|
8
|
+
--duet-ink text and default icon colour (black / white)
|
|
9
|
+
--duet-icon icon colour when not selected (--duet-ink)
|
|
10
|
+
--duet-icon-size icons in round buttons, groups and menus (22px)
|
|
11
|
+
--duet-tab-icon-size icons in the tab pill (24px)
|
|
12
|
+
--duet-icon-weight stroke width of SVG line icons (2)
|
|
13
|
+
--duet-badge, --duet-badge-neutral, --duet-badge-ink badge fills (alert, neutral) and text
|
|
14
|
+
--duet-solid opaque surface: menus in Safari's column, the badge's ring
|
|
15
|
+
--duet-frost, --duet-menu glass veil of controls and of menus
|
|
16
|
+
--duet-sel, --duet-lift selection and press highlight
|
|
17
|
+
Icons size and weigh your own SVGs and font glyphs (font-size: 1em); the system glyphs keep theirs. */
|
|
4
18
|
.duet {
|
|
5
19
|
-webkit-text-size-adjust: 100%; text-size-adjust: 100%;
|
|
6
20
|
--duet-solid: rgb(246 247 250);
|
|
@@ -13,6 +27,8 @@
|
|
|
13
27
|
--duet-menu: rgb(255 255 255 / 0.55);
|
|
14
28
|
--duet-ink: #000;
|
|
15
29
|
--duet-tint: #0088ff;
|
|
30
|
+
--duet-icon-size: 22px; --duet-tab-icon-size: 24px; --duet-icon-weight: 2;
|
|
31
|
+
--duet-badge: #ff3b30; --duet-badge-neutral: #6e6e73; --duet-badge-ink: #fff;
|
|
16
32
|
color: var(--duet-ink);
|
|
17
33
|
-webkit-tap-highlight-color: transparent;
|
|
18
34
|
/* A held press would otherwise start text selection and iOS's loupe instead of the lift. */
|
|
@@ -23,18 +39,24 @@
|
|
|
23
39
|
:root:not([data-lg-theme="light"]) .duet {
|
|
24
40
|
--duet-solid: rgb(38 38 40); --duet-ring: rgb(255 255 255 / 0.16);
|
|
25
41
|
--duet-frost: rgb(0 0 0 / 0.24); --duet-lift: rgb(255 255 255 / 0.16);
|
|
26
|
-
--duet-sel: rgb(0 0 0 / 0.22); --duet-menu: rgb(0 0 0 / 0.4); --duet-ink: #fff;
|
|
42
|
+
--duet-sel: rgb(0 0 0 / 0.22); --duet-menu: rgb(0 0 0 / 0.4); --duet-ink: #fff; --duet-badge-neutral: #636366;
|
|
27
43
|
}
|
|
28
44
|
}
|
|
29
45
|
[data-lg-theme="dark"] .duet {
|
|
30
46
|
--duet-solid: rgb(38 38 40); --duet-ring: rgb(255 255 255 / 0.16);
|
|
31
47
|
--duet-frost: rgb(0 0 0 / 0.24); --duet-lift: rgb(255 255 255 / 0.16);
|
|
32
|
-
--duet-sel: rgb(0 0 0 / 0.22); --duet-menu: rgb(0 0 0 / 0.4); --duet-ink: #fff;
|
|
48
|
+
--duet-sel: rgb(0 0 0 / 0.22); --duet-menu: rgb(0 0 0 / 0.4); --duet-ink: #fff; --duet-badge-neutral: #636366;
|
|
33
49
|
}
|
|
34
50
|
.duet button {
|
|
35
51
|
appearance: none; border: 0; background: none; color: inherit; padding: 0; margin: 0; cursor: pointer;
|
|
36
52
|
display: flex; align-items: center; justify-content: center;
|
|
37
53
|
}
|
|
54
|
+
/* Your icons: one size per context and one weight, like SF Symbols in a native bar. */
|
|
55
|
+
.duet :is(.duet-item, .duet-tab, .duet-menu-item) { color: var(--duet-icon, var(--duet-ink)); font-size: var(--duet-icon-size); }
|
|
56
|
+
.duet .duet-tab { font-size: var(--duet-tab-icon-size); }
|
|
57
|
+
.duet :is(.duet-item, .duet-menu-item) > svg:not([data-duet-glyph]) { width: var(--duet-icon-size); height: var(--duet-icon-size); stroke-width: var(--duet-icon-weight); }
|
|
58
|
+
.duet .duet-tab > svg:not([data-duet-glyph]) { width: var(--duet-tab-icon-size); height: var(--duet-tab-icon-size); stroke-width: var(--duet-icon-weight); }
|
|
59
|
+
.duet .duet-primary .duet-item { color: var(--duet-tint); }
|
|
38
60
|
.duet-glass {
|
|
39
61
|
position: fixed; z-index: 50; box-sizing: border-box; border-radius: 999px; color: var(--duet-ink);
|
|
40
62
|
box-shadow: 0 4px 14px rgb(0 0 0 / 0.06);
|
|
@@ -144,9 +166,10 @@
|
|
|
144
166
|
.duet-glass:has(.duet-badge) { overflow: visible; contain: layout style; }
|
|
145
167
|
.duet .duet-badge {
|
|
146
168
|
position: absolute; top: 2px; left: calc(50% + 5px); min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box;
|
|
147
|
-
border-radius: 9px; background:
|
|
169
|
+
border-radius: 9px; background: var(--duet-badge); color: var(--duet-badge-ink); font: 600 12px/18px -apple-system, system-ui; text-align: center;
|
|
148
170
|
font-variant-numeric: tabular-nums; pointer-events: none; box-shadow: 0 0 0 1.5px var(--duet-solid);
|
|
149
171
|
}
|
|
172
|
+
.duet .duet-badge[data-tone="neutral"] { background: var(--duet-badge-neutral); }
|
|
150
173
|
.duet[data-side="right"] .duet-badge { left: auto; right: calc(50% + 5px); }
|
|
151
174
|
.duet-accessory { display: flex; align-items: center; padding: 0 18px; font: 600 17px -apple-system, system-ui; gap: 12px; }
|
|
152
175
|
.duet-accessory { transition: opacity 0.2s, transform 0.2s; }
|
|
@@ -190,10 +213,16 @@
|
|
|
190
213
|
.duet[data-drag="held"], .duet[data-drag="switch"] { transition: none; }
|
|
191
214
|
/* Switching sides moves every control in the same frame: nothing glides across the page on its own. */
|
|
192
215
|
.duet[data-drag="switch"] * { transition: none !important; }
|
|
216
|
+
/* Mid fold or turn (Context.hidden "moving") and in the turned in-app preview ("preview"): not drawn. */
|
|
217
|
+
.duet[data-hidden] { visibility: hidden; pointer-events: none; }
|
|
218
|
+
/* Loading, and while the window settles after a fold, turn or resize: the controls appear where they settle, without transitions or entrance animations. */
|
|
219
|
+
/* The root itself keeps its animation: in Safari's column it is the scroll follow (duet-follow), not an entrance. */
|
|
220
|
+
.duet[data-settling], .duet[data-settling] * { transition: none !important; }
|
|
221
|
+
.duet[data-settling] * { animation: none !important; }
|
|
193
222
|
.duet[data-drag="free"] { transition: --duet-drag 0.12s ease-out; }
|
|
194
223
|
.duet-menu-item { width: 46.5px; height: 43px; flex: none; border-radius: 999px; }
|
|
195
224
|
/* Destructive items (blueprint): only in "…", last, red, set apart by a hairline in the labelled list. */
|
|
196
|
-
.duet-menu-item[data-destructive] { color: #ff3b30; }
|
|
225
|
+
.duet .duet-menu-item[data-destructive] { color: #ff3b30; }
|
|
197
226
|
.duet-menu[data-style="morph"] > .duet-menu-item[data-sep] { box-shadow: 0 -0.5px 0 var(--duet-ring); border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
198
227
|
/* The primary action: the one tinted button, so the thumb finds it. */
|
|
199
228
|
.duet-primary { color: var(--duet-tint); }
|
|
@@ -208,8 +237,9 @@
|
|
|
208
237
|
.duet[data-side="left"] .duet-menu[data-style="morph"] { flex-direction: column; }
|
|
209
238
|
.duet-menu[data-style="morph"] > .duet-menu-item {
|
|
210
239
|
width: auto; height: 44px; border-radius: 16px; display: flex; align-items: center; justify-content: flex-start; gap: 12px; padding: 0 12px;
|
|
211
|
-
font: 400
|
|
240
|
+
font-weight: 400; text-align: left; white-space: nowrap;
|
|
212
241
|
}
|
|
242
|
+
.duet-menu[data-style="morph"] .duet-menu-label { font-size: 17px; color: var(--duet-ink); }
|
|
213
243
|
.duet-menu[data-style="morph"] > .duet-menu-item > svg { flex: none; }
|
|
214
244
|
.duet-menu-label { overflow: hidden; text-overflow: ellipsis; }
|
|
215
245
|
.duet-menu[data-style="morph"] > .duet-menu-item { animation: duet-rise 0.15s 0.02s ease-out both; }
|
|
@@ -224,10 +254,11 @@ html:has(> body .duet-scroller), html:has(> body .duet-scroller) > body { height
|
|
|
224
254
|
.duet-scroller { position: fixed; inset: 0; overflow-y: auto; overscroll-behavior: contain; box-sizing: border-box; }
|
|
225
255
|
/* The page spans the whole window and the controls float over it (blueprint rules 14/15): backgrounds run on
|
|
226
256
|
under the controls, and only text and buttons keep clear, by the insets (--duet-inset-left/right), through
|
|
227
|
-
column() or bleed(). Safari's column
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
257
|
+
column() or bleed(). Safari's column paints in-document content that overflows the layout viewport, but no
|
|
258
|
+
fixed box reaches it: a fixed box stops at the viewport edge, and one covering the middle of that edge has its
|
|
259
|
+
colour painted over the column instead (the guard below). So there the scroller is an in-document box that runs
|
|
260
|
+
on under the column. The document still never scrolls, so the chrome layer never moves. */
|
|
261
|
+
html[data-duet-mode="column"] .duet-scroller { position: absolute; width: calc(100% + var(--duet-inset-left) + var(--duet-inset-right)); bottom: auto; height: calc(100% + var(--duet-inset-bottom)); }
|
|
231
262
|
/* That overflow widens the document, and touch-action on <html> stops at this scroll container, so without its own
|
|
232
263
|
a sideways swipe pans the whole page, chrome and all, into the column. Carousels inside are scrollers of their own
|
|
233
264
|
and still pan. */
|
|
@@ -235,9 +266,8 @@ html[data-duet-mode="column"] .duet-scroller { touch-action: pan-y; }
|
|
|
235
266
|
/* Safari's URL pill floats under the page's bottom edge: the scroller runs on under it (above) and this spacer lets
|
|
236
267
|
the last content scroll clear of it. */
|
|
237
268
|
.duet-scroller::after { content: ""; display: block; height: var(--duet-inset-bottom, 0px); }
|
|
238
|
-
/*
|
|
239
|
-
document is scrolled one column across (chrome.tsx). */
|
|
240
|
-
html[data-duet-mode="column"][data-duet-split] .duet-scroller { width: calc(100% + var(--duet-inset-left) + var(--duet-inset-right)); }
|
|
269
|
+
/* A column on the left (closed landscape turned left, or a split pane, which spans one on each side since its own
|
|
270
|
+
is on its outer edge, which the page cannot see): the document is scrolled one column across (chrome.tsx). */
|
|
241
271
|
/* Emulated (the site's web Duo): the frame already spans the column and runs under the pill. */
|
|
242
272
|
html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%; height: 100%; }
|
|
243
273
|
|
|
@@ -247,14 +277,52 @@ html[data-duet-mode="column"][data-duet-emulated] .duet-scroller { width: 100%;
|
|
|
247
277
|
column buttons are a veil over that colour that follows the system appearance, not the page (sampled
|
|
248
278
|
over dark and cream pages); ours match so the two sets read as one. The accessory sits over the page,
|
|
249
279
|
not the column, so it keeps real glass; menus unroll over page content without blur, so they stay opaque. */
|
|
280
|
+
/* The keyboard leaves Safari's column in place but covers its lower part; the controls step aside while typing. */
|
|
281
|
+
.duet[data-mode="column"][data-keyboard] { visibility: hidden; }
|
|
282
|
+
/* The layer only paints in the column as long as it widens the document (overflow-x clip, a clip margin or rtl
|
|
283
|
+
all stop the paint), and Safari zooms out to that width when a text field takes focus (to 0.94, column held).
|
|
284
|
+
So from focus, before the zoom and the keyboard, the layer is clipped out of the document width. */
|
|
285
|
+
html:has(:read-write:focus) .duet[data-mode="column"] { overflow: clip; visibility: hidden; }
|
|
250
286
|
.duet[data-mode="column"] { position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 50; }
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
/* A page's full-screen fixed overlay (a modal scrim, `position: fixed; inset: 0`) would hide the whole column,
|
|
288
|
+
controls included: Safari 26 hit-tests the middle of each viewport edge, 4px in, for a fixed or sticky box
|
|
289
|
+
(LocalFrameView::fixedContainerEdges) and, when one covers it, paints that box's colour, or plain chrome behind a
|
|
290
|
+
backdrop-filter, over the inset beyond that edge, Safari's column included, instead of the page's in-document
|
|
291
|
+
content. The guard is a small fixed box straddling that point on the column's edge, above every page layer, so
|
|
292
|
+
the hit test lands on it instead: too small to count as a container itself, it has none above it, and the column
|
|
293
|
+
keeps showing the controls. That hit test skips layers with nothing fixed or sticky in them, so the guard is fixed
|
|
294
|
+
and outside the layer; it keeps a (transparent) background, since a box with no background, no backdrop and no
|
|
295
|
+
children counts as transparent and the hit test then retries without it, honouring pointer-events. It takes no
|
|
296
|
+
taps (simulator, 2026-10-27, #79). */
|
|
297
|
+
.duet-guard {
|
|
298
|
+
position: fixed; z-index: 2147483647; top: 50%; width: 16px; height: 64px; margin-top: -32px; pointer-events: none;
|
|
299
|
+
background-image: linear-gradient(transparent, transparent);
|
|
253
300
|
}
|
|
301
|
+
.duet-guard[data-side="right"] { right: -8px; }
|
|
302
|
+
.duet-guard[data-side="left"] { left: -8px; }
|
|
303
|
+
/* Mid-turn or mid-fold the layer still carries the last posture's positions (the old screen's column), which would
|
|
304
|
+
widen the document past the new window, and Safari then zooms the page to fit it (scale 1.14 unfolding from
|
|
305
|
+
closed landscape into the tall window, e2e round 2). Clipped, it adds no width until the window lands. */
|
|
306
|
+
.duet[data-mode="column"][data-hidden="moving"] { overflow: clip; }
|
|
307
|
+
/* No children (no .duet-scroller): the document scrolls, and the layer rides it on a scroll timeline that Safari
|
|
308
|
+
runs with the scroll itself, so it holds through momentum and smooth scrollTo/scrollIntoView (chrome.tsx sets
|
|
309
|
+
data-follow and --duet-scroll-max, the document's scroll range, or a transform where this is unsupported). */
|
|
310
|
+
.duet[data-mode="column"][data-follow] { animation: duet-follow linear both; animation-timeline: scroll(root block); }
|
|
311
|
+
@keyframes duet-follow { from { transform: translateY(0); } to { transform: translateY(var(--duet-scroll-max, 0px)); } }
|
|
312
|
+
/* The rubber band at either end moves the document past its scroll range, where the timeline (clamped to it) cannot
|
|
313
|
+
follow, so the layer rode the bounce: up to 160pt over Safari's own column buttons, or out of the column (#76). The
|
|
314
|
+
document there does not rubber-band, like a page with children, whose document never scrolls. Zero specificity, so
|
|
315
|
+
a page's own overscroll-behavior wins. */
|
|
316
|
+
:where(html[data-duet-mode="column"]:has(> body .duet[data-follow])) { overscroll-behavior-y: none; }
|
|
317
|
+
/* A swipe that runs off the end of a sideways scroller chains to the widened document and pans it, chrome and
|
|
318
|
+
all, into the column. Zero specificity, so a page's own overscroll-behavior wins. */
|
|
319
|
+
:where(html[data-duet-mode="column"]) :where(*) { overscroll-behavior-x: contain; }
|
|
320
|
+
/* Menus open over the page, not the column, so they keep the page's appearance (the base tokens). */
|
|
321
|
+
:root .duet[data-mode="column"] .duet-glass:not(.duet-accessory) { --duet-col: rgb(240 239 236 / 0.55); --duet-edge: 0.3; --duet-rim: 0.15; }
|
|
322
|
+
:root .duet[data-mode="column"] .duet-glass:not(.duet-accessory, .duet-menu) { --duet-solid: rgb(246 247 250); --duet-ink: #000; }
|
|
254
323
|
@media (prefers-color-scheme: dark) {
|
|
255
|
-
:root .duet[data-mode="column"] .duet-glass:not(.duet-accessory) {
|
|
256
|
-
|
|
257
|
-
}
|
|
324
|
+
:root .duet[data-mode="column"] .duet-glass:not(.duet-accessory) { --duet-col: rgb(90 89 84 / 0.43); --duet-edge: 0.6; --duet-rim: 0; }
|
|
325
|
+
:root .duet[data-mode="column"] .duet-glass:not(.duet-accessory, .duet-menu) { --duet-solid: rgb(38 38 40); --duet-ink: #fff; }
|
|
258
326
|
}
|
|
259
327
|
.duet[data-mode="column"] .duet-glass { position: absolute; }
|
|
260
328
|
/* Rim, measured at device pixels against Safari's buttons on cream and near-black pages, light and dark UI.
|