@satinhq/kit 0.1.0-beta.2 → 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.
- package/NOTICE +2 -2
- package/README.md +1 -1
- package/dist/acknowledgements/inventory.json +5 -5
- package/dist/actions/RowActions.d.ts +2 -2
- package/dist/actions/RowActions.js +2 -2
- package/dist/controls/civilDate.d.ts +1 -1
- package/dist/controls/civilDate.js +1 -1
- package/dist/docs/components/file-importer.md +1 -1
- package/dist/docs/components/paste-button.md +1 -1
- package/dist/docs/components/photos-picker.md +1 -1
- package/dist/docs/components/share-link.md +1 -1
- package/dist/docs/limitations.md +1 -1
- package/dist/docs/push.md +3 -3
- package/dist/docs/reference.md +1870 -0
- package/dist/glass/lens/controlLift.d.ts +1 -1
- package/dist/glass/lens/controlLift.js +1 -1
- package/dist/glass/lens/interactivePress.d.ts +2 -2
- package/dist/glass/lens/interactivePress.js +2 -2
- package/dist/glass/lens/menu-opening-native-fits.json +1 -1
- package/dist/glass/lens/menuContextFit.json +1 -1
- package/dist/glass/lens/menuDropFit.json +1 -1
- package/dist/glass/lens/menuField.d.ts +2 -2
- package/dist/glass/lens/menuField.js +3 -3
- package/dist/glass/lens/menuGlyphDropFit.json +1 -1
- package/dist/glass/lens/menuMorphFit.json +1 -1
- package/dist/glass/lens/menuMorphPhoneFit.json +1 -1
- package/dist/glass/lens/tabAccessoryMotion.d.ts +1 -1
- package/dist/glass/lens/tabSearchMember.d.ts +1 -1
- package/dist/glass/lens/tabSearchMember.js +1 -1
- package/dist/glass/menuPresenter.js +1 -1
- package/dist/glass/regions.d.ts +1 -1
- package/dist/glass/regions.js +1 -1
- package/dist/glass/shapeTrack.d.ts +1 -1
- package/dist/glass/submenuMotion.d.ts +1 -1
- package/dist/glass/submenuMotion.js +1 -1
- package/dist/glass/webgpu/entry.worker.js +3 -6
- package/dist/glass/webgpu/frameClock.d.ts +6 -2
- package/dist/glass/webgpu/frameClock.js +9 -2
- package/dist/glass/webgpu/frameProbe.d.ts +1 -1
- package/dist/glass/webgpu/frameProbe.js +3 -3
- package/dist/glass/webgpu/production.js +4 -0
- package/dist/glass/webgpu/raster.d.ts +18 -0
- package/dist/glass/webgpu/raster.js +105 -15
- package/dist/glass/webgpu/rasterMaterial.d.ts +4 -0
- package/dist/glass/webgpu/rasterMaterial.js +17 -1
- package/dist/glass/webgpu/regionScene.d.ts +4 -1
- package/dist/glass/webgpu/regionScene.js +30 -1
- package/dist/glass/webgpu/renderer.js +23 -7
- package/dist/keyboard/adapter.js +2 -2
- package/dist/keyboard/avoidance.d.ts +1 -1
- package/dist/keyboard/avoidance.js +1 -1
- package/dist/keyboard/motion.d.ts +1 -1
- package/dist/keyboard/motion.js +1 -1
- package/dist/list-form/ListForm.d.ts +2 -2
- package/dist/list-form/ListForm.js +1 -1
- package/dist/list-form/ListItem.js +1 -1
- package/dist/list-form/RowLabel.d.ts +1 -1
- package/dist/list-form/RowLabel.js +1 -1
- package/dist/list-form/Slider.js +1 -1
- package/dist/list-form/editMotion.js +1 -1
- package/dist/list-form/editing.d.ts +5 -5
- package/dist/list-form/editing.js +1 -1
- package/dist/motion/animationValue.d.ts +2 -2
- package/dist/motion/animationValue.js +9 -10
- package/dist/motion/matchedGeometry.d.ts +4 -4
- package/dist/motion/matchedGeometry.js +7 -8
- package/dist/motion/springTrack.d.ts +1 -2
- package/dist/motion/springTrack.js +10 -5
- package/dist/navigation/ScreenContainer.js +1 -1
- package/dist/navigation/chromeCurves.js +1 -1
- package/dist/navigation/types.d.ts +1 -1
- package/dist/navigation/zoomGeometry.js +1 -1
- package/dist/navigation/zoomMotion.d.ts +1 -1
- package/dist/navigation/zoomMotion.js +2 -2
- package/dist/navigation/zoomTransition.js +3 -4
- package/dist/presentation/Presentation.d.ts +2 -2
- package/dist/presentation/Presentation.js +1 -1
- package/dist/presentation/presentationSource.js +1 -1
- package/dist/public/controls.d.ts +4 -4
- package/dist/public/controls.js +7 -7
- package/dist/public/glass.d.ts +3 -4
- package/dist/public/glass.js +1 -2
- package/dist/public/navigationTransition.d.ts +1 -1
- package/dist/public/navigationTransition.js +1 -1
- package/dist/public/scroll.d.ts +1 -1
- package/dist/public/scroll.js +1 -1
- package/dist/public/toolbar.js +1 -1
- package/dist/safe-area/SafeAreaInset.js +3 -3
- package/dist/safe-area/modifiers.js +2 -2
- package/dist/safe-area/useSafeAreaMetrics.js +2 -2
- package/dist/scroll/ScrollSurface.d.ts +5 -5
- package/dist/scroll/ScrollSurface.js +1 -1
- package/dist/scroll/position.js +1 -1
- package/dist/scroll/screenGeometry.d.ts +1 -1
- package/dist/scroll/screenGeometry.js +1 -1
- package/dist/search/SearchDrawer.js +1 -1
- package/dist/search/searchMotion.d.ts +1 -1
- package/dist/search/searchMotion.js +1 -1
- package/dist/search/useSearchPresentation.js +1 -1
- package/dist/shapes/corners.js +1 -1
- package/dist/styles.css +55 -34
- package/dist/text-input/TextEditor.js +7 -3
- package/package.json +4 -4
- package/dist/docs/swiftui-reference.md +0 -1895
|
@@ -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
|
|
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
|
|
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
|
|
4
|
-
* back button, Glass.interactive() surfaces). Fitted 2026-09-25 to
|
|
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
|
|
5
|
-
* back button, Glass.interactive() surfaces). Fitted 2026-09-25 to
|
|
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
|
|
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
|
|
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": "
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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):
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
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 (
|
|
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]
|
package/dist/glass/regions.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/glass/regions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { workerAnimationFrameClock } from './frameClock.js';
|
|
2
2
|
import { glassFailureCode } from './errorCode.js';
|
|
3
3
|
import { createWebGPURenderer } from './renderer.js';
|
|
4
4
|
import { glassTiming, setGlassTimingSink } from './profile.js';
|
|
5
5
|
setGlassTimingSink(event => globalThis.postMessage({ glassProfile: event }));
|
|
6
6
|
glassTiming('worker-start');
|
|
7
|
-
const clock =
|
|
7
|
+
const clock = workerAnimationFrameClock;
|
|
8
8
|
const renderer = createWebGPURenderer(message => globalThis.postMessage(message), clock);
|
|
9
9
|
globalThis.onmessage = event => {
|
|
10
|
-
|
|
11
|
-
clock.receive(event.data.request);
|
|
12
|
-
else
|
|
13
|
-
renderer.receive(event);
|
|
10
|
+
renderer.receive(event);
|
|
14
11
|
};
|
|
15
12
|
globalThis.addEventListener('error', event => globalThis.postMessage({
|
|
16
13
|
glassError: {
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { Envelope } from '../worker/protocol.js';
|
|
2
2
|
/** Epoch milliseconds from the document's animation frame, shared with WAAPI. */
|
|
3
3
|
export interface FrameClock {
|
|
4
|
+
readonly kind?: 'page-raf' | 'worker-raf';
|
|
4
5
|
request(callback: (at: number) => void): number;
|
|
5
6
|
cancel(token: number): void;
|
|
6
7
|
}
|
|
7
8
|
export declare const documentFrameClock: FrameClock;
|
|
9
|
+
/** A retained worker timeline can sample without a page callback or message round trip.
|
|
10
|
+
* Both realms use epoch milliseconds, so the same WAAPI track remains the time authority. */
|
|
11
|
+
export declare const workerAnimationFrameClock: FrameClock;
|
|
8
12
|
type FrameRequest = {
|
|
9
13
|
glassFrame: {
|
|
10
14
|
token: number;
|
|
11
15
|
cancel?: boolean;
|
|
12
16
|
};
|
|
13
17
|
};
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
18
|
+
/** Page-linked worker clock retained for diagnostic comparisons. Its round trip
|
|
19
|
+
* also inherits page stalls; the independent worker renderer does not use it. */
|
|
16
20
|
export declare class WorkerFrameClock implements FrameClock {
|
|
17
21
|
private sequence;
|
|
18
22
|
private pending?;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
export const documentFrameClock = {
|
|
2
|
+
kind: 'page-raf',
|
|
2
3
|
request: callback => requestAnimationFrame(timestamp => callback(performance.timeOrigin + timestamp)),
|
|
3
4
|
cancel: token => cancelAnimationFrame(token),
|
|
4
5
|
};
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
6
|
+
/** A retained worker timeline can sample without a page callback or message round trip.
|
|
7
|
+
* Both realms use epoch milliseconds, so the same WAAPI track remains the time authority. */
|
|
8
|
+
export const workerAnimationFrameClock = {
|
|
9
|
+
...documentFrameClock,
|
|
10
|
+
kind: 'worker-raf',
|
|
11
|
+
};
|
|
12
|
+
/** Page-linked worker clock retained for diagnostic comparisons. Its round trip
|
|
13
|
+
* also inherits page stalls; the independent worker renderer does not use it. */
|
|
7
14
|
export class WorkerFrameClock {
|
|
8
15
|
sequence = 0;
|
|
9
16
|
pending;
|
|
@@ -44,7 +44,7 @@ export declare class FrameProbe {
|
|
|
44
44
|
private post;
|
|
45
45
|
constructor(post: (summary: FrameSummary[]) => void);
|
|
46
46
|
get timestampQuery(): boolean;
|
|
47
|
-
useDevice(device: GPUDevice | undefined): void;
|
|
47
|
+
useDevice(device: GPUDevice | undefined, gpuTiming?: boolean): void;
|
|
48
48
|
/** Wraps one frame's encoding and submission. Returns the frame's sample: its completion and
|
|
49
49
|
* GPU times are filled in when they arrive. */
|
|
50
50
|
frame(kind: FrameKind, interval: number | null, restart: boolean, encode: (e: GPUCommandEncoder) => void, canSubmit?: () => boolean): FrameProbeSample | undefined;
|
|
@@ -78,12 +78,12 @@ export class FrameProbe {
|
|
|
78
78
|
this.post = post;
|
|
79
79
|
}
|
|
80
80
|
get timestampQuery() {
|
|
81
|
-
return !!this.
|
|
81
|
+
return !!this.timer;
|
|
82
82
|
}
|
|
83
|
-
useDevice(device) {
|
|
83
|
+
useDevice(device, gpuTiming = true) {
|
|
84
84
|
this.device = device;
|
|
85
85
|
this.timer = undefined;
|
|
86
|
-
if (device?.features.has('timestamp-query'))
|
|
86
|
+
if (gpuTiming && device?.features.has('timestamp-query'))
|
|
87
87
|
try {
|
|
88
88
|
this.timer = new GPUFrameTimer(device);
|
|
89
89
|
}
|
|
@@ -385,6 +385,10 @@ export class ProductionGlassRuntime {
|
|
|
385
385
|
element.style.cssText =
|
|
386
386
|
'position:absolute;inset:0;overflow:visible;clip-path:none;transform:none';
|
|
387
387
|
this.canvas.style.cssText = `position:absolute;left:0;top:0;width:${geometry.width}px;height:${geometry.height}px;pointer-events:none`;
|
|
388
|
+
if (!this.client.documentOwned) {
|
|
389
|
+
this.canvas.style.objectFit = 'contain';
|
|
390
|
+
this.canvas.style.objectPosition = 'left bottom';
|
|
391
|
+
}
|
|
388
392
|
this.canvas.dataset.satinWebgpu = 'production';
|
|
389
393
|
// Chrome canvases start as one pixel; each preparation places them on the band their surfaces
|
|
390
394
|
// reach. A remount (geometry change, rebind) keeps the band the worker is drawing for until the
|
|
@@ -37,6 +37,19 @@ export declare class WebGPURaster {
|
|
|
37
37
|
private overlayPipeline;
|
|
38
38
|
private presentPipeline;
|
|
39
39
|
private format;
|
|
40
|
+
private presentationRoot?;
|
|
41
|
+
private presentationOrigin?;
|
|
42
|
+
private presentationMembers;
|
|
43
|
+
private presentationSequence?;
|
|
44
|
+
private collectingPresentation;
|
|
45
|
+
/** Member samples encode their materials first. The root then presents the whole frame once. */
|
|
46
|
+
withSharedPresentation(draw: () => void): void;
|
|
47
|
+
/** Retain independent material sampling, but store overlapping fixed chrome once. */
|
|
48
|
+
joinPresentation(root: WebGPURaster, origin: {
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
}): void;
|
|
52
|
+
leavePresentation(): void;
|
|
40
53
|
private lost;
|
|
41
54
|
private suspended;
|
|
42
55
|
private covered;
|
|
@@ -172,8 +185,10 @@ export declare class WebGPURaster {
|
|
|
172
185
|
* region presenter's layer already is its sample rectangle and keeps layer space. */
|
|
173
186
|
private localResult;
|
|
174
187
|
/** Device-pixel offset of this layer's canvas within its layer. */
|
|
188
|
+
private presentationViewport;
|
|
175
189
|
private canvasOffset;
|
|
176
190
|
private canvasSize;
|
|
191
|
+
private resizeCanvas;
|
|
177
192
|
/** Size the shared layer canvas to a band of its layer (the element is placed there by
|
|
178
193
|
* the page): a lift overlay presents a strip, not a second full-screen canvas. */
|
|
179
194
|
setCanvasViewport(viewport: readonly [number, number, number, number] | undefined): void;
|
|
@@ -201,6 +216,9 @@ export declare class WebGPURaster {
|
|
|
201
216
|
* Returns false, drawing nothing, when the canvas already shows exactly this (see
|
|
202
217
|
* `presentedLayers`): the same results at the same places, or nothing at all. */
|
|
203
218
|
static presentLayer(encoder: GPUCommandEncoder, rasters: readonly WebGPURaster[]): boolean;
|
|
219
|
+
private presentationOffset;
|
|
220
|
+
private groupPresentUniform;
|
|
221
|
+
private groupPresentScissor;
|
|
204
222
|
private presentDraw;
|
|
205
223
|
/** Scroll-attached glass is part of the scrolling content, and the navigation bar's scroll-edge
|
|
206
224
|
* effect is painted over that content (CSS, a layer above the scroll container). Its own
|
|
@@ -96,6 +96,48 @@ export class WebGPURaster {
|
|
|
96
96
|
overlayPipeline;
|
|
97
97
|
presentPipeline;
|
|
98
98
|
format = 'rgba8unorm';
|
|
99
|
+
presentationRoot;
|
|
100
|
+
presentationOrigin;
|
|
101
|
+
presentationMembers = new Set();
|
|
102
|
+
presentationSequence;
|
|
103
|
+
collectingPresentation = false;
|
|
104
|
+
/** Member samples encode their materials first. The root then presents the whole frame once. */
|
|
105
|
+
withSharedPresentation(draw) {
|
|
106
|
+
this.collectingPresentation = true;
|
|
107
|
+
try {
|
|
108
|
+
draw();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.collectingPresentation = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Retain independent material sampling, but store overlapping fixed chrome once. */
|
|
115
|
+
joinPresentation(root, origin) {
|
|
116
|
+
if (this.presentationRoot === root) {
|
|
117
|
+
this.presentationOrigin = origin;
|
|
118
|
+
if (root.setup)
|
|
119
|
+
root.resizeCanvas();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (this.setup)
|
|
123
|
+
this.clear();
|
|
124
|
+
this.presentationRoot?.presentationMembers.delete(this);
|
|
125
|
+
this.presentationRoot = root;
|
|
126
|
+
this.presentationOrigin = origin;
|
|
127
|
+
root.presentationMembers.add(this);
|
|
128
|
+
if (root.setup)
|
|
129
|
+
root.resizeCanvas();
|
|
130
|
+
root.forgetPresent();
|
|
131
|
+
}
|
|
132
|
+
leavePresentation() {
|
|
133
|
+
const root = this.presentationRoot;
|
|
134
|
+
root?.presentationMembers.delete(this);
|
|
135
|
+
if (root?.setup)
|
|
136
|
+
root.resizeCanvas();
|
|
137
|
+
root?.forgetPresent();
|
|
138
|
+
this.presentationRoot = undefined;
|
|
139
|
+
this.presentationOrigin = undefined;
|
|
140
|
+
}
|
|
99
141
|
lost = false;
|
|
100
142
|
suspended = false;
|
|
101
143
|
covered = false;
|
|
@@ -152,6 +194,8 @@ export class WebGPURaster {
|
|
|
152
194
|
this.setup = { ...this.setup, appearance, spec: retainedAppearanceSpec(spec, appearance) };
|
|
153
195
|
}
|
|
154
196
|
dispose() {
|
|
197
|
+
this.presentationRoot?.presentationMembers.delete(this);
|
|
198
|
+
this.presentationRoot?.forgetPresent();
|
|
155
199
|
this.unsubscribe?.();
|
|
156
200
|
this.release();
|
|
157
201
|
this.passes.destroy();
|
|
@@ -571,7 +615,13 @@ export class WebGPURaster {
|
|
|
571
615
|
const encoder = this.device?.createCommandEncoder();
|
|
572
616
|
if (!encoder || !this.encodeResult(encoder, x))
|
|
573
617
|
return;
|
|
574
|
-
|
|
618
|
+
if (this.presentationRoot) {
|
|
619
|
+
const root = this.presentationRoot;
|
|
620
|
+
if (!root.collectingPresentation && root.targets.result)
|
|
621
|
+
WebGPURaster.presentLayer(encoder, root.presentationSequence ?? [root]);
|
|
622
|
+
}
|
|
623
|
+
else
|
|
624
|
+
this.custom(encoder, this.presentPipeline, drawable(this.context, 'single', encoder).createView(), this.presentUniform(), [this.targets.result], !clear, this.presentScissor(), [this.canvas.width, this.canvas.height]);
|
|
575
625
|
if (this.timing) {
|
|
576
626
|
encoder.resolveQuerySet(this.timing.queries, 0, 2, this.timing.resolve, 0);
|
|
577
627
|
encoder.copyBufferToBuffer(this.timing.resolve, 0, this.timing.read, 0, 16);
|
|
@@ -601,18 +651,34 @@ export class WebGPURaster {
|
|
|
601
651
|
* region presenter's layer already is its sample rectangle and keeps layer space. */
|
|
602
652
|
localResult = false;
|
|
603
653
|
/** Device-pixel offset of this layer's canvas within its layer. */
|
|
604
|
-
|
|
654
|
+
presentationViewport() {
|
|
605
655
|
const viewport = canvasViewports.get(this.canvas);
|
|
656
|
+
if (!viewport || !this.setup || !this.presentationMembers.size)
|
|
657
|
+
return viewport;
|
|
658
|
+
// The placeholder keeps its original band. object-fit:contain, aligned to the
|
|
659
|
+
// bottom, lets the worker omit transparent rows above all retained morph bounds.
|
|
660
|
+
const top = Math.max(viewport[1], Math.floor(Math.min(this.setup.spec.frameY - 64, ...[...this.presentationMembers].map(member => member.presentationOrigin.y))));
|
|
661
|
+
return [viewport[0], top, viewport[2], viewport[1] + viewport[3] - top];
|
|
662
|
+
}
|
|
663
|
+
canvasOffset() {
|
|
664
|
+
const viewport = this.presentationViewport();
|
|
606
665
|
const resolution = this.setup.resolution;
|
|
607
666
|
return viewport ? [viewport[0] * resolution, viewport[1] * resolution] : [0, 0];
|
|
608
667
|
}
|
|
609
668
|
canvasSize() {
|
|
610
|
-
const viewport =
|
|
669
|
+
const viewport = this.presentationViewport();
|
|
611
670
|
const { resolution, width, height } = this.setup;
|
|
612
671
|
return viewport
|
|
613
672
|
? [Math.round(viewport[2] * resolution), Math.round(viewport[3] * resolution)]
|
|
614
673
|
: [Math.round(width * resolution), Math.round(height * resolution)];
|
|
615
674
|
}
|
|
675
|
+
resizeCanvas() {
|
|
676
|
+
const [width, height] = this.canvasSize();
|
|
677
|
+
if (this.canvas.width !== width)
|
|
678
|
+
this.canvas.width = width;
|
|
679
|
+
if (this.canvas.height !== height)
|
|
680
|
+
this.canvas.height = height;
|
|
681
|
+
}
|
|
616
682
|
/** Size the shared layer canvas to a band of its layer (the element is placed there by
|
|
617
683
|
* the page): a lift overlay presents a strip, not a second full-screen canvas. */
|
|
618
684
|
setCanvasViewport(viewport) {
|
|
@@ -694,20 +760,21 @@ export class WebGPURaster {
|
|
|
694
760
|
* `presentedLayers`): the same results at the same places, or nothing at all. */
|
|
695
761
|
static presentLayer(encoder, rasters) {
|
|
696
762
|
const root = rasters[0];
|
|
697
|
-
|
|
763
|
+
root.presentationSequence = rasters;
|
|
764
|
+
const visible = [...new Set([...rasters, ...root.presentationMembers])].filter(raster => !raster.resultEmpty && !!raster.targets.result && !raster.covered && !raster.suspended);
|
|
698
765
|
const signature = JSON.stringify([
|
|
699
766
|
root.canvas.width,
|
|
700
767
|
root.canvas.height,
|
|
701
768
|
visible.map(raster => [
|
|
702
769
|
raster.resultOwner,
|
|
703
770
|
raster.resultRevision,
|
|
704
|
-
raster.
|
|
705
|
-
raster.
|
|
771
|
+
raster.groupPresentUniform(root),
|
|
772
|
+
raster.groupPresentScissor(root),
|
|
706
773
|
]),
|
|
707
774
|
]);
|
|
708
775
|
if (presentedLayers.get(root.canvas) === signature)
|
|
709
776
|
return false;
|
|
710
|
-
const draws = visible.map(raster => raster.presentDraw());
|
|
777
|
+
const draws = visible.map(raster => raster.presentDraw(root));
|
|
711
778
|
const size = [root.canvas.width, root.canvas.height];
|
|
712
779
|
encodePass(encoder, {
|
|
713
780
|
colorAttachments: [
|
|
@@ -734,8 +801,35 @@ export class WebGPURaster {
|
|
|
734
801
|
presentedLayers.set(root.canvas, signature);
|
|
735
802
|
return true;
|
|
736
803
|
}
|
|
737
|
-
|
|
738
|
-
|
|
804
|
+
presentationOffset(root) {
|
|
805
|
+
if (this.presentationRoot !== root || !this.presentationOrigin)
|
|
806
|
+
return [0, 0];
|
|
807
|
+
const resolution = this.setup.resolution;
|
|
808
|
+
const [x, y] = this.canvasOffset();
|
|
809
|
+
const [rootX, rootY] = root.canvasOffset();
|
|
810
|
+
return [
|
|
811
|
+
this.presentationOrigin.x * resolution + x - rootX,
|
|
812
|
+
this.presentationOrigin.y * resolution + y - rootY,
|
|
813
|
+
];
|
|
814
|
+
}
|
|
815
|
+
groupPresentUniform(root) {
|
|
816
|
+
const values = this.presentUniform();
|
|
817
|
+
const [x, y] = this.presentationOffset(root);
|
|
818
|
+
values[0] += x;
|
|
819
|
+
values[1] += y;
|
|
820
|
+
values[8] += x;
|
|
821
|
+
values[9] += y;
|
|
822
|
+
return values;
|
|
823
|
+
}
|
|
824
|
+
groupPresentScissor(root) {
|
|
825
|
+
const rect = this.presentScissor();
|
|
826
|
+
if (!rect)
|
|
827
|
+
return rect;
|
|
828
|
+
const [x, y] = this.presentationOffset(root);
|
|
829
|
+
return [rect[0] + x, rect[1] + y, rect[2], rect[3]];
|
|
830
|
+
}
|
|
831
|
+
presentDraw(root) {
|
|
832
|
+
const buffer = this.passes.uniform(new Float32Array(this.groupPresentUniform(root)));
|
|
739
833
|
const group = this.device.createBindGroup({
|
|
740
834
|
layout: this.presentPipeline.getBindGroupLayout(0),
|
|
741
835
|
entries: [
|
|
@@ -744,7 +838,7 @@ export class WebGPURaster {
|
|
|
744
838
|
{ binding: 2, resource: viewOf(this.targets.result.texture) },
|
|
745
839
|
],
|
|
746
840
|
});
|
|
747
|
-
return { raster: this, group, rect: this.
|
|
841
|
+
return { raster: this, group, rect: this.groupPresentScissor(root) };
|
|
748
842
|
}
|
|
749
843
|
/** Scroll-attached glass is part of the scrolling content, and the navigation bar's scroll-edge
|
|
750
844
|
* effect is painted over that content (CSS, a layer above the scroll container). Its own
|
|
@@ -946,11 +1040,7 @@ export class WebGPURaster {
|
|
|
946
1040
|
const setup = this.setup;
|
|
947
1041
|
const { spec, appearance, resolution } = setup;
|
|
948
1042
|
const targets = this.targets;
|
|
949
|
-
|
|
950
|
-
if (this.canvas.width !== canvasWidth)
|
|
951
|
-
this.canvas.width = canvasWidth;
|
|
952
|
-
if (this.canvas.height !== canvasHeight)
|
|
953
|
-
this.canvas.height = canvasHeight;
|
|
1043
|
+
this.resizeCanvas();
|
|
954
1044
|
const sampleRect = this.sampleRectangle();
|
|
955
1045
|
this.presentRect = sampleRect;
|
|
956
1046
|
// Result space = layer space shifted by an integer pixel origin, so every kernel
|
|
@@ -13,6 +13,10 @@ export declare function usesBarKernel(spec: ResultLayout['spec']): boolean;
|
|
|
13
13
|
* its accessory, toolbar and glass buttons. Menus and presentation treatments keep the chained
|
|
14
14
|
* pyramid their materials were fitted on. */
|
|
15
15
|
export declare function usesAreaMips(spec: ResultLayout['spec']): boolean;
|
|
16
|
+
/** The tab material does not paint its 128 pt backdrop overscan. Keep a generous
|
|
17
|
+
* 64 pt output margin around every capsule; the fitted bar shadow has radius 4 pt
|
|
18
|
+
* and offset 8 pt. The source and blur pyramid still retain their full sample area. */
|
|
19
|
+
export declare function tabMaterialBounds(spec: ResultLayout['spec']): number[] | undefined;
|
|
16
20
|
export declare function encodeMaterial(encoder: GPUCommandEncoder, passes: Passes, targets: Record<string, Target>, layout: ResultLayout, material: Values): void;
|
|
17
21
|
/** The tab bar's selection pill (native selection recipe) and, while the pill lifts, its lens,
|
|
18
22
|
* over a composed scratch of the backdrop and the bar material. */
|
|
@@ -25,9 +25,25 @@ export function usesBarKernel(spec) {
|
|
|
25
25
|
export function usesAreaMips(spec) {
|
|
26
26
|
return usesBarKernel(spec);
|
|
27
27
|
}
|
|
28
|
+
/** The tab material does not paint its 128 pt backdrop overscan. Keep a generous
|
|
29
|
+
* 64 pt output margin around every capsule; the fitted bar shadow has radius 4 pt
|
|
30
|
+
* and offset 8 pt. The source and blur pyramid still retain their full sample area. */
|
|
31
|
+
export function tabMaterialBounds(spec) {
|
|
32
|
+
if (!spec.tabSelection || spec.materialProfile !== 'chrome' || spec.treatment)
|
|
33
|
+
return undefined;
|
|
34
|
+
const members = spec.shape.members?.length
|
|
35
|
+
? spec.shape.members
|
|
36
|
+
: [spec.shape.first, ...(spec.shape.second ? [spec.shape.second] : [])];
|
|
37
|
+
const margin = 64 + (Math.max(0, spec.shape.blendRadius) * (members.length - 1)) / 4;
|
|
38
|
+
const left = Math.min(...members.map(s => s.centerX - s.halfWidth)) + spec.sampleInsetX - margin;
|
|
39
|
+
const right = Math.max(...members.map(s => s.centerX + s.halfWidth)) + spec.sampleInsetX + margin;
|
|
40
|
+
const top = Math.min(...members.map(s => s.centerY - s.halfHeight)) + spec.sampleInsetY - margin;
|
|
41
|
+
const bottom = Math.max(...members.map(s => s.centerY + s.halfHeight)) + spec.sampleInsetY + margin;
|
|
42
|
+
return [left, top, right - left, bottom - top];
|
|
43
|
+
}
|
|
28
44
|
export function encodeMaterial(encoder, passes, targets, layout, material) {
|
|
29
45
|
const { spec, appearance, resolution, sampleWidth, sampleHeight } = layout;
|
|
30
|
-
const fieldBounds = morphFieldSampleBounds(spec);
|
|
46
|
+
const fieldBounds = tabMaterialBounds(spec) ?? morphFieldSampleBounds(spec);
|
|
31
47
|
const fieldScissor = fieldBounds?.map(value => value * resolution);
|
|
32
48
|
const first = spec.shape.first;
|
|
33
49
|
if (spec.treatment === 'contextBackdrop' || spec.treatment === 'contextPreview') {
|