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/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 { DUO, INLINE_WIDTH, ISLAND, MORPH, isSplit, magnet, plan, stable } from "./geometry.js";
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) => emulated ? `${side === "right" ? emulated.insetRight : emulated.insetLeft}px` : `env(safe-area-inset-${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
- function read() {
95
- const s = signals(), c = stable(damper, s, preferredSide(isSplit(s)), performance.now());
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
- // Safe-area insets and outerWidth settle up to ~400ms after resize and rotation.
106
- const on = () => { cb(); setTimeout(cb, 300); setTimeout(cb, 600); };
107
- addEventListener("resize", on);
108
- addEventListener("orientationchange", on);
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. A scroll-driven transform lags momentum
166
- * scrolling and drifts (issue #1); `follow` only covers pages without the scroller. Touches there reach the page
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
- function useColumnLayer(on, split, column) {
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 follow = () => {
201
- if (split && Math.round(scrollX) !== column)
202
- scrollTo(column, scrollY);
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.style.transform = scrollY ? `translateY(${scrollY}px)` : "";
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 resize.
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
- for (const el of els)
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 either way the inset there is the column's width.
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
- const inset = ctx.mode === "strip" ? envInset(ctx.side) : ctx.mode === "inline" ? `${INLINE_WIDTH}px` : `${ctx.screenWidth - ctx.width}px`;
357
- const right = ctx.side === "right" || split ? inset : envInset("right"), left = ctx.side === "left" || split ? inset : envInset("left");
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", !!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
- const [nav, nav2] = useColumnLayer(ctx.mode === "column", split, column);
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 badge = (a) => a.badge !== undefined && a.badge !== "" && _jsx("span", { className: "duet-badge", children: a.badge });
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 w = 2 * ISLAND.pad + n * ISLAND.item;
521
- return { top: b.top + b.height / 2 - DUO.control / 2, left: left ? pl.left : pl.left + DUO.control - w, width: w, height: DUO.control, "--x0": `${pl.left}px` };
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, outer edge on the button's, top on its top; opening upwards
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 w = MORPH.width, h = 2 * MORPH.pad + n * MORPH.row, y0 = b.top + b.height / 2 - DUO.control / 2;
527
- const top = y0 + h > ctx.height - MORPH.pad ? Math.max(MORPH.pad, y0 + DUO.control - h) : y0;
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.dark === undefined ? undefined : p.dark ? "dark" : "light", children: [p.children !== undefined && _jsx("div", { ref: scroller, className: "duet-scroller", children: p.children }), sides.map(bar)] }));
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: #ff3b30; color: #fff; font: 600 12px/18px -apple-system, system-ui; text-align: center;
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 17px -apple-system, system-ui; text-align: left; white-space: nowrap;
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 only shows in-document content and clips fixed boxes at the viewport
228
- edge, so there the scroller is an in-document box that runs on under the column. The document still never
229
- scrolls, so the chrome layer never moves. */
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)); }
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
- /* Split pane: a column on each side (the pane's own is on its outer edge, which the page cannot see); the
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
- :root .duet[data-mode="column"] .duet-glass:not(.duet-accessory) {
252
- --duet-col: rgb(240 239 236 / 0.55); --duet-solid: rgb(246 247 250); --duet-ink: #000; --duet-edge: 0.3; --duet-rim: 0.15;
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
- --duet-col: rgb(90 89 84 / 0.43); --duet-solid: rgb(38 38 40); --duet-ink: #fff; --duet-edge: 0.6; --duet-rim: 0;
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.