@vectoriox/iox-ui 5.84.0 → 5.85.0

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 (37) hide show
  1. package/actions-core/index.d.ts +18 -2
  2. package/composite-core/index.d.ts +93 -15
  3. package/design-core/index.d.ts +566 -4
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +18 -1
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +307 -39
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +680 -15
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +35 -4
  11. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-scene-core.mjs +101 -5
  13. package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
  14. package/fesm2022/vectoriox-iox-ui.mjs +285 -12
  15. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  16. package/generation-core/index.d.ts +13 -0
  17. package/index.d.ts +143 -42
  18. package/package.json +1 -1
  19. package/scene-core/index.d.ts +32 -4
  20. package/src/actions-core/dialog-motion.ts +25 -0
  21. package/src/composite-core/header.ts +25 -0
  22. package/src/composite-core/public-api.ts +1 -0
  23. package/src/composite-core/slider-runtime.ts +82 -0
  24. package/src/composite-core/slider.ts +194 -27
  25. package/src/composite-core/structure.ts +14 -9
  26. package/src/design-core/flavor-spec.generated.ts +596 -9
  27. package/src/design-core/header-behaviour.ts +76 -0
  28. package/src/design-core/public-api.ts +1 -0
  29. package/src/design-core/theme-color.ts +21 -5
  30. package/src/generation-core/compose-site.ts +8 -0
  31. package/src/generation-core/node-baselines.generated.ts +17 -3
  32. package/src/generation-core/patterns.ts +2 -0
  33. package/src/generation-core/site-spec.generated.ts +8 -0
  34. package/src/scene-core/scene.ts +18 -0
  35. package/src/scene-core/scroll-driver.ts +83 -6
  36. package/styles/iox-design.css +232 -1
  37. package/styles/iox-forms.css +5 -2
@@ -0,0 +1,76 @@
1
+ /**
2
+ * How a site's header behaves as the page scrolls (premium components T2, M4). Two traits on the header's ROOT (the
3
+ * Navigation bar, generation-core `NavRole.Root`) — edited in its "Header" section, stamped by the composer from a
4
+ * `nav.*` pattern's `header` — and ONE answer every renderer applies: the attributes below, which the shared CSS
5
+ * (`iox-design.css`) and the header runtime (`HeaderRuntime`, main entry) read. A header with neither trait renders
6
+ * exactly as before (it scrolls away with the page, its divider line as its look draws it).
7
+ *
8
+ * Framework-free: the composer (generation-core) and both renderers read it.
9
+ */
10
+
11
+ export enum HeaderBehaviour {
12
+ /** It scrolls away with the page (the default; what every header saved before M4 does). */
13
+ Scrolls = 'scrolls',
14
+ /** It stays at the top while the page scrolls under it. */
15
+ Stays = 'stays',
16
+ /** It steps aside while the page scrolls down and comes back the moment it scrolls up. */
17
+ Hides = 'hides',
18
+ }
19
+
20
+ /** What separates the header from the page once the page has moved under it (Emil 99: an edge, not a divider). */
21
+ export enum HeaderEdge {
22
+ /** A soft fade of the header's own ground over the content under it. */
23
+ Fade = 'fade',
24
+ /** A hairline in the header's border colour. */
25
+ Line = 'line',
26
+ None = 'none',
27
+ }
28
+
29
+ /** The traits, stored in the root's props (no default: a plain Container saves neither). */
30
+ export enum HeaderTrait {
31
+ Behaviour = 'headerBehaviour',
32
+ Edge = 'headerEdge',
33
+ }
34
+
35
+ export enum HeaderAttribute {
36
+ /** `stays` | `hides` on a header that stays at the top. Absent: it scrolls away. */
37
+ Behaviour = 'data-iox-header',
38
+ /** `fade` | `line` | `none`: the edge it shows once the page has scrolled. Absent: its look's own divider stays. */
39
+ Edge = 'data-iox-header-edge',
40
+ /** Written by the header runtime while a `hides` header has stepped aside. */
41
+ Hidden = 'data-iox-header-hidden',
42
+ }
43
+
44
+ /** What a composed header gets (the `nav.*` patterns' `header`, the palette's Navigation bar). */
45
+ export const COMPOSED_HEADER: Readonly<{ behaviour: HeaderBehaviour; edge: HeaderEdge }> = {
46
+ behaviour: HeaderBehaviour.Hides,
47
+ edge: HeaderEdge.Fade,
48
+ };
49
+
50
+ const BEHAVIOURS = new Set<string>(Object.values(HeaderBehaviour));
51
+ const EDGES = new Set<string>(Object.values(HeaderEdge));
52
+
53
+ /** A stored behaviour, or Scrolls for anything else. */
54
+ export function headerBehaviourOf(value: unknown): HeaderBehaviour {
55
+ return typeof value === 'string' && BEHAVIOURS.has(value) ? value as HeaderBehaviour : HeaderBehaviour.Scrolls;
56
+ }
57
+
58
+ /** A stored edge, or null (none chosen: the look's divider stays as it is). */
59
+ export function headerEdgeOf(value: unknown): HeaderEdge | null {
60
+ return typeof value === 'string' && EDGES.has(value) ? value as HeaderEdge : null;
61
+ }
62
+
63
+ /** True when the header keeps its place at the top of the screen (it stays, or it hides and comes back). */
64
+ export function headerStaysAtTop(behaviour: unknown): boolean {
65
+ return headerBehaviourOf(behaviour) !== HeaderBehaviour.Scrolls;
66
+ }
67
+
68
+ /** The attributes a header renders, from its two trait values — `{}` for a header that keeps today's behaviour. */
69
+ export function headerAttributes(behaviour: unknown, edge: unknown): Record<string, string> {
70
+ const out: Record<string, string> = {};
71
+ const b = headerBehaviourOf(behaviour);
72
+ if (b !== HeaderBehaviour.Scrolls) out[HeaderAttribute.Behaviour] = b;
73
+ const e = headerEdgeOf(edge);
74
+ if (e) out[HeaderAttribute.Edge] = e;
75
+ return out;
76
+ }
@@ -13,6 +13,7 @@ export * from './design-token.model';
13
13
  export * from './css-names';
14
14
  export * from './token-css';
15
15
  export * from './theme-color';
16
+ export * from './header-behaviour';
16
17
  export * from './flavor';
17
18
  export * from './element-styles';
18
19
  export * from './adopt';
@@ -3,6 +3,8 @@ import { DesignTokenLike } from './token-css';
3
3
 
4
4
  /** The token the browser's own chrome takes its colour from: the page's ground. */
5
5
  export const THEME_COLOR_TOKEN = 'bg';
6
+ /** …or the header's ground, when a header stays at the top of the screen (premium components T2, M4). */
7
+ export const HEADER_THEME_COLOR_TOKEN = 'nav-bg';
6
8
 
7
9
  const VAR_REF = /^var\(\s*--iox-([\w-]+)\s*(?:,\s*([^()]+))?\)$/;
8
10
  /** A colour the browser's chrome can wear: opaque hex, rgb() or hsl() — never a gradient, a mix or a translucent one. */
@@ -20,22 +22,36 @@ function resolveIn(map: ReadonlyMap<string, string>, value: string | undefined,
20
22
  return resolveIn(map, map.get(ref[1]) ?? ref[2]?.trim(), depth + 1);
21
23
  }
22
24
 
23
- function colourFor(rows: ReadonlyArray<DesignTokenLike>, dark: boolean): string | undefined {
25
+ function colourFor(rows: ReadonlyArray<DesignTokenLike>, dark: boolean, token: string): string | undefined {
24
26
  const map = new Map(rows.map(r => [r.name, dark ? (r.values?.dark ?? r.value) : r.value] as [string, string]));
25
- const colour = resolveIn(map, map.get(THEME_COLOR_TOKEN));
27
+ const colour = resolveIn(map, map.get(token));
26
28
  return colour && SOLID_COLOUR.test(colour) ? colour : undefined;
27
29
  }
28
30
 
31
+ export interface ThemeColorOptions {
32
+ /** A header stays at the top of the screen (it stays, or hides and comes back): the browser's chrome meets the
33
+ * HEADER, so it takes the header's ground when that is a solid colour (a translucent one falls back to the page's). */
34
+ header?: boolean;
35
+ }
36
+
37
+ function themeColourFor(rows: ReadonlyArray<DesignTokenLike>, dark: boolean, options: ThemeColorOptions): string | undefined {
38
+ return (options.header ? colourFor(rows, dark, HEADER_THEME_COLOR_TOKEN) : undefined) ?? colourFor(rows, dark, THEME_COLOR_TOKEN);
39
+ }
40
+
29
41
  /**
30
42
  * The `theme-color` the browser's chrome takes per colour scheme (premium components G15 — mobile-native M10): the
31
43
  * page's ground in each mode the site shows, resolved through the org's rows. A light or dark site answers its one
32
44
  * mode; `auto` answers both (the engine writes them with `media="(prefers-color-scheme: …)"`). A value that is not a
33
45
  * solid colour answers nothing — the browser keeps its own.
34
46
  */
35
- export function themeColors(rows: ReadonlyArray<DesignTokenLike> | null | undefined, mode: DesignMode | string | null | undefined = 'auto'): ThemeColors {
47
+ export function themeColors(
48
+ rows: ReadonlyArray<DesignTokenLike> | null | undefined,
49
+ mode: DesignMode | string | null | undefined = 'auto',
50
+ options: ThemeColorOptions = {},
51
+ ): ThemeColors {
36
52
  const list = rows ?? [];
37
53
  const out: ThemeColors = {};
38
- if (mode !== 'dark') { const light = colourFor(list, false); if (light) out.light = light; }
39
- if (mode !== 'light') { const dark = colourFor(list, true); if (dark) out.dark = dark; }
54
+ if (mode !== 'dark') { const light = themeColourFor(list, false, options); if (light) out.light = light; }
55
+ if (mode !== 'light') { const dark = themeColourFor(list, true, options); if (dark) out.dark = dark; }
40
56
  return out;
41
57
  }
@@ -7,6 +7,7 @@ import {
7
7
  AccordionAutoBinds, CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
8
8
  tabPanelImageOf, tabPanelTextOf, tabsListOf, tabsPanels, tabsPanelsOf, tabsTriggers, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, TABS_LABEL_BIND_PROP,
9
9
  TABS_TEXT_BIND_PROP, TABS_WORDS, TabsAutoBinds, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
10
+ setHeaderBehaviour,
10
11
  } from '@vectoriox/iox-ui/composite-core';
11
12
 
12
13
  import { EntranceMark, EntrancePart, planEntrances } from './entrances';
@@ -1210,6 +1211,12 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
1210
1211
  const shape = patternById(plan.globals[slot])?.phoneMenu;
1211
1212
  if (shape) globals = planNavShape(globals, shape as NavShape, {}, slot).globals;
1212
1213
  }
1214
+ // Premium M4 (T2): a header pattern's behaviour as the page scrolls — the same edit the Header section makes.
1215
+ for (const slot of Object.values(GlobalSlot)) {
1216
+ const header = patternById(plan.globals[slot])?.header;
1217
+ const root = header ? globals.find(g => g.id === slot) : null;
1218
+ if (header && root) setHeaderBehaviour(root as unknown as CompositeNode, header.behaviour, header.edge ?? null);
1219
+ }
1213
1220
 
1214
1221
  return {
1215
1222
  source: clone(plan.source),
@@ -1302,6 +1309,7 @@ export function composeNavHeader(input: NavHeaderInput, options: NavHeaderOption
1302
1309
  root.globalPosition = pattern.global;
1303
1310
  const shape = pattern.phoneMenu as NavShape | undefined;
1304
1311
  const header = shape ? planNavShape([root], shape, { newId: options.newId }, root.id).globals[0] : root;
1312
+ if (pattern.header) setHeaderBehaviour(header as unknown as CompositeNode, pattern.header.behaviour, pattern.header.edge ?? null);
1305
1313
  return { header, issues };
1306
1314
  }
1307
1315
 
@@ -691,7 +691,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
691
691
  "defaultOn",
692
692
  "activateOn",
693
693
  "orientation",
694
- "ariaLabel"
694
+ "ariaLabel",
695
+ "headerBehaviour",
696
+ "headerEdge"
695
697
  ],
696
698
  "styleTraits": [
697
699
  "position",
@@ -3114,6 +3116,10 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
3114
3116
  "slideToClickedSlide": false,
3115
3117
  "effect": "slide",
3116
3118
  "speed": 300,
3119
+ "scrollPlays": false,
3120
+ "controlsPlacement": "under",
3121
+ "enableCaption": false,
3122
+ "enableCount": false,
3117
3123
  "enableNavigation": false,
3118
3124
  "navigationColor": "#ffffff",
3119
3125
  "navigationSize": 44,
@@ -3221,7 +3227,11 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
3221
3227
  "__effectsLabel",
3222
3228
  "effect",
3223
3229
  "speed",
3230
+ "scrollPlays",
3224
3231
  "__navLabel",
3232
+ "controlsPlacement",
3233
+ "enableCaption",
3234
+ "enableCount",
3225
3235
  "enableNavigation",
3226
3236
  "navigationColor",
3227
3237
  "navigationSize",
@@ -3326,7 +3336,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
3326
3336
  "container": true
3327
3337
  },
3328
3338
  "SliderSlide": {
3329
- "props": {},
3339
+ "props": {
3340
+ "caption": ""
3341
+ },
3330
3342
  "styleProps": {
3331
3343
  "position": "static",
3332
3344
  "top": "auto",
@@ -3405,7 +3417,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
3405
3417
  "transitionDelay": "0ms",
3406
3418
  "transitionTimingFunction": "ease"
3407
3419
  },
3408
- "traits": [],
3420
+ "traits": [
3421
+ "caption"
3422
+ ],
3409
3423
  "styleTraits": [
3410
3424
  "position",
3411
3425
  "top",
@@ -153,6 +153,8 @@ export interface SitePattern {
153
153
  readonly global?: `${PatternGlobalPosition}`;
154
154
  /** A navigation pattern's phone shape (CC1): `planNavShape` adds it after the pattern is stamped. */
155
155
  readonly phoneMenu?: 'drawer' | 'bar' | 'both';
156
+ /** A navigation pattern's behaviour as the page scrolls (premium M4, T2): design-core HeaderBehaviour / HeaderEdge. */
157
+ readonly header?: { readonly behaviour: 'scrolls' | 'stays' | 'hides'; readonly edge?: 'fade' | 'line' | 'none' };
156
158
  /** SC6: a Scene section — held while the page scrolls, moved by the scroll. Only at How animated Expressive, at
157
159
  * most one per page and on at most two pages (`validateSitePlan`); it never takes the look's entrances. */
158
160
  readonly scene?: boolean;
@@ -1148,6 +1148,10 @@ export const SPEC_SITE_PATTERNS = {
1148
1148
  "label": "Navigation bar",
1149
1149
  "global": "before",
1150
1150
  "phoneMenu": "drawer",
1151
+ "header": {
1152
+ "behaviour": "hides",
1153
+ "edge": "fade"
1154
+ },
1151
1155
  "intent": "The brand on one side, the pages and one call to action on the other — the default header.",
1152
1156
  "node": {
1153
1157
  "t": "Container",
@@ -1217,6 +1221,10 @@ export const SPEC_SITE_PATTERNS = {
1217
1221
  "label": "Navigation — the name centred",
1218
1222
  "global": "before",
1219
1223
  "phoneMenu": "drawer",
1224
+ "header": {
1225
+ "behaviour": "hides",
1226
+ "edge": "fade"
1227
+ },
1220
1228
  "intent": "The name centred above a centred row of pages — when the name is the brand.",
1221
1229
  "node": {
1222
1230
  "t": "Container",
@@ -80,6 +80,24 @@ export enum SceneAttribute {
80
80
  Root = 'data-iox-scene',
81
81
  Tracks = 'data-iox-tracks',
82
82
  PhoneTracks = 'data-iox-tracks-phone',
83
+ /** A root's stage that is not a Scene's `scene.stage` part: the slider a scroll plays (premium M4). */
84
+ Stage = 'data-iox-scene-stage',
85
+ }
86
+
87
+ /**
88
+ * A SLIDER THE SCROLL PLAYS (premium components M4 — M1.3's held gallery). Its progress rests on each picture and changes
89
+ * only over the middle share of each step, so every picture arrives and stays a moment while the page holds it: `p` is
90
+ * the scroll's progress through the runway (0–1), `n` the slides; the answer is Swiper's progress (0–1).
91
+ */
92
+ export const SLIDER_HOLD_SHARE = 0.2;
93
+ /** Page px of scroll per picture, as a share of the screen's height (the runway the renderers reserve). */
94
+ export const SLIDER_SCROLL_STEP_SCREENS = 0.55;
95
+ export function heldProgress(p: number, n: number): number {
96
+ if (!(n >= 2) || !Number.isFinite(p)) return 0;
97
+ const s = clamp01(p) * (n - 1);
98
+ const i = Math.min(n - 2, Math.floor(s));
99
+ const f = clamp01((s - i - SLIDER_HOLD_SHARE) / (1 - 2 * SLIDER_HOLD_SHARE));
100
+ return round((i + smoothstep(f)) / (n - 1));
83
101
  }
84
102
 
85
103
  /** At or under this stage width a layer plays its phone tracks. The `mobile` breakpoint's width. */
@@ -1,7 +1,7 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
2
  import { FramePlayer, normalizeFrameSequence, VideoAttribute } from '@vectoriox/iox-ui/media-core';
3
3
 
4
- import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
4
+ import { heldProgress, normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
5
5
  import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
6
6
 
7
7
  /**
@@ -24,9 +24,32 @@ const SETTLED_PX = 0.25;
24
24
  const MAX_FRAME_SECONDS = 0.1;
25
25
  /** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
26
26
  const WAKING_FRAME_SECONDS = 1 / 60;
27
- const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}`;
27
+ const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}, [${SceneAttribute.Stage}]`;
28
28
  const TRANSFORM_WILL_CHANGE = 'transform, opacity';
29
29
 
30
+ /**
31
+ * Dispatched (bubbling) by something the driver plays that arrived after the driver last looked — a Swiper booted on a
32
+ * slider's stage. Never a DOM `scroll`: nothing that follows the driver may dispatch one (SC2).
33
+ */
34
+ export const SCENE_WAKE_EVENT = 'iox-scene-wake';
35
+
36
+ /**
37
+ * ONE driver per root (premium M4 — §0 "To verify first"). The live site attaches a driver per runtime instance (the
38
+ * page's and the globals'); an instance that scanned a container holding the other's Scenes would drive them a second
39
+ * time, two damped values fighting over one layer. A root is held by the first driver that adds it until that driver
40
+ * lets it go.
41
+ */
42
+ const HELD = new WeakMap<HTMLElement, ScrollDriver>();
43
+
44
+ /** The slider a stage carries when the scroll plays it: a booted Swiper (it sets `el.swiper`), read by shape. */
45
+ interface PlayedSlider { slides?: ArrayLike<unknown>; setProgress?: (progress: number, speed?: number) => void; destroyed?: boolean }
46
+ function sliderOf(stage: HTMLElement | null): PlayedSlider | null {
47
+ // The stage is the runway's child: the `.swiper` itself (both engine paths), or the canvas's shell around it.
48
+ const holder = (stage?.classList.contains('swiper') ? stage : stage?.querySelector('.swiper')) as (HTMLElement & { swiper?: PlayedSlider }) | null | undefined;
49
+ const swiper = holder?.swiper;
50
+ return swiper && typeof swiper.setProgress === 'function' && !swiper.destroyed ? swiper : null;
51
+ }
52
+
30
53
  /** One frame of the damping: frame-rate independent, so 60 Hz and 120 Hz settle in the same time. */
31
54
  export function dampToward(current: number, target: number, seconds: number, rate: number = SCENE_DAMPING): number {
32
55
  return current + (target - current) * (1 - Math.exp(-seconds * rate));
@@ -86,6 +109,8 @@ interface Scene {
86
109
  /** Page px and the stage's own width, measured when something resizes and never per frame. */
87
110
  height: number;
88
111
  travel: number;
112
+ /** Page px from the top of the screen at which the stage holds: 0 for a Scene, mid-screen for a played slider. */
113
+ stick: number;
89
114
  phone: boolean;
90
115
  inlineSign: 1 | -1;
91
116
  /** No frame written yet: the first one lands exactly where the scroll is, with no glide. */
@@ -105,20 +130,28 @@ export class ScrollDriver {
105
130
  private frame = 0;
106
131
  private lastTime = 0;
107
132
  private readonly wakeUp = (): void => this.wake();
133
+ private readonly onWakeEvent = (e: Event): void => {
134
+ const scene = [...this.scenes.values()].find(s => s.root.contains(e.target as Node));
135
+ if (scene) { scene.fresh = true; this.wake(); }
136
+ };
108
137
 
109
138
  constructor(private readonly options: ScrollDriverOptions = {}) {
110
139
  this.host = options.host ?? window;
111
140
  // In capture: a scroll does not bubble, and the canvas's Lenis scrolls an element, not the window.
112
141
  this.host.addEventListener('scroll', this.wakeUp, { capture: true, passive: true });
113
142
  this.host.addEventListener('resize', this.wakeUp, { passive: true });
143
+ this.host.addEventListener(SCENE_WAKE_EVENT, this.onWakeEvent, { capture: true });
114
144
  const Observer = (this.host as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
115
145
  this.resize = Observer ? new Observer(() => this.remeasureAll()) : null;
116
146
  }
117
147
 
118
- /** Drive a Scene's root. Idempotent: adding a root again re-reads it. */
119
- add(root: HTMLElement): void {
148
+ /** Drive a Scene's root. Idempotent: adding a root again re-reads it. False when another driver holds it. */
149
+ add(root: HTMLElement): boolean {
150
+ const holder = HELD.get(root);
151
+ if (holder && holder !== this) return false;
120
152
  this.remove(root);
121
- const scene: Scene = { root, stage: null, layers: [], progress: 0, target: 0, height: 0, travel: 0, phone: false, inlineSign: 1, fresh: true, mutations: null };
153
+ HELD.set(root, this);
154
+ const scene: Scene = { root, stage: null, layers: [], progress: 0, target: 0, height: 0, travel: 0, stick: 0, phone: false, inlineSign: 1, fresh: true, mutations: null };
122
155
  this.scenes.set(root, scene);
123
156
  this.resize?.observe(root);
124
157
  if (this.options.observe) {
@@ -129,11 +162,18 @@ export class ScrollDriver {
129
162
  }
130
163
  }
131
164
  this.readScene(scene);
165
+ return true;
166
+ }
167
+
168
+ /** True when this driver holds `root`. */
169
+ holds(root: HTMLElement): boolean {
170
+ return this.scenes.has(root);
132
171
  }
133
172
 
134
173
  remove(root: HTMLElement): void {
135
174
  const scene = this.scenes.get(root);
136
175
  if (!scene) return;
176
+ if (HELD.get(root) === this) HELD.delete(root);
137
177
  scene.mutations?.disconnect();
138
178
  this.resize?.unobserve(root);
139
179
  if (scene.stage) scene.stage.style.removeProperty('translate');
@@ -198,6 +238,7 @@ export class ScrollDriver {
198
238
  this.frame = 0;
199
239
  this.host.removeEventListener('scroll', this.wakeUp, { capture: true });
200
240
  this.host.removeEventListener('resize', this.wakeUp);
241
+ this.host.removeEventListener(SCENE_WAKE_EVENT, this.onWakeEvent, { capture: true });
201
242
  for (const root of [...this.scenes.keys()]) this.remove(root);
202
243
  this.resize?.disconnect();
203
244
  }
@@ -250,6 +291,9 @@ export class ScrollDriver {
250
291
  private measure(scene: Scene): void {
251
292
  scene.height = scene.root.offsetHeight;
252
293
  scene.travel = Math.max(0, scene.height - (scene.stage?.offsetHeight ?? scene.height));
294
+ // Where the stage holds: its sticky top (a played slider holds mid-screen; a Scene's stage at 0). Read when
295
+ // something resizes, never per frame.
296
+ scene.stick = scene.stage ? (parseFloat(this.host.getComputedStyle(scene.stage).top) || 0) : 0;
253
297
  scene.phone = (scene.stage?.offsetWidth ?? Infinity) <= SCENE_PHONE_MAX_WIDTH;
254
298
  scene.inlineSign = scene.stage && this.host.getComputedStyle(scene.stage).direction === 'rtl' ? -1 : 1;
255
299
  for (const layer of scene.layers) layer.stale = true;
@@ -281,11 +325,14 @@ export class ScrollDriver {
281
325
  const rect = scene.root.getBoundingClientRect();
282
326
  const origin = this.options.originOf?.(scene.root)?.getBoundingClientRect().top ?? 0;
283
327
  const zoom = scene.height ? rect.height / scene.height : 1;
284
- const { local, progress } = sceneScrollState(rect.top, origin, zoom, scene.travel);
328
+ const { local, progress } = sceneScrollState(rect.top, origin + scene.stick * zoom, zoom, scene.travel);
285
329
  return { scene, local, target: progress };
286
330
  }
287
331
 
288
332
  private write(scene: Scene): void {
333
+ // A slider the scroll plays (M4): the damped moment, held on each picture.
334
+ const slider = sliderOf(scene.stage);
335
+ if (slider) slider.setProgress!(heldProgress(scene.progress, slider.slides?.length ?? 0), 0);
289
336
  for (const layer of scene.layers) {
290
337
  const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
291
338
  this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
@@ -359,3 +406,33 @@ export function attachScenes(container: HTMLElement, options: ScrollDriverOption
359
406
  roots.forEach(root => driver.add(root));
360
407
  return driver;
361
408
  }
409
+
410
+ /**
411
+ * The Scene roots that belong to `elements` (each, and what it holds) — what one runtime instance owns: the page's nodes,
412
+ * or the globals'. Never a whole container that also holds another instance's Scenes (premium M4: the globals' instance
413
+ * scanned the app root and drove the page's Scenes a second time).
414
+ */
415
+ export function sceneRootsIn(elements: Iterable<Element>): HTMLElement[] {
416
+ const out = new Set<HTMLElement>();
417
+ for (const el of elements) {
418
+ if ((el as HTMLElement).hasAttribute?.(SceneAttribute.Root)) out.add(el as HTMLElement);
419
+ // A wrapper the node renders around itself (a played slider's runway around its `.swiper`) is the node's own.
420
+ const parent = el.parentElement;
421
+ if (parent?.hasAttribute(SceneAttribute.Root) && parent.children.length === 1) out.add(parent);
422
+ el.querySelectorAll?.<HTMLElement>(`[${SceneAttribute.Root}]`).forEach(root => out.add(root));
423
+ }
424
+ return [...out];
425
+ }
426
+
427
+ /** The live site's door for a runtime that owns some nodes: their Scenes on one driver (null when none, or under reduced
428
+ * motion — the page's own CSS then lets each Scene flow). A root another driver holds stays with it. */
429
+ export function attachSceneRoots(roots: ReadonlyArray<HTMLElement>, options: ScrollDriverOptions = {}): ScrollDriver | null {
430
+ if (!roots.length) return null;
431
+ const host = options.host ?? window;
432
+ if (host.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
433
+ const driver = new ScrollDriver(options);
434
+ const held = roots.filter(root => driver.add(root));
435
+ if (held.length) return driver;
436
+ driver.destroy();
437
+ return null;
438
+ }