@satinhq/kit 0.1.0-beta.0 → 0.1.0-beta.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.
Files changed (38) hide show
  1. package/NOTICE +1 -1
  2. package/dist/acknowledgements/inventory.json +1 -1
  3. package/dist/app/deployment.js +8 -2
  4. package/dist/app/installCheck.js +4 -3
  5. package/dist/app/launchWatchdog.js +6 -1
  6. package/dist/docs/swiftui-reference.md +2 -2
  7. package/dist/glass/GlassMaterialRoot.d.ts +3 -1
  8. package/dist/glass/GlassMaterialRoot.js +2 -2
  9. package/dist/glass/GlassPresentation.d.ts +1 -0
  10. package/dist/glass/GlassPresentation.js +4 -1
  11. package/dist/glass/MaterialEffectRoot.js +5 -1
  12. package/dist/glass/TabBar.js +7 -0
  13. package/dist/glass/errors.d.ts +3 -1
  14. package/dist/glass/errors.js +49 -3
  15. package/dist/glass/fallback/canvasOwnership.d.ts +7 -0
  16. package/dist/glass/fallback/canvasOwnership.js +23 -0
  17. package/dist/glass/fallback/material.js +3 -2
  18. package/dist/glass/fallback/regions.d.ts +1 -0
  19. package/dist/glass/fallback/regions.js +9 -12
  20. package/dist/glass/fallback/runtime.d.ts +2 -0
  21. package/dist/glass/fallback/runtime.js +15 -4
  22. package/dist/glass/lens/tabGlyphColours.d.ts +2 -2
  23. package/dist/glass/lens/tabGlyphColours.js +5 -4
  24. package/dist/glass/liquidContext.d.ts +1 -0
  25. package/dist/glass/webgpu/client.d.ts +3 -0
  26. package/dist/glass/webgpu/client.js +57 -9
  27. package/dist/glass/webgpu/errorCode.js +3 -2
  28. package/dist/glass/webgpu/layers.d.ts +5 -1
  29. package/dist/glass/webgpu/layers.js +61 -6
  30. package/dist/glass/webgpu/minimize.js +20 -2
  31. package/dist/glass/webgpu/production.js +9 -1
  32. package/dist/glass/webgpu/productionPrepare.js +2 -1
  33. package/dist/glass/webgpu/sourcePixels.d.ts +4 -4
  34. package/dist/glass/webgpu/sourcePixels.js +48 -8
  35. package/dist/root/SatinRoot.js +9 -5
  36. package/dist/styles.css +6 -6
  37. package/dist/sw/renderServiceWorker.js +12 -3
  38. package/package.json +2 -1
package/NOTICE CHANGED
@@ -12,7 +12,7 @@ is a separately installed build dependency. Consumer Vite builds emit NOTICE.txt
12
12
  and licenses.json from their actual emitted module graph, including runtime peers.
13
13
  Development dependencies are not assumed to be bundled.
14
14
 
15
- ## Satin 0.1.0-beta.0
15
+ ## Satin 0.1.0-beta.2
16
16
 
17
17
  License: MIT
18
18
  Source: https://opensource.org/license/mit
@@ -1,7 +1,7 @@
1
1
  [
2
2
  {
3
3
  "name": "Satin",
4
- "version": "0.1.0-beta.0",
4
+ "version": "0.1.0-beta.2",
5
5
  "license": "MIT",
6
6
  "source": "https://opensource.org/license/mit",
7
7
  "text": "MIT License\n\nCopyright (c) 2026 Satin contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
@@ -57,7 +57,7 @@ export async function checkDeployment(expected, pageUrl, fetcher = fetch, includ
57
57
  fail('HTTPS is required for installation and service workers.', page.href, false, 'Serve the app over trusted HTTPS.');
58
58
  return failures;
59
59
  }
60
- const request = async (path, kind, allowDirectoryCanonicalization = false) => {
60
+ const request = async (path, kind, allowDirectoryCanonicalization = false, attempt = 0) => {
61
61
  const url = new URL(path, page).href;
62
62
  const controller = new AbortController();
63
63
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
@@ -68,7 +68,7 @@ export async function checkDeployment(expected, pageUrl, fetcher = fetch, includ
68
68
  cache: 'no-store',
69
69
  redirect: path === expected.scope + 'sw.js' ? 'manual' : 'follow',
70
70
  });
71
- definite = true;
71
+ definite = response.status < 500 && response.status !== 408 && response.status !== 429;
72
72
  if (response.status !== 200)
73
73
  throw new Error(`HTTP ${response.status}`);
74
74
  const finalUrl = new URL(response.url || url);
@@ -119,6 +119,12 @@ export async function checkDeployment(expected, pageUrl, fetcher = fetch, includ
119
119
  }
120
120
  catch (error) {
121
121
  const reason = error instanceof Error ? error.message : String(error);
122
+ // Retry transport failures only. A mismatched deployment cannot heal with backoff.
123
+ if ((!definite || controller.signal.aborted) && attempt < 2) {
124
+ clearTimeout(timer);
125
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
126
+ return request(path, kind, allowDirectoryCanonicalization, attempt + 1);
127
+ }
122
128
  if (controller.signal.aborted) {
123
129
  fail(`${label(url)} did not finish within ${requestTimeoutMs / 1000} s`, url, true, 'Check the server or connection, then try again.');
124
130
  }
@@ -100,7 +100,7 @@ onPass) {
100
100
  title.textContent = standalone
101
101
  ? 'Wrong Home Screen icon'
102
102
  : unverified
103
- ? 'Could not verify this app'
103
+ ? "Can't reach the app right now"
104
104
  : 'This app is misconfigured';
105
105
  const explanation = document.createElement('p');
106
106
  const action = document.createElement('p');
@@ -119,11 +119,12 @@ onPass) {
119
119
  return;
120
120
  }
121
121
  if (unverified) {
122
- explanation.textContent = 'The connection may be slow or offline.';
123
- action.textContent = 'Try again when your connection is ready.';
122
+ explanation.textContent = 'Check your connection and try again.';
123
+ action.remove();
124
124
  const retry = document.createElement('button');
125
125
  retry.textContent = 'Retry';
126
126
  retry.addEventListener('click', () => {
127
+ retry.disabled = true;
127
128
  void installPageCheck(expected, check, 30_000, onPass);
128
129
  });
129
130
  content.append(retry);
@@ -173,8 +173,13 @@ export function installLaunchWatchdog(budgetMs) {
173
173
  if (progress?.type === 'SATIN_DOWNLOAD_PROGRESS' &&
174
174
  progress.downloading &&
175
175
  Number.isFinite(progress.loaded) &&
176
- progress.loaded >= 0)
176
+ progress.loaded >= 0) {
177
177
  receiveProgress(progress);
178
+ // A first install can download before the application module is available.
179
+ // Controlled documents are cached launches/updates and keep their existing policy.
180
+ if (!navigator.serviceWorker?.controller && !revealed)
181
+ window.__SATIN_LAUNCH_SCREEN__?.progress(progress);
182
+ }
178
183
  });
179
184
  // A client's worker messages are queued until DOMContentLoaded, and a bootstrap module that
180
185
  // never finishes holds DOMContentLoaded back: the worker's "this resource stalled" message
@@ -1883,7 +1883,7 @@ export function DraftEditor() {
1883
1883
  - `@satinhq/kit:6b1393b088849383b4d58af841b53afabf734a131abd2a0c51e5e47d76a031ee`
1884
1884
  - `@satinhq/kit/vite:63ab5323f1fdbfe3425716de812e63889353f0a7252fdb379ddd29f0113c6c7b`
1885
1885
  - `@satinhq/kit/install:85c36af3f6d918e999f65db362675d8982468350930a540177d3d9809c88ca01`
1886
- - `@satinhq/kit/internal:debba3992b9960c73ceaa33af510cfd6162fbb3271b7807f1041fd9968c4a6e0`
1886
+ - `@satinhq/kit/internal:39d720eaa8548f9bf1a0c01ef5868efc9b437c21e18114ef50436047fdbd4e80`
1887
1887
  - `@satinhq/kit/unstable:09d854e967c086ed0eaa8b3decbbd326306e1f35e493b79cc1f375060319a840`
1888
1888
  - `@satinhq/kit/server:1b9db029ea048edc062313e7116510843dab71d9a5630842f778bc45736e3990`
1889
1889
  - `@satinhq/kit/testing:cb9036de4e4b3df41671c759c0fc123b059bd412849ac0796479ead545fa8728`
@@ -1892,4 +1892,4 @@ export function DraftEditor() {
1892
1892
 
1893
1893
  ## Framework source fingerprint
1894
1894
 
1895
- SHA-256 of sorted TS/TSX/CSS source paths and bytes, excluding tests: c4f66a9a5ac0b203be131846ee35fd33d9ec84aaf9fbf8a7dacd1c3278f251e3
1895
+ SHA-256 of sorted TS/TSX/CSS source paths and bytes, excluding tests: 1dfb5233c6482a7c54b0a396ffaa7079d66757b8816b1584c0eb39aad9428415
@@ -1,7 +1,9 @@
1
1
  import { type PropsWithChildren } from 'react';
2
2
  export type GlassDisplacementPolicy = 'auto' | 'disabled';
3
3
  /** The app paint layer remains untouched. Liquid regions own their retained image. */
4
- export declare function GlassMaterialRoot({ children, displacement, onLaunchReady, }: PropsWithChildren<{
4
+ export declare function GlassMaterialRoot({ children, displacement, prepared, onLaunchReady, }: PropsWithChildren<{
5
5
  displacement: GlassDisplacementPolicy;
6
+ /** Lazy renderer modules wait for the verified offline bundle on first install. */
7
+ prepared?: boolean;
6
8
  onLaunchReady?: (ready: boolean) => void;
7
9
  }>): import("react").JSX.Element;
@@ -12,7 +12,7 @@ function readAccessibilityPreferences() {
12
12
  };
13
13
  }
14
14
  /** The app paint layer remains untouched. Liquid regions own their retained image. */
15
- export function GlassMaterialRoot({ children, displacement, onLaunchReady, }) {
15
+ export function GlassMaterialRoot({ children, displacement, prepared = true, onLaunchReady, }) {
16
16
  const scene = useRef(null);
17
17
  const [chromeLayer, setChromeLayer] = useState(null);
18
18
  const renderTest = 'passed';
@@ -35,5 +35,5 @@ export function GlassMaterialRoot({ children, displacement, onLaunchReady, }) {
35
35
  const registerRegion = useCallback((_registration) => () => { }, []);
36
36
  const diagnostics = useMemo(() => ({ accessibility, capability, renderTest, lensBoost: 1, mapRegenerationCount: 0 }), [accessibility, capability.reason, capability.state, renderTest]);
37
37
  const context = useMemo(() => ({ chromeLayer, diagnostics, registerRegion }), [chromeLayer, diagnostics, registerRegion]);
38
- return (_jsxs(GlassMaterialContext, { value: context, children: [_jsx("div", { ref: scene, "data-satin-glass-capability": capability.state, "data-satin-scene-paint-layer": "true", "data-satin-diagnostic-glass-off": import.meta.env.SATIN_DIAGNOSTIC_GLASS_OFF || undefined, children: _jsx(MaterialEffectRoot, { scene: scene, chrome: chromeLayer, enabled: capability.state === 'displacement', capabilitySettled: true, onLaunchReady: onLaunchReady, children: children }) }), _jsx("div", { ref: setChromeLayer, "data-satin-glass-capability": capability.state, "data-satin-glass-chrome-layer": "true", "data-satin-glass-contrast": accessibility.increasedContrast ? 'more' : 'standard', "data-satin-glass-motion": accessibility.reducedMotion ? 'reduced' : 'full', "data-satin-glass-transparency": accessibility.reducedTransparency ? 'reduced' : 'full' })] }));
38
+ return (_jsxs(GlassMaterialContext, { value: context, children: [_jsx("div", { ref: scene, "data-satin-glass-capability": capability.state, "data-satin-scene-paint-layer": "true", "data-satin-diagnostic-glass-off": import.meta.env.SATIN_DIAGNOSTIC_GLASS_OFF || undefined, children: _jsx(MaterialEffectRoot, { scene: scene, chrome: chromeLayer, enabled: prepared && capability.state === 'displacement', capabilitySettled: prepared, onLaunchReady: onLaunchReady, children: children }) }), _jsx("div", { ref: setChromeLayer, "data-satin-glass-capability": capability.state, "data-satin-glass-chrome-layer": "true", "data-satin-glass-contrast": accessibility.increasedContrast ? 'more' : 'standard', "data-satin-glass-motion": accessibility.reducedMotion ? 'reduced' : 'full', "data-satin-glass-transparency": accessibility.reducedTransparency ? 'reduced' : 'full' })] }));
39
39
  }
@@ -9,6 +9,7 @@ export type GlassPresentationConsumer = Readonly<{
9
9
  content: HTMLDivElement;
10
10
  scroller: HTMLDivElement;
11
11
  tabs?: Readonly<{
12
+ tint?: string;
12
13
  insetHost?: HTMLElement;
13
14
  intrinsicWidth?: boolean;
14
15
  placement?: 'bottom' | 'top';
@@ -970,7 +970,10 @@ export function GlassPresentation({ consumer }) {
970
970
  runtimeRef.current?.selectTabAnimated(next);
971
971
  }, children: tabNames.map((label, index) => (_jsxs("button", { className: index === selectedTab
972
972
  ? 'satin-lens-tab-item satin-lens-is-selected'
973
- : 'satin-lens-tab-item', style: tabGlyphStyle(appearance, contentPage, index), type: "button", role: "tab", "data-tab-index": index, "data-satin-tab-role": consumer?.tabs?.items[index]?.role, id: consumer?.tabs?.items[index]?.tabId, "aria-controls": consumer?.tabs?.items[index]?.panelId, disabled: consumer?.tabs?.items[index]?.disabled, "aria-label": consumer?.tabs?.items[index]?.accessibilityLabel ??
973
+ : 'satin-lens-tab-item', style: {
974
+ ...tabGlyphStyle(appearance, contentPage, index, consumer?.tabs?.tint),
975
+ '--satin-tab-tint': consumer?.tabs?.tint,
976
+ }, type: "button", role: "tab", "data-tab-index": index, "data-satin-tab-role": consumer?.tabs?.items[index]?.role, id: consumer?.tabs?.items[index]?.tabId, "aria-controls": consumer?.tabs?.items[index]?.panelId, disabled: consumer?.tabs?.items[index]?.disabled, "aria-label": consumer?.tabs?.items[index]?.accessibilityLabel ??
974
977
  (tabBarPlacement === 'top' && consumer?.tabs?.items[index]?.role === 'search'
975
978
  ? typeof label === 'string'
976
979
  ? label
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { fallbackGlassRequested } from './fallback/selection.js';
3
- import { glassFallbackRequired, reportGlassError, subscribeGlassFallback } from './errors.js';
3
+ import { glassFallbackRequired, reportGlassError, subscribeGlassFallback, watchGlassTransportRecovery, } from './errors.js';
4
4
  import { GlassRegionRegistry } from './regionRegistry.js';
5
5
  import { observeTabSources } from '../tabs/sourceRevision.js';
6
6
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
@@ -11,6 +11,10 @@ import { isSupportedTabCount } from './tabBarModel.js';
11
11
  /** One renderer per app. Registration is semantic; backdrop discovery is framework-owned. */
12
12
  export function MaterialEffectRoot({ children, scene, chrome, enabled, capabilitySettled, onLaunchReady, }) {
13
13
  const automaticFallback = useSyncExternalStore(subscribeGlassFallback, glassFallbackRequired, () => false);
14
+ useEffect(() => {
15
+ if (automaticFallback)
16
+ return watchGlassTransportRecovery();
17
+ }, [automaticFallback]);
14
18
  const [requestedFallback] = useState(fallbackGlassRequested);
15
19
  const backend = requestedFallback || automaticFallback ? 'fallback' : 'webgpu';
16
20
  const [regions] = useState(() => new GlassRegionRegistry());
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useControlStyle } from '../public/controlStyle.js';
2
3
  import { orderedTabItems, TopTabLabel } from '../tabs/presentation.js';
3
4
  import { handleTabKeyDown } from '../tabs/keyboard.js';
4
5
  import { readDeviceIdiom } from '../layout/deviceIdiom.js';
@@ -19,6 +20,7 @@ import { useGlassMaterialContext } from './context.js';
19
20
  import { resolveTabBarModel, TabBarAccessoryContext } from './tabBarModel.js';
20
21
  import {} from './tokens.js';
21
22
  export function TabBar({ className, adaptive = true, onShowSidebar, items: declaredItems, onSelect, selected, style, variant = 'regular', ...props }) {
23
+ const { tint } = useControlStyle();
22
24
  const liquid = useContext(MaterialEffectContext);
23
25
  const layoutClass = useLayoutClass();
24
26
  const metrics = useSafeAreaMetrics();
@@ -83,6 +85,7 @@ export function TabBar({ className, adaptive = true, onShowSidebar, items: decla
83
85
  onSelect,
84
86
  adaptive,
85
87
  variant,
88
+ tint,
86
89
  insetHost: elementRef.current ?? undefined,
87
90
  placement: 'top',
88
91
  itemFrames,
@@ -96,6 +99,7 @@ export function TabBar({ className, adaptive = true, onShowSidebar, items: decla
96
99
  onSelect,
97
100
  adaptive,
98
101
  variant,
102
+ tint,
99
103
  liquidTop,
100
104
  itemFrames,
101
105
  ]);
@@ -108,6 +112,7 @@ export function TabBar({ className, adaptive = true, onShowSidebar, items: decla
108
112
  onSelect,
109
113
  adaptive,
110
114
  variant,
115
+ tint,
111
116
  insetHost: elementRef.current ?? undefined,
112
117
  intrinsicWidth,
113
118
  });
@@ -120,6 +125,7 @@ export function TabBar({ className, adaptive = true, onShowSidebar, items: decla
120
125
  onSelect,
121
126
  adaptive,
122
127
  variant,
128
+ tint,
123
129
  sidebar,
124
130
  intrinsicWidth,
125
131
  ]);
@@ -146,6 +152,7 @@ export function TabBar({ className, adaptive = true, onShowSidebar, items: decla
146
152
  const tabBarStyle = {
147
153
  ...style,
148
154
  ...(sidebar ? sidebarCssTokens : {}),
155
+ '--satin-tab-tint': tint,
149
156
  '--satin-tab-count': items.length,
150
157
  '--satin-tab-selection-offset': `${selectedIndex * 100}%`,
151
158
  '--satin-tab-selected-index': selectedIndex,
@@ -12,7 +12,7 @@ export type GlassError = Readonly<{
12
12
  recovering?: true;
13
13
  }>;
14
14
  export declare function subscribeGlassFallback(listener: () => void): () => void;
15
- /** Successful resource reconstruction can retire only an adapter-outage fallback. */
15
+ /** Retire a device outage only after resource reconstruction and presentation settle. */
16
16
  export declare function setGlassAdapterRecovery(pending: boolean): void;
17
17
  export declare function glassFallbackRequired(): boolean;
18
18
  export declare function reportGlassError(code: GlassErrorCode, cause: unknown, stage: string, reason?: string, recovery?: {
@@ -20,3 +20,5 @@ export declare function reportGlassError(code: GlassErrorCode, cause: unknown, s
20
20
  }): GlassError;
21
21
  export declare function subscribeGlassErrors(listener: (error: GlassError) => void): () => void;
22
22
  export declare function getGlassErrors(): readonly GlassError[];
23
+ /** Retry a failed transport on a visible page; device reconstruction keeps its own owner. */
24
+ export declare function watchGlassTransportRecovery(): () => void;
@@ -1,10 +1,13 @@
1
1
  import { automaticGlassFallbackAllowed, strictGlassRequested } from './failurePolicy.js';
2
2
  import { recordGraphicsEvent } from '../graphicsProbe.js';
3
3
  const history = [];
4
- // Independent of diagnostic history. Only a reconstructed adapter outage can clear fallback.
4
+ // Diagnostic history survives recovery; it is not the fallback state.
5
5
  let fallbackRequired = false;
6
6
  let permanentFallback = false;
7
7
  let adapterRecoveryPending = false;
8
+ let transportRecoveryPending = false;
9
+ let nextTransportRetry = 0;
10
+ let transportFailures = 0;
8
11
  const fallbackListeners = new Set();
9
12
  export function subscribeGlassFallback(listener) {
10
13
  fallbackListeners.add(listener);
@@ -12,7 +15,7 @@ export function subscribeGlassFallback(listener) {
12
15
  fallbackListeners.delete(listener);
13
16
  };
14
17
  }
15
- /** Successful resource reconstruction can retire only an adapter-outage fallback. */
18
+ /** Retire a device outage only after resource reconstruction and presentation settle. */
16
19
  export function setGlassAdapterRecovery(pending) {
17
20
  adapterRecoveryPending = pending;
18
21
  if (!pending && fallbackRequired && !permanentFallback) {
@@ -50,7 +53,17 @@ export function reportGlassError(code, cause, stage, reason, recovery) {
50
53
  history.shift();
51
54
  const release = automaticGlassFallbackAllowed();
52
55
  if (release && !error.recovering) {
53
- if (!adapterRecoveryPending || (code !== 'adapter-unavailable' && code !== 'readiness-timeout'))
56
+ const transportFailure = code === 'worker-startup' ||
57
+ (!adapterRecoveryPending &&
58
+ ['adapter-unavailable', 'device-request', 'readiness-timeout'].includes(code)) ||
59
+ (code === 'presentation' &&
60
+ /import.*module|module.*script|loading.*chunk|load failed/i.test(message));
61
+ if (transportFailure) {
62
+ transportRecoveryPending = true;
63
+ nextTransportRetry = Date.now() + Math.min(3000 * 2 ** transportFailures++, 30_000);
64
+ }
65
+ else if (!adapterRecoveryPending ||
66
+ ['gpu-unavailable', 'unsupported-configuration', 'shader-compilation', 'validation'].includes(code))
54
67
  permanentFallback = true;
55
68
  fallbackRequired = true;
56
69
  for (const listener of fallbackListeners)
@@ -76,3 +89,36 @@ export function subscribeGlassErrors(listener) {
76
89
  export function getGlassErrors() {
77
90
  return history.slice();
78
91
  }
92
+ /** Retry a failed transport on a visible page; device reconstruction keeps its own owner. */
93
+ export function watchGlassTransportRecovery() {
94
+ let timer;
95
+ const retry = () => {
96
+ clearTimeout(timer);
97
+ if (!fallbackRequired ||
98
+ permanentFallback ||
99
+ !transportRecoveryPending ||
100
+ adapterRecoveryPending)
101
+ return;
102
+ if (document.visibilityState !== 'visible')
103
+ return;
104
+ const delay = nextTransportRetry - Date.now();
105
+ if (delay > 0) {
106
+ timer = setTimeout(retry, delay);
107
+ return;
108
+ }
109
+ transportRecoveryPending = false;
110
+ fallbackRequired = false;
111
+ for (const listener of fallbackListeners)
112
+ listener();
113
+ };
114
+ document.addEventListener('visibilitychange', retry);
115
+ window.addEventListener('pageshow', retry);
116
+ window.addEventListener('focus', retry);
117
+ retry();
118
+ return () => {
119
+ clearTimeout(timer);
120
+ document.removeEventListener('visibilitychange', retry);
121
+ window.removeEventListener('pageshow', retry);
122
+ window.removeEventListener('focus', retry);
123
+ };
124
+ }
@@ -0,0 +1,7 @@
1
+ /** Parking WebGPU retains its last pixels. CSS ownership must hide those pixels,
2
+ * including lift/selection canvases, without destroying the renderer's reusable canvas. */
3
+ export declare class FallbackCanvasOwnership {
4
+ private visibility;
5
+ update(canvases: Iterable<HTMLCanvasElement>): void;
6
+ dispose(): void;
7
+ }
@@ -0,0 +1,23 @@
1
+ /** Parking WebGPU retains its last pixels. CSS ownership must hide those pixels,
2
+ * including lift/selection canvases, without destroying the renderer's reusable canvas. */
3
+ export class FallbackCanvasOwnership {
4
+ visibility = new Map();
5
+ update(canvases) {
6
+ const live = new Set(canvases);
7
+ for (const [canvas, visibility] of this.visibility) {
8
+ if (live.has(canvas))
9
+ continue;
10
+ canvas.style.visibility = visibility;
11
+ this.visibility.delete(canvas);
12
+ }
13
+ for (const canvas of live) {
14
+ if (this.visibility.has(canvas))
15
+ continue;
16
+ this.visibility.set(canvas, canvas.style.visibility);
17
+ canvas.style.visibility = 'hidden';
18
+ }
19
+ }
20
+ dispose() {
21
+ this.update([]);
22
+ }
23
+ }
@@ -1,4 +1,4 @@
1
- import { animateElement } from '../../motion/animate.js';
1
+ import { animateElement, timelineStart } from '../../motion/animate.js';
2
2
  /** CSS owns backdrop sampling, including video that cannot be read into a GPU texture. */
3
3
  export class FallbackMaterial {
4
4
  element = document.createElement('div');
@@ -61,6 +61,7 @@ export class FallbackMaterial {
61
61
  this.paint(specs[specs.length - 1], appearance);
62
62
  const count = Math.max(...specs.map(spec => this.shapes(spec).length));
63
63
  this.ensure(count, appearance, specs[specs.length - 1]);
64
+ const start = timelineStart(at);
64
65
  this.parts.forEach((part, index) => {
65
66
  const frames = specs.map((spec, sample) => {
66
67
  const shape = this.shapes(spec)[index];
@@ -70,7 +71,7 @@ export class FallbackMaterial {
70
71
  };
71
72
  });
72
73
  const animation = animateElement(part, frames, { duration, easing: 'linear', fill: 'both' });
73
- animation.startTime = at;
74
+ animation.startTime = start;
74
75
  this.animations.push(animation);
75
76
  });
76
77
  return Promise.all(this.animations.map(animation => animation.finished)).then(() => { }, () => { });
@@ -1,6 +1,7 @@
1
1
  import type { GlassRegionRegistry } from '../regionRegistry.js';
2
2
  /** Registered DOM owners already follow scroll, sheets and navigation on the compositor. */
3
3
  export declare class FallbackRegions {
4
+ private canvases;
4
5
  private entries;
5
6
  private unsubscribe;
6
7
  private frame;
@@ -4,11 +4,13 @@ import { sampleMenuCorner } from '../lens/menuCornerMotion.js';
4
4
  import { confirmHeldSources, heldPresentations } from '../presentationFrame.js';
5
5
  import { animateElement, timelineStart } from '../../motion/animate.js';
6
6
  import { FallbackMaterial } from './material.js';
7
+ import { FallbackCanvasOwnership } from './canvasOwnership.js';
7
8
  import { GLASS_SHAPE_TRACK_EVENT, glassShapeTrack } from '../shapeTrackRegistry.js';
8
9
  import { trackedShapeSpec } from '../shapeTrack.js';
9
10
  import { glassRadius } from '../regions.js';
10
11
  /** Registered DOM owners already follow scroll, sheets and navigation on the compositor. */
11
12
  export class FallbackRegions {
13
+ canvases = new FallbackCanvasOwnership();
12
14
  entries = new Map();
13
15
  unsubscribe = [];
14
16
  frame = 0;
@@ -43,12 +45,15 @@ export class FallbackRegions {
43
45
  }
44
46
  refresh() {
45
47
  const registrations = this.registry?.values() ?? [];
48
+ this.canvases.update([
49
+ ...registrations.flatMap(entry => [entry.canvas, ...(entry.overlay ? [entry.overlay] : [])]),
50
+ ...[...(this.registry?.containers.values() ?? [])].map(entry => entry.canvas),
51
+ ]);
46
52
  const live = new Set(registrations.map(entry => entry.id));
47
53
  for (const [id, entry] of this.entries) {
48
54
  if (!live.has(id)) {
49
55
  entry.ink?.cancel();
50
56
  entry.material.dispose();
51
- entry.restoreCanvas();
52
57
  this.entries.delete(id);
53
58
  }
54
59
  }
@@ -90,18 +95,10 @@ export class FallbackRegions {
90
95
  const signature = JSON.stringify([region, this.appearance]);
91
96
  let entry = this.entries.get(id);
92
97
  if (!entry) {
93
- // A live demotion can leave the last GPU frame on this canvas. Retain the
94
- // canvas for presentation receipts, but let only the CSS material paint.
95
- const canvas = registration.canvas;
96
- const visibility = canvas.style.visibility;
97
- canvas.style.visibility = 'hidden';
98
98
  entry = {
99
99
  material: new FallbackMaterial(element),
100
100
  signature: '',
101
101
  generation: 0,
102
- restoreCanvas: () => {
103
- canvas.style.visibility = visibility;
104
- },
105
102
  };
106
103
  this.entries.set(id, entry);
107
104
  }
@@ -129,7 +126,7 @@ export class FallbackRegions {
129
126
  if (track.epoch === Number.MAX_VALUE || frames.length === 1)
130
127
  entry.material.paint(frames[0], this.appearance);
131
128
  else
132
- void entry.material.play(frames, track.duration, timelineStart(track.epoch - performance.timeOrigin), this.appearance);
129
+ void entry.material.play(frames, track.duration, track.epoch - performance.timeOrigin, this.appearance);
133
130
  continue;
134
131
  }
135
132
  if (!emerge || emerge.ready === false) {
@@ -204,7 +201,7 @@ export class FallbackRegions {
204
201
  };
205
202
  });
206
203
  entry.ink = animateElement(ink, frames, { duration, easing: 'linear', fill: 'both' });
207
- entry.ink.startTime = at;
204
+ entry.ink.startTime = timelineStart(at);
208
205
  }
209
206
  if (emerge.closing)
210
207
  this.registry?.emergeInk({
@@ -242,8 +239,8 @@ export class FallbackRegions {
242
239
  this.entries.forEach(entry => {
243
240
  entry.ink?.cancel();
244
241
  entry.material.dispose();
245
- entry.restoreCanvas();
246
242
  });
247
243
  this.entries.clear();
244
+ this.canvases.dispose();
248
245
  }
249
246
  }
@@ -3,6 +3,7 @@ import type { RestorePath } from '../lens/tabRestoreMotion.js';
3
3
  import type { ScreenId } from '../lens/Scene.js';
4
4
  /** Material replacement only: semantic controls and measured motion keep their existing owners. */
5
5
  export declare class FallbackGlassRuntime {
6
+ private canvases;
6
7
  private regions;
7
8
  private dock;
8
9
  private thumb;
@@ -25,6 +26,7 @@ export declare class FallbackGlassRuntime {
25
26
  private scroll;
26
27
  private dockSpec;
27
28
  private minimizeTrack;
29
+ private playMinimizeTrack;
28
30
  restoreMinimizingTabBar(path?: RestorePath): boolean;
29
31
  private thumbSpec;
30
32
  private paintTabs;
@@ -6,8 +6,10 @@ import { tabHeldGeometry } from '../webgpu/interactionProfiles.js';
6
6
  import { WorkerMinimize } from '../webgpu/minimize.js';
7
7
  import { FallbackMaterial } from './material.js';
8
8
  import { FallbackRegions } from './regions.js';
9
+ import { FallbackCanvasOwnership } from './canvasOwnership.js';
9
10
  /** Material replacement only: semantic controls and measured motion keep their existing owners. */
10
11
  export class FallbackGlassRuntime {
12
+ canvases = new FallbackCanvasOwnership();
11
13
  regions;
12
14
  dock;
13
15
  thumb;
@@ -25,18 +27,23 @@ export class FallbackGlassRuntime {
25
27
  Object.assign(globalThis, {
26
28
  __SATIN_GLASS_RENDERER__: { backend: 'fallback', errors: getGlassErrors },
27
29
  });
30
+ const band = options.elements.band;
31
+ const shell = band.canvas.closest('[data-satin-liquid]');
32
+ this.canvases.update([
33
+ band.canvas,
34
+ band.rimCanvas,
35
+ ...(shell?.querySelectorAll('canvas[data-satin-webgpu],canvas[data-satin-glass-lift]') ?? []),
36
+ ]);
28
37
  this.regions = new FallbackRegions(options.regions, options.appearance);
29
38
  this.dock = new FallbackMaterial(options.elements.chrome.dock);
30
39
  this.thumb = new FallbackMaterial(options.elements.chrome.dock);
31
40
  this.dock.element.style.zIndex = '-2';
32
41
  this.thumb.element.style.zIndex = '-1';
33
42
  this.minimizeDom = new WorkerMinimize(options, path => this.restoreMinimizingTabBar(path), {
34
- begin: x => {
35
- this.tabPointerDown(x, this.options.geometry.tabBarSelectedIndex ?? 0);
36
- },
43
+ begin: x => this.tabPointerDown(x, this.options.geometry.tabBarSelectedIndex ?? 0),
37
44
  move: x => this.tabPointerMove(x),
38
45
  end: cancelled => this.tabPointerUp(cancelled),
39
- });
46
+ }, track => this.playMinimizeTrack(track));
40
47
  this.bindScroll();
41
48
  this.unsubscribeAppearance = options.subscribeAppearance?.(() => this.commitAppearance(options.currentAppearance?.() ?? options.appearance));
42
49
  this.updateGeometry({});
@@ -90,6 +97,9 @@ export class FallbackGlassRuntime {
90
97
  }
91
98
  minimizeTrack(direction, path = 'tap') {
92
99
  const track = this.minimizeDom.start(direction, this.dockSpec(), path);
100
+ this.playMinimizeTrack(track);
101
+ }
102
+ playMinimizeTrack(track) {
93
103
  void this.dock.play(track.specs, track.duration, track.at, this.options.appearance);
94
104
  void this.thumb.play(track.specs.map(spec => this.thumbSpec(spec)), track.duration, track.at, this.options.appearance);
95
105
  }
@@ -272,6 +282,7 @@ export class FallbackGlassRuntime {
272
282
  this.unsubscribeAppearance?.();
273
283
  this.minimizeDom.dispose();
274
284
  this.regions.dispose();
285
+ this.canvases.dispose();
275
286
  this.dock.dispose();
276
287
  this.thumb.dispose();
277
288
  }
@@ -3,13 +3,13 @@ import type { Appearance } from './theme.js';
3
3
  * glass mirror; scrolling needs no new GPU readback and no fixture-name lookup.
4
4
  * The chroma correction is a first-order fit, pending textured ink acceptance.
5
5
  */
6
- export declare function tabGlyphContextStyle(appearance: Appearance, rgb: readonly number[]): {
6
+ export declare function tabGlyphContextStyle(appearance: Appearance, rgb: readonly number[], tint?: string): {
7
7
  '--tab-selected-icon': string;
8
8
  '--tab-selected-label': string;
9
9
  '--tab-unselected-icon': string;
10
10
  '--tab-unselected-label': string;
11
11
  };
12
- export declare function tabGlyphStyle(appearance: Appearance, _background: string, _index: number): {
12
+ export declare function tabGlyphStyle(appearance: Appearance, _background: string, _index: number, tint?: string): {
13
13
  '--tab-selected-icon': string;
14
14
  '--tab-selected-label': string;
15
15
  '--tab-unselected-icon': string;
@@ -15,7 +15,7 @@ function neutral(appearance, k, kind) {
15
15
  * glass mirror; scrolling needs no new GPU readback and no fixture-name lookup.
16
16
  * The chroma correction is a first-order fit, pending textured ink acceptance.
17
17
  */
18
- export function tabGlyphContextStyle(appearance, rgb) {
18
+ export function tabGlyphContextStyle(appearance, rgb, tint) {
19
19
  const k = luma(rgb);
20
20
  const chroma = rgb.reduce((sum, v, i) => sum + (v - k) * axis[i], 0) / norm;
21
21
  const colour = (kind) => {
@@ -24,7 +24,8 @@ export function tabGlyphContextStyle(appearance, rgb) {
24
24
  .map((v, i) => Math.max(0, Math.min(255, v + chroma * (fit[appearance][2][kind][i] - blueNeutral[i]))).toFixed(3))
25
25
  .join(' ')})`;
26
26
  };
27
- const selected = colour('selected'), unselected = colour('unselected');
27
+ // Explicit author tint is a semantic colour, not the system-blue vibrancy fit.
28
+ const selected = tint || colour('selected'), unselected = colour('unselected');
28
29
  return {
29
30
  '--tab-selected-icon': selected,
30
31
  '--tab-selected-label': selected,
@@ -32,6 +33,6 @@ export function tabGlyphContextStyle(appearance, rgb) {
32
33
  '--tab-unselected-label': unselected,
33
34
  };
34
35
  }
35
- export function tabGlyphStyle(appearance, _background, _index) {
36
- return tabGlyphContextStyle(appearance, appearance === 'light' ? [1, 1, 1] : [0, 0, 0]);
36
+ export function tabGlyphStyle(appearance, _background, _index, tint) {
37
+ return tabGlyphContextStyle(appearance, appearance === 'light' ? [1, 1, 1] : [0, 0, 0], tint);
37
38
  }
@@ -1,6 +1,7 @@
1
1
  import type { GlassRegionRegistry } from './regionRegistry.js';
2
2
  import type { TabBarProps, TabItemFrame } from './TabBar.js';
3
3
  export type RegisteredTabBar = TabBarProps & Readonly<{
4
+ tint?: string;
4
5
  insetHost?: HTMLElement;
5
6
  intrinsicWidth?: boolean;
6
7
  /** 'top': the regular-width iPad bar, laid out by `itemFrames` inside `insetHost`. */
@@ -21,6 +21,7 @@ export declare class WebGPUClient {
21
21
  };
22
22
  private adapterDeadline?;
23
23
  private failed?;
24
+ private startingTransport;
24
25
  private frames;
25
26
  private webgpu;
26
27
  private currentAppearance;
@@ -45,5 +46,7 @@ export declare class WebGPUClient {
45
46
  private focus;
46
47
  private bindLifecycle;
47
48
  resume(): void;
49
+ /** Dispose a failed transport before constructing its replacement. */
50
+ close(): void;
48
51
  park(): Promise<unknown>;
49
52
  }