@vectoriox/iox-ui 5.73.4 → 5.74.1

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.
@@ -85,7 +85,7 @@ declare function normalizeTracks(tracks: unknown): SceneTrack[];
85
85
  */
86
86
  declare function revealClipPath(showing: number, from?: SceneRevealFrom, inlineSign?: 1 | -1): string;
87
87
  /** A numeric track's value at progress `p`. */
88
- declare function valueAt(keys: ReadonlyArray<SceneKey>, p: number): number;
88
+ declare function valueAt(keys: ReadonlyArray<SceneKey>, p: number, ease?: (t: number) => number): number;
89
89
  /**
90
90
  * A colour track's value at progress `p`. Mixed by the browser, never computed here, so a design token stays a live
91
91
  * reference (a re-theme reaches a Scene, as it reaches every saved page) and the path is perceptual.
@@ -104,7 +104,10 @@ declare function sceneFrame(tracks: ReadonlyArray<SceneTrack>, p: number, option
104
104
  declare const SCENE_WORD_DIM = 0.16;
105
105
  /** How many of a layer's words are lit at `p`, in %; null when it has no such track. */
106
106
  declare function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): number | null;
107
- /** How far a layer's pictures have played at `p`, in %; null when it has no such track. */
107
+ /**
108
+ * How far a layer's pictures have played at `p`, in %; null when it has no such track. A film runs straight between its
109
+ * keys, where everything else eases: eased over a 20-screen Scene it would crawl for its first screens and rush the middle.
110
+ */
108
111
  declare function scenePlayed(tracks: ReadonlyArray<SceneTrack>, p: number): number | null;
109
112
  /** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
110
113
  declare function sceneWordOpacity(index: number, count: number, lit: number): number;
@@ -130,7 +133,7 @@ declare function applySceneFirstFrame(el: HTMLElement | null | undefined, node:
130
133
  */
131
134
  declare const SCENE_FLOW_RULES: string;
132
135
  /** Under reduced motion the driver never starts, so the same rules hold for the whole visit. */
133
- declare const SCENE_REDUCED_MOTION_CSS = "@media (prefers-reduced-motion: reduce){.iox-role-scene{height:auto!important}.iox-role-scene-stage{position:relative!important;top:auto!important;height:auto!important;overflow:visible!important;container-type:normal!important}[data-iox-tracks]{transform:none!important;opacity:1!important;clip-path:none!important;pointer-events:auto!important}[data-iox-video-frames]{height:auto!important}[data-iox-video-frames]>.iox-video__poster{position:static!important;height:auto!important}}";
136
+ declare const SCENE_REDUCED_MOTION_CSS = "@media (prefers-reduced-motion: reduce){.iox-role-scene{height:auto!important}.iox-role-scene-stage{position:relative!important;top:auto!important;height:auto!important;overflow:visible!important;container-type:normal!important}[data-iox-tracks]{transform:none!important;opacity:1!important;clip-path:none!important;pointer-events:auto!important}[data-iox-video-frames]{height:auto!important}[data-iox-video-frames]>.iox-video__poster,[data-iox-video-frames]>picture>.iox-video__poster{position:static!important;height:auto!important}}";
134
137
 
135
138
  declare function sceneKeyPosition(p: number): number;
136
139
  interface SceneTrackDef {
@@ -25,10 +25,18 @@ export interface FrameSequence {
25
25
  wide: FrameSetInfo;
26
26
  /** Absent = phones play the wide set. */
27
27
  phone?: FrameSetInfo;
28
+ /**
29
+ * A phone film of its own, framed for a tall screen, lives under its own name: a replacement that fails half way leaves
30
+ * the one playing untouched. Absent = the phone set is the wide film made smaller, under `base`.
31
+ */
32
+ phoneBase?: string;
28
33
  /** What the sequence weighs in storage, both sets. Shown to the author. */
29
34
  bytes?: number;
30
35
  }
31
36
 
37
+ /** At or under this width a screen is a phone. Scene's own limit says the same: scene-core reads media-core, never the reverse. */
38
+ export const FRAMES_PHONE_MAX_WIDTH = 767;
39
+
32
40
  export enum FrameExtension {
33
41
  Webp = 'webp',
34
42
  Jpeg = 'jpg',
@@ -58,11 +66,13 @@ export function normalizeFrameSequence(raw: unknown): FrameSequence | null {
58
66
  const wide = setInfo(value.wide);
59
67
  if (!wide || typeof value.base !== 'string' || !SAFE_BASE.test(value.base) || !ALL_EXTENSIONS.has(String(value.ext))) return null;
60
68
  const phone = setInfo(value.phone);
69
+ const phoneBase = phone && typeof value.phoneBase === 'string' && SAFE_BASE.test(value.phoneBase) ? value.phoneBase.replace(/\/+$/, '') : '';
61
70
  return {
62
71
  base: value.base.replace(/\/+$/, ''),
63
72
  ext: value.ext as FrameExtension,
64
73
  wide,
65
74
  ...(phone ? { phone } : {}),
75
+ ...(phoneBase ? { phoneBase } : {}),
66
76
  ...(Number.isFinite(value.bytes) && (value.bytes as number) > 0 ? { bytes: value.bytes } : {}),
67
77
  };
68
78
  }
@@ -78,7 +88,8 @@ export function frameFileName(index: number, ext: FrameExtension): string {
78
88
 
79
89
  /** Picture `index` (from 0) of a set. */
80
90
  export function frameUrl(sequence: FrameSequence, set: FrameSet, index: number): string {
81
- return `${sequence.base}/${set}/${frameFileName(index, sequence.ext)}`;
91
+ const base = set === FrameSet.Phone && sequence.phoneBase ? sequence.phoneBase : sequence.base;
92
+ return `${base}/${set}/${frameFileName(index, sequence.ext)}`;
82
93
  }
83
94
 
84
95
  /** The first picture of each set, for a poster's `srcset`; '' when there is only one set. */
@@ -92,6 +103,42 @@ export function frameSetFor(sequence: FrameSequence, phone: boolean): { set: Fra
92
103
  return phone && sequence.phone ? { set: FrameSet.Phone, info: sequence.phone } : { set: FrameSet.Wide, info: sequence.wide };
93
104
  }
94
105
 
106
+ /** Where `played` % stands in a set of `count`: 2.5 is half way from the third picture to the fourth. */
107
+ export function framePosition(played: number, count: number): number {
108
+ return (Math.min(100, Math.max(0, played)) / 100) * (count - 1);
109
+ }
110
+
111
+ /** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
112
+ export const FRAMES_SHOWING_ATTRIBUTE = 'data-iox-frames-showing';
113
+
114
+ /** How long a stopped scroll waits before settling on a whole picture, and how fast it gets there per frame. */
115
+ const REST_AFTER_MS = 140;
116
+ const REST_RATE = 0.25;
117
+
118
+ /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
119
+ export const FRAME_BLEND_STEPS = 32;
120
+
121
+ /** What to draw: picture `from`, with `to` over it at `mix` when the moment falls between two that have arrived. */
122
+ export interface FrameBlend {
123
+ from: number;
124
+ to: number;
125
+ mix: number;
126
+ }
127
+
128
+ /**
129
+ * The moment between two pictures. A long Scene gives a picture 50px of scroll or more, so a slow scroll would otherwise
130
+ * show the film stepping. Blending the two neighbours keeps it moving under the finger, at the cost of one more drawImage.
131
+ * A neighbour still loading never blends: the nearest arrived picture stands in, and null means none has.
132
+ */
133
+ export function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number): FrameBlend | null {
134
+ const from = Math.floor(position);
135
+ const to = from + 1;
136
+ const mix = Math.round((position - from) * FRAME_BLEND_STEPS) / FRAME_BLEND_STEPS;
137
+ if (mix > 0 && mix < 1 && loaded[from] && loaded[to]) return { from, to, mix };
138
+ const nearest = nearestLoadedFrame(loaded, Math.round(position));
139
+ return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
140
+ }
141
+
95
142
  /** The picture at `played` % of a set of `count`. */
96
143
  export function frameIndexAt(played: number, count: number): number {
97
144
  if (count <= 1) return 0;
@@ -207,6 +254,9 @@ export interface FramePlayerOptions {
207
254
  * Browser only. It writes and never reads, so the driver can call it in its write phase.
208
255
  */
209
256
  export class FramePlayer {
257
+ private played: number | null = null;
258
+ private restTimer: number | null = null;
259
+ private restFrame: number | null = null;
210
260
  private readonly set: FrameSet;
211
261
  private readonly info: FrameSetInfo;
212
262
  private readonly images: Array<HTMLImageElement | null>;
@@ -215,8 +265,8 @@ export class FramePlayer {
215
265
  private observer: IntersectionObserver | null = null;
216
266
  private canvas: HTMLCanvasElement | null = null;
217
267
  private context: CanvasRenderingContext2D | null = null;
218
- private wanted = 0;
219
- private drawn = -1;
268
+ private position = 0;
269
+ private drawn: FrameBlend | null = null;
220
270
  private nextToLoad = 0;
221
271
  private loading = 0;
222
272
  private started = false;
@@ -244,8 +294,34 @@ export class FramePlayer {
244
294
 
245
295
  /** Stand at `played` %. */
246
296
  seek(played: number): void {
247
- this.wanted = frameIndexAt(played, this.info.count);
297
+ if (played === this.played) return;
298
+ this.played = played;
299
+ this.stopResting();
300
+ this.position = framePosition(played, this.info.count);
248
301
  this.draw();
302
+ this.restTimer = this.host.setTimeout?.(() => this.rest(), REST_AFTER_MS) ?? null;
303
+ }
304
+
305
+ /** A stopped scroll must not stand on two pictures at once: a moving subject would show twice. It settles on the nearer. */
306
+ private rest(): void {
307
+ this.restTimer = null;
308
+ const target = Math.round(this.position);
309
+ const step = (): void => {
310
+ this.restFrame = null;
311
+ if (this.destroyed) return;
312
+ const left = target - this.position;
313
+ this.position = Math.abs(left) < 1 / FRAME_BLEND_STEPS ? target : this.position + left * REST_RATE;
314
+ this.draw();
315
+ if (this.position !== target) this.restFrame = this.host.requestAnimationFrame(step);
316
+ };
317
+ step();
318
+ }
319
+
320
+ private stopResting(): void {
321
+ if (this.restTimer !== null) this.host.clearTimeout?.(this.restTimer);
322
+ if (this.restFrame !== null) this.host.cancelAnimationFrame?.(this.restFrame);
323
+ this.restTimer = null;
324
+ this.restFrame = null;
249
325
  }
250
326
 
251
327
  /** Whether a re-render took the canvas away with the markup it sat in. */
@@ -255,6 +331,8 @@ export class FramePlayer {
255
331
 
256
332
  destroy(): void {
257
333
  this.destroyed = true;
334
+ this.stopResting();
335
+ this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
258
336
  this.observer?.disconnect();
259
337
  this.observer = null;
260
338
  for (const image of this.pending) {
@@ -314,13 +392,23 @@ export class FramePlayer {
314
392
  }
315
393
 
316
394
  private draw(): void {
317
- const index = nearestLoadedFrame(this.images, this.wanted);
318
- if (index < 0 || index === this.drawn) return;
319
- const image = this.images[index]!;
395
+ const blend = frameBlendAt(this.images, this.position);
396
+ const last = this.drawn;
397
+ if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
398
+ const image = this.images[blend.from]!;
320
399
  if (!this.ensureCanvas(image)) return;
321
- this.context!.clearRect(0, 0, this.canvas!.width, this.canvas!.height);
322
- this.context!.drawImage(image, 0, 0, this.canvas!.width, this.canvas!.height);
323
- this.drawn = index;
400
+ const context = this.context!;
401
+ const { width, height } = this.canvas!;
402
+ context.clearRect(0, 0, width, height);
403
+ context.drawImage(image, 0, 0, width, height);
404
+ if (blend.mix > 0) {
405
+ context.globalAlpha = blend.mix;
406
+ context.drawImage(this.images[blend.to]!, 0, 0, width, height);
407
+ context.globalAlpha = 1;
408
+ }
409
+ // Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
410
+ if (!this.drawn) this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
411
+ this.drawn = blend;
324
412
  }
325
413
 
326
414
  private ensureCanvas(image: HTMLImageElement): boolean {
@@ -1,6 +1,6 @@
1
1
  import { ConsentCategory, ConsentDecision, hasConsent } from '@vectoriox/iox-ui/consent-core';
2
2
 
3
- import { FrameSequence, FrameSet, frameUrl, normalizeFrameSequence, phoneFrameSrcset } from './frames';
3
+ import { FRAMES_PHONE_MAX_WIDTH, FrameSequence, FrameSet, frameUrl, normalizeFrameSequence, phoneFrameSrcset } from './frames';
4
4
 
5
5
  /**
6
6
  * VIDEO (CC3 — complex-components plan §7b, board 5). A LEAF type with two sources:
@@ -441,7 +441,13 @@ export function videoInnerHtml(plan: VideoPlan, options: VideoMarkupOptions): st
441
441
  const srcset = own ? phoneFrameSrcset(plan.frames!) : '';
442
442
  // Its real size, so where a Scene flows (reduced motion, no script) the picture holds its height before it loads.
443
443
  const size = own ? ` width="${plan.frames!.wide.width}" height="${plan.frames!.wide.height}"` : '';
444
- return `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''}${size} alt="${esc(plan.title)}" decoding="async">`;
444
+ const image = (attrs: string): string => `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${attrs} alt="${esc(plan.title)}" decoding="async">`;
445
+ // A phone film is framed for a tall screen, so it is chosen by the screen, never by the pixels a high-density phone asks for.
446
+ if (own && plan.frames!.phoneBase) {
447
+ const { width, height } = plan.frames!.phone!;
448
+ return `<picture><source media="(max-width: ${FRAMES_PHONE_MAX_WIDTH}px)" srcset="${esc(frameUrl(plan.frames!, FrameSet.Phone, 0))}" width="${width}" height="${height}">${image(size)}</picture>`;
449
+ }
450
+ return image(`${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''}${size}`);
445
451
  }
446
452
  const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
447
453
  const label = esc(videoPlayLabel(plan, words));
@@ -1,5 +1,5 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
- import { VideoAttribute, VideoClass } from '@vectoriox/iox-ui/media-core';
2
+ import { FRAMES_PHONE_MAX_WIDTH, VideoAttribute, VideoClass } from '@vectoriox/iox-ui/media-core';
3
3
 
4
4
  /**
5
5
  * A Scene's data and maths (premium-sites plan §5b). A Scene is a recipe of ordinary nodes — a tall Section, a pinned
@@ -83,7 +83,7 @@ export enum SceneAttribute {
83
83
  }
84
84
 
85
85
  /** At or under this stage width a layer plays its phone tracks. The `mobile` breakpoint's width. */
86
- export const SCENE_PHONE_MAX_WIDTH = 767;
86
+ export const SCENE_PHONE_MAX_WIDTH = FRAMES_PHONE_MAX_WIDTH;
87
87
 
88
88
  const DECIMALS = 10000;
89
89
  const round = (v: number): number => Math.round(v * DECIMALS) / DECIMALS;
@@ -150,8 +150,10 @@ export function revealClipPath(showing: number, from: SceneRevealFrom = SCENE_RE
150
150
  }
151
151
  }
152
152
 
153
- /** The two keys around `p` and how far between them it eases. */
154
- function span(keys: ReadonlyArray<SceneKey>, p: number): { a: SceneKey; b: SceneKey; f: number } {
153
+ const linear = (t: number): number => t;
154
+
155
+ /** The two keys around `p` and how far between them it has come. */
156
+ function span(keys: ReadonlyArray<SceneKey>, p: number, ease: (t: number) => number = smoothstep): { a: SceneKey; b: SceneKey; f: number } {
155
157
  const at = clamp01(p);
156
158
  if (at <= keys[0][0]) return { a: keys[0], b: keys[0], f: 0 };
157
159
  for (let i = 1; i < keys.length; i++) {
@@ -159,7 +161,7 @@ function span(keys: ReadonlyArray<SceneKey>, p: number): { a: SceneKey; b: Scene
159
161
  const a = keys[i - 1];
160
162
  const b = keys[i];
161
163
  const width = b[0] - a[0];
162
- return { a, b, f: width > 0 ? smoothstep((at - a[0]) / width) : 1 };
164
+ return { a, b, f: width > 0 ? ease((at - a[0]) / width) : 1 };
163
165
  }
164
166
  }
165
167
  const last = keys[keys.length - 1];
@@ -167,9 +169,9 @@ function span(keys: ReadonlyArray<SceneKey>, p: number): { a: SceneKey; b: Scene
167
169
  }
168
170
 
169
171
  /** A numeric track's value at progress `p`. */
170
- export function valueAt(keys: ReadonlyArray<SceneKey>, p: number): number {
172
+ export function valueAt(keys: ReadonlyArray<SceneKey>, p: number, ease?: (t: number) => number): number {
171
173
  if (!keys.length) return 0;
172
- const { a, b, f } = span(keys, p);
174
+ const { a, b, f } = span(keys, p, ease);
173
175
  const from = Number(a[1]);
174
176
  return round(from + (Number(b[1]) - from) * f);
175
177
  }
@@ -229,10 +231,13 @@ export function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): num
229
231
  return track ? clampPercent(valueAt(track.keys, p)) : null;
230
232
  }
231
233
 
232
- /** How far a layer's pictures have played at `p`, in %; null when it has no such track. */
234
+ /**
235
+ * How far a layer's pictures have played at `p`, in %; null when it has no such track. A film runs straight between its
236
+ * keys, where everything else eases: eased over a 20-screen Scene it would crawl for its first screens and rush the middle.
237
+ */
233
238
  export function scenePlayed(tracks: ReadonlyArray<SceneTrack>, p: number): number | null {
234
239
  const track = tracks.find(t => t.prop === SceneProp.Play);
235
- return track ? clampPercent(valueAt(track.keys, p)) : null;
240
+ return track ? clampPercent(valueAt(track.keys, p, linear)) : null;
236
241
  }
237
242
 
238
243
  /** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
@@ -307,7 +312,7 @@ export const SCENE_FLOW_RULES = `.iox-role-scene{height:auto!important}`
307
312
  + `[${SceneAttribute.Tracks}]{transform:none!important;opacity:1!important;clip-path:none!important;pointer-events:auto!important}`
308
313
  // A Video the scroll plays stands as its poster, at the poster's own height: a percent of a stage that now flows is nothing.
309
314
  + `[${VideoAttribute.Frames}]{height:auto!important}`
310
- + `[${VideoAttribute.Frames}]>.${VideoClass.Poster}{position:static!important;height:auto!important}`;
315
+ + `[${VideoAttribute.Frames}]>.${VideoClass.Poster},[${VideoAttribute.Frames}]>picture>.${VideoClass.Poster}{position:static!important;height:auto!important}`;
311
316
 
312
317
  /** Under reduced motion the driver never starts, so the same rules hold for the whole visit. */
313
318
  export const SCENE_REDUCED_MOTION_CSS = `@media (prefers-reduced-motion: reduce){${SCENE_FLOW_RULES}}`;
@@ -353,10 +353,13 @@ dialog[data-iox-dialog]:not([open]) {
353
353
  :where(.iox-video[data-iox-video-frames]) { width: 100%; }
354
354
  .iox-video[data-iox-video-poster-empty]:not([data-iox-video-playing]) { background-color: var(--iox-surface-2); }
355
355
  .iox-video[data-iox-video-playing] { background-color: var(--iox-inverse-bg); }
356
- .iox-video > :is(.iox-video__poster, .iox-video__media, .iox-video__frame, .iox-video__frames) {
356
+ .iox-video > picture { display: contents; }
357
+ .iox-video > :is(.iox-video__poster, .iox-video__media, .iox-video__frame, .iox-video__frames),
358
+ .iox-video > picture > .iox-video__poster {
357
359
  position: absolute; inset: 0; width: 100%; height: 100%; border: 0; margin: 0; display: block; object-fit: cover;
358
360
  }
359
- .iox-video > .iox-video__poster { z-index: -1; }
361
+ .iox-video > .iox-video__poster, .iox-video > picture > .iox-video__poster { z-index: -1; }
362
+ .iox-video[data-iox-frames-showing] > .iox-video__poster, .iox-video[data-iox-frames-showing] > picture > .iox-video__poster { visibility: hidden; }
360
363
  .iox-video > .iox-video__media { object-fit: contain; background-color: var(--iox-inverse-bg); }
361
364
  .iox-video[data-iox-video-mode='background'] > .iox-video__media { object-fit: cover; }
362
365
  .iox-video__play {