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.
@@ -0,0 +1,129 @@
1
+ const watchers = new Set();
2
+ // A desktop page that draws a Duo around this page in an iframe (the site's web Duo) sends the signals the
3
+ // real device would give: `postMessage({ type: "duet:signals", signals }, origin)`. Same origin only. The page
4
+ // says `duet:ready` once it listens, so the host answers then instead of guessing when the page has loaded.
5
+ let emulated = null;
6
+ if (typeof window !== "undefined" && window.top !== window) {
7
+ addEventListener("message", (e) => {
8
+ if (e.source !== parent || e.origin !== location.origin || e.data?.type !== "duet:signals")
9
+ return;
10
+ emulated = e.data.signals;
11
+ watchers.forEach((f) => f());
12
+ });
13
+ parent.postMessage({ type: "duet:ready" }, location.origin);
14
+ }
15
+ /** The signals a host page sends into this iframe, if any (see above). */
16
+ export const emulatedSignals = () => emulated;
17
+ let probe = null;
18
+ /** The current signals, read from the window (or the emulating host page). Browser only. */
19
+ export function readSignals() {
20
+ if (emulated)
21
+ return emulated;
22
+ if (!probe) {
23
+ probe = document.createElement("div");
24
+ probe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;"
25
+ + "padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)";
26
+ document.body.appendChild(probe);
27
+ }
28
+ const pad = getComputedStyle(probe);
29
+ return {
30
+ innerWidth: innerWidth,
31
+ innerHeight: innerHeight,
32
+ outerWidth: outerWidth,
33
+ outerHeight: outerHeight,
34
+ visualHeight: visualViewport?.height ?? innerHeight,
35
+ insetTop: parseFloat(pad.paddingTop) || 0,
36
+ insetBottom: parseFloat(pad.paddingBottom) || 0,
37
+ screenWidth: innerWidth > innerHeight ? Math.max(screen.width, screen.height) : Math.min(screen.width, screen.height),
38
+ screenHeight: innerWidth > innerHeight ? Math.min(screen.width, screen.height) : Math.max(screen.width, screen.height),
39
+ insetRight: parseFloat(pad.paddingRight) || 0,
40
+ insetLeft: parseFloat(pad.paddingLeft) || 0,
41
+ framed: window.top !== window,
42
+ pixelRatio: devicePixelRatio,
43
+ userAgent: navigator.userAgent,
44
+ touchPoints: navigator.maxTouchPoints,
45
+ angle: screen.orientation?.angle ?? (((window.orientation ?? 0) + 360) % 360),
46
+ standalone: navigator.standalone === true || matchMedia("(display-mode: standalone)").matches,
47
+ scale: visualViewport?.scale ?? 1,
48
+ };
49
+ }
50
+ /** Geometry after an event is not final: a fold moves outerWidth up to ~2s after the viewport, and nothing fires when
51
+ * it lands. So every event starts a settle: poll every `poll` ms until outer, inner and visual sizes hold for `quiet`
52
+ * ms, re-reading on each change and once at the end. A page far narrower or wider than its window (more than `lag`
53
+ * apart: Safari's column is 84) is a fold caught halfway, so it does not count as holding until `cap`. After `cap` ms
54
+ * settling() turns false even if sizes still move, but polling goes on until they hold, so a real feedback loop
55
+ * cannot keep restarting the settle. */
56
+ export const SETTLE = { poll: 100, quiet: 500, cap: 2000, lag: 100 };
57
+ const subs = new Set();
58
+ let settle;
59
+ // Insets count too: the bottom inset lands only after viewport-fit=cover is set, and the preview rule reads it.
60
+ const insets = () => { if (!probe)
61
+ return ""; const p = getComputedStyle(probe); return `${p.paddingBottom} ${p.paddingLeft} ${p.paddingRight}`; };
62
+ const sizes = () => `${outerWidth}x${outerHeight} ${innerWidth}x${innerHeight} ${visualViewport?.height ?? innerHeight} ${insets()}`;
63
+ const notify = () => subs.forEach((f) => f());
64
+ function kick() {
65
+ if (settle) {
66
+ settle.since = Date.now();
67
+ return;
68
+ }
69
+ const now = Date.now();
70
+ settle = { start: now, since: now, last: sizes(), capped: false, timer: setInterval(tick, SETTLE.poll) };
71
+ }
72
+ function tick() {
73
+ const s = settle, now = Date.now(), k = sizes(), capped = now - s.start >= SETTLE.cap;
74
+ const changed = k !== s.last, lagging = Math.abs(outerWidth - innerWidth) > SETTLE.lag;
75
+ if (changed)
76
+ [s.last, s.since] = [k, now];
77
+ if (now - s.since >= SETTLE.quiet && (!lagging || capped)) {
78
+ clearInterval(s.timer);
79
+ settle = undefined;
80
+ return notify();
81
+ }
82
+ // Re-read on a change, and once as settling() turns false.
83
+ if (changed || (capped && !s.capped))
84
+ notify();
85
+ s.capped = capped;
86
+ }
87
+ /** True while the window is still settling after a geometry event, for at most SETTLE.cap: mode flips then are the
88
+ * transition, not a feedback loop, so stable() must not count them. */
89
+ let pages = 0;
90
+ /** Bumped on pageshow and whenever the page becomes visible: the page may now be in another container (the in-app
91
+ * preview's "Open in Safari" hands it to a tab without reloading), so stable() decides the preview afresh. */
92
+ export const epoch = () => pages;
93
+ export const settling = () => !!settle && Date.now() - settle.start < SETTLE.cap;
94
+ /** Calls `cb` whenever the signals may have changed, so it can call readSignals() and detect() again: on resize,
95
+ * rotation (a turn from one landscape to the other keeps the window size, so screen.orientation too), the keyboard
96
+ * (visualViewport), and on pageshow and visibilitychange, as when an in-app preview's "Open in Safari" hands the
97
+ * page to a Safari tab without reloading it. Each event, and the call itself (a fresh tab settles with no event),
98
+ * starts a settle (SETTLE): `cb` runs at once, on every size change while it polls, and once when sizes hold.
99
+ * Returns the unsubscribe. Browser only. */
100
+ export function watchSignals(cb) {
101
+ const on = () => { kick(); cb(); };
102
+ const shown = () => { pages++; on(); };
103
+ const visible = () => { if (document.visibilityState === "visible")
104
+ shown(); };
105
+ const win = ["resize", "orientationchange"];
106
+ win.forEach((e) => addEventListener(e, on));
107
+ addEventListener("pageshow", shown);
108
+ document.addEventListener("visibilitychange", visible);
109
+ screen.orientation?.addEventListener("change", on);
110
+ visualViewport?.addEventListener("resize", on);
111
+ subs.add(cb);
112
+ watchers.add(cb);
113
+ kick();
114
+ const t = setTimeout(cb, 0);
115
+ return () => {
116
+ subs.delete(cb);
117
+ watchers.delete(cb);
118
+ clearTimeout(t);
119
+ if (!subs.size && settle) {
120
+ clearInterval(settle.timer);
121
+ settle = undefined;
122
+ }
123
+ win.forEach((e) => removeEventListener(e, on));
124
+ removeEventListener("pageshow", shown);
125
+ document.removeEventListener("visibilitychange", visible);
126
+ screen.orientation?.removeEventListener("change", on);
127
+ visualViewport?.removeEventListener("resize", on);
128
+ };
129
+ }
@@ -0,0 +1,22 @@
1
+ /** Light or dark, or follow the system. */
2
+ export type Appearance = "auto" | "light" | "dark";
3
+ /** How the app's own icons are drawn. The chrome's system glyphs (back, close, "…", search) keep theirs. */
4
+ export interface DuetIcons {
5
+ /** Icons in round buttons, groups and menus. Number is px. Default 22. */
6
+ size?: number | string;
7
+ /** Icons in the tab pill. Number is px. Default 24. */
8
+ tabSize?: number | string;
9
+ /** Stroke width of SVG line icons (Lucide and the like), the way SF Symbols have a weight. Default 2. */
10
+ weight?: number;
11
+ /** Colour of icons that are not selected. Default: the ink, black in light and white in dark. */
12
+ color?: string;
13
+ }
14
+ export interface DuetTheme {
15
+ /** Accent for the selected tab, the active menu item and the primary button, like iOS's tintColor. */
16
+ tint?: string;
17
+ icons?: DuetIcons;
18
+ }
19
+ /** The custom properties a theme sets; unset fields are left to the stylesheet (and to your CSS). */
20
+ export declare function themeVars({ tint, icons }: DuetTheme): Record<string, string>;
21
+ /** GlassyUI's theme for an appearance (and the older `dark` flag): undefined follows the system. */
22
+ export declare function glassTheme(appearance?: Appearance, dark?: boolean): "light" | "dark" | undefined;
package/dist/theme.js ADDED
@@ -0,0 +1,27 @@
1
+ // The small theming surface: an accent (iOS tintColor), an appearance (overrideUserInterfaceStyle) and how
2
+ // your icons are drawn. Everything lands as --duet-* custom properties on each strip, so plain CSS can set the
3
+ // same tokens instead (see duet.css). No JSX here, so the tests import it directly.
4
+ const px = (v) => (typeof v === "number" ? `${v}px` : v);
5
+ /** The custom properties a theme sets; unset fields are left to the stylesheet (and to your CSS). */
6
+ export function themeVars({ tint, icons = {} }) {
7
+ const v = {};
8
+ if (tint)
9
+ v["--duet-tint"] = tint;
10
+ if (icons.size !== undefined)
11
+ v["--duet-icon-size"] = px(icons.size);
12
+ if (icons.tabSize !== undefined)
13
+ v["--duet-tab-icon-size"] = px(icons.tabSize);
14
+ if (icons.weight !== undefined)
15
+ v["--duet-icon-weight"] = String(icons.weight);
16
+ if (icons.color)
17
+ v["--duet-icon"] = icons.color;
18
+ return v;
19
+ }
20
+ /** GlassyUI's theme for an appearance (and the older `dark` flag): undefined follows the system. */
21
+ export function glassTheme(appearance, dark) {
22
+ if (appearance && appearance !== "auto")
23
+ return appearance;
24
+ if (appearance === "auto")
25
+ return undefined;
26
+ return dark === undefined ? undefined : dark ? "dark" : "light";
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duet-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-rc.2",
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",