duet-ui 0.2.1 → 0.2.3

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 CHANGED
@@ -67,7 +67,9 @@ Each mode has a band the controls may use, and Duet keeps every control inside i
67
67
 
68
68
  ### Only on the Duo
69
69
 
70
- `useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals.
70
+ `useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. iOS never names the model, so it takes two checks: an iPhone user agent (or Safari's desktop-site Mac agent with touch), and a screen or window size only the Duo has. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals.
71
+
72
+ `useDuet().browser` is `"safari"`, `"chrome"`, `"firefox"`, `"edge"`, `"webview"` (an in-app browser such as Instagram's) or `"other"`. Only Safari gets `column`: the others draw their own chrome where Safari leaves its column, so they get `inline`.
71
73
 
72
74
  The server can't tell, so `<DuoCookie>` tells it: it keeps a `duet-duo` cookie in step with the browser, and a new device reloads once into the right layout. In the Next.js App Router:
73
75
 
package/dist/chrome.js CHANGED
@@ -47,6 +47,8 @@ function signals() {
47
47
  insetLeft: parseFloat(pad.paddingLeft) || 0,
48
48
  framed: window.top !== window,
49
49
  pixelRatio: devicePixelRatio,
50
+ userAgent: navigator.userAgent,
51
+ touchPoints: navigator.maxTouchPoints,
50
52
  standalone: navigator.standalone === true || matchMedia("(display-mode: standalone)").matches,
51
53
  };
52
54
  }
@@ -116,7 +118,7 @@ function subscribe(cb) {
116
118
  visualViewport?.removeEventListener("resize", on);
117
119
  };
118
120
  }
119
- const ssr = { mode: "column", duo: true, layout: "narrow", side: "right", width: 382, height: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
121
+ const ssr = { mode: "column", duo: true, browser: "safari", layout: "narrow", side: "right", width: 382, height: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
120
122
  export function useDuet() {
121
123
  return useSyncExternalStore(subscribe, read, () => ssr);
122
124
  }
@@ -504,7 +506,7 @@ export function Duet(props) {
504
506
  return null;
505
507
  const close = kind === "more" && !morph, rolling = menu !== kind;
506
508
  return (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-menu", role: "menu", "data-style": morph ? "morph" : undefined, style: morph ? morphAt(anchor, items.length) : menuAt(anchor, items.length + (close ? 1 : 0)), "data-closing": rolling || undefined, onAnimationEnd: (e) => { if (rolling && (e.animationName === "duet-roll" || e.animationName === "duet-unmorph"))
507
- setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.12 + 0.04 * i : 0.14 + 0.06 * (items.length - i)}s` }) }, onClick: () => { setMenu(null); if (radio)
509
+ setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.06 + 0.025 * i : 0.05 + 0.035 * (items.length - i)}s` }) }, onClick: () => { setMenu(null); if (radio)
508
510
  select(a.id);
509
511
  else
510
512
  a.onPress?.(); }, children: [a.icon, morph && _jsx("span", { className: "duet-menu-label", children: a.label }), badge(a)] }, a.id))), close && _jsx("button", { className: "duet-menu-item", "aria-label": "Close", onClick: () => setMenu(null), children: _jsx(X, { size: 20 }) })] }));
package/dist/duet.css CHANGED
@@ -163,9 +163,9 @@
163
163
  .duet-menu {
164
164
  position: fixed; z-index: 51; display: flex; flex-direction: row; align-items: center; justify-content: flex-end;
165
165
  padding: 0 3px; overflow: hidden; transition: none;
166
- animation: duet-unroll 0.44s var(--duet-spring-out) both, duet-thin 0.44s ease-out both;
166
+ animation: duet-unroll 0.36s var(--duet-spring-out) both, duet-thin 0.36s ease-out both;
167
167
  }
168
- .duet-menu[data-closing] { animation: duet-roll 0.26s cubic-bezier(0.32, 0.72, 0, 1) both, duet-thin 0.26s ease-out both; pointer-events: none; }
168
+ .duet-menu[data-closing] { animation: duet-roll 0.22s cubic-bezier(0.32, 0.72, 0, 1) both, duet-thin 0.22s ease-out both; pointer-events: none; }
169
169
  /* A touch denser than the controls, so the strip under it does not compete with the menu icons. */
170
170
  .duet-menu::before { background-color: var(--duet-menu); }
171
171
  .duet-menu > * { animation: duet-fade 0.16s 0.03s var(--duet-spring-out) both; }
@@ -202,9 +202,9 @@
202
202
  circle (--x0/--y0 is its top-left) and springs out to the list; closing shrinks it back into the button. */
203
203
  .duet-menu[data-style="morph"] {
204
204
  flex-direction: column; align-items: stretch; justify-content: flex-start; padding: 6px; border-radius: 22px;
205
- animation: duet-morph 0.42s var(--duet-spring-out) both;
205
+ animation: duet-morph 0.36s var(--duet-spring-out) both;
206
206
  }
207
- .duet-menu[data-style="morph"][data-closing] { animation: duet-unmorph 0.24s cubic-bezier(0.32, 0.72, 0, 1) both; }
207
+ .duet-menu[data-style="morph"][data-closing] { animation: duet-unmorph 0.2s cubic-bezier(0.32, 0.72, 0, 1) both; }
208
208
  .duet[data-side="left"] .duet-menu[data-style="morph"] { flex-direction: column; }
209
209
  .duet-menu[data-style="morph"] > .duet-menu-item {
210
210
  width: auto; height: 44px; border-radius: 16px; display: flex; align-items: center; justify-content: flex-start; gap: 12px; padding: 0 12px;
@@ -80,6 +80,10 @@ export interface Signals {
80
80
  framed?: boolean;
81
81
  /** devicePixelRatio: tells the in-app preview's 375x667 window from an iPhone SE's (2x). */
82
82
  pixelRatio?: number;
83
+ /** navigator.userAgent and maxTouchPoints: tell iOS and Safari apart (see isIOS, browserOf). Unknown (an
84
+ * emulator's signals) restricts nothing. */
85
+ userAgent?: string;
86
+ touchPoints?: number;
83
87
  }
84
88
  export interface Context {
85
89
  mode: Mode;
@@ -103,6 +107,8 @@ export interface Context {
103
107
  /** A Safari split-view pane on the open Duo (see isSplit). In column mode nothing tells which pane it is, so
104
108
  * the controls are drawn in both columns, each pane showing only its own (chrome.tsx); `side` is moot. */
105
109
  split?: boolean;
110
+ /** The browser the page runs in (see browserOf). Only Safari gets the column. */
111
+ browser: Browser;
106
112
  }
107
113
  export type Layout = "narrow" | "wide";
108
114
  /** Page width from which two panes fit. The page spans the window, controls floating over it: the open Duo gives
@@ -110,6 +116,13 @@ export type Layout = "narrow" | "wide";
110
116
  export declare const WIDE = 600;
111
117
  export declare const isPreview: (s: Signals) => boolean;
112
118
  export declare const isDuo: (s: Signals) => boolean;
119
+ /** iOS has no model in its UA, so the sizes above say Duo; the UA says iPhone. Safari's "Request Desktop Website"
120
+ * sends a Mac UA, told from a Mac by touch. undefined when the signals carry no UA. */
121
+ export declare const isIOS: (s: Signals) => boolean | undefined;
122
+ export type Browser = "safari" | "chrome" | "firefox" | "edge" | "webview" | "other";
123
+ /** Every iOS browser is WebKit; the others name themselves in the UA. An in-app web view (Instagram, Facebook)
124
+ * drops the "Safari/" token; the in-app Safari preview keeps it, and is Safari. */
125
+ export declare function browserOf(ua: string | undefined): Browser;
113
126
  /** A Safari split-view pane: its window is 469x669 in either pane. Nothing tells the left pane from the right
114
127
  * (device probe 2026-10-25: screenX, availLeft, insets, visualViewport and touch screenX read the same in both),
115
128
  * though Safari's column sits on the pane's outer edge. So the inline side is remembered apart for split view. */
package/dist/geometry.js CHANGED
@@ -53,11 +53,32 @@ export const isPreview = (s) => !s.standalone && s.pixelRatio === PREVIEW.pixelR
53
53
  * Other phones and desktops never match, so apps can switch to a Duo layout on it alone. */
54
54
  const DUO_SIZES = [[466, 678], [669, 951], [469, 669]];
55
55
  const duoSize = (w, h) => DUO_SIZES.some(([a, b]) => Math.abs(Math.min(w, h) - a) <= 1 && Math.abs(Math.max(w, h) - b) <= 1);
56
- export const isDuo = (s) => duoSize(s.screenWidth, s.screenHeight) || duoSize(s.outerWidth, s.outerHeight);
56
+ export const isDuo = (s) => isIOS(s) !== false && (duoSize(s.screenWidth, s.screenHeight) || duoSize(s.outerWidth, s.outerHeight));
57
+ /** iOS has no model in its UA, so the sizes above say Duo; the UA says iPhone. Safari's "Request Desktop Website"
58
+ * sends a Mac UA, told from a Mac by touch. undefined when the signals carry no UA. */
59
+ export const isIOS = (s) => s.userAgent === undefined ? undefined
60
+ : /iPhone|iPod/.test(s.userAgent) || (/Macintosh/.test(s.userAgent) && (s.touchPoints ?? 0) > 1);
61
+ /** Every iOS browser is WebKit; the others name themselves in the UA. An in-app web view (Instagram, Facebook)
62
+ * drops the "Safari/" token; the in-app Safari preview keeps it, and is Safari. */
63
+ export function browserOf(ua) {
64
+ if (ua === undefined)
65
+ return "safari";
66
+ if (/EdgiOS|Edg\//.test(ua))
67
+ return "edge";
68
+ if (/CriOS|Chrome\//.test(ua))
69
+ return "chrome";
70
+ if (/FxiOS|Firefox\//.test(ua))
71
+ return "firefox";
72
+ if (/AppleWebKit/.test(ua) && !/Safari\//.test(ua))
73
+ return "webview";
74
+ if (/Version\/[\d.]+.*Safari\//.test(ua) && !/OPiOS|OPT\/|GSA\/|Ddg\//.test(ua))
75
+ return "safari";
76
+ return "other";
77
+ }
57
78
  /** A Safari split-view pane: its window is 469x669 in either pane. Nothing tells the left pane from the right
58
79
  * (device probe 2026-10-25: screenX, availLeft, insets, visualViewport and touch screenX read the same in both),
59
80
  * though Safari's column sits on the pane's outer edge. So the inline side is remembered apart for split view. */
60
- export const isSplit = (s) => !s.standalone && Math.abs(s.outerWidth - 469) <= 1 && Math.abs(s.outerHeight - 669) <= 1;
81
+ export const isSplit = (s) => !s.standalone && isIOS(s) !== false && Math.abs(s.outerWidth - 469) <= 1 && Math.abs(s.outerHeight - 669) <= 1;
61
82
  /** Measured on the Duo simulator: Safari's column is 84pt wide (outer − inner), the chrome below the
62
83
  * page is 98pt (80 with the compact URL bar), the window is the closed screen's 678pt tall. A split-view
63
84
  * pane on the open Duo (469x669 outer, 385x571 inner) has the same column on its outer edge, so either
@@ -70,13 +91,14 @@ const SAFARI_COLUMN = { width: [80, 88], chrome: [68, 102] };
70
91
  const within = (v, [a, b]) => v >= a && v <= b;
71
92
  /** preferred: the side the user picked for inline controls, if any. */
72
93
  export function detect(s, preferred, allowColumn = true) {
73
- const duo = isDuo(s);
94
+ const duo = isDuo(s), browser = browserOf(s.userAgent);
74
95
  // Only a Home Screen app owns the strip: a Safari tab reports the column as an 84pt inset for a frame while folding.
75
96
  if (s.standalone && (s.insetRight >= 60 || s.insetLeft >= 60)) {
76
- return { mode: "strip", duo, layout: layoutOf(s.innerWidth), side: s.insetRight >= 60 ? "right" : "left", width: s.innerWidth, height: s.innerHeight, offsetTop: Math.max(0, s.screenHeight - s.innerHeight), screenWidth: s.screenWidth, screenHeight: s.screenHeight, insetTop: s.insetTop, insetBottom: s.insetBottom };
97
+ return { mode: "strip", duo, browser, layout: layoutOf(s.innerWidth), side: s.insetRight >= 60 ? "right" : "left", width: s.innerWidth, height: s.innerHeight, offsetTop: Math.max(0, s.screenHeight - s.innerHeight), screenWidth: s.screenWidth, screenHeight: s.screenHeight, insetTop: s.insetTop, insetBottom: s.insetBottom };
77
98
  }
78
- const base = { duo, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
79
- const column = allowColumn && !s.standalone && !s.framed
99
+ const base = { duo, browser, width: s.innerWidth, height: s.innerHeight, offsetTop: 0, insetTop: s.insetTop, insetBottom: s.insetBottom };
100
+ // Other iOS browsers draw their own chrome where Safari has its column: inline for them.
101
+ const column = allowColumn && !s.standalone && !s.framed && browser === "safari"
80
102
  && within(s.outerWidth - s.innerWidth, SAFARI_COLUMN.width)
81
103
  && within(s.outerHeight - s.innerHeight, SAFARI_COLUMN.chrome)
82
104
  && s.innerHeight > s.innerWidth // landscape moves Safari's column buttons around
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export { Duet, DuoCookie, useDuet, type DuetProps, type DuetItem, type DuetTab }
2
2
  export { bleed, column } from "./layout.ts";
3
3
  export { DuetPanes, DuetPaneBar, usePanes, type DuetPanesProps, type DuetPaneBarProps } from "./duet-panes.tsx";
4
4
  export { panes, backFrom, type PaneWindow } from "./panes.ts";
5
- export { detect, isDuo, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE, type Context, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
5
+ export { browserOf, detect, isDuo, isIOS, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE, type Browser, type Context, type Layout, type Mode, type Plan, type Side, type Signals, type Spec } from "./geometry.ts";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { Duet, DuoCookie, useDuet } from "./chrome.js";
2
2
  export { bleed, column } from "./layout.js";
3
3
  export { DuetPanes, DuetPaneBar, usePanes } from "./duet-panes.js";
4
4
  export { panes, backFrom } from "./panes.js";
5
- export { detect, isDuo, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
5
+ export { browserOf, detect, isDuo, isIOS, isSplit, plan, magnet, DUO, INLINE_WIDTH, MAGNET, WIDE } from "./geometry.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duet-ui",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Native-feeling controls for React web apps on the foldable iPhone: the side strip, Safari's column, split view and Home Screen apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",