@vectoriox/iox-ui 5.73.3 → 5.74.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.
@@ -24,9 +24,16 @@ interface FrameSequence {
24
24
  wide: FrameSetInfo;
25
25
  /** Absent = phones play the wide set. */
26
26
  phone?: FrameSetInfo;
27
+ /**
28
+ * A phone film of its own, framed for a tall screen, lives under its own name: a replacement that fails half way leaves
29
+ * the one playing untouched. Absent = the phone set is the wide film made smaller, under `base`.
30
+ */
31
+ phoneBase?: string;
27
32
  /** What the sequence weighs in storage, both sets. Shown to the author. */
28
33
  bytes?: number;
29
34
  }
35
+ /** 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. */
36
+ declare const FRAMES_PHONE_MAX_WIDTH = 767;
30
37
  declare enum FrameExtension {
31
38
  Webp = "webp",
32
39
  Jpeg = "jpg"
@@ -50,6 +57,22 @@ declare function frameSetFor(sequence: FrameSequence, phone: boolean): {
50
57
  set: FrameSet;
51
58
  info: FrameSetInfo;
52
59
  };
60
+ /** Where `played` % stands in a set of `count`: 2.5 is half way from the third picture to the fourth. */
61
+ declare function framePosition(played: number, count: number): number;
62
+ /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
63
+ declare const FRAME_BLEND_STEPS = 32;
64
+ /** What to draw: picture `from`, with `to` over it at `mix` when the moment falls between two that have arrived. */
65
+ interface FrameBlend {
66
+ from: number;
67
+ to: number;
68
+ mix: number;
69
+ }
70
+ /**
71
+ * The moment between two pictures. A long Scene gives a picture 50px of scroll or more, so a slow scroll would otherwise
72
+ * show the film stepping. Blending the two neighbours keeps it moving under the finger, at the cost of one more drawImage.
73
+ * A neighbour still loading never blends: the nearest arrived picture stands in, and null means none has.
74
+ */
75
+ declare function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number): FrameBlend | null;
53
76
  /** The picture at `played` % of a set of `count`. */
54
77
  declare function frameIndexAt(played: number, count: number): number;
55
78
  /**
@@ -112,7 +135,7 @@ declare class FramePlayer {
112
135
  private observer;
113
136
  private canvas;
114
137
  private context;
115
- private wanted;
138
+ private position;
116
139
  private drawn;
117
140
  private nextToLoad;
118
141
  private loading;
@@ -414,5 +437,5 @@ declare function attachVideos(container: ParentNode, options?: VideoRuntimeOptio
414
437
  /** The runtime already running at `el`, if any. */
415
438
  declare function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null;
416
439
 
417
- export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameFileName, frameIndexAt, frameLoadOrder, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
418
- export type { ConsentGrantDetail, ConsentTransport, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
440
+ export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
441
+ export type { ConsentGrantDetail, ConsentTransport, FrameBlend, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.73.3",
3
+ "version": "5.74.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -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,35 @@ 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
+ /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
112
+ export const FRAME_BLEND_STEPS = 32;
113
+
114
+ /** What to draw: picture `from`, with `to` over it at `mix` when the moment falls between two that have arrived. */
115
+ export interface FrameBlend {
116
+ from: number;
117
+ to: number;
118
+ mix: number;
119
+ }
120
+
121
+ /**
122
+ * The moment between two pictures. A long Scene gives a picture 50px of scroll or more, so a slow scroll would otherwise
123
+ * show the film stepping. Blending the two neighbours keeps it moving under the finger, at the cost of one more drawImage.
124
+ * A neighbour still loading never blends: the nearest arrived picture stands in, and null means none has.
125
+ */
126
+ export function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number): FrameBlend | null {
127
+ const from = Math.floor(position);
128
+ const to = from + 1;
129
+ const mix = Math.round((position - from) * FRAME_BLEND_STEPS) / FRAME_BLEND_STEPS;
130
+ if (mix > 0 && mix < 1 && loaded[from] && loaded[to]) return { from, to, mix };
131
+ const nearest = nearestLoadedFrame(loaded, Math.round(position));
132
+ return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
133
+ }
134
+
95
135
  /** The picture at `played` % of a set of `count`. */
96
136
  export function frameIndexAt(played: number, count: number): number {
97
137
  if (count <= 1) return 0;
@@ -215,8 +255,8 @@ export class FramePlayer {
215
255
  private observer: IntersectionObserver | null = null;
216
256
  private canvas: HTMLCanvasElement | null = null;
217
257
  private context: CanvasRenderingContext2D | null = null;
218
- private wanted = 0;
219
- private drawn = -1;
258
+ private position = 0;
259
+ private drawn: FrameBlend | null = null;
220
260
  private nextToLoad = 0;
221
261
  private loading = 0;
222
262
  private started = false;
@@ -244,7 +284,7 @@ export class FramePlayer {
244
284
 
245
285
  /** Stand at `played` %. */
246
286
  seek(played: number): void {
247
- this.wanted = frameIndexAt(played, this.info.count);
287
+ this.position = framePosition(played, this.info.count);
248
288
  this.draw();
249
289
  }
250
290
 
@@ -314,13 +354,21 @@ export class FramePlayer {
314
354
  }
315
355
 
316
356
  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]!;
357
+ const blend = frameBlendAt(this.images, this.position);
358
+ const last = this.drawn;
359
+ if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
360
+ const image = this.images[blend.from]!;
320
361
  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;
362
+ const context = this.context!;
363
+ const { width, height } = this.canvas!;
364
+ context.clearRect(0, 0, width, height);
365
+ context.drawImage(image, 0, 0, width, height);
366
+ if (blend.mix > 0) {
367
+ context.globalAlpha = blend.mix;
368
+ context.drawImage(this.images[blend.to]!, 0, 0, width, height);
369
+ context.globalAlpha = 1;
370
+ }
371
+ this.drawn = blend;
324
372
  }
325
373
 
326
374
  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}}`;
@@ -348,12 +348,17 @@ dialog[data-iox-dialog]:not([open]) {
348
348
  container-type: inline-size;
349
349
  }
350
350
  /* The poster blank (DS3): no poster yet = the token fill, never a request to the provider. */
351
+ /* A Video the scroll plays, given a height: without its own width, a 16 / 9 ratio would work its width out from that height
352
+ and run off the stage. Zero specificity, so the node's own width wins. */
353
+ :where(.iox-video[data-iox-video-frames]) { width: 100%; }
351
354
  .iox-video[data-iox-video-poster-empty]:not([data-iox-video-playing]) { background-color: var(--iox-surface-2); }
352
355
  .iox-video[data-iox-video-playing] { background-color: var(--iox-inverse-bg); }
353
- .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 {
354
359
  position: absolute; inset: 0; width: 100%; height: 100%; border: 0; margin: 0; display: block; object-fit: cover;
355
360
  }
356
- .iox-video > .iox-video__poster { z-index: -1; }
361
+ .iox-video > .iox-video__poster, .iox-video > picture > .iox-video__poster { z-index: -1; }
357
362
  .iox-video > .iox-video__media { object-fit: contain; background-color: var(--iox-inverse-bg); }
358
363
  .iox-video[data-iox-video-mode='background'] > .iox-video__media { object-fit: cover; }
359
364
  .iox-video__play {