@vectoriox/iox-ui 5.83.1 → 5.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/actions-core/index.d.ts +18 -2
  2. package/composite-core/index.d.ts +93 -15
  3. package/design-core/index.d.ts +566 -4
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +18 -1
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +307 -39
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +680 -15
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +665 -24
  11. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-harness-core.mjs +39 -1
  13. package/fesm2022/vectoriox-iox-ui-harness-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 +285 -12
  17. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  18. package/generation-core/index.d.ts +414 -8
  19. package/harness-core/index.d.ts +81 -3
  20. package/index.d.ts +143 -42
  21. package/package.json +1 -1
  22. package/scene-core/index.d.ts +32 -4
  23. package/src/actions-core/dialog-motion.ts +25 -0
  24. package/src/composite-core/header.ts +25 -0
  25. package/src/composite-core/public-api.ts +1 -0
  26. package/src/composite-core/slider-runtime.ts +82 -0
  27. package/src/composite-core/slider.ts +194 -27
  28. package/src/composite-core/structure.ts +14 -9
  29. package/src/design-core/flavor-spec.generated.ts +596 -9
  30. package/src/design-core/header-behaviour.ts +76 -0
  31. package/src/design-core/public-api.ts +1 -0
  32. package/src/design-core/theme-color.ts +21 -5
  33. package/src/generation-core/ai-run.ts +10 -2
  34. package/src/generation-core/art-direction.ts +122 -0
  35. package/src/generation-core/audit-site.ts +14 -0
  36. package/src/generation-core/compose-site.ts +8 -0
  37. package/src/generation-core/node-baselines.generated.ts +17 -3
  38. package/src/generation-core/patterns.ts +2 -0
  39. package/src/generation-core/pictures.ts +464 -0
  40. package/src/generation-core/plan-wire.ts +2 -0
  41. package/src/generation-core/public-api.ts +2 -0
  42. package/src/generation-core/site-build-run.ts +3 -0
  43. package/src/generation-core/site-plan.model.ts +4 -0
  44. package/src/generation-core/site-spec.generated.ts +126 -1
  45. package/src/generation-core/stock-photos.ts +40 -10
  46. package/src/generation-core/validate-site-plan.ts +14 -0
  47. package/src/harness-core/business-types.ts +3 -1
  48. package/src/harness-core/generation-view.ts +50 -1
  49. package/src/harness-core/harness-spec.generated.ts +30 -0
  50. package/src/scene-core/scene.ts +18 -0
  51. package/src/scene-core/scroll-driver.ts +83 -6
  52. package/styles/iox-design.css +232 -1
  53. package/styles/iox-forms.css +5 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.83.1",
3
+ "version": "5.85.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -70,8 +70,19 @@ interface SceneFrame {
70
70
  declare enum SceneAttribute {
71
71
  Root = "data-iox-scene",
72
72
  Tracks = "data-iox-tracks",
73
- PhoneTracks = "data-iox-tracks-phone"
73
+ PhoneTracks = "data-iox-tracks-phone",
74
+ /** A root's stage that is not a Scene's `scene.stage` part: the slider a scroll plays (premium M4). */
75
+ Stage = "data-iox-scene-stage"
74
76
  }
77
+ /**
78
+ * A SLIDER THE SCROLL PLAYS (premium components M4 — M1.3's held gallery). Its progress rests on each picture and changes
79
+ * only over the middle share of each step, so every picture arrives and stays a moment while the page holds it: `p` is
80
+ * the scroll's progress through the runway (0–1), `n` the slides; the answer is Swiper's progress (0–1).
81
+ */
82
+ declare const SLIDER_HOLD_SHARE = 0.2;
83
+ /** Page px of scroll per picture, as a share of the screen's height (the runway the renderers reserve). */
84
+ declare const SLIDER_SCROLL_STEP_SCREENS = 0.55;
85
+ declare function heldProgress(p: number, n: number): number;
75
86
  /** At or under this stage width a layer plays its phone tracks. The `mobile` breakpoint's width. */
76
87
  declare const SCENE_PHONE_MAX_WIDTH = 767;
77
88
  /**
@@ -287,6 +298,11 @@ declare function buildSceneRecipe(create: CompositeNodeFactory, words?: Partial<
287
298
  * - Reads come first in a frame and writes after, and a write only ever touches compositor properties.
288
299
  */
289
300
  declare const SCENE_DAMPING = 9;
301
+ /**
302
+ * Dispatched (bubbling) by something the driver plays that arrived after the driver last looked — a Swiper booted on a
303
+ * slider's stage. Never a DOM `scroll`: nothing that follows the driver may dispatch one (SC2).
304
+ */
305
+ declare const SCENE_WAKE_EVENT = "iox-scene-wake";
290
306
  /** One frame of the damping: frame-rate independent, so 60 Hz and 120 Hz settle in the same time. */
291
307
  declare function dampToward(current: number, target: number, seconds: number, rate?: number): number;
292
308
  /** Where a root stands: scrolled `local` page px into its `travel`. `zoom` undoes the canvas's scale. */
@@ -321,9 +337,12 @@ declare class ScrollDriver {
321
337
  private frame;
322
338
  private lastTime;
323
339
  private readonly wakeUp;
340
+ private readonly onWakeEvent;
324
341
  constructor(options?: ScrollDriverOptions);
325
- /** Drive a Scene's root. Idempotent: adding a root again re-reads it. */
326
- add(root: HTMLElement): void;
342
+ /** Drive a Scene's root. Idempotent: adding a root again re-reads it. False when another driver holds it. */
343
+ add(root: HTMLElement): boolean;
344
+ /** True when this driver holds `root`. */
345
+ holds(root: HTMLElement): boolean;
327
346
  remove(root: HTMLElement): void;
328
347
  /** Read a root's layers again now, keeping where it stands: an editor changed their tracks. */
329
348
  reread(root: HTMLElement): void;
@@ -366,6 +385,15 @@ declare class ScrollDriver {
366
385
  /** A page's Scenes, driven by one driver; null when it holds none, or when the visitor asked for less motion (the
367
386
  * page's own CSS then lets each Scene flow). The live site's door. */
368
387
  declare function attachScenes(container: HTMLElement, options?: ScrollDriverOptions): ScrollDriver | null;
388
+ /**
389
+ * The Scene roots that belong to `elements` (each, and what it holds) — what one runtime instance owns: the page's nodes,
390
+ * or the globals'. Never a whole container that also holds another instance's Scenes (premium M4: the globals' instance
391
+ * scanned the app root and drove the page's Scenes a second time).
392
+ */
393
+ declare function sceneRootsIn(elements: Iterable<Element>): HTMLElement[];
394
+ /** The live site's door for a runtime that owns some nodes: their Scenes on one driver (null when none, or under reduced
395
+ * motion — the page's own CSS then lets each Scene flow). A root another driver holds stays with it. */
396
+ declare function attachSceneRoots(roots: ReadonlyArray<HTMLElement>, options?: ScrollDriverOptions): ScrollDriver | null;
369
397
 
370
398
  /**
371
399
  * Text into pieces, in the browser only (premium sites SC3, Motion v2 §3c). A saved node keeps its plain text and the
@@ -416,5 +444,5 @@ declare function revealStagger(groups: number, by: TextRevealBy): number;
416
444
  */
417
445
  declare function playTextReveal(el: HTMLElement, by: TextRevealBy, timing: KeyframeAnimationOptions): Animation | null;
418
446
 
419
- export { LIGHT_UP_PIECE_CAP, REVEAL_PIECE_CAP, SCENE_BUDGET, SCENE_DAMPING, SCENE_FILM_SMOOTH, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_MAX_SCREENS, SCENE_MIN_SCREENS, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_REVEAL_DEFAULT, SCENE_STAGE_HEIGHT, SCENE_TRACK_DEFS, SCENE_WORDS, SCENE_WORD_DIM, SceneAttribute, SceneProp, SceneRevealFrom, ScrollDriver, TextRevealBy, applySceneFirstFrame, attachScenes, buildSceneRecipe, colourAt, dampToward, lineIndexes, movedSceneKey, normalizeTracks, playTextReveal, revealClipPath, revealStagger, sceneBudget, sceneColourKeyAt, sceneDisplayValue, sceneFilmDensity, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneHasKeyAt, sceneHeightForScreens, sceneKeyPosition, sceneLayersOf, sceneNodeAttributes, scenePlayed, sceneRevealFromOf, sceneRootOnPath, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, sceneWordOpacity, sceneWordsLit, scenesIn, splitText, syncSceneAttributes, textPieces, valueAt, withSceneKey, withScenePlayback, withSceneRevealFrom, withoutSceneKey, withoutScenePlayback };
447
+ export { LIGHT_UP_PIECE_CAP, REVEAL_PIECE_CAP, SCENE_BUDGET, SCENE_DAMPING, SCENE_FILM_SMOOTH, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_MAX_SCREENS, SCENE_MIN_SCREENS, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_REVEAL_DEFAULT, SCENE_STAGE_HEIGHT, SCENE_TRACK_DEFS, SCENE_WAKE_EVENT, SCENE_WORDS, SCENE_WORD_DIM, SLIDER_HOLD_SHARE, SLIDER_SCROLL_STEP_SCREENS, SceneAttribute, SceneProp, SceneRevealFrom, ScrollDriver, TextRevealBy, applySceneFirstFrame, attachSceneRoots, attachScenes, buildSceneRecipe, colourAt, dampToward, heldProgress, lineIndexes, movedSceneKey, normalizeTracks, playTextReveal, revealClipPath, revealStagger, sceneBudget, sceneColourKeyAt, sceneDisplayValue, sceneFilmDensity, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneHasKeyAt, sceneHeightForScreens, sceneKeyPosition, sceneLayersOf, sceneNodeAttributes, scenePlayed, sceneRevealFromOf, sceneRootOnPath, sceneRootsIn, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, sceneWordOpacity, sceneWordsLit, scenesIn, splitText, syncSceneAttributes, textPieces, valueAt, withSceneKey, withScenePlayback, withSceneRevealFrom, withoutSceneKey, withoutScenePlayback };
420
448
  export type { SceneBudget, SceneFilmDensity, SceneFrame, SceneFrameOptions, SceneFrameState, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneTrackDef, SceneTreeNode, SceneWords, ScrollDriverOptions, SplitText, SplitTextOptions, TextPiece };
@@ -103,3 +103,28 @@ export function closeDialogAnimated(el: DialogElementLike, options: ExitMotionOp
103
103
  export function dialogTogglesClosed(el: Pick<DialogElementLike, 'open' | 'hasAttribute'>): boolean {
104
104
  return el.open && !el.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
105
105
  }
106
+
107
+ /** The element-style marker a styled dialog carries (design-core ELEMENT_STYLE_MARKER_CLASS — actions-core does not
108
+ * import design-core). */
109
+ const STYLED_DIALOG_CLASS = 'iox-es';
110
+
111
+ export interface FocusableSheet {
112
+ classList: { contains(token: string): boolean };
113
+ querySelector(selector: string): unknown;
114
+ hasAttribute(name: string): boolean;
115
+ setAttribute(name: string, value: string): void;
116
+ focus(options?: FocusOptions): void;
117
+ }
118
+
119
+ /**
120
+ * A styled dialog a visitor just opened takes focus on its SHEET (premium components M4 — M1.1's carry-over). A bare
121
+ * `showModal()` focuses its first control, and a phone can ring it after a tap; the sheet never rings (iox-design.css) and
122
+ * the first Tab moves to that control, with its ring. A dialog with an author's `[autofocus]` keeps it; a dialog saved
123
+ * before element styles keeps the browser's behaviour. True when the sheet took focus.
124
+ */
125
+ export function focusDialogSheet(dialog: FocusableSheet | null | undefined): boolean {
126
+ if (!dialog || !dialog.classList.contains(STYLED_DIALOG_CLASS) || dialog.querySelector('[autofocus]')) return false;
127
+ if (!dialog.hasAttribute('tabindex')) dialog.setAttribute('tabindex', '-1');
128
+ dialog.focus({ preventScroll: true });
129
+ return true;
130
+ }
@@ -0,0 +1,25 @@
1
+ import { HeaderBehaviour, HeaderEdge, headerBehaviourOf, headerEdgeOf, headerStaysAtTop, HeaderTrait } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import { CompositeNode, readProp, readStyle, writeProp, writeStyles } from './composite-node';
4
+
5
+ /**
6
+ * Set how a header behaves as the page scrolls (premium components T2, M4) — ONE edit for the builder's Header section
7
+ * and the composer (generation-core), on either node shape. A header that stays at the top is the containing block of its
8
+ * edge: the live site makes it sticky (iox-design.css), and a header stored `static` becomes `relative` — what the canvas,
9
+ * which forces a node's own position, draws it with. Nothing else of the author's is touched.
10
+ */
11
+ export function setHeaderBehaviour(node: CompositeNode, behaviour: HeaderBehaviour | `${HeaderBehaviour}`, edge: HeaderEdge | `${HeaderEdge}` | null): void {
12
+ const b = headerBehaviourOf(behaviour);
13
+ writeProp(node, HeaderTrait.Behaviour, b);
14
+ writeProp(node, HeaderTrait.Edge, headerEdgeOf(edge) ?? '');
15
+ const position = readStyle(node, 'position');
16
+ if (headerStaysAtTop(b) && (position === undefined || position === null || position === '' || position === 'static')) writeStyles(node, { position: 'relative' });
17
+ }
18
+
19
+ /** The header's behaviour and edge as stored (Scrolls / none for a header that never chose). */
20
+ export function headerBehaviourOfNode(node: CompositeNode | null | undefined): { behaviour: HeaderBehaviour; edge: HeaderEdge | null } {
21
+ return {
22
+ behaviour: headerBehaviourOf(node ? readProp(node, HeaderTrait.Behaviour) : undefined),
23
+ edge: headerEdgeOf(node ? readProp(node, HeaderTrait.Edge) : undefined),
24
+ };
25
+ }
@@ -24,3 +24,4 @@ export * from './segmented';
24
24
  export * from './exclusive';
25
25
  export * from './exclusive-runtime';
26
26
  export * from './composite-runtime';
27
+ export * from './header';
@@ -178,3 +178,85 @@ export function attachCarouselA11y(swiper: CarouselSwiper, options: CarouselA11y
178
178
  carouselOf(swiper.el)?.destroy();
179
179
  return new CarouselA11y(swiper, options);
180
180
  }
181
+
182
+ // ── The bar's words (premium components M4, M1.3) ──────────────────────────────────────────────────────
183
+
184
+ /** Where the Caption and the Count parts get their words: generated content (`attr()`), never the element's text — so
185
+ * the canvas's Text component and the runtime never write the same node. */
186
+ export const SLIDER_TEXT_ATTRIBUTE = 'data-iox-text';
187
+ /** A slide's caption, written by every renderer (iox-ui `nodeA11yAttributes`, SLIDE_CAPTION_ATTRIBUTE). */
188
+ const CAPTION_ATTRIBUTE = 'data-iox-caption';
189
+ /** Held for a frame while the caption changes, so its new words fade in (iox-design.css). */
190
+ export const SLIDER_SWAPPING_ATTRIBUTE = 'data-iox-swapping';
191
+ const BAR_RUNTIME_KEY = '__ioxSliderBar';
192
+
193
+ /** What the caption says for a slide: its own caption, else its picture's description, else nothing. */
194
+ export function sliderCaptionOf(slide: Element | null | undefined): string {
195
+ if (!slide) return '';
196
+ const holder = slide.hasAttribute(CAPTION_ATTRIBUTE) ? slide : slide.querySelector(`[${CAPTION_ATTRIBUTE}]`);
197
+ const own = holder?.getAttribute(CAPTION_ATTRIBUTE)?.trim();
198
+ if (own) return own;
199
+ return slide.querySelector('img[alt]')?.getAttribute('alt')?.trim() ?? '';
200
+ }
201
+
202
+ /** "2 / 5" — the slide in view and how many there are; nothing for an empty slider. */
203
+ export function sliderCountText(index: number, total: number): string {
204
+ if (!(total > 0)) return '';
205
+ const i = Math.min(total - 1, Math.max(0, Math.floor(Number.isFinite(index) ? index : 0)));
206
+ return `${i + 1} / ${total}`;
207
+ }
208
+
209
+ /** The parts of a running Swiper the bar's runtime reads. */
210
+ export interface BarSwiper {
211
+ el: HTMLElement;
212
+ slides: HTMLElement[];
213
+ activeIndex: number;
214
+ realIndex?: number;
215
+ destroyed?: boolean;
216
+ on(event: string, handler: (...args: unknown[]) => void): void;
217
+ off(event: string, handler: (...args: unknown[]) => void): void;
218
+ }
219
+
220
+ /**
221
+ * Keep the bar's Caption and Count on the slide in view — the canvas, Preview and both engine paths (bootSlider). The
222
+ * caption changes behind a short fade (the first one does not); the count reads left to right in every language.
223
+ * Returns the stop; a slider with neither part gets a no-op.
224
+ */
225
+ export function attachSliderBar(swiper: BarSwiper): () => void {
226
+ ((swiper.el as unknown as Record<string, () => void>)[BAR_RUNTIME_KEY])?.();
227
+ const caption = sliderPartElement(swiper.el, SliderRole.Caption);
228
+ const count = sliderPartElement(swiper.el, SliderRole.Count);
229
+ if (!caption && !count) return () => { /* nothing to keep */ };
230
+ const view = swiper.el.ownerDocument?.defaultView;
231
+ const raf = (fn: () => void) => (view?.requestAnimationFrame ? view.requestAnimationFrame(() => view.requestAnimationFrame(fn)) : setTimeout(fn, 32));
232
+ let first = true;
233
+ const sync = () => {
234
+ if (swiper.destroyed) return;
235
+ const slides = swiper.slides ?? [];
236
+ if (count) {
237
+ count.setAttribute('dir', 'ltr');
238
+ count.setAttribute(SLIDER_TEXT_ATTRIBUTE, sliderCountText(swiper.realIndex ?? swiper.activeIndex, slides.length));
239
+ }
240
+ if (caption) {
241
+ const words = sliderCaptionOf(slides[swiper.activeIndex]);
242
+ if (caption.getAttribute(SLIDER_TEXT_ATTRIBUTE) !== words) {
243
+ if (!first) {
244
+ caption.setAttribute(SLIDER_SWAPPING_ATTRIBUTE, '');
245
+ raf(() => caption.removeAttribute(SLIDER_SWAPPING_ATTRIBUTE));
246
+ }
247
+ caption.setAttribute(SLIDER_TEXT_ATTRIBUTE, words);
248
+ }
249
+ }
250
+ first = false;
251
+ };
252
+ swiper.on('slideChange', sync);
253
+ swiper.on('slidesLengthChange', sync);
254
+ sync();
255
+ const stop = () => {
256
+ swiper.off('slideChange', sync);
257
+ swiper.off('slidesLengthChange', sync);
258
+ delete (swiper.el as unknown as Record<string, unknown>)[BAR_RUNTIME_KEY];
259
+ };
260
+ (swiper.el as unknown as Record<string, () => void>)[BAR_RUNTIME_KEY] = stop;
261
+ return stop;
262
+ }
@@ -27,10 +27,37 @@ export enum SliderRole {
27
27
  Prev = 'slider.prev',
28
28
  Next = 'slider.next',
29
29
  Pagination = 'slider.pagination',
30
+ /** Premium M4 (M1.3): the bar UNDER the picture that holds the controls — caption · count · arrows. */
31
+ Bar = 'slider.bar',
32
+ /** The caption of the slide in view (its `caption`, else its picture's description) — drawn by the runtime. */
33
+ Caption = 'slider.caption',
34
+ /** "1 / 3", drawn by the runtime, left to right in every language. */
35
+ Count = 'slider.count',
30
36
  }
31
37
 
32
38
  /** Parts in the order they are stamped: the pause control first (APG: the first focus stop), the dots last. */
33
- export const SLIDER_PART_ROLES: ReadonlyArray<SliderRole> = [SliderRole.Pause, SliderRole.Prev, SliderRole.Next, SliderRole.Pagination];
39
+ export const SLIDER_PART_ROLES: ReadonlyArray<SliderRole> = [
40
+ SliderRole.Pause, SliderRole.Prev, SliderRole.Next, SliderRole.Pagination, SliderRole.Bar, SliderRole.Caption, SliderRole.Count,
41
+ ];
42
+
43
+ /** Where the controls sit (premium M4): over the picture (CC3, every slider saved before M4) or in the bar under it. */
44
+ export enum SliderPlacement {
45
+ Over = 'over',
46
+ Under = 'under',
47
+ }
48
+ export const SLIDER_PLACEMENT_TRAIT = 'controlsPlacement';
49
+ /** The bar's parts in reading order: the pause control, the caption at the reading start, the count, the dots, the
50
+ * arrows at the end (M1.3). */
51
+ export const SLIDER_BAR_ORDER: ReadonlyArray<SliderRole> = [
52
+ SliderRole.Pause, SliderRole.Caption, SliderRole.Count, SliderRole.Pagination, SliderRole.Prev, SliderRole.Next,
53
+ ];
54
+ /** Parts that exist only in the bar. */
55
+ const BAR_ONLY: ReadonlyArray<SliderRole> = [SliderRole.Caption, SliderRole.Count];
56
+
57
+ /** Premium M4: the trait that has the SCROLL play the slider (held mid-screen, resting on each picture). */
58
+ export const SLIDER_SCROLL_PLAYS_TRAIT = 'scrollPlays';
59
+ /** Premium M4: a slide's caption (SliderSlide ▸ Caption) — the bar's Caption part shows the one in view. */
60
+ export const SLIDE_CAPTION_TRAIT = 'caption';
34
61
 
35
62
  export const SLIDER_TYPE = 'SliderContainer';
36
63
  export const SLIDE_TYPE = 'SliderSlide';
@@ -40,12 +67,18 @@ export enum SliderFlag {
40
67
  Navigation = 'enableNavigation',
41
68
  Pagination = 'enablePagination',
42
69
  Autoplay = 'enableAutoplay',
70
+ /** Premium M4: a caption under the picture (the bar only). */
71
+ Caption = 'enableCaption',
72
+ /** Premium M4: "1 / 3" under the picture (the bar only). */
73
+ Count = 'enableCount',
43
74
  }
44
75
 
45
76
  const FLAG_PARTS: ReadonlyArray<readonly [SliderFlag, ReadonlyArray<SliderRole>]> = [
46
77
  [SliderFlag.Autoplay, [SliderRole.Pause]],
47
78
  [SliderFlag.Navigation, [SliderRole.Prev, SliderRole.Next]],
48
79
  [SliderFlag.Pagination, [SliderRole.Pagination]],
80
+ [SliderFlag.Caption, [SliderRole.Caption]],
81
+ [SliderFlag.Count, [SliderRole.Count]],
49
82
  ];
50
83
 
51
84
  /** The trait that names the carousel (Slider ▸ Accessibility ▸ Carousel name) — the A11y trait every named part uses. */
@@ -61,9 +94,25 @@ export function isSlider(node: RoleLike | null | undefined): boolean {
61
94
  return node?.type === SLIDER_TYPE;
62
95
  }
63
96
 
64
- /** The slider's own part playing `role` (a direct child), or undefined. */
65
- export function sliderPart<T extends RoleLike>(slider: { children?: T[] | null }, role: SliderRole): T | undefined {
66
- return (slider.children ?? []).find(c => c?.role === role);
97
+ /** The slider's own part playing `role` — a direct child, or one in its bar — or undefined. */
98
+ export function sliderPart<T extends RoleLike & { children?: T[] | null }>(slider: { children?: T[] | null }, role: SliderRole): T | undefined {
99
+ const direct = (slider.children ?? []).find(c => c?.role === role);
100
+ if (direct) return direct;
101
+ const bar = (slider.children ?? []).find(c => c?.role === SliderRole.Bar);
102
+ return (bar?.children ?? []).find(c => c?.role === role);
103
+ }
104
+
105
+ /**
106
+ * Where the slider's controls sit. The STRUCTURE decides once there are controls — a bar means under the picture, a
107
+ * control of its own means over it (every slider saved before M4) — so a stored trait can never disagree with what is
108
+ * drawn. A slider with no controls yet takes its `controlsPlacement` (the builder's new Slider: under the picture).
109
+ */
110
+ export function sliderPlacementOf(slider: CompositeNode | null | undefined): SliderPlacement {
111
+ if (!slider) return SliderPlacement.Over;
112
+ const children = slider.children ?? [];
113
+ if (children.some(c => c?.role === SliderRole.Bar)) return SliderPlacement.Under;
114
+ if (children.some(isSliderPart)) return SliderPlacement.Over;
115
+ return readProp(slider, SLIDER_PLACEMENT_TRAIT) === SliderPlacement.Under ? SliderPlacement.Under : SliderPlacement.Over;
67
116
  }
68
117
 
69
118
  /** True when the slider's controls are parts (it was created or migrated in CC3) — Swiper then draws none of its own. */
@@ -95,15 +144,18 @@ enum Type {
95
144
  ButtonBlock = 'ButtonBlock',
96
145
  Icon = 'Icon',
97
146
  Container = 'Container',
147
+ Text = 'Text',
98
148
  }
99
149
 
100
150
  enum Style {
101
151
  Control = 'button-secondary',
102
152
  Icon = 'icon',
153
+ /** The caption and the count read as small print. */
154
+ Words = 'small',
103
155
  }
104
156
 
105
157
  /** Each part's glyph (Phosphor) and its default accessible name. */
106
- export const SLIDER_PART_GLYPH: Readonly<Record<Exclude<SliderRole, SliderRole.Pagination>, string>> = {
158
+ export const SLIDER_PART_GLYPH: Readonly<Record<SliderRole.Pause | SliderRole.Prev | SliderRole.Next, string>> = {
107
159
  [SliderRole.Pause]: 'ph ph-pause',
108
160
  [SliderRole.Prev]: 'ph ph-caret-left',
109
161
  [SliderRole.Next]: 'ph ph-caret-right',
@@ -115,9 +167,30 @@ const PART_LABEL: Readonly<Record<SliderRole, string>> = {
115
167
  [SliderRole.Prev]: 'Previous',
116
168
  [SliderRole.Next]: 'Next',
117
169
  [SliderRole.Pagination]: 'Pagination',
170
+ [SliderRole.Bar]: 'Controls',
171
+ [SliderRole.Caption]: 'Caption',
172
+ [SliderRole.Count]: 'Count',
118
173
  };
119
174
 
120
175
  const ZERO = '0px 0px 0px 0px';
176
+ /** A control in the bar: in flow, its own size, no offsets (the bar spaces its parts). */
177
+ const BAR_PART_LAYOUT: Readonly<Record<string, string>> = { position: 'static', top: 'auto', margin: ZERO };
178
+ /**
179
+ * The bar (M1.3): a row under the picture. Its inline padding keeps the PAGE's margin when the slider bleeds to the
180
+ * screen's edge — the gutter less however far the slider is already inset from the screen (`100%` is the slider's
181
+ * width, the bar's containing block) — so in a padded section it is 0 and at full bleed it is the gutter.
182
+ */
183
+ const BAR_INLINE_PAD = 'max(0px, calc(var(--iox-space-gutter, 24px) - (100vw - 100%) / 2))';
184
+ const BAR_BASE: Readonly<Record<string, string>> = {
185
+ position: 'relative', display: 'flex', flexDirection: 'row', flexWrap: 'nowrap', alignItems: 'center', justifyContent: 'flex-start',
186
+ gap: '12px', width: '100%', height: 'auto', minHeight: '44px', margin: ZERO, padding: `12px ${BAR_INLINE_PAD} 0px ${BAR_INLINE_PAD}`, zIndex: '10',
187
+ };
188
+ /** The caption takes the room the controls leave; its words end in an ellipsis rather than push them off. */
189
+ const CAPTION_BASE: Readonly<Record<string, string>> = {
190
+ flexGrow: '1', flexShrink: '1', flexBasis: '0%', minWidth: '0px', width: 'auto', minHeight: 'auto', margin: ZERO, padding: ZERO,
191
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
192
+ };
193
+ const COUNT_BASE: Readonly<Record<string, string>> = { flexGrow: '0', flexShrink: '0', width: 'auto', minHeight: 'auto', margin: ZERO, padding: ZERO };
121
194
  /** A round 44px control (board 6 `.st-ctl`), over the slides (z above `.swiper-wrapper`). The PLACE (which edge) is the
122
195
  * shared CSS's, in logical insets, so a carousel reads the same way in RTL. */
123
196
  const CONTROL_BASE: Readonly<Record<string, string>> = {
@@ -158,6 +231,17 @@ function legacyLookOf(slider: CompositeNode): LegacySliderLook {
158
231
 
159
232
  /** One part, from the recipe (board 6: `button-secondary` controls, token dots) or in a migrated slider's legacy look. */
160
233
  export function buildSliderPart(create: CompositeNodeFactory, role: SliderRole, legacy?: LegacySliderLook): CompositeNode {
234
+ if (role === SliderRole.Bar) {
235
+ const bar = makeCompositePart(create, { key: 'bar', type: Type.Container, props: {}, elementStyle: NO_ELEMENT_STYLE }, role, PART_LABEL[role], BAR_BASE);
236
+ bar.children = [];
237
+ return bar;
238
+ }
239
+ if (role === SliderRole.Caption || role === SliderRole.Count) {
240
+ // Their words are the runtime's (the slide in view), drawn as the part's generated content: the node's own
241
+ // content stays empty, so no renderer ever fights the runtime over the element's text.
242
+ return makeCompositePart(create, { key: role.split('.')[1], type: Type.Text, props: { content: '', tag: 'p' }, elementStyle: Style.Words },
243
+ role, PART_LABEL[role], role === SliderRole.Caption ? CAPTION_BASE : COUNT_BASE);
244
+ }
161
245
  if (role === SliderRole.Pagination) {
162
246
  const dots = makeCompositePart(create, { key: 'pagination', type: Type.Container, props: {}, elementStyle: NO_ELEMENT_STYLE }, role, PART_LABEL[role],
163
247
  legacy?.paginationColor ? { ...PAGINATION_BASE, color: legacy.paginationColor } : PAGINATION_BASE);
@@ -186,50 +270,131 @@ export function buildSliderPart(create: CompositeNodeFactory, role: SliderRole,
186
270
 
187
271
  const truthy = (v: unknown): boolean => v === true || v === 'true';
188
272
 
189
- /** The parts the slider's switches call for, in stamping order. */
190
- export function wantedSliderParts(slider: CompositeNode): SliderRole[] {
191
- return FLAG_PARTS.filter(([flag]) => truthy(readProp(slider, flag))).flatMap(([, roles]) => roles);
273
+ /** The parts the slider's switches call for, in stamping order — and its bar, while the controls sit under the
274
+ * picture. A caption and a count live in the bar only. */
275
+ export function wantedSliderParts(slider: CompositeNode, placement: SliderPlacement = sliderPlacementOf(slider)): SliderRole[] {
276
+ const under = placement === SliderPlacement.Under;
277
+ const roles = FLAG_PARTS.filter(([flag]) => truthy(readProp(slider, flag))).flatMap(([, r]) => r).filter(r => under || !BAR_ONLY.includes(r));
278
+ // The bar exists while it holds something: an empty band under the picture is never drawn.
279
+ return under && roles.length ? [...roles, SliderRole.Bar] : roles;
192
280
  }
193
281
 
282
+ /** The slider's parts wherever they are: its direct children and those in its bar. */
283
+ function allSliderParts(slider: CompositeNode): CompositeNode[] {
284
+ const direct = (slider.children ?? []).filter(isSliderPart);
285
+ const bar = direct.find(c => c.role === SliderRole.Bar);
286
+ return [...direct, ...(bar?.children ?? []).filter(isSliderPart)];
287
+ }
288
+
289
+ const barIndex = (role: string | undefined): number => SLIDER_BAR_ORDER.indexOf(role as SliderRole);
290
+
194
291
  export interface SliderPartsChange {
195
292
  added: CompositeNode[];
196
293
  removed: CompositeNode[];
197
294
  }
198
295
 
199
296
  /**
200
- * Make the slider's parts match its switches — ONE edit (a palette switch, the migration, the composer). Missing
201
- * parts are stamped from the recipe (or, `legacy`, in the migrated look) at their place: the pause control, Previous
202
- * and Next before the first slide, the pagination after the last; parts whose switch is off are removed. Everything
203
- * else (the slides, the author's edits to an existing part) is kept.
297
+ * Make the slider's parts match its switches — ONE edit (a palette switch, the migration, the composer, a change of
298
+ * placement). Missing parts are stamped from the recipe (or, `legacy`, in the migrated look) at their place: over the
299
+ * picture, the pause control, Previous and Next before the first slide and the pagination after the last; under it
300
+ * (premium M4), every control in the bar in its reading order, the bar after the slides. Parts whose switch is off are
301
+ * removed wherever they are. Everything else (the slides, the author's edits to an existing part) is kept.
204
302
  */
205
303
  export function syncSliderParts(slider: CompositeNode, create: CompositeNodeFactory, legacy?: LegacySliderLook): SliderPartsChange {
206
- const wanted = new Set(wantedSliderParts(slider));
207
- const children = [...(slider.children ?? [])];
208
- const removed = children.filter(c => isSliderPart(c) && !wanted.has(c.role as SliderRole));
209
- const kept = children.filter(c => !removed.includes(c));
304
+ // A migrated slider keeps the place Swiper drew its controls in: over the picture.
305
+ const wanted = new Set(wantedSliderParts(slider, legacy ? SliderPlacement.Over : sliderPlacementOf(slider)));
306
+ const under = wanted.has(SliderRole.Bar);
307
+ const removed = allSliderParts(slider).filter(c => !wanted.has(c.role as SliderRole));
210
308
  const added: CompositeNode[] = [];
309
+ let kept = [...(slider.children ?? [])].filter(c => !removed.includes(c));
310
+ let bar = kept.find(c => c.role === SliderRole.Bar) ?? null;
311
+ if (bar) bar.children = (bar.children ?? []).filter(c => !removed.includes(c));
312
+ if (under && !bar) {
313
+ bar = buildSliderPart(create, SliderRole.Bar);
314
+ added.push(bar);
315
+ kept.push(bar);
316
+ }
211
317
  for (const role of SLIDER_PART_ROLES) {
212
- if (!wanted.has(role) || kept.some(c => c.role === role)) continue;
318
+ if (role === SliderRole.Bar || !wanted.has(role) || allSliderPartsOf(kept, bar).some(c => c.role === role)) continue;
213
319
  const part = buildSliderPart(create, role, legacy);
214
320
  added.push(part);
215
- if (role === SliderRole.Pagination) {
321
+ if (under && bar) {
322
+ if (role !== SliderRole.Caption && role !== SliderRole.Count) writeStyles(part, BAR_PART_LAYOUT);
323
+ placeInBar(bar, part);
324
+ } else if (role === SliderRole.Pagination) {
216
325
  kept.push(part);
217
326
  } else {
218
- // Before the first slide, after the parts that come before it in stamping order.
219
- const order = SLIDER_PART_ROLES.indexOf(role);
220
- const firstSlide = kept.findIndex(c => !isSliderPart(c));
221
- let at = firstSlide < 0 ? kept.length : firstSlide;
222
- for (let i = 0; i < kept.length; i++) {
223
- const r = kept[i].role as SliderRole;
224
- if (isSliderPart(kept[i]) && SLIDER_PART_ROLES.indexOf(r) > order && i < at) { at = i; break; }
225
- }
226
- kept.splice(at, 0, part);
327
+ kept = placeBeforeSlides(kept, part, role);
227
328
  }
228
329
  }
229
330
  if (added.length || removed.length) slider.children = kept;
230
331
  return { added, removed };
231
332
  }
232
333
 
334
+ function allSliderPartsOf(children: ReadonlyArray<CompositeNode>, bar: CompositeNode | null): CompositeNode[] {
335
+ return [...children.filter(isSliderPart), ...(bar?.children ?? []).filter(isSliderPart)];
336
+ }
337
+
338
+ /** Into the bar at its reading-order place (after the parts that come before it). */
339
+ function placeInBar(bar: CompositeNode, part: CompositeNode): void {
340
+ const children = [...(bar.children ?? [])];
341
+ const order = barIndex(part.role);
342
+ const at = children.findIndex(c => barIndex(c.role) > order);
343
+ children.splice(at < 0 ? children.length : at, 0, part);
344
+ bar.children = children;
345
+ }
346
+
347
+ /** Before the first slide, after the parts that come before it in stamping order. */
348
+ function placeBeforeSlides(kept: CompositeNode[], part: CompositeNode, role: SliderRole): CompositeNode[] {
349
+ const out = [...kept];
350
+ const order = SLIDER_PART_ROLES.indexOf(role);
351
+ const firstSlide = out.findIndex(c => !isSliderPart(c));
352
+ let at = firstSlide < 0 ? out.length : firstSlide;
353
+ for (let i = 0; i < out.length; i++) {
354
+ const r = out[i].role as SliderRole;
355
+ if (isSliderPart(out[i]) && SLIDER_PART_ROLES.indexOf(r) > order && i < at) { at = i; break; }
356
+ }
357
+ out.splice(at, 0, part);
358
+ return out;
359
+ }
360
+
361
+ /** The layout a control has over the picture (its recipe's place: absolute, the arrows at the vertical middle). */
362
+ function overLayoutFor(role: SliderRole): Readonly<Record<string, string>> {
363
+ if (role === SliderRole.Pagination) return { position: 'absolute', margin: ZERO };
364
+ return role === SliderRole.Pause ? { position: 'absolute', top: 'auto', margin: ZERO } : { position: 'absolute', top: ARROW_TOP, margin: ZERO };
365
+ }
366
+
367
+ /**
368
+ * Move the controls over the picture or into the bar under it (premium M4, M1.3 — Slider ▸ Controls) — ONE edit and
369
+ * one undo step. The parts keep their identity and the author's edits; only their place changes (in flow in the bar,
370
+ * absolute over the picture). A caption and a count exist only in the bar. True when anything changed.
371
+ */
372
+ export function setSliderPlacement(slider: CompositeNode, placement: SliderPlacement, create: CompositeNodeFactory): boolean {
373
+ const was = sliderPlacementOf(slider);
374
+ writeProp(slider, SLIDER_PLACEMENT_TRAIT, placement);
375
+ if (was === placement) return false;
376
+ const children = [...(slider.children ?? [])];
377
+ const bar = children.find(c => c.role === SliderRole.Bar) ?? null;
378
+ if (placement === SliderPlacement.Under) {
379
+ const controls = children.filter(c => isSliderPart(c) && c.role !== SliderRole.Bar);
380
+ const rest = children.filter(c => !controls.includes(c));
381
+ const target = bar ?? buildSliderPart(create, SliderRole.Bar);
382
+ for (const part of controls) { writeStyles(part, BAR_PART_LAYOUT); placeInBar(target, part); }
383
+ slider.children = bar ? rest : [...rest, target];
384
+ } else {
385
+ const inBar = (bar?.children ?? []).filter(isSliderPart);
386
+ let rest = children.filter(c => c !== bar);
387
+ for (const part of inBar) {
388
+ if (BAR_ONLY.includes(part.role as SliderRole)) continue;
389
+ writeStyles(part, overLayoutFor(part.role as SliderRole));
390
+ rest = part.role === SliderRole.Pagination ? [...rest, part] : placeBeforeSlides(rest, part, part.role as SliderRole);
391
+ }
392
+ slider.children = rest;
393
+ }
394
+ syncSliderParts(slider, create);
395
+ return true;
396
+ }
397
+
233
398
  /** True when a saved slider still relies on Swiper-drawn controls: a switch is on and no part exists yet. */
234
399
  export function needsSliderMigration(slider: CompositeNode | null | undefined): boolean {
235
400
  return !!slider && isSlider(slider) && !hasSliderParts(slider) && wantedSliderParts(slider).length > 0;
@@ -354,6 +519,8 @@ export function buildCarouselRecipe(create: CompositeNodeFactory, options: Carou
354
519
  // Rewind: Previous and Next are always there to press (board 6) — neither greys out at an end.
355
520
  slidesPerView: 1, spaceBetween: 0, loop: 'rewind', enableNavigation: true, enablePagination: true, enableAutoplay: true, autoplayDelay: 5000,
356
521
  autoplayPauseOnMouseEnter: true, autoHeight: true, [SLIDER_NAME_TRAIT]: options.name ?? 'What clients say',
522
+ // Board 6 draws its controls in a row above the quotes — its own place, not the M4 bar under a picture.
523
+ [SLIDER_PLACEMENT_TRAIT]: SliderPlacement.Over,
357
524
  })) writeProp(slider, name, value);
358
525
  slider.children = (options.quotes ?? CAROUSEL_QUOTES).map((q, i) => {
359
526
  const slide = makeCompositePart(create, { key: `slide-${i + 1}`, type: SLIDE_TYPE, props: {}, elementStyle: 'card' }, null, 'Slide', QUOTE_SLIDE_BASE);
@@ -11,7 +11,8 @@ import { SLIDER_TYPE, SliderRole } from './slider';
11
11
  export interface CompositeRoleSpec {
12
12
  /** The role of the part's parent; null for a composite root. */
13
13
  readonly parent: string | null;
14
- /** The TYPE of the part's parent, for a part whose parent carries no role (a Slider's controls). */
14
+ /** The TYPE of the part's parent, for a part whose parent carries no role (a Slider's controls). With `parent`
15
+ * also set, either parent places it (a Slider's control lives in the slider or in its bar). */
15
16
  readonly parentType?: string;
16
17
  readonly locked?: boolean;
17
18
  /** May be deleted, but never moved out of its parent (a Slider's controls work only as its direct children). */
@@ -59,13 +60,17 @@ export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> =
59
60
  // A Scene always keeps its stage: everything it pins and clips lives there.
60
61
  [SceneRole.Scene]: { parent: null },
61
62
  [SceneRole.Stage]: { parent: SceneRole.Scene, locked: true },
62
- // CC3 — a Slider's controls are its own direct children (Swiper is handed them). The pause control stays while
63
- // Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it, never Delete. The arrows and the dots follow
64
- // their switches and may be deleted.
65
- [SliderRole.Pause]: { parent: null, parentType: SLIDER_TYPE, locked: true },
66
- [SliderRole.Prev]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
67
- [SliderRole.Next]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
68
- [SliderRole.Pagination]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
63
+ // CC3 — a Slider's controls are its own direct children (Swiper is handed them) — or, premium M4, children of its
64
+ // bar under the picture. The pause control stays while Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it,
65
+ // never Delete. The arrows, the dots, the caption and the count follow their switches and may be deleted; the bar
66
+ // follows Slider ▸ Controls.
67
+ [SliderRole.Pause]: { parent: SliderRole.Bar, parentType: SLIDER_TYPE, locked: true },
68
+ [SliderRole.Prev]: { parent: SliderRole.Bar, parentType: SLIDER_TYPE, pinned: true },
69
+ [SliderRole.Next]: { parent: SliderRole.Bar, parentType: SLIDER_TYPE, pinned: true },
70
+ [SliderRole.Pagination]: { parent: SliderRole.Bar, parentType: SLIDER_TYPE, pinned: true },
71
+ [SliderRole.Bar]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
72
+ [SliderRole.Caption]: { parent: SliderRole.Bar, pinned: true },
73
+ [SliderRole.Count]: { parent: SliderRole.Bar, pinned: true },
69
74
  };
70
75
 
71
76
  export enum StructureEdit {
@@ -95,7 +100,7 @@ export function structureRefusal(node: RoleLike | null | undefined, edit: Struct
95
100
  if (edit === StructureEdit.Move && acceptsRefusal(node, parent)) return StructureRefusal.NotAccepted;
96
101
  if (!spec?.locked && !spec?.pinned) return null;
97
102
  if (edit === StructureEdit.Delete) return spec.locked ? StructureRefusal.LockedPart : null;
98
- const placed = spec.parentType ? parent?.type === spec.parentType : parent?.role === spec.parent;
103
+ const placed = (!!spec.parentType && parent?.type === spec.parentType) || (spec.parent !== null && parent?.role === spec.parent);
99
104
  return placed ? null : StructureRefusal.LockedPlacement;
100
105
  }
101
106