@satinhq/kit 0.1.0-beta.1 → 0.1.0-beta.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.
Files changed (129) hide show
  1. package/NOTICE +2 -2
  2. package/README.md +1 -1
  3. package/dist/acknowledgements/inventory.json +5 -5
  4. package/dist/actions/RowActions.d.ts +2 -2
  5. package/dist/actions/RowActions.js +2 -2
  6. package/dist/app/deployment.js +8 -2
  7. package/dist/app/installCheck.js +4 -3
  8. package/dist/app/launchWatchdog.js +6 -1
  9. package/dist/controls/civilDate.d.ts +1 -1
  10. package/dist/controls/civilDate.js +1 -1
  11. package/dist/docs/components/file-importer.md +1 -1
  12. package/dist/docs/components/paste-button.md +1 -1
  13. package/dist/docs/components/photos-picker.md +1 -1
  14. package/dist/docs/components/share-link.md +1 -1
  15. package/dist/docs/limitations.md +1 -1
  16. package/dist/docs/push.md +3 -3
  17. package/dist/docs/reference.md +1870 -0
  18. package/dist/glass/GlassMaterialRoot.d.ts +3 -1
  19. package/dist/glass/GlassMaterialRoot.js +2 -2
  20. package/dist/glass/MaterialEffectRoot.js +5 -1
  21. package/dist/glass/errors.d.ts +3 -1
  22. package/dist/glass/errors.js +49 -3
  23. package/dist/glass/fallback/canvasOwnership.d.ts +7 -0
  24. package/dist/glass/fallback/canvasOwnership.js +23 -0
  25. package/dist/glass/fallback/material.js +3 -2
  26. package/dist/glass/fallback/regions.d.ts +1 -0
  27. package/dist/glass/fallback/regions.js +9 -12
  28. package/dist/glass/fallback/runtime.d.ts +2 -0
  29. package/dist/glass/fallback/runtime.js +15 -4
  30. package/dist/glass/lens/controlLift.d.ts +1 -1
  31. package/dist/glass/lens/controlLift.js +1 -1
  32. package/dist/glass/lens/interactivePress.d.ts +2 -2
  33. package/dist/glass/lens/interactivePress.js +2 -2
  34. package/dist/glass/lens/menu-opening-native-fits.json +1 -1
  35. package/dist/glass/lens/menuContextFit.json +1 -1
  36. package/dist/glass/lens/menuDropFit.json +1 -1
  37. package/dist/glass/lens/menuField.d.ts +2 -2
  38. package/dist/glass/lens/menuField.js +3 -3
  39. package/dist/glass/lens/menuGlyphDropFit.json +1 -1
  40. package/dist/glass/lens/menuMorphFit.json +1 -1
  41. package/dist/glass/lens/menuMorphPhoneFit.json +1 -1
  42. package/dist/glass/lens/tabAccessoryMotion.d.ts +1 -1
  43. package/dist/glass/lens/tabSearchMember.d.ts +1 -1
  44. package/dist/glass/lens/tabSearchMember.js +1 -1
  45. package/dist/glass/menuPresenter.js +1 -1
  46. package/dist/glass/regions.d.ts +1 -1
  47. package/dist/glass/regions.js +1 -1
  48. package/dist/glass/shapeTrack.d.ts +1 -1
  49. package/dist/glass/submenuMotion.d.ts +1 -1
  50. package/dist/glass/submenuMotion.js +1 -1
  51. package/dist/glass/webgpu/client.d.ts +3 -0
  52. package/dist/glass/webgpu/client.js +57 -9
  53. package/dist/glass/webgpu/entry.worker.js +3 -6
  54. package/dist/glass/webgpu/errorCode.js +3 -2
  55. package/dist/glass/webgpu/frameClock.d.ts +6 -2
  56. package/dist/glass/webgpu/frameClock.js +9 -2
  57. package/dist/glass/webgpu/frameProbe.d.ts +1 -1
  58. package/dist/glass/webgpu/frameProbe.js +3 -3
  59. package/dist/glass/webgpu/layers.d.ts +5 -1
  60. package/dist/glass/webgpu/layers.js +61 -6
  61. package/dist/glass/webgpu/minimize.js +20 -2
  62. package/dist/glass/webgpu/production.js +13 -1
  63. package/dist/glass/webgpu/raster.d.ts +18 -0
  64. package/dist/glass/webgpu/raster.js +105 -15
  65. package/dist/glass/webgpu/rasterMaterial.d.ts +4 -0
  66. package/dist/glass/webgpu/rasterMaterial.js +17 -1
  67. package/dist/glass/webgpu/regionScene.d.ts +4 -1
  68. package/dist/glass/webgpu/regionScene.js +30 -1
  69. package/dist/glass/webgpu/renderer.js +23 -7
  70. package/dist/glass/webgpu/sourcePixels.d.ts +4 -4
  71. package/dist/glass/webgpu/sourcePixels.js +48 -8
  72. package/dist/keyboard/adapter.js +2 -2
  73. package/dist/keyboard/avoidance.d.ts +1 -1
  74. package/dist/keyboard/avoidance.js +1 -1
  75. package/dist/keyboard/motion.d.ts +1 -1
  76. package/dist/keyboard/motion.js +1 -1
  77. package/dist/list-form/ListForm.d.ts +2 -2
  78. package/dist/list-form/ListForm.js +1 -1
  79. package/dist/list-form/ListItem.js +1 -1
  80. package/dist/list-form/RowLabel.d.ts +1 -1
  81. package/dist/list-form/RowLabel.js +1 -1
  82. package/dist/list-form/Slider.js +1 -1
  83. package/dist/list-form/editMotion.js +1 -1
  84. package/dist/list-form/editing.d.ts +5 -5
  85. package/dist/list-form/editing.js +1 -1
  86. package/dist/motion/animationValue.d.ts +2 -2
  87. package/dist/motion/animationValue.js +9 -10
  88. package/dist/motion/matchedGeometry.d.ts +4 -4
  89. package/dist/motion/matchedGeometry.js +7 -8
  90. package/dist/motion/springTrack.d.ts +1 -2
  91. package/dist/motion/springTrack.js +10 -5
  92. package/dist/navigation/ScreenContainer.js +1 -1
  93. package/dist/navigation/chromeCurves.js +1 -1
  94. package/dist/navigation/types.d.ts +1 -1
  95. package/dist/navigation/zoomGeometry.js +1 -1
  96. package/dist/navigation/zoomMotion.d.ts +1 -1
  97. package/dist/navigation/zoomMotion.js +2 -2
  98. package/dist/navigation/zoomTransition.js +3 -4
  99. package/dist/presentation/Presentation.d.ts +2 -2
  100. package/dist/presentation/Presentation.js +1 -1
  101. package/dist/presentation/presentationSource.js +1 -1
  102. package/dist/public/controls.d.ts +4 -4
  103. package/dist/public/controls.js +7 -7
  104. package/dist/public/glass.d.ts +3 -4
  105. package/dist/public/glass.js +1 -2
  106. package/dist/public/navigationTransition.d.ts +1 -1
  107. package/dist/public/navigationTransition.js +1 -1
  108. package/dist/public/scroll.d.ts +1 -1
  109. package/dist/public/scroll.js +1 -1
  110. package/dist/public/toolbar.js +1 -1
  111. package/dist/root/SatinRoot.js +9 -5
  112. package/dist/safe-area/SafeAreaInset.js +3 -3
  113. package/dist/safe-area/modifiers.js +2 -2
  114. package/dist/safe-area/useSafeAreaMetrics.js +2 -2
  115. package/dist/scroll/ScrollSurface.d.ts +5 -5
  116. package/dist/scroll/ScrollSurface.js +1 -1
  117. package/dist/scroll/position.js +1 -1
  118. package/dist/scroll/screenGeometry.d.ts +1 -1
  119. package/dist/scroll/screenGeometry.js +1 -1
  120. package/dist/search/SearchDrawer.js +1 -1
  121. package/dist/search/searchMotion.d.ts +1 -1
  122. package/dist/search/searchMotion.js +1 -1
  123. package/dist/search/useSearchPresentation.js +1 -1
  124. package/dist/shapes/corners.js +1 -1
  125. package/dist/styles.css +55 -34
  126. package/dist/sw/renderServiceWorker.js +12 -3
  127. package/dist/text-input/TextEditor.js +7 -3
  128. package/package.json +4 -4
  129. package/dist/docs/swiftui-reference.md +0 -1895
@@ -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
  }
@@ -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());
@@ -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
  }
@@ -2,7 +2,7 @@ import type { InteractivePressProfile } from './interactivePress.js';
2
2
  import { type TabHeldGeometry } from './tabMotion.js';
3
3
  export { SCOPE_THUMB } from './scopeSegments.js';
4
4
  /**
5
- * The held glass lens of a Toggle or Slider thumb. Measured 2026-10-09 on stock SwiftUI controls
5
+ * The held glass lens of a Toggle or Slider thumb. Measured 2026-10-09 on native controls
6
6
  * (iPhone 17 Pro Max Simulator, iOS 27.0, dark; counter-retimed idb contacts; `pooled-response-models.json`): a stationary hold keeps the 37 × 24 pt
7
7
  * white thumb for about 333 ms, then the thumb becomes a clear lens that springs to its held size.
8
8
  * Release restores the size with a slower spring. Mass is 1; width and height share one response.
@@ -2,7 +2,7 @@ import { TAB_INTERACTIVE_MOTION } from './tabMotion.js';
2
2
  import { SCOPE_THUMB } from './scopeSegments.js';
3
3
  export { SCOPE_THUMB } from './scopeSegments.js';
4
4
  /**
5
- * The held glass lens of a Toggle or Slider thumb. Measured 2026-10-09 on stock SwiftUI controls
5
+ * The held glass lens of a Toggle or Slider thumb. Measured 2026-10-09 on native controls
6
6
  * (iPhone 17 Pro Max Simulator, iOS 27.0, dark; counter-retimed idb contacts; `pooled-response-models.json`): a stationary hold keeps the 37 × 24 pt
7
7
  * white thumb for about 333 ms, then the thumb becomes a clear lens that springs to its held size.
8
8
  * Release restores the size with a slower spring. Mass is 1; width and height share one response.
@@ -1,7 +1,7 @@
1
1
  import { type GlassContactKind } from '../contactPhysics.js';
2
2
  /**
3
- * The one press response of interactive Liquid Glass (buttons, bar and toolbar buttons, the
4
- * back button, Glass.interactive() surfaces). Fitted 2026-09-25 to stock SwiftUI on the iOS
3
+ * The one press response of interactive glass (buttons, bar and toolbar buttons, the
4
+ * back button, Glass.interactive() surfaces). Fitted 2026-09-25 to native controls on the iOS
5
5
  * 27.0 Simulator (ParityReference `--glass-press`, idb presses at human speed: 0.6 s holds and
6
6
  * 0.12 s taps, light and dark; glass edges measured on the centre row and column):
7
7
  *
@@ -1,8 +1,8 @@
1
1
  import { MENU_MORPH_DURATION, sampleMenuMorph } from './menuMorphMotion.js';
2
2
  import { GLASS_CONTACT_PHYSICS } from '../contactPhysics.js';
3
3
  /**
4
- * The one press response of interactive Liquid Glass (buttons, bar and toolbar buttons, the
5
- * back button, Glass.interactive() surfaces). Fitted 2026-09-25 to stock SwiftUI on the iOS
4
+ * The one press response of interactive glass (buttons, bar and toolbar buttons, the
5
+ * back button, Glass.interactive() surfaces). Fitted 2026-09-25 to native controls on the iOS
6
6
  * 27.0 Simulator (ParityReference `--glass-press`, idb presses at human speed: 0.6 s holds and
7
7
  * 0.12 s taps, light and dark; glass edges measured on the centre row and column):
8
8
  *
@@ -5,7 +5,7 @@
5
5
  "centerX": 364,
6
6
  "centerY": 156,
7
7
  "diameter": 58,
8
- "measurement": "Fresh native button versus glass-off control: 58 x 58.333 pt at 90-level threshold. The SwiftUI label frame is 44 pt."
8
+ "measurement": "Fresh native button versus glass-off control: 58 x 58.333 pt at 90-level threshold. The label frame is 44 pt."
9
9
  },
10
10
  "panel": {
11
11
  "centerX": 267.5,
@@ -3,7 +3,7 @@
3
3
  "scene": "reference iOS 27 Simulator Notes, light and dark touch-clocked recordings",
4
4
  "source": "native opening measurements/curve fit and native-samples.json; recognition = touch +750ms",
5
5
  "limits": "Translucent threshold contours are estimates; same-device optical acceptance remains pending.",
6
- "content": "openContent: native iPad Pro 11 (2nd gen) iOS 27.0.1 SwiftUI .contextMenu on a chat bubble, warm-light-chat-tall (3 counter-qualified trials, native menu measurements curve fit, width RMS 0.028, height RMS 0.017) on the recognition clock (content settle start = this epoch + 55 ms). Centres follow their size axis (native's first resolved frame is already at 89% width). Dark uses the light fit (dark windows were rejected)."
6
+ "content": "openContent: native iPad Pro 11 (2nd gen) iOS 27.0.1 .contextMenu on a chat bubble, warm-light-chat-tall (3 counter-qualified trials, native menu measurements curve fit, width RMS 0.028, height RMS 0.017) on the recognition clock (content settle start = this epoch + 55 ms). Centres follow their size axis (native's first resolved frame is already at 89% width). Dark uses the light fit (dark windows were rejected)."
7
7
  },
8
8
  "open": {
9
9
  "dark": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "provenance": {
3
- "scene": "Stock SwiftUI Menu(\"Menu selection\") in a List Section (native reference app), iPadOS 27.0, light",
3
+ "scene": "Native controls Menu(\"Menu selection\") in a List Section (native reference app), iPadOS 27.0, light",
4
4
  "device": "iPad Pro 12.9 (6th gen), real HID taps via WDA",
5
5
  "capture": "Native ReplayKit recording in a native reference app with a 60 Hz refresh counter (3.31% skipped refreshes, max skip 1: fit-valid, not playback-valid)",
6
6
  "method": "Platter box per refresh (blurred frame difference against the closed frame, the label's text masked), timed by the counter. Open: two cycles, normalised from a point at the drop's first centre (Satin's plain drop grows from a point) to the settled panel; frames with at least 60% of the settled mask area, the 230-450 ms settle weighted 3x. Close: platter edges read per refresh from 60 Hz tiles (+-8 px), normalised from the panel to the drop that pours back into the label.",
@@ -10,7 +10,7 @@ import type { MenuSourceKind } from '../regions.js';
10
10
  * The trigger's glyph and the menu's rows are ink drawn inside that glass (the ink kernel), so
11
11
  * the growing rim lenses them and no page element is scaled, blurred or clipped.
12
12
  *
13
- * Phone reference: iOS 27.0 iPhone 17 Pro Max Simulator, stock SwiftUI toolbar Menu
13
+ * Phone reference: iOS 27.0 iPhone 17 Pro Max Simulator, native toolbar menu
14
14
  * (native recording menu measurements). Times are ms after the morph's time zero:
15
15
  * native release +43 ms on open (regionScene anchors it on the trigger's release), close tap
16
16
  * +41 ms on close. Measured (native release/tap times): the pressed 48 pt button holds, dips to
@@ -25,7 +25,7 @@ import type { MenuSourceKind } from '../regions.js';
25
25
  * shape passes its button. Rows fade from the first close frame (246 → 96 in about 15 ms of
26
26
  * motion, a faint blurred copy until about +140 ms).
27
27
  *
28
- * Triggers that are not a circle (same Simulator, stock SwiftUI Menu from a labelled capsule
28
+ * Triggers that are not a circle (same Simulator, native menu from a labelled capsule
29
29
  * 129 x 50, a full-width button 400 x 50 and a toolbar group 152 x 44; phase-morph-shapes-notes.md):
30
30
  * the drop does not start as the trigger. The whole trigger glass contracts in about three frames
31
31
  * (its width to 0.71 and 0.47 of rest while its height is at 0.90 and 0.74: it shortens faster
@@ -11,7 +11,7 @@ import { menuSpring } from './menuSpring.js';
11
11
  * The trigger's glyph and the menu's rows are ink drawn inside that glass (the ink kernel), so
12
12
  * the growing rim lenses them and no page element is scaled, blurred or clipped.
13
13
  *
14
- * Phone reference: iOS 27.0 iPhone 17 Pro Max Simulator, stock SwiftUI toolbar Menu
14
+ * Phone reference: iOS 27.0 iPhone 17 Pro Max Simulator, native toolbar menu
15
15
  * (native recording menu measurements). Times are ms after the morph's time zero:
16
16
  * native release +43 ms on open (regionScene anchors it on the trigger's release), close tap
17
17
  * +41 ms on close. Measured (native release/tap times): the pressed 48 pt button holds, dips to
@@ -26,7 +26,7 @@ import { menuSpring } from './menuSpring.js';
26
26
  * shape passes its button. Rows fade from the first close frame (246 → 96 in about 15 ms of
27
27
  * motion, a faint blurred copy until about +140 ms).
28
28
  *
29
- * Triggers that are not a circle (same Simulator, stock SwiftUI Menu from a labelled capsule
29
+ * Triggers that are not a circle (same Simulator, native menu from a labelled capsule
30
30
  * 129 x 50, a full-width button 400 x 50 and a toolbar group 152 x 44; phase-morph-shapes-notes.md):
31
31
  * the drop does not start as the trigger. The whole trigger glass contracts in about three frames
32
32
  * (its width to 0.71 and 0.47 of rest while its height is at 0.90 and 0.74: it shortens faster
@@ -456,7 +456,7 @@ const MENU_LABEL_INK = {
456
456
  lens: [[0, 0]],
457
457
  },
458
458
  };
459
- /** A plain glyph's drop (a Menu labelled with a bare symbol, no glass at rest): stock SwiftUI
459
+ /** A plain glyph's drop (a Menu labelled with a bare symbol, no glass at rest): the measured platform control
460
460
  * in the iOS 27.0 Simulator, counter-retimed (menuGlyphDropFit.json). Open: the glyph vanishes on
461
461
  * the tap; the body's first frame is already 94 x 82 pt with the rows inside it, small and
462
462
  * blurred; they reach full size by +120 ms and are sharp by +154 ms. Close: the rows blur and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "provenance": {
3
- "source": "plain-menu-morph native/PlainMenuRef.swift: stock SwiftUI Menu { … } label: { Image(systemName: \"ellipsis\") } (no glass), iOS 27.0 Simulator, iPhone 17 Pro Max, light; raw/pmm-light-1.mp4",
3
+ "source": "plain-menu-morph native/PlainMenuRef.swift: native controls Menu { … } label: { Image(systemName: \"ellipsis\") } (no glass), iOS 27.0 Simulator, iPhone 17 Pro Max, light; raw/pmm-light-1.mp4",
4
4
  "cadence": "refresh counter 58.47 Hz, skips 0.23 %, max skip 1, 0 unreadable: valid for fitting (duplicates removed by counter, times = refresh index x 17.1 ms)",
5
5
  "openOnset": "counter 1512, the refresh where the glyph disappears (the tap)",
6
6
  "closeOnset": "counter 1562, the last refresh before the open menu moves",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "provenance": {
3
- "scene": "ParityReference --menu-morph, stock SwiftUI toolbar Menu, black fixture",
3
+ "scene": "ParityReference --menu-morph, native controls toolbar Menu, black fixture",
4
4
  "capture": "native menu native recording",
5
5
  "contoursSha256": "548f8629dda83afa8467ced2bb0533db024468359f35f602e54febb6ba918310",
6
6
  "units": "milliseconds and normalized centerX, centerY, width, height",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "provenance": {
3
- "scene": "ParityReference toolbar-ellipsis, stock SwiftUI toolbar Menu over black (iPhone 17 Pro Max Simulator, iOS 27.0)",
3
+ "scene": "ParityReference toolbar-ellipsis, native controls toolbar Menu over black (iPhone 17 Pro Max Simulator, iOS 27.0)",
4
4
  "capture": "morph2 native recording menu measurements.mp4",
5
5
  "model": "One damped spring per axis, each a displacement from 1 to 0: released after `delay` ms with initial velocity `velocity` (per second; negative moves toward the target), natural frequency `omega` (rad/s), damping ratio `zeta`. centerX, centerY, width and height are the travelling member, normalized between the trigger (0) and the panel (1).",
6
6
  "open": "ms after the trigger release +43 ms. Fitted to the native outline bounds (union of trigger and drop), RMS 0.018-0.041 per axis (native measurements).",
@@ -3,7 +3,7 @@ import { type FieldSpring } from '../fieldMotion.js';
3
3
  * Motion of the TabView bottom accessory between its expanded row (above the tab bar) and its
4
4
  * inline row (beside the minimized tab circle).
5
5
  *
6
- * Native reference: stock SwiftUI `.tabViewBottomAccessory` + `.tabBarMinimizeBehavior(.onScrollDown)`
6
+ * Measured bottom accessory motion during tab-bar minimization
7
7
  * with three Music-style tabs, iPhone 17 Pro Max, iOS 27.0 Simulator, counter-retimed
8
8
  * (RMS 2.6 pt collapse, 1.1 pt
9
9
  * expand). Each edge follows an underdamped spring (damping ratio about 0.72), and the two axes
@@ -2,7 +2,7 @@ import type { CapsuleShape } from '../renderSpecs.js';
2
2
  /**
3
3
  * The trailing Search member of a minimizing tab bar (`Tab(role: .search)` on iPhone).
4
4
  *
5
- * Native reference: stock SwiftUI on iOS 27.0, iPhone 17 Pro Max (search-tab-split, Simulator
5
+ * Native reference: native controls on iOS 27.0, iPhone 17 Pro Max (search-tab-split, Simulator
6
6
  * tracks at 60 Hz) and the owner's Apple Music recording on a physical iPhone. On collapse the
7
7
  * bar's trailing end separates into its own capsule within one or two frames: at the split it
8
8
  * spans x = 339–413 of the 21–419 bar, full height. It then narrows from its leading edge into
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The trailing Search member of a minimizing tab bar (`Tab(role: .search)` on iPhone).
3
3
  *
4
- * Native reference: stock SwiftUI on iOS 27.0, iPhone 17 Pro Max (search-tab-split, Simulator
4
+ * Native reference: native controls on iOS 27.0, iPhone 17 Pro Max (search-tab-split, Simulator
5
5
  * tracks at 60 Hz) and the owner's Apple Music recording on a physical iPhone. On collapse the
6
6
  * bar's trailing end separates into its own capsule within one or two frames: at the split it
7
7
  * spans x = 339–413 of the 21–419 bar, full height. It then narrows from its leading edge into
@@ -58,7 +58,7 @@ const MENU_GLYPH_RETURN_MS = 400;
58
58
  /** Safety margin past the close morph for the renderer's settle acknowledgement. */
59
59
  const MENU_SETTLE_TIMEOUT_MS = 700;
60
60
  /** The order of a menu that opens upward: the first action nearest the trigger, so the list and
61
- * each section in it read bottom to top (stock SwiftUI Menu, iPhone, a trigger near the bottom of
61
+ * each section in it read bottom to top (native menu, iPhone, a trigger near the bottom of
62
62
  * the screen). A submenu's own rows open separately and keep their order. */
63
63
  function nearestFirst(actions) {
64
64
  return [...actions]
@@ -225,7 +225,7 @@ export declare function glassPresenterBounds(frame: GlassRegion['frame'], inset:
225
225
  * The 12M guard permits that stack plus chrome; it is not a memory benchmark. */
226
226
  export declare const GLASS_REGION_LIMIT = 16;
227
227
  export declare const GLASS_REGION_PIXEL_LIMIT = 12000000;
228
- /** Immutable SwiftUI-style material values. RGBA channels use the range 0...1. */
228
+ /** Immutable glass material values. RGBA channels use the range 0...1. */
229
229
  export declare class Glass {
230
230
  static readonly regular: Glass;
231
231
  static readonly clear: Glass;
@@ -26,7 +26,7 @@ export function glassPresenterBounds(frame, inset, emerge) {
26
26
  * The 12M guard permits that stack plus chrome; it is not a memory benchmark. */
27
27
  export const GLASS_REGION_LIMIT = 16;
28
28
  export const GLASS_REGION_PIXEL_LIMIT = 12_000_000;
29
- /** Immutable SwiftUI-style material values. RGBA channels use the range 0...1. */
29
+ /** Immutable glass material values. RGBA channels use the range 0...1. */
30
30
  export class Glass {
31
31
  static regular = new Glass('regular');
32
32
  static clear = new Glass('clear');
@@ -8,7 +8,7 @@ import type { SurfaceRenderSpec } from './renderSpecs.js';
8
8
  * seems to slide and smear while nothing behind it moves (owner, Oct 13: "it looks like content is
9
9
  * moving underneath as the menu grows").
10
10
  *
11
- * Native Liquid Glass keeps the content behind it in place: only the edge moves. A shape track
11
+ * Native glass keeps the content behind it in place: only the edge moves. A shape track
12
12
  * does the same. The element keeps one layout box for the whole run (the envelope of every frame
13
13
  * the glass passes through), so its canvas never resizes or moves and samples the page where it
14
14
  * really is. The worker draws the glass at the track's frame for each frame's presentation time,
@@ -1,6 +1,6 @@
1
1
  import { type SpringParameters } from '../motion/springTrack.js';
2
2
  /**
3
- * Nested menus, measured from stock SwiftUI on iOS 27.0 (submenus/SPEC.md).
3
+ * Nested menus, measured from native controls on iOS 27.0 (submenus/SPEC.md).
4
4
  *
5
5
  * iPhone stacks each submenu over its parent: the parent shrinks to 0.97 about its top edge and
6
6
  * its rows dim to half ink, and the child grows out of the row that opened it into a full-width
@@ -4,7 +4,7 @@ import { setGlassShapeTrack } from './shapeTrackRegistry.js';
4
4
  import { shapeTrackClock, shapeTrackEnvelope, shapeTrackFrame, } from './shapeTrack.js';
5
5
  import { springMotion, springTrack, sampledKeyframes, } from '../motion/springTrack.js';
6
6
  /**
7
- * Nested menus, measured from stock SwiftUI on iOS 27.0 (submenus/SPEC.md).
7
+ * Nested menus, measured from native controls on iOS 27.0 (submenus/SPEC.md).
8
8
  *
9
9
  * iPhone stacks each submenu over its parent: the parent shrinks to 0.97 about its top edge and
10
10
  * its rows dim to half ink, and the child grows out of the row that opened it into a full-width
@@ -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
  }