@vectoriox/iox-ui 5.69.0 → 5.70.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.
@@ -0,0 +1,220 @@
1
+ import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
+
3
+ import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, SceneTrack } from './scene';
4
+
5
+ /**
6
+ * The ONE scroll loop (premium-sites plan §5b, SC0). Every Scene on a page reads one progress value from it, on the
7
+ * canvas and on the live site alike. It reads where the root is and nothing else, so it has one code path: under the
8
+ * canvas's zoom the same rectangle divides by the same zoom, and the answer is the same at any scale.
9
+ *
10
+ * - The value is DAMPED: the scroll stops and the page settles a moment later. This is much of the prototype's weight,
11
+ * and why this is not a CSS scroll timeline, which cannot do it.
12
+ * - The live site pins with `position: sticky`, on the compositor. The canvas cannot (its page never scrolls itself,
13
+ * its Lenis scrolls a wrapper around a zoomed frame), so `pin` holds the stage there with `translate` instead.
14
+ * `translate`, not `transform`: a Stage may carry tracks of its own.
15
+ * - It sleeps. No frame is asked for unless something moved, so a page holding a settled Scene costs nothing.
16
+ * - Reads come first in a frame and writes after, and a write only ever touches compositor properties.
17
+ */
18
+
19
+ export const SCENE_DAMPING = 9;
20
+ const SETTLED = 0.0005;
21
+ const MAX_FRAME_SECONDS = 0.1;
22
+ /** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
23
+ const WAKING_FRAME_SECONDS = 1 / 60;
24
+ const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}`;
25
+ const TRANSFORM_WILL_CHANGE = 'transform, opacity';
26
+
27
+ /** One frame of the damping: frame-rate independent, so 60 Hz and 120 Hz settle in the same time. */
28
+ export function dampToward(current: number, target: number, seconds: number, rate: number = SCENE_DAMPING): number {
29
+ return current + (target - current) * (1 - Math.exp(-seconds * rate));
30
+ }
31
+
32
+ /** Where a root stands: scrolled `local` page px into its `travel`. `zoom` undoes the canvas's scale. */
33
+ export function sceneScrollState(rootTop: number, originTop: number, zoom: number, travel: number): { local: number; progress: number } {
34
+ if (travel <= 0) return { local: 0, progress: 0 };
35
+ const local = Math.min(travel, Math.max(0, -(rootTop - originTop) / (zoom || 1)));
36
+ return { local, progress: local / travel };
37
+ }
38
+
39
+ export interface ScrollDriverOptions {
40
+ /** Hold the stage with `translate` (the canvas). The live site's stage is sticky and needs nothing. */
41
+ pin?: boolean;
42
+ /** The element a root scrolls under, when that is not the window (the canvas's frame, Preview's page). */
43
+ originOf?: (root: HTMLElement) => HTMLElement | null;
44
+ /** Follow what changes inside a root: tracks edited, layers added (the canvas). */
45
+ observe?: boolean;
46
+ damping?: number;
47
+ /** The window, for a test. */
48
+ host?: Window;
49
+ }
50
+
51
+ interface Layer {
52
+ el: HTMLElement;
53
+ tracks: SceneTrack[];
54
+ phoneTracks: SceneTrack[];
55
+ last: SceneFrame;
56
+ }
57
+
58
+ interface Scene {
59
+ root: HTMLElement;
60
+ stage: HTMLElement | null;
61
+ layers: Layer[];
62
+ progress: number;
63
+ /** Page px and the stage's own width, measured when something resizes and never per frame. */
64
+ height: number;
65
+ travel: number;
66
+ phone: boolean;
67
+ inlineSign: 1 | -1;
68
+ /** No frame written yet: the first one lands exactly where the scroll is, with no glide. */
69
+ fresh: boolean;
70
+ mutations: MutationObserver | null;
71
+ }
72
+
73
+ function parseTracks(raw: string | null): SceneTrack[] {
74
+ if (!raw) return [];
75
+ try { return normalizeTracks(JSON.parse(raw)); } catch { return []; }
76
+ }
77
+
78
+ export class ScrollDriver {
79
+ private readonly scenes = new Map<HTMLElement, Scene>();
80
+ private readonly host: Window;
81
+ private readonly resize: ResizeObserver | null;
82
+ private frame = 0;
83
+ private lastTime = 0;
84
+ private readonly wakeUp = (): void => this.wake();
85
+
86
+ constructor(private readonly options: ScrollDriverOptions = {}) {
87
+ this.host = options.host ?? window;
88
+ // In capture: a scroll does not bubble, and the canvas's Lenis scrolls an element, not the window.
89
+ this.host.addEventListener('scroll', this.wakeUp, { capture: true, passive: true });
90
+ this.host.addEventListener('resize', this.wakeUp, { passive: true });
91
+ const Observer = (this.host as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
92
+ this.resize = Observer ? new Observer(() => this.remeasureAll()) : null;
93
+ }
94
+
95
+ /** Drive a Scene's root. Idempotent: adding a root again re-reads it. */
96
+ add(root: HTMLElement): void {
97
+ this.remove(root);
98
+ const scene: Scene = { root, stage: null, layers: [], progress: 0, height: 0, travel: 0, phone: false, inlineSign: 1, fresh: true, mutations: null };
99
+ this.scenes.set(root, scene);
100
+ this.resize?.observe(root);
101
+ if (this.options.observe) {
102
+ const Observer = (this.host as Window & { MutationObserver?: typeof MutationObserver }).MutationObserver;
103
+ if (Observer) {
104
+ scene.mutations = new Observer(() => this.reread(scene));
105
+ scene.mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: [SceneAttribute.Tracks, SceneAttribute.PhoneTracks] });
106
+ }
107
+ }
108
+ this.reread(scene);
109
+ }
110
+
111
+ remove(root: HTMLElement): void {
112
+ const scene = this.scenes.get(root);
113
+ if (!scene) return;
114
+ scene.mutations?.disconnect();
115
+ this.resize?.unobserve(root);
116
+ if (scene.stage) scene.stage.style.removeProperty('translate');
117
+ for (const layer of scene.layers) layer.el.style.removeProperty('will-change');
118
+ this.scenes.delete(root);
119
+ }
120
+
121
+ /** The damped progress a root's layers stand at. */
122
+ progressOf(root: HTMLElement): number | null {
123
+ return this.scenes.get(root)?.progress ?? null;
124
+ }
125
+
126
+ /** Something moved that no scroll or resize announces (the canvas's zoom). */
127
+ wake(): void {
128
+ if (this.frame) return;
129
+ this.frame = this.host.requestAnimationFrame(this.tick);
130
+ }
131
+
132
+ destroy(): void {
133
+ if (this.frame) this.host.cancelAnimationFrame(this.frame);
134
+ this.frame = 0;
135
+ this.host.removeEventListener('scroll', this.wakeUp, { capture: true });
136
+ this.host.removeEventListener('resize', this.wakeUp);
137
+ for (const root of [...this.scenes.keys()]) this.remove(root);
138
+ this.resize?.disconnect();
139
+ }
140
+
141
+ /** Read a root's stage and layers from its DOM: its own Scene's only, never a nested one's. */
142
+ private reread(scene: Scene): void {
143
+ scene.stage = scene.root.querySelector<HTMLElement>(STAGE_SELECTOR);
144
+ scene.layers = Array.from(scene.root.querySelectorAll<HTMLElement>(`[${SceneAttribute.Tracks}], [${SceneAttribute.PhoneTracks}]`))
145
+ .filter(el => el === scene.root || el.parentElement?.closest(`[${SceneAttribute.Root}]`) === scene.root)
146
+ .map(el => this.readLayer(el, scene));
147
+ this.measure(scene);
148
+ scene.fresh = true;
149
+ this.wake();
150
+ }
151
+
152
+ private readLayer(el: HTMLElement, scene: Scene): Layer {
153
+ const previous = scene.layers.find(layer => layer.el === el);
154
+ el.style.setProperty('will-change', TRANSFORM_WILL_CHANGE);
155
+ return { el, tracks: parseTracks(el.getAttribute(SceneAttribute.Tracks)), phoneTracks: parseTracks(el.getAttribute(SceneAttribute.PhoneTracks)), last: previous?.last ?? {} };
156
+ }
157
+
158
+ private remeasureAll(): void {
159
+ for (const scene of this.scenes.values()) this.measure(scene);
160
+ this.wake();
161
+ }
162
+
163
+ private measure(scene: Scene): void {
164
+ scene.height = scene.root.offsetHeight;
165
+ scene.travel = Math.max(0, scene.height - (scene.stage?.offsetHeight ?? scene.height));
166
+ scene.phone = (scene.stage?.offsetWidth ?? Infinity) <= SCENE_PHONE_MAX_WIDTH;
167
+ scene.inlineSign = scene.stage && this.host.getComputedStyle(scene.stage).direction === 'rtl' ? -1 : 1;
168
+ for (const layer of scene.layers) layer.last = {};
169
+ }
170
+
171
+ private readonly tick = (now: number): void => {
172
+ this.frame = 0;
173
+ const seconds = this.lastTime ? Math.min(MAX_FRAME_SECONDS, (now - this.lastTime) / 1000) : WAKING_FRAME_SECONDS;
174
+ this.lastTime = now;
175
+ const reads = [...this.scenes.values()].map(scene => this.read(scene));
176
+ let moving = false;
177
+ for (const { scene, local, target } of reads) {
178
+ if (this.options.pin && scene.stage) scene.stage.style.setProperty('translate', `0 ${local}px`);
179
+ const next = scene.fresh ? target : dampToward(scene.progress, target, seconds, this.options.damping);
180
+ const settled = Math.abs(next - target) < SETTLED;
181
+ scene.progress = settled ? target : next;
182
+ scene.fresh = false;
183
+ this.write(scene);
184
+ moving = moving || !settled;
185
+ }
186
+ if (moving) this.wake();
187
+ else this.lastTime = 0;
188
+ };
189
+
190
+ private read(scene: Scene): { scene: Scene; local: number; target: number } {
191
+ const rect = scene.root.getBoundingClientRect();
192
+ const origin = this.options.originOf?.(scene.root)?.getBoundingClientRect().top ?? 0;
193
+ const zoom = scene.height ? rect.height / scene.height : 1;
194
+ const { local, progress } = sceneScrollState(rect.top, origin, zoom, scene.travel);
195
+ return { scene, local, target: progress };
196
+ }
197
+
198
+ private write(scene: Scene): void {
199
+ for (const layer of scene.layers) {
200
+ const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
201
+ const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
202
+ for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
203
+ if (frame[key] !== layer.last[key]) layer.el.style[key] = frame[key]!;
204
+ }
205
+ layer.last = frame;
206
+ }
207
+ }
208
+ }
209
+
210
+ /** A page's Scenes, driven by one driver; null when it holds none, or when the visitor asked for less motion (the
211
+ * page's own CSS then lets each Scene flow). The live site's door. */
212
+ export function attachScenes(container: HTMLElement, options: ScrollDriverOptions = {}): ScrollDriver | null {
213
+ const roots = container.querySelectorAll<HTMLElement>(`[${SceneAttribute.Root}]`);
214
+ if (!roots.length) return null;
215
+ const host = options.host ?? window;
216
+ if (host.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
217
+ const driver = new ScrollDriver(options);
218
+ roots.forEach(root => driver.add(root));
219
+ return driver;
220
+ }
@@ -67,6 +67,13 @@ iox-gallery.iox-es-media[data-iox-empty] > .iox-gallery {
67
67
  --iox-button-ghost-ink: currentColor;
68
68
  }
69
69
 
70
+ /* ── Scene (premium-sites plan §5b) ───────────────────────────────────────────────────────────────
71
+ The stage is the size container its layers' tracks measure against (`cqw` / `cqh`, scene-core), so one frame is
72
+ right at any width and under any canvas zoom, with no measuring. Its pin, size and clip are the node's own styles. */
73
+ .iox-role-scene-stage {
74
+ container-type: size;
75
+ }
76
+
70
77
  /* ── Card v2 (composite T23; complex-components board 8) ──────────────────────────────────────────
71
78
  The parts are ordinary nodes; `nodeDesignClasses` writes `iox-role-<role>` on each, so these rules are
72
79
  the same in every renderer. Zero-specificity where an author's own value must win. */