@vectoriox/iox-ui 5.84.0 → 5.86.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 (47) hide show
  1. package/actions-core/index.d.ts +47 -3
  2. package/composite-core/index.d.ts +93 -15
  3. package/design-core/index.d.ts +2013 -187
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +153 -70
  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 +2222 -85
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +27 -4
  11. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +250 -12
  13. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  14. package/fesm2022/vectoriox-iox-ui-scene-core.mjs +101 -5
  15. package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
  16. package/fesm2022/vectoriox-iox-ui.mjs +1545 -78
  17. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  18. package/forms-core/index.d.ts +23 -4
  19. package/generation-core/index.d.ts +13 -0
  20. package/index.d.ts +563 -62
  21. package/package.json +1 -1
  22. package/scene-core/index.d.ts +32 -4
  23. package/src/actions-core/action.model.ts +1 -1
  24. package/src/actions-core/dialog-motion.ts +25 -0
  25. package/src/actions-core/dialog-opener.ts +9 -3
  26. package/src/actions-core/element-actions.ts +25 -1
  27. package/src/actions-core/popover-opener.ts +50 -0
  28. package/src/actions-core/public-api.ts +1 -0
  29. package/src/composite-core/header.ts +25 -0
  30. package/src/composite-core/public-api.ts +1 -0
  31. package/src/composite-core/slider-runtime.ts +82 -0
  32. package/src/composite-core/slider.ts +194 -27
  33. package/src/composite-core/structure.ts +14 -9
  34. package/src/design-core/flavor-spec.generated.ts +2145 -85
  35. package/src/design-core/header-behaviour.ts +76 -0
  36. package/src/design-core/public-api.ts +1 -0
  37. package/src/design-core/theme-color.ts +21 -5
  38. package/src/forms-core/submit-lifecycle.ts +30 -3
  39. package/src/generation-core/baseline-roles.ts +1 -0
  40. package/src/generation-core/compose-site.ts +8 -0
  41. package/src/generation-core/node-baselines.generated.ts +231 -11
  42. package/src/generation-core/patterns.ts +2 -0
  43. package/src/generation-core/site-spec.generated.ts +8 -0
  44. package/src/scene-core/scene.ts +18 -0
  45. package/src/scene-core/scroll-driver.ts +83 -6
  46. package/styles/iox-design.css +431 -1
  47. package/styles/iox-forms.css +29 -4
@@ -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
+ }
@@ -142,13 +142,40 @@ iox-gallery.iox-es-media[data-iox-empty] > .iox-gallery {
142
142
  --iox-image-hover-scale: 1;
143
143
  }
144
144
  .iox-es-card .iox-es-media {
145
- transition: transform var(--iox-dur-slow) var(--iox-ease-standard), scale var(--iox-dur-slow) var(--iox-ease-standard);
145
+ transition: transform var(--iox-dur-ui) var(--iox-ease-standard), scale var(--iox-dur-ui) var(--iox-ease-standard);
146
146
  }
147
147
  @media (hover: hover) and (pointer: fine) {
148
148
  :is(a.iox-es-card, .iox-es-card:has(> a.linked-container-root), .iox-es-card:has(.iox-card-link)):hover .iox-es-media {
149
149
  scale: var(--card-media-zoom);
150
150
  }
151
151
  }
152
+ /* M4 (the D3 follow-ups — Emil 144, 28, 74): a LINKED card's lift, its raised shadow and its picture's zoom are ONE
153
+ timing under 300ms (dur-ui; the card style's dur-base and the media's dur-slow ran past it in most looks). Its shadow
154
+ rises by fading a pseudo-element that wears card-shadow-raise — the card's own box-shadow no longer changes on hover
155
+ (every card's hover shadow is its rest shadow now; a non-linked card never rose). `translate: 0px` is identity: it
156
+ makes the card the pseudo's containing block and stacking context whatever position the card holds (the canvas
157
+ forces its own), so the pseudo sits under the card's content and its shadow outside the card's edge. */
158
+ :is(a.iox-es-card, .iox-es-card:has(> a.linked-container-root), .iox-es-card:has(.iox-card-link)) {
159
+ --iox-card-shadow-hover: var(--iox-card-shadow);
160
+ --iox-dur-base: var(--iox-dur-ui);
161
+ translate: 0px;
162
+ }
163
+ :is(a.iox-es-card, .iox-es-card:has(> a.linked-container-root), .iox-es-card:has(.iox-card-link))::before {
164
+ content: '';
165
+ position: absolute;
166
+ inset: calc(var(--iox-card-border-width, 0px) * -1);
167
+ border-radius: inherit;
168
+ box-shadow: var(--iox-card-shadow-raise);
169
+ opacity: 0;
170
+ pointer-events: none;
171
+ z-index: -1;
172
+ transition: opacity var(--iox-dur-ui) var(--iox-ease-standard);
173
+ }
174
+ @media (hover: hover) and (pointer: fine) {
175
+ :is(a.iox-es-card, .iox-es-card:has(> a.linked-container-root), .iox-es-card:has(.iox-card-link)):hover::before {
176
+ opacity: 1;
177
+ }
178
+ }
152
179
 
153
180
  /* ── CC1: mobile navigation (complex-components plan §7a, R1; boards 1–2) ─────────────────────── */
154
181
 
@@ -226,6 +253,16 @@ dialog[data-iox-dialog].iox-es:is([data-iox-closing], :not([open]))::backdrop {
226
253
  @starting-style {
227
254
  dialog[data-iox-dialog].iox-es[open]::backdrop { opacity: 0; }
228
255
  }
256
+ /* M4 (M1.1's carry-over — Emil 101): a styled dialog separates from the page behind it by a hairline edge in the look's
257
+ `dialog-edge` (its line colour) — on a DARK page under a dark scrim its shadow is swallowed and the phone drawer
258
+ vanished into the page. An outline, so the dialog's own box-shadow (the look's, or the author's) is untouched; drawn
259
+ inside its edge, following its radius. The sheet itself takes focus when it opens (the engine), so it never rings:
260
+ the first Tab moves to its Close, with the ring. */
261
+ dialog[data-iox-dialog].iox-es {
262
+ outline: var(--iox-border-width, 1px) solid var(--iox-dialog-edge, transparent);
263
+ outline-offset: calc(var(--iox-border-width, 1px) * -1);
264
+ }
265
+
229
266
  /* The drawer's pages arrive WITH it (decision D1 — Emil 64/65/110: one entrance per container, no menu-row stagger):
230
267
  the sheet slides in carrying its links, and they leave with it. */
231
268
 
@@ -240,6 +277,7 @@ dialog[data-iox-dialog] {
240
277
  .iox-role-nav-links,
241
278
  .iox-role-nav-drawer-links {
242
279
  --iox-link-decoration: none;
280
+ --iox-link-underline-rest: 0;
243
281
  }
244
282
 
245
283
  /* The BOTTOM BAR (board 2, "The CSS, exactly"): fixed to the bottom of the viewport (on the canvas, of the
@@ -474,3 +512,395 @@ span.iox-video__play { pointer-events: none; }
474
512
  background: var(--iox-scrim);
475
513
  }
476
514
  }
515
+
516
+ /* ── Premium components M4 (T2): how a header behaves as the page scrolls ─────────────────────────────────────────
517
+ Keyed on the attributes every renderer writes from the header's two traits (design-core headerAttributes). A header
518
+ that STAYS or HIDES keeps to the top of the screen while the page scrolls under it; one that hides steps aside on the
519
+ way down, moved by the header runtime (`translate`, from the live value — HeaderRuntime). On the live site the
520
+ globals' wrapper is `display: contents`, so the header sticks to the whole page; on the builder canvas the header lives
521
+ in the frame-pinned overlay (global-elements.md) and the canvas keeps its own `position`. */
522
+ :root [data-iox-header] {
523
+ position: sticky;
524
+ inset-block-start: 0;
525
+ z-index: 30;
526
+ }
527
+ /* Faded under reduced motion (the runtime): not clickable while it is away. */
528
+ [data-iox-header][data-iox-header-hidden] {
529
+ pointer-events: none;
530
+ }
531
+ /* The EDGE (Emil 99): once the page has moved under it, a header shows a soft fade of its own ground — or a hairline —
532
+ instead of a divider drawn at all times. Its look's own bottom line gives way; the edge is a pseudo-element that only
533
+ fades in (no layout, no box-shadow). An eased gradient (Emil 174): no visible band where the fade ends. Only on a
534
+ header that stays at the top (one that scrolls away is gone by the time the page has moved); the header is its
535
+ containing block — sticky here, and `relative` as stored (the Header section writes it; the canvas forces a node's
536
+ own position, and a transform on the header would trap its drawer's stage). */
537
+ [data-iox-header][data-iox-header-edge] {
538
+ --iox-nav-border-width: 0px;
539
+ }
540
+ [data-iox-header][data-iox-header-edge]::after {
541
+ content: '';
542
+ position: absolute;
543
+ inset-inline: 0;
544
+ inset-block-start: 100%;
545
+ block-size: 16px;
546
+ pointer-events: none;
547
+ opacity: 0;
548
+ transition: opacity var(--iox-dur-ui) var(--iox-ease-standard);
549
+ }
550
+ [data-iox-header][data-iox-header-edge='fade']::after {
551
+ background: linear-gradient(to bottom,
552
+ color-mix(in srgb, var(--iox-nav-bg, transparent) 100%, transparent) 0%,
553
+ color-mix(in srgb, var(--iox-nav-bg, transparent) 74%, transparent) 19%,
554
+ color-mix(in srgb, var(--iox-nav-bg, transparent) 54%, transparent) 34%,
555
+ color-mix(in srgb, var(--iox-nav-bg, transparent) 34%, transparent) 50%,
556
+ color-mix(in srgb, var(--iox-nav-bg, transparent) 17%, transparent) 67%,
557
+ color-mix(in srgb, var(--iox-nav-bg, transparent) 6%, transparent) 83%,
558
+ transparent 100%);
559
+ }
560
+ [data-iox-header][data-iox-header-edge='line']::after {
561
+ block-size: var(--iox-border-width, 1px);
562
+ background: var(--iox-nav-border, currentColor);
563
+ }
564
+ [data-iox-scrolled] [data-iox-header][data-iox-header-edge]:not([data-iox-header-edge='none'])::after {
565
+ opacity: 1;
566
+ }
567
+ /* Emil 194/195: reduced transparency makes the bar solid (the token sheet clears its blur and its ground); more
568
+ contrast makes it near-solid with a defined, contrasting edge. A Brand-scheme bar keeps its own solid ground. */
569
+ @media (prefers-contrast: more) {
570
+ .iox-es-nav:not([class*='iox-scheme-']) {
571
+ --iox-nav-bg: var(--iox-bg);
572
+ }
573
+ .iox-es-nav,
574
+ [data-iox-header][data-iox-header-edge] {
575
+ --iox-nav-backdrop: none;
576
+ --iox-nav-border: var(--iox-text);
577
+ --iox-nav-border-width: 0px 0px var(--iox-border-width, 1px) 0px;
578
+ }
579
+ .iox-es-bottom-bar {
580
+ --iox-tabbar-bg: var(--iox-bg);
581
+ --iox-tabbar-backdrop: none;
582
+ --iox-tabbar-border: var(--iox-text);
583
+ }
584
+ }
585
+
586
+ /* ── Premium components M4 (T3): a link's underline, drawn ──────────────────────────────────────────────────────────
587
+ An element-styled link draws its underline as a line in its own ink under its words, scaled along them from the
588
+ READING START (mirrored in RTL) — so it can grow on hover. `link-underline-rest` is how much shows at rest (1 in
589
+ running content; 0 in a navigation's lists, by their role): it grows on hover (hover devices only — G1), for keyboard
590
+ focus, and the current page's link holds its 2px accent line. The node's own text-decoration reads none here (the
591
+ two decoration tokens are re-declared), so a line is never drawn twice; a legacy link keeps its text-decoration.
592
+ A background line, so it follows the words onto a second line and needs no positioning (the canvas forces a node's
593
+ position); its growth is a background-size the shared compiler adds to the link's transition (withStateTransitions).
594
+ The colour and the line move on the hover timing — ≤150ms on `ease` (Emil 92). */
595
+ .iox-es-link {
596
+ --iox-link-decoration: none;
597
+ --iox-link-current-decoration: none;
598
+ --iox-dur-fast: var(--iox-dur-hover);
599
+ --iox-ease-standard: var(--iox-ease-hover);
600
+ background-image: linear-gradient(currentColor, currentColor);
601
+ background-repeat: no-repeat;
602
+ background-position: 0% calc(100% - var(--iox-link-underline-offset, 0px));
603
+ background-size: calc(var(--iox-link-underline-rest, 1) * 100%) var(--iox-link-underline-thickness, 1px);
604
+ -webkit-box-decoration-break: clone;
605
+ box-decoration-break: clone;
606
+ }
607
+ .iox-es-link:dir(rtl) {
608
+ background-position-x: 100%;
609
+ }
610
+ @media (hover: hover) and (pointer: fine) {
611
+ .iox-es-link:hover {
612
+ background-size: 100% var(--iox-link-underline-thickness, 1px);
613
+ }
614
+ }
615
+ .iox-es-link:focus-visible {
616
+ background-size: 100% var(--iox-link-underline-thickness, 1px);
617
+ }
618
+ .iox-es-link[aria-current='page'] {
619
+ background-image: linear-gradient(var(--iox-link-current-underline, currentColor), var(--iox-link-current-underline, currentColor));
620
+ background-size: 100% var(--iox-link-current-underline-thickness, 2px);
621
+ }
622
+ /* Reduced motion: the line appears and goes in place (it never travels); the colour still eases (Emil 192). */
623
+ @media (prefers-reduced-motion: reduce) {
624
+ .iox-es.iox-es-link {
625
+ transition-property: color;
626
+ }
627
+ }
628
+ /* A chip's colour moves on the hover timing too (Emil 92 — M3's review). */
629
+ .iox-es-chip {
630
+ --iox-dur-fast: var(--iox-dur-hover);
631
+ --iox-ease-standard: var(--iox-ease-hover);
632
+ }
633
+
634
+ /* ── Premium components M4 (Emil 55/85): a press lands faster than its release ──────────────────────────────────────
635
+ While pressed, every pressable's transition runs on dur-press; it releases on its own (slower) dur-fast. */
636
+ :is(.iox-es-button-primary, .iox-es-button-secondary, .iox-es-button-ghost, .iox-es-bottom-bar-item):active {
637
+ transition-duration: var(--iox-dur-press);
638
+ }
639
+
640
+ /* ── Premium components M4 (T3): the Slider's feel, its bar and the slider the scroll plays ─────────────────────────
641
+ One curve for the slides: the look's (Emil 18/21 — never Swiper's built-in `ease`). A horizontal carousel leaves
642
+ vertical panning to the page (M9). */
643
+ :where(.swiper) {
644
+ --swiper-wrapper-transition-timing-function: var(--iox-slider-ease, ease);
645
+ }
646
+ :where(.swiper-horizontal) {
647
+ touch-action: pan-y;
648
+ }
649
+ /* The BAR under the picture (M1.3): the caption at the reading start, then the count and the arrows — in flow, never on
650
+ the picture (the bar node lays them out; these keep its words from pushing the arrows). The count reads "1 / 3" in
651
+ every language. */
652
+ :where(.iox-role-slider-bar) .iox-role-slider-caption {
653
+ min-width: 0;
654
+ }
655
+ /* Their words are the slide in view's, written by the runtime as an attribute (composite-core attachSliderBar). */
656
+ :is(.iox-role-slider-caption, .iox-role-slider-count)::before {
657
+ content: attr(data-iox-text);
658
+ }
659
+ .iox-role-slider-count {
660
+ direction: ltr;
661
+ unicode-bidi: isolate;
662
+ font-variant-numeric: tabular-nums;
663
+ white-space: nowrap;
664
+ }
665
+ /* The caption follows the slide in view: it changes behind a short fade (the runtime holds it at 0 for a frame). */
666
+ .iox-role-slider-caption {
667
+ transition: opacity var(--iox-dur-hover) var(--iox-ease-hover);
668
+ }
669
+ .iox-role-slider-caption[data-iox-swapping] {
670
+ opacity: 0;
671
+ transition-duration: 0ms;
672
+ }
673
+ /* On a phone a "cards" stack stays a centred card with its neighbours peeking out (one picture the full width is
674
+ planSwiperOptions' default for every other effect). */
675
+ @media (max-width: 767px) {
676
+ .swiper.swiper-cards {
677
+ width: 86%;
678
+ margin-inline: auto;
679
+ }
680
+ }
681
+ /* A Slider the SCROLL plays: its runway holds the slider mid-screen while the page scrolls past half a screen per
682
+ picture; the ONE scene driver moves it, resting on each picture (scene-core `heldProgress`). `--slider-steps` is
683
+ written by the renderers (no layout shift), `--slider-h` by the slider's boot. On the canvas the driver pins it
684
+ (its page never scrolls itself) and `--preview-vh` stands for the frame's height. Under reduced motion it is an
685
+ ordinary slider: no runway, swiped and pressed. */
686
+ .iox-slider-runway {
687
+ position: relative;
688
+ }
689
+ .iox-slider-runway > :is(.swiper, .slider-edit-shell) {
690
+ position: sticky;
691
+ inset-block-start: max(0px, calc((var(--preview-vh, 1svh) * 100 - var(--slider-h, 0px)) / 2));
692
+ }
693
+ .iox-slider-runway::after {
694
+ content: '';
695
+ display: block;
696
+ block-size: calc(var(--slider-steps, 0) * var(--preview-vh, 1svh) * 55);
697
+ }
698
+ @media (prefers-reduced-motion: reduce) {
699
+ .iox-slider-runway::after {
700
+ display: none;
701
+ }
702
+ .iox-slider-runway > :is(.swiper, .slider-edit-shell) {
703
+ position: relative;
704
+ inset-block-start: 0;
705
+ }
706
+ }
707
+
708
+ /* ── The floating family (premium components M5 — N3 toast · N4 tooltip and popover) ──────────────────────────────
709
+ ONE motion grammar for all three: they come FROM where they belong — a tooltip and a popover grow from their trigger
710
+ (the transform-origin placeFloating writes, Emil 47), a toast slides from its edge and back to it (Emil 129–130) —
711
+ and leave faster than they came (Emil 30). A neighbour tooltip, a keyboard open and Esc have no motion at all
712
+ (`data-iox-instant`, Emil 2/125). On a page that wears the design system the look's tokens time them (a look whose
713
+ motion is Off: 0ms); under reduced motion they keep the fade and drop the movement (Emil 192). The element styles'
714
+ colours and type are the contract's floating baseline, verbatim (`.iox-es-tooltip` / `.iox-es-toast`, pinned by the
715
+ token-names spec); a legacy page keeps the neutral literals of the unmarked rules. */
716
+
717
+ /* The layer they are placed in: the viewport on the site (a top-layer popover, so a fixed header, a slider's clip or a
718
+ nav's backdrop-filter cannot hold them), the page frame on the canvas (fixed inside the zoomed frame). */
719
+ .iox-float-layer {
720
+ position: fixed;
721
+ inset: 0;
722
+ width: auto;
723
+ height: auto;
724
+ max-width: none;
725
+ max-height: none;
726
+ margin: 0;
727
+ padding: 0;
728
+ border: 0;
729
+ background: transparent;
730
+ color: inherit;
731
+ overflow: visible;
732
+ pointer-events: none;
733
+ z-index: 60;
734
+ }
735
+ .iox-float-layer[popover]:not(:popover-open) { display: none; }
736
+
737
+ /* The tooltip. */
738
+ .iox-tooltip {
739
+ --tip-scale: 0.97;
740
+ position: absolute;
741
+ top: 0;
742
+ left: 0;
743
+ box-sizing: border-box;
744
+ max-width: min(280px, calc(100% - 24px));
745
+ padding: 6px 10px;
746
+ font: 500 13px/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
747
+ background-color: #1f2328;
748
+ color: #ffffff;
749
+ border-radius: 6px;
750
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
751
+ overflow-wrap: anywhere;
752
+ text-align: start;
753
+ pointer-events: auto;
754
+ transition: opacity 125ms cubic-bezier(0.23, 1, 0.32, 1), scale 125ms cubic-bezier(0.23, 1, 0.32, 1);
755
+ }
756
+ .iox-tooltip[hidden] { display: none; }
757
+ @starting-style { .iox-tooltip:not([hidden]) { opacity: 0; scale: var(--tip-scale); } }
758
+ .iox-tooltip[data-iox-closing] { opacity: 0; scale: var(--tip-scale); transition-duration: 80ms; }
759
+ .iox-es-tooltip {
760
+ background-color: var(--iox-tooltip-bg);
761
+ color: var(--iox-tooltip-ink);
762
+ border-radius: var(--iox-tooltip-radius);
763
+ box-shadow: var(--iox-tooltip-shadow);
764
+ font-family: var(--iox-font-body);
765
+ font-size: var(--iox-tooltip-size);
766
+ font-weight: var(--iox-weight-strong);
767
+ }
768
+ .iox-tooltip.iox-es-tooltip {
769
+ --tip-scale: var(--iox-tooltip-scale);
770
+ transition: opacity var(--iox-tooltip-dur-in) var(--iox-tooltip-ease), scale var(--iox-tooltip-dur-in) var(--iox-tooltip-ease);
771
+ }
772
+ .iox-tooltip.iox-es-tooltip[data-iox-closing] { transition-duration: var(--iox-tooltip-dur-out); }
773
+ .iox-tooltip[data-iox-instant],
774
+ .iox-tooltip.iox-es-tooltip[data-iox-instant] { transition: none; }
775
+
776
+ /* The toast stack, at its edge — above the home indicator (M7) and above a bottom tab bar (CC1's reserve). */
777
+ .iox-toasts {
778
+ position: absolute;
779
+ inset-inline: 0;
780
+ display: flex;
781
+ flex-direction: column;
782
+ align-items: center;
783
+ gap: 8px;
784
+ padding-inline: 16px;
785
+ box-sizing: border-box;
786
+ pointer-events: none;
787
+ }
788
+ .iox-toasts[data-iox-edge='bottom'] { bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
789
+ .iox-toasts[data-iox-edge='top'] { top: calc(16px + env(safe-area-inset-top, 0px)); flex-direction: column-reverse; }
790
+ html:has(.iox-bar-reserve) .iox-toasts[data-iox-edge='bottom'] { bottom: calc(16px + var(--iox-tabbar-height, 64px) + env(safe-area-inset-bottom, 0px)); }
791
+ .iox-toast {
792
+ --toast-from: translateY(100%);
793
+ pointer-events: auto;
794
+ box-sizing: border-box;
795
+ display: flex;
796
+ align-items: center;
797
+ gap: 8px;
798
+ max-width: min(440px, 100%);
799
+ padding-block: 10px;
800
+ padding-inline: 16px 6px;
801
+ font: 500 15px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif;
802
+ background-color: #1f2328;
803
+ color: #ffffff;
804
+ border-radius: 10px;
805
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
806
+ transition: transform 400ms ease, opacity 400ms ease;
807
+ }
808
+ .iox-toasts[data-iox-edge='top'] .iox-toast { --toast-from: translateY(-100%); }
809
+ @starting-style { .iox-toast { opacity: 0; transform: var(--toast-from); } }
810
+ .iox-toast[data-iox-closing] { opacity: 0; transform: var(--toast-from); transition-duration: 220ms; }
811
+ .iox-es-toast {
812
+ background-color: var(--iox-toast-bg);
813
+ color: var(--iox-toast-ink);
814
+ border-radius: var(--iox-toast-radius);
815
+ box-shadow: var(--iox-toast-shadow);
816
+ font-family: var(--iox-font-body);
817
+ font-size: var(--iox-size-body);
818
+ font-weight: var(--iox-weight-strong);
819
+ }
820
+ .iox-toast.iox-es-toast { transition: transform var(--iox-toast-dur-in) var(--iox-toast-ease), opacity var(--iox-toast-dur-in) var(--iox-toast-ease); }
821
+ .iox-toast.iox-es-toast[data-iox-closing] { transition-duration: var(--iox-toast-dur-out); }
822
+ .iox-toast__text { margin: 0; flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
823
+ /* The close control: a 44px target (M2) drawn in the toast's own ink, whatever the look — its ring too (currentColor
824
+ on the toast's fill is the pair the toast already clears). */
825
+ .iox-toast__close {
826
+ flex: none;
827
+ display: inline-grid;
828
+ place-items: center;
829
+ inline-size: 44px;
830
+ block-size: 44px;
831
+ margin-block: -8px;
832
+ padding: 0;
833
+ border: 0;
834
+ border-radius: 999px;
835
+ background: transparent;
836
+ color: inherit;
837
+ cursor: pointer;
838
+ opacity: 0.8;
839
+ touch-action: manipulation;
840
+ transition: opacity 150ms ease-out, background-color 150ms ease-out;
841
+ }
842
+ .iox-toast__close:focus-visible { outline: 2px solid currentColor; outline-offset: -4px; opacity: 1; }
843
+ @media (hover: hover) and (pointer: fine) {
844
+ .iox-toast__close:hover { opacity: 1; background-color: color-mix(in srgb, currentColor 14%, transparent); }
845
+ }
846
+
847
+ /* The popover — the node itself, in the top layer beside its opener (placeFloating writes top / left / the origin).
848
+ Closed it is the browser's hidden popover; the panel takes focus on open and draws no ring (its controls do). */
849
+ [data-iox-popover] { box-sizing: border-box; }
850
+ [data-iox-popover][popover] {
851
+ --pop-scale: 0.95;
852
+ position: fixed;
853
+ inset: auto;
854
+ margin: 0;
855
+ max-width: calc(100vw - 24px);
856
+ max-height: calc(100dvh - 24px);
857
+ overflow: auto;
858
+ overscroll-behavior: contain;
859
+ outline: none;
860
+ transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1), scale 200ms cubic-bezier(0.23, 1, 0.32, 1), overlay 200ms allow-discrete, display 200ms allow-discrete;
861
+ }
862
+ @starting-style { [data-iox-popover][popover]:popover-open { opacity: 0; scale: var(--pop-scale); } }
863
+ [data-iox-popover][popover][data-iox-closing] { opacity: 0; scale: var(--pop-scale); transition-duration: 140ms; }
864
+ .iox-es-popover[data-iox-popover][popover] {
865
+ --pop-scale: var(--iox-popover-scale);
866
+ transition: opacity var(--iox-popover-dur-in) var(--iox-popover-ease), scale var(--iox-popover-dur-in) var(--iox-popover-ease),
867
+ overlay var(--iox-popover-dur-in) allow-discrete, display var(--iox-popover-dur-in) allow-discrete;
868
+ }
869
+ .iox-es-popover[data-iox-popover][popover][data-iox-closing] { transition-duration: var(--iox-popover-dur-out); }
870
+ /* On a phone: a bottom sheet over the scrim, the safe area kept (the date picker's decision, FLOATING_SHEET_QUERY). */
871
+ [data-iox-popover][popover][data-iox-sheet] {
872
+ inset: auto 0 0 0;
873
+ width: 100%;
874
+ max-width: none;
875
+ max-height: 85dvh;
876
+ border-end-start-radius: 0;
877
+ border-end-end-radius: 0;
878
+ padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
879
+ transition: translate 500ms cubic-bezier(0.32, 0.72, 0, 1), overlay 500ms allow-discrete, display 500ms allow-discrete;
880
+ }
881
+ @starting-style { [data-iox-popover][popover][data-iox-sheet]:popover-open { opacity: 1; scale: 1; translate: 0 100%; } }
882
+ [data-iox-popover][popover][data-iox-sheet][data-iox-closing] { opacity: 1; scale: 1; translate: 0 100%; transition-duration: 280ms; }
883
+ [data-iox-popover][popover][data-iox-sheet]::backdrop { background: rgba(0, 0, 0, 0.45); transition: opacity 500ms cubic-bezier(0.32, 0.72, 0, 1); }
884
+ @starting-style { [data-iox-popover][popover][data-iox-sheet]:popover-open::backdrop { opacity: 0; } }
885
+ [data-iox-popover][popover][data-iox-sheet][data-iox-closing]::backdrop { opacity: 0; transition-duration: 280ms; }
886
+ .iox-es-popover[data-iox-popover][popover][data-iox-sheet] {
887
+ padding-bottom: calc(var(--iox-popover-pad) + env(safe-area-inset-bottom, 0px));
888
+ transition: translate var(--iox-sheet-dur-in) var(--iox-sheet-ease), overlay var(--iox-sheet-dur-in) allow-discrete, display var(--iox-sheet-dur-in) allow-discrete;
889
+ }
890
+ .iox-es-popover[data-iox-popover][popover][data-iox-sheet][data-iox-closing] { transition-duration: var(--iox-sheet-dur-out); }
891
+ .iox-es-popover[data-iox-popover][popover][data-iox-sheet]::backdrop { background: var(--iox-scrim); transition: opacity var(--iox-sheet-dur-in) var(--iox-sheet-ease); }
892
+ .iox-es-popover[data-iox-popover][popover][data-iox-sheet][data-iox-closing]::backdrop { transition-duration: var(--iox-sheet-dur-out); }
893
+ .iox-es-popover[data-iox-popover][popover][data-iox-instant],
894
+ .iox-es-popover[data-iox-popover][popover][data-iox-instant]::backdrop,
895
+ [data-iox-popover][popover][data-iox-instant],
896
+ [data-iox-popover][popover][data-iox-instant]::backdrop { transition: none; }
897
+
898
+ /* Reduced motion: each of them fades — gentler, not zero; nothing scales or slides. */
899
+ @media (prefers-reduced-motion: reduce) {
900
+ .iox-tooltip.iox-tooltip,
901
+ [data-iox-popover][popover][popover] { --tip-scale: 1; --pop-scale: 1; }
902
+ .iox-toasts[data-iox-edge] .iox-toast.iox-toast { --toast-from: none; }
903
+ [data-iox-popover][popover][data-iox-sheet][data-iox-sheet] { transition-property: opacity, overlay, display; }
904
+ @starting-style { [data-iox-popover][popover][data-iox-sheet][data-iox-sheet]:popover-open { opacity: 0; translate: none; } }
905
+ [data-iox-popover][popover][data-iox-sheet][data-iox-sheet][data-iox-closing] { opacity: 0; translate: none; }
906
+ }