@vectoriox/iox-ui 5.37.0 → 5.38.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 (38) hide show
  1. package/actions-core/index.d.ts +6 -1
  2. package/composite-core/index.d.ts +294 -14
  3. package/design-core/index.d.ts +130 -3
  4. package/fesm2022/vectoriox-iox-ui-actions-core.mjs +9 -3
  5. package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +694 -59
  7. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-design-core.mjs +172 -3
  9. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +410 -10
  11. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  12. package/fesm2022/vectoriox-iox-ui-media-core.mjs +571 -0
  13. package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -0
  14. package/fesm2022/vectoriox-iox-ui.mjs +478 -118
  15. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  16. package/generation-core/index.d.ts +167 -4
  17. package/index.d.ts +206 -24
  18. package/media-core/index.d.ts +277 -0
  19. package/media-core/package.json +3 -0
  20. package/package.json +5 -1
  21. package/src/actions-core/element-actions.ts +9 -2
  22. package/src/composite-core/composite-node.ts +4 -0
  23. package/src/composite-core/lightbox.ts +192 -0
  24. package/src/composite-core/public-api.ts +3 -0
  25. package/src/composite-core/slider-runtime.ts +180 -0
  26. package/src/composite-core/slider.ts +378 -0
  27. package/src/composite-core/structure.ts +17 -4
  28. package/src/design-core/element-styles.ts +27 -0
  29. package/src/design-core/flavor-spec.generated.ts +146 -2
  30. package/src/generation-core/baseline-roles.ts +2 -0
  31. package/src/generation-core/compose-site.ts +80 -4
  32. package/src/generation-core/node-baselines.generated.ts +148 -4
  33. package/src/generation-core/patterns.ts +17 -0
  34. package/src/generation-core/site-spec.generated.ts +164 -0
  35. package/src/media-core/public-api.ts +9 -0
  36. package/src/media-core/video-runtime.ts +239 -0
  37. package/src/media-core/video.ts +443 -0
  38. package/styles/iox-design.css +96 -0
@@ -0,0 +1,180 @@
1
+ import { sliderLiveRegion, sliderPauseAttributes, sliderPauseGlyph, SliderRole, SliderWords, sliderWords } from './slider';
2
+
3
+ /**
4
+ * The accessible carousel's RUNTIME (CC3, board 6; closes vectoriox/iox-ui#13) — DOM only, over a running Swiper,
5
+ * ONE copy for the engine (both paths) and the admin's Preview. Swiper's A11y module gives the container
6
+ * `aria-roledescription="carousel"`, every slide `role=group` + "slide" + "n of m"; this adds what the APG
7
+ * carousel needs on top:
8
+ * - the PAUSE control (role `slider.pause`, rendered first): it stops rotation for good and starts it again; its
9
+ * name says what it will do, its glyph swaps;
10
+ * - rotation PAUSES while focus is anywhere inside (hover is Swiper's `pauseOnMouseEnter`) and resumes when it
11
+ * leaves — unless the visitor pressed pause;
12
+ * - the wrapper's live region is `off` while the slides move on their own and `polite` otherwise;
13
+ * - slides that are not in view are `inert` (and hidden from assistive technology), so Tab never lands in one.
14
+ * The builder's EDIT canvas never attaches it (no autoplay there, and a slide must stay clickable).
15
+ */
16
+
17
+ /** The parts of the Swiper instance this runtime reads — a real Swiper, or a stub in a spec. */
18
+ export interface CarouselSwiper {
19
+ el: HTMLElement;
20
+ wrapperEl: HTMLElement;
21
+ slides: HTMLElement[];
22
+ activeIndex: number;
23
+ destroyed?: boolean;
24
+ params?: { slidesPerView?: number | 'auto'; autoplay?: { pauseOnMouseEnter?: boolean } | boolean };
25
+ autoplay?: { running: boolean; paused?: boolean; start(): void; stop(): void; pause(): void; resume(): void };
26
+ on(event: string, handler: (...args: unknown[]) => void): void;
27
+ off(event: string, handler: (...args: unknown[]) => void): void;
28
+ }
29
+
30
+ export interface CarouselA11yOptions {
31
+ words?: SliderWords;
32
+ }
33
+
34
+ const SLIDE_VISIBLE = 'swiper-slide-visible';
35
+ const RUNTIME_KEY = '__ioxCarousel';
36
+ const ROTATION_EVENTS = 'autoplayStart autoplayStop autoplayPause autoplayResume';
37
+ const SLIDE_EVENTS = 'slideChange transitionEnd resize slidesLengthChange';
38
+
39
+ const AUTHOR_NAME_KEY = '__ioxAuthorName';
40
+
41
+ /** The name the AUTHOR gave the pause control (its rendered `aria-label` before this runtime first relabelled it). */
42
+ function authorNameOf(el: HTMLElement | null): string | null {
43
+ if (!el) return null;
44
+ const host = el as unknown as Record<string, unknown>;
45
+ if (host[AUTHOR_NAME_KEY] === undefined) host[AUTHOR_NAME_KEY] = el.getAttribute('aria-label') ?? '';
46
+ return (host[AUTHOR_NAME_KEY] as string) || null;
47
+ }
48
+
49
+ /** The part of `role` that belongs to THIS slider (never one of a slider nested in a slide). */
50
+ export function sliderPartElement(sliderEl: Element, role: SliderRole): HTMLElement | null {
51
+ const cls = `iox-role-${role.replace('.', '-')}`;
52
+ for (const el of Array.from(sliderEl.querySelectorAll<HTMLElement>(`.${cls}`))) {
53
+ if (el.closest('.swiper') === sliderEl && !el.closest('.swiper-wrapper')) return el;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export class CarouselA11y {
59
+ /** The visitor pressed pause: rotation stays stopped until they press it again. */
60
+ private stoppedByVisitor = false;
61
+ private focusInside = false;
62
+ /** The pointer is over the carousel and Swiper holds rotation for it (`pauseOnMouseEnter`). */
63
+ private hovering = false;
64
+ private readonly cleanups: Array<() => void> = [];
65
+ private readonly words: SliderWords;
66
+ private readonly pause: HTMLElement | null;
67
+ private readonly authorName: string | null;
68
+
69
+ constructor(private readonly swiper: CarouselSwiper, options: CarouselA11yOptions = {}) {
70
+ const el = swiper.el;
71
+ this.words = options.words ?? sliderWords(el.ownerDocument?.documentElement?.getAttribute('lang'));
72
+ this.pause = sliderPartElement(el, SliderRole.Pause);
73
+ this.authorName = authorNameOf(this.pause);
74
+ this.listen(el, 'focusin', () => this.onFocusIn());
75
+ this.listen(el, 'pointerenter', () => { this.hovering = this.hoverPauses; this.sync(); });
76
+ this.listen(el, 'pointerleave', () => { this.hovering = false; this.sync(); });
77
+ this.listen(el, 'focusout', (e) => this.onFocusOut(e as FocusEvent));
78
+ if (this.pause) this.listen(this.pause, 'click', (e) => { e.preventDefault(); this.togglePause(); });
79
+ const sync = () => this.sync();
80
+ swiper.on(ROTATION_EVENTS, sync);
81
+ swiper.on(SLIDE_EVENTS, sync);
82
+ this.cleanups.push(() => { swiper.off(ROTATION_EVENTS, sync); swiper.off(SLIDE_EVENTS, sync); });
83
+ (el as unknown as Record<string, unknown>)[RUNTIME_KEY] = this;
84
+ this.sync();
85
+ }
86
+
87
+ /** True while the slides move on their own: autoplay runs and nothing holds it — not focus inside, not the
88
+ * pointer over it. (Swiper's own `paused` also flips during every transition, so it is not read here.) */
89
+ get rotating(): boolean {
90
+ return !!this.swiper.autoplay?.running && !this.focusInside && !this.hovering;
91
+ }
92
+
93
+ private get hoverPauses(): boolean {
94
+ const autoplay = this.swiper.params?.autoplay;
95
+ return typeof autoplay === 'object' && !!autoplay?.pauseOnMouseEnter;
96
+ }
97
+
98
+ /** The pause control: stop rotation for good, or start it again. */
99
+ togglePause(): void {
100
+ const a = this.swiper.autoplay;
101
+ if (!a) return;
102
+ if (a.running) {
103
+ this.stoppedByVisitor = true;
104
+ a.stop();
105
+ } else {
106
+ this.stoppedByVisitor = false;
107
+ a.start();
108
+ // Focus is on the pause control itself — inside the carousel: the rotation it just started waits until
109
+ // focus leaves (APG), but the control already reads "stop".
110
+ if (this.focusInside) a.pause();
111
+ }
112
+ this.sync();
113
+ }
114
+
115
+ destroy(): void {
116
+ this.cleanups.splice(0).forEach(fn => fn());
117
+ delete (this.swiper.el as unknown as Record<string, unknown>)[RUNTIME_KEY];
118
+ }
119
+
120
+ private listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
121
+ target.addEventListener(type, fn);
122
+ this.cleanups.push(() => target.removeEventListener(type, fn));
123
+ }
124
+
125
+ private onFocusIn(): void {
126
+ this.focusInside = true;
127
+ if (this.swiper.autoplay?.running && !this.swiper.autoplay.paused) this.swiper.autoplay.pause();
128
+ this.sync();
129
+ }
130
+
131
+ private onFocusOut(e: FocusEvent): void {
132
+ const next = e.relatedTarget as Node | null;
133
+ if (next && this.swiper.el.contains(next)) return;
134
+ this.focusInside = false;
135
+ if (!this.stoppedByVisitor && this.swiper.autoplay?.running && this.swiper.autoplay.paused) this.swiper.autoplay.resume();
136
+ this.sync();
137
+ }
138
+
139
+ /** Re-state the carousel: the live region, the pause control, the slides out of view. */
140
+ sync(): void {
141
+ if (this.swiper.destroyed) return;
142
+ const rotating = this.rotating;
143
+ this.swiper.wrapperEl?.setAttribute('aria-live', sliderLiveRegion(rotating));
144
+ if (this.pause) {
145
+ const running = !!this.swiper.autoplay?.running;
146
+ const attrs = sliderPauseAttributes(running, this.words, this.authorName);
147
+ for (const [name, value] of Object.entries(attrs)) this.pause.setAttribute(name, value);
148
+ if (!('aria-pressed' in attrs)) this.pause.removeAttribute('aria-pressed');
149
+ const icon = this.pause.querySelector('i');
150
+ if (icon) icon.className = `${sliderPauseGlyph(running)} ${Array.from(icon.classList).filter(c => !c.startsWith('ph')).join(' ')}`.trim();
151
+ }
152
+ this.syncHiddenSlides();
153
+ }
154
+
155
+ private syncHiddenSlides(): void {
156
+ const slides = this.swiper.slides ?? [];
157
+ const anyVisibleMarked = slides.some(s => s.classList?.contains(SLIDE_VISIBLE));
158
+ slides.forEach((slide, i) => {
159
+ const visible = anyVisibleMarked ? slide.classList.contains(SLIDE_VISIBLE) : i === this.swiper.activeIndex;
160
+ if (visible) {
161
+ slide.removeAttribute('inert');
162
+ slide.removeAttribute('aria-hidden');
163
+ } else {
164
+ slide.setAttribute('inert', '');
165
+ slide.setAttribute('aria-hidden', 'true');
166
+ }
167
+ });
168
+ }
169
+ }
170
+
171
+ /** The carousel runtime attached to a slider element, if any. */
172
+ export function carouselOf(el: Element | null | undefined): CarouselA11y | null {
173
+ return ((el as unknown as Record<string, unknown> | null)?.[RUNTIME_KEY] as CarouselA11y | undefined) ?? null;
174
+ }
175
+
176
+ /** Attach once per Swiper (a re-init gets a fresh runtime). */
177
+ export function attachCarouselA11y(swiper: CarouselSwiper, options: CarouselA11yOptions = {}): CarouselA11y {
178
+ carouselOf(swiper.el)?.destroy();
179
+ return new CarouselA11y(swiper, options);
180
+ }
@@ -0,0 +1,378 @@
1
+ import { CompositeNode, CompositeNodeFactory, makeCompositePart, NO_ELEMENT_STYLE, readProp, writeProp, writeStyles } from './composite-node';
2
+
3
+ /**
4
+ * THE ACCESSIBLE CAROUSEL (CC3 — complex-components plan §7c, board 6; board 9 Q13). The Slider's controls are no
5
+ * longer drawn by Swiper: Previous, Next, Pause and Pagination are REAL nodes with roles — selectable on the canvas,
6
+ * in the layer tree and the breadcrumb, and styled on their own (R2) — handed to Swiper as its navigation and
7
+ * pagination elements.
8
+ *
9
+ * SliderContainer (.swiper)
10
+ * ├─ ButtonBlock `button-secondary` role slider.pause FIRST focus stop; present while Autoplay is on, locked
11
+ * ├─ ButtonBlock `button-secondary` role slider.prev › Icon caret (points the reading way in RTL)
12
+ * ├─ ButtonBlock `button-secondary` role slider.next › Icon caret
13
+ * ├─ (the slides — `.swiper-wrapper` › SliderSlide …)
14
+ * └─ Container role slider.pagination Swiper draws the dots (or "3 / 6") inside it
15
+ *
16
+ * The parts render INSIDE the `.swiper` element, never inside `.swiper-wrapper` (slider.md: a `.swiper-slide` must be
17
+ * the wrapper's direct child): the parts that come before the first slide in the tree render before the wrapper, the
18
+ * rest after it. By default they sit over the slides like Swiper's own controls (the shared CSS, iox-design.css, at
19
+ * zero specificity — a part's own styles win), so a saved slider migrated from Swiper-drawn controls keeps its look.
20
+ *
21
+ * The Bindings switches stay the source of truth: Navigation arrows ⇔ Previous + Next, Pagination ⇔ Pagination,
22
+ * Autoplay ⇔ Pause (WCAG 2.2.2 — "added while Autoplay is on, cannot be removed"). `syncSliderParts` is the one edit.
23
+ */
24
+
25
+ export enum SliderRole {
26
+ Pause = 'slider.pause',
27
+ Prev = 'slider.prev',
28
+ Next = 'slider.next',
29
+ Pagination = 'slider.pagination',
30
+ }
31
+
32
+ /** 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];
34
+
35
+ export const SLIDER_TYPE = 'SliderContainer';
36
+ export const SLIDE_TYPE = 'SliderSlide';
37
+
38
+ /** The Bindings switches that own the parts. */
39
+ export enum SliderFlag {
40
+ Navigation = 'enableNavigation',
41
+ Pagination = 'enablePagination',
42
+ Autoplay = 'enableAutoplay',
43
+ }
44
+
45
+ const FLAG_PARTS: ReadonlyArray<readonly [SliderFlag, ReadonlyArray<SliderRole>]> = [
46
+ [SliderFlag.Autoplay, [SliderRole.Pause]],
47
+ [SliderFlag.Navigation, [SliderRole.Prev, SliderRole.Next]],
48
+ [SliderFlag.Pagination, [SliderRole.Pagination]],
49
+ ];
50
+
51
+ /** The trait that names the carousel (Slider ▸ Accessibility ▸ Carousel name) — the A11y trait every named part uses. */
52
+ export const SLIDER_NAME_TRAIT = 'ariaLabel';
53
+
54
+ interface RoleLike { role?: string | null; type?: string }
55
+
56
+ export function isSliderPart(node: RoleLike | null | undefined): boolean {
57
+ return !!node?.role && (SLIDER_PART_ROLES as ReadonlyArray<string>).includes(node.role);
58
+ }
59
+
60
+ export function isSlider(node: RoleLike | null | undefined): boolean {
61
+ return node?.type === SLIDER_TYPE;
62
+ }
63
+
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);
67
+ }
68
+
69
+ /** True when the slider's controls are parts (it was created or migrated in CC3) — Swiper then draws none of its own. */
70
+ export function hasSliderParts(slider: { children?: ReadonlyArray<RoleLike> | null }): boolean {
71
+ return (slider.children ?? []).some(isSliderPart);
72
+ }
73
+
74
+ /** A slider's children split as they render: parts before the first slide, the slides (into `.swiper-wrapper`), and
75
+ * the parts after it. */
76
+ export function splitSliderChildren<T extends RoleLike>(children: ReadonlyArray<T> | null | undefined): { lead: T[]; slides: T[]; trail: T[] } {
77
+ const lead: T[] = [];
78
+ const slides: T[] = [];
79
+ const trail: T[] = [];
80
+ for (const child of children ?? []) {
81
+ if (!isSliderPart(child)) slides.push(child);
82
+ else (slides.length ? trail : lead).push(child);
83
+ }
84
+ return { lead, slides, trail };
85
+ }
86
+
87
+ /** The slides only (the template slide of a bound slider is `sliderSlides(children)[0]`). */
88
+ export function sliderSlides<T extends RoleLike>(children: ReadonlyArray<T> | null | undefined): T[] {
89
+ return (children ?? []).filter(c => !isSliderPart(c));
90
+ }
91
+
92
+ // ── the parts' recipe ───────────────────────────────────────────────────────────────────────────────
93
+
94
+ enum Type {
95
+ ButtonBlock = 'ButtonBlock',
96
+ Icon = 'Icon',
97
+ Container = 'Container',
98
+ }
99
+
100
+ enum Style {
101
+ Control = 'button-secondary',
102
+ Icon = 'icon',
103
+ }
104
+
105
+ /** Each part's glyph (Phosphor) and its default accessible name. */
106
+ export const SLIDER_PART_GLYPH: Readonly<Record<Exclude<SliderRole, SliderRole.Pagination>, string>> = {
107
+ [SliderRole.Pause]: 'ph ph-pause',
108
+ [SliderRole.Prev]: 'ph ph-caret-left',
109
+ [SliderRole.Next]: 'ph ph-caret-right',
110
+ };
111
+ export const SLIDER_PLAY_GLYPH = 'ph-fill ph-play';
112
+
113
+ const PART_LABEL: Readonly<Record<SliderRole, string>> = {
114
+ [SliderRole.Pause]: 'Pause',
115
+ [SliderRole.Prev]: 'Previous',
116
+ [SliderRole.Next]: 'Next',
117
+ [SliderRole.Pagination]: 'Pagination',
118
+ };
119
+
120
+ const ZERO = '0px 0px 0px 0px';
121
+ /** A round 44px control (board 6 `.st-ctl`), over the slides (z above `.swiper-wrapper`). The PLACE (which edge) is the
122
+ * shared CSS's, in logical insets, so a carousel reads the same way in RTL. */
123
+ const CONTROL_BASE: Readonly<Record<string, string>> = {
124
+ position: 'absolute', zIndex: '10', display: 'flex', alignItems: 'center', justifyContent: 'center',
125
+ width: '44px', height: '44px', minWidth: '44px', padding: ZERO, margin: ZERO, borderRadius: '999px',
126
+ };
127
+ const ICON_BASE: Readonly<Record<string, string>> = { fontSize: '20px', color: 'inherit' };
128
+ /** The arrows' vertical middle over the slides: half the 44px control above the centre line. */
129
+ const ARROW_TOP = 'calc(50% - 22px)';
130
+ const PAGINATION_BASE: Readonly<Record<string, string>> = {
131
+ position: 'absolute', zIndex: '10', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
132
+ width: 'auto', minHeight: 'auto', height: 'auto', padding: ZERO, margin: ZERO, color: 'var(--iox-accent)',
133
+ };
134
+
135
+ /**
136
+ * What a saved slider drew through Swiper before CC3, carried into its parts by the migration so it looks the same:
137
+ * bare chevrons in the author's arrow colour and size, the dots in the author's colour. Swiper's own default colour
138
+ * is its theme blue — what the live site drew, because the engine never applied the colour traits.
139
+ */
140
+ export interface LegacySliderLook {
141
+ navigationColor?: string;
142
+ navigationSize?: number;
143
+ paginationColor?: string;
144
+ }
145
+
146
+ export const SWIPER_THEME_COLOR = '#007aff';
147
+
148
+ function legacyLookOf(slider: CompositeNode): LegacySliderLook {
149
+ const color = readProp(slider, 'navigationColor');
150
+ const size = Number(readProp(slider, 'navigationSize'));
151
+ const dots = readProp(slider, 'paginationColor');
152
+ return {
153
+ navigationColor: typeof color === 'string' && color ? color : SWIPER_THEME_COLOR,
154
+ navigationSize: Number.isFinite(size) && size > 0 ? size : 44,
155
+ paginationColor: typeof dots === 'string' && dots ? dots : SWIPER_THEME_COLOR,
156
+ };
157
+ }
158
+
159
+ /** One part, from the recipe (board 6: `button-secondary` controls, token dots) or in a migrated slider's legacy look. */
160
+ export function buildSliderPart(create: CompositeNodeFactory, role: SliderRole, legacy?: LegacySliderLook): CompositeNode {
161
+ if (role === SliderRole.Pagination) {
162
+ const dots = makeCompositePart(create, { key: 'pagination', type: Type.Container, props: {}, elementStyle: NO_ELEMENT_STYLE }, role, PART_LABEL[role],
163
+ legacy?.paginationColor ? { ...PAGINATION_BASE, color: legacy.paginationColor } : PAGINATION_BASE);
164
+ dots.children = [];
165
+ return dots;
166
+ }
167
+ const key = role.split('.')[1];
168
+ // Previous / Next sit at the vertical middle (their own `top`, so a part set back in flow drops it); the inline edge
169
+ // is the shared CSS's, logical, so the arrows swap sides in RTL.
170
+ const placed = role === SliderRole.Pause ? CONTROL_BASE : { ...CONTROL_BASE, top: ARROW_TOP };
171
+ const base = legacy
172
+ ? { ...placed, backgroundColor: 'transparent', borderWidth: ZERO, color: legacy.navigationColor ?? SWIPER_THEME_COLOR, boxShadow: 'none' }
173
+ : placed;
174
+ // Previous / Next carry their accessible name (Swiper's A11y module reads it); the pause control's name is its
175
+ // runtime's — it says what pressing it will do.
176
+ const props: Record<string, unknown> = { tag: 'button', type: 'button' };
177
+ if (role !== SliderRole.Pause) props[SLIDER_NAME_TRAIT] = role === SliderRole.Prev ? SLIDER_WORDS.en.prev : SLIDER_WORDS.en.next;
178
+ const button = makeCompositePart(create, { key, type: Type.ButtonBlock, props, elementStyle: legacy ? NO_ELEMENT_STYLE : Style.Control },
179
+ role, PART_LABEL[role], base);
180
+ const iconBase = legacy ? { ...ICON_BASE, fontSize: `${Math.round((legacy.navigationSize ?? 44) * 0.6)}px` } : ICON_BASE;
181
+ const icon = makeCompositePart(create, { key: `${key}-icon`, type: Type.Icon, props: { iconClass: SLIDER_PART_GLYPH[role] }, elementStyle: legacy ? NO_ELEMENT_STYLE : Style.Icon },
182
+ null, 'Icon', iconBase);
183
+ button.children = [icon];
184
+ return button;
185
+ }
186
+
187
+ const truthy = (v: unknown): boolean => v === true || v === 'true';
188
+
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);
192
+ }
193
+
194
+ export interface SliderPartsChange {
195
+ added: CompositeNode[];
196
+ removed: CompositeNode[];
197
+ }
198
+
199
+ /**
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.
204
+ */
205
+ 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));
210
+ const added: CompositeNode[] = [];
211
+ for (const role of SLIDER_PART_ROLES) {
212
+ if (!wanted.has(role) || kept.some(c => c.role === role)) continue;
213
+ const part = buildSliderPart(create, role, legacy);
214
+ added.push(part);
215
+ if (role === SliderRole.Pagination) {
216
+ kept.push(part);
217
+ } 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);
227
+ }
228
+ }
229
+ if (added.length || removed.length) slider.children = kept;
230
+ return { added, removed };
231
+ }
232
+
233
+ /** True when a saved slider still relies on Swiper-drawn controls: a switch is on and no part exists yet. */
234
+ export function needsSliderMigration(slider: CompositeNode | null | undefined): boolean {
235
+ return !!slider && isSlider(slider) && !hasSliderParts(slider) && wantedSliderParts(slider).length > 0;
236
+ }
237
+
238
+ /**
239
+ * The migration of a saved slider (board 9 Q13): its Swiper-drawn arrows, dots — and, when Autoplay is on, the pause
240
+ * control WCAG 2.2.2 requires — become parts, in the place and the colours Swiper drew them. Idempotent: a slider
241
+ * that already has parts is left alone. True when it changed.
242
+ */
243
+ export function migrateSliderControls(slider: CompositeNode, create: CompositeNodeFactory): boolean {
244
+ if (!needsSliderMigration(slider)) return false;
245
+ const change = syncSliderParts(slider, create, legacyLookOf(slider));
246
+ return change.added.length > 0;
247
+ }
248
+
249
+ /**
250
+ * A switch flipped in the panel: on a slider still on Swiper-drawn controls, migrate it first (so its other controls
251
+ * keep their look), then stamp / remove the parts the new switch calls for. ONE edit.
252
+ */
253
+ export function setSliderFlag(slider: CompositeNode, flag: SliderFlag, on: boolean, create: CompositeNodeFactory): SliderPartsChange {
254
+ const wasLegacy = !hasSliderParts(slider);
255
+ const before = wantedSliderParts(slider);
256
+ const migrated: CompositeNode[] = [];
257
+ if (wasLegacy && before.length) {
258
+ syncSliderParts(slider, create, legacyLookOf(slider)).added.forEach(p => migrated.push(p));
259
+ }
260
+ writeProp(slider, flag, on);
261
+ const change = syncSliderParts(slider, create);
262
+ return { added: [...migrated, ...change.added], removed: change.removed };
263
+ }
264
+
265
+ // ── accessibility vocabulary (both runtimes) ───────────────────────────────────────────────────────
266
+
267
+ export interface SliderWords {
268
+ stop: string;
269
+ start: string;
270
+ prev: string;
271
+ next: string;
272
+ carousel: string;
273
+ slide: string;
274
+ /** `{{index}}` and `{{slidesLength}}`, Swiper's placeholders. */
275
+ slideLabel: string;
276
+ goTo: string;
277
+ }
278
+
279
+ export const SLIDER_WORDS: Readonly<Record<'en' | 'he', SliderWords>> = {
280
+ en: {
281
+ stop: 'Stop automatic slide show', start: 'Start automatic slide show', prev: 'Previous slide', next: 'Next slide',
282
+ carousel: 'carousel', slide: 'slide', slideLabel: '{{index}} of {{slidesLength}}', goTo: 'Go to slide {{index}}',
283
+ },
284
+ he: {
285
+ stop: 'עצירת המצגת האוטומטית', start: 'הפעלת המצגת האוטומטית', prev: 'השקופית הקודמת', next: 'השקופית הבאה',
286
+ carousel: 'קרוסלה', slide: 'שקופית', slideLabel: '{{index}} מתוך {{slidesLength}}', goTo: 'מעבר לשקופית {{index}}',
287
+ },
288
+ };
289
+
290
+ export function sliderWords(lang?: string | null): SliderWords {
291
+ return (lang ?? '').toLowerCase().startsWith('he') ? SLIDER_WORDS.he : SLIDER_WORDS.en;
292
+ }
293
+
294
+ /** The wrapper's live region (APG): silent while the slides move on their own, polite otherwise. */
295
+ export function sliderLiveRegion(rotating: boolean): 'off' | 'polite' {
296
+ return rotating ? 'off' : 'polite';
297
+ }
298
+
299
+ /**
300
+ * The pause control's state (APG carousel): its NAME says what it will do ("Stop automatic slide show" while it
301
+ * rotates, "Start automatic slide show" when stopped). An author who named the control keeps the name, and the
302
+ * state is `aria-pressed` instead (pressed = stopped).
303
+ */
304
+ export function sliderPauseAttributes(rotating: boolean, words: SliderWords = SLIDER_WORDS.en, authorName?: string | null): Record<string, string> {
305
+ const name = (authorName ?? '').trim();
306
+ return name
307
+ ? { 'aria-label': name, 'aria-pressed': rotating ? 'false' : 'true' }
308
+ : { 'aria-label': rotating ? words.stop : words.start };
309
+ }
310
+
311
+ /** The pause control's glyph: pause while it rotates, play when stopped. */
312
+ export function sliderPauseGlyph(rotating: boolean): string {
313
+ return rotating ? SLIDER_PART_GLYPH[SliderRole.Pause] : SLIDER_PLAY_GLYPH;
314
+ }
315
+
316
+ // ── the testimonial carousel (board 6; generation-core `proof.testimonial-carousel`) ─────────────────
317
+
318
+ export interface CarouselQuote {
319
+ quote: string;
320
+ who: string;
321
+ }
322
+
323
+ /** Board 6's sample quotes — PLACEHOLDERS: testimonials are facts the studio gives, never invented. */
324
+ export const CAROUSEL_QUOTES: ReadonlyArray<CarouselQuote> = [
325
+ { quote: 'A sample quote, two lines long, in the client’s own words.', who: '[Client name] · [Project, city]' },
326
+ { quote: 'Testimonials are facts: replace these with words a client actually said.', who: '[Client name] · [Project, city]' },
327
+ { quote: 'A third sample quote so the rotation has somewhere to go.', who: '[Client name] · [Project, city]' },
328
+ ];
329
+
330
+ export interface CarouselOptions {
331
+ /** The carousel's accessible name (a `region` landmark). */
332
+ name?: string;
333
+ quotes?: ReadonlyArray<CarouselQuote>;
334
+ }
335
+
336
+ /** A quote card. The carousel's controls sit in a ROW above the slides and the dots below them (board 6), so the
337
+ * card needs no room kept for an overlay. */
338
+ const QUOTE_SLIDE_BASE: Readonly<Record<string, string>> = {
339
+ width: '100%', aspectRatio: 'auto', minHeight: 'auto', height: 'auto', display: 'flex', flexDirection: 'column', gap: '12px', padding: '32px 32px 32px 32px',
340
+ };
341
+ /** Board 6's carousel: the pause control, Previous and Next in one row above the slides (in flow — they read in the
342
+ * page's direction), the dots under them. */
343
+ const ROW_CONTROL_BASE: Readonly<Record<string, string>> = { position: 'static', display: 'inline-flex', margin: '0px 4px 12px 4px' };
344
+ const ROW_DOTS_BASE: Readonly<Record<string, string>> = { position: 'static', margin: '12px 0px 0px 0px' };
345
+ const QUOTE_TEXT_BASE: Readonly<Record<string, string>> = { margin: ZERO, padding: ZERO, minHeight: 'auto', width: 'auto' };
346
+
347
+ /**
348
+ * The testimonial carousel (board 6): a named Slider, one quote per slide (each a `card`), rotating every 5s with its
349
+ * control parts — the pause control first, Previous · Next, the dots. ONE recipe for the composer and the builder.
350
+ */
351
+ export function buildCarouselRecipe(create: CompositeNodeFactory, options: CarouselOptions = {}): CompositeNode {
352
+ const slider = makeCompositePart(create, { key: 'carousel', type: SLIDER_TYPE, props: {}, elementStyle: NO_ELEMENT_STYLE }, null, 'Carousel', { width: '100%' });
353
+ for (const [name, value] of Object.entries({
354
+ // Rewind: Previous and Next are always there to press (board 6) — neither greys out at an end.
355
+ slidesPerView: 1, spaceBetween: 0, loop: 'rewind', enableNavigation: true, enablePagination: true, enableAutoplay: true, autoplayDelay: 5000,
356
+ autoplayPauseOnMouseEnter: true, autoHeight: true, [SLIDER_NAME_TRAIT]: options.name ?? 'What clients say',
357
+ })) writeProp(slider, name, value);
358
+ slider.children = (options.quotes ?? CAROUSEL_QUOTES).map((q, i) => {
359
+ const slide = makeCompositePart(create, { key: `slide-${i + 1}`, type: SLIDE_TYPE, props: {}, elementStyle: 'card' }, null, 'Slide', QUOTE_SLIDE_BASE);
360
+ slide.children = [
361
+ makeCompositePart(create, { key: `quote-${i + 1}`, type: 'Text', props: { content: q.quote, tag: 'p' }, elementStyle: 'lead' }, null, 'Quote', QUOTE_TEXT_BASE),
362
+ makeCompositePart(create, { key: `who-${i + 1}`, type: 'Text', props: { content: q.who, tag: 'p' }, elementStyle: 'small' }, null, 'Client', QUOTE_TEXT_BASE),
363
+ ];
364
+ return slide;
365
+ });
366
+ syncSliderParts(slider, create);
367
+ for (const part of slider.children ?? []) {
368
+ if (part.role === SliderRole.Pagination) writeStyles(part, ROW_DOTS_BASE);
369
+ else if (isSliderPart(part)) writeStyles(part, ROW_CONTROL_BASE);
370
+ }
371
+ return slider;
372
+ }
373
+
374
+ /** The slide a lightbox opens at for the clicked row (clamped to the slides there are). */
375
+ export function slideIndexForRow(rowIndex: number, slideCount: number): number {
376
+ if (!Number.isFinite(rowIndex) || rowIndex < 0 || slideCount <= 0) return 0;
377
+ return Math.min(Math.floor(rowIndex), slideCount - 1);
378
+ }
@@ -1,5 +1,6 @@
1
1
  import { AccordionRole, DisclosureRole } from './composite-kinds';
2
2
  import { CardPart, CardRole } from './card';
3
+ import { SLIDER_TYPE, SliderRole } from './slider';
3
4
 
4
5
  /**
5
6
  * The structure every composite declares (composite-components plan §3a `RoleSpec`): where a role may
@@ -10,7 +11,11 @@ import { CardPart, CardRole } from './card';
10
11
  export interface CompositeRoleSpec {
11
12
  /** The role of the part's parent; null for a composite root. */
12
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). */
15
+ readonly parentType?: string;
13
16
  readonly locked?: boolean;
17
+ /** May be deleted, but never moved out of its parent (a Slider's controls work only as its direct children). */
18
+ readonly pinned?: boolean;
14
19
  readonly part?: CardPart;
15
20
  }
16
21
 
@@ -36,6 +41,13 @@ export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> =
36
41
  [DisclosureRole.More]: { parent: DisclosureRole.Trigger },
37
42
  [DisclosureRole.Less]: { parent: DisclosureRole.Trigger },
38
43
  [DisclosureRole.Icon]: { parent: DisclosureRole.Trigger },
44
+ // CC3 — a Slider's controls are its own direct children (Swiper is handed them). The pause control stays while
45
+ // Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it, never Delete. The arrows and the dots follow
46
+ // their switches and may be deleted.
47
+ [SliderRole.Pause]: { parent: null, parentType: SLIDER_TYPE, locked: true },
48
+ [SliderRole.Prev]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
49
+ [SliderRole.Next]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
50
+ [SliderRole.Pagination]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
39
51
  };
40
52
 
41
53
  export enum StructureEdit {
@@ -51,7 +63,7 @@ export enum StructureRefusal {
51
63
  LockedPlacement = 'locked-placement',
52
64
  }
53
65
 
54
- interface RoleLike { role?: string | null }
66
+ interface RoleLike { role?: string | null; type?: string }
55
67
 
56
68
  /**
57
69
  * Why the structure guard refuses `edit` on `node`, or null when it is allowed. `ancestors` (Move only) =
@@ -59,10 +71,11 @@ interface RoleLike { role?: string | null }
59
71
  */
60
72
  export function structureRefusal(node: RoleLike | null | undefined, edit: StructureEdit, ancestors: ReadonlyArray<RoleLike> = []): StructureRefusal | null {
61
73
  const spec = node?.role ? COMPOSITE_ROLE_SPECS[node.role] : undefined;
62
- if (!spec?.locked) return null;
63
- if (edit === StructureEdit.Delete) return StructureRefusal.LockedPart;
74
+ if (!spec?.locked && !spec?.pinned) return null;
75
+ if (edit === StructureEdit.Delete) return spec.locked ? StructureRefusal.LockedPart : null;
64
76
  const parent = ancestors[ancestors.length - 1];
65
- return parent?.role === spec.parent ? null : StructureRefusal.LockedPlacement;
77
+ const placed = spec.parentType ? parent?.type === spec.parentType : parent?.role === spec.parent;
78
+ return placed ? null : StructureRefusal.LockedPlacement;
66
79
  }
67
80
 
68
81
  /** True when `node` is a locked part (shown with a lock and no delete in the layer tree). Deleting a whole
@@ -290,6 +290,33 @@ export function switchElementStyle(
290
290
  return { applied: true, kept };
291
291
  }
292
292
 
293
+ /**
294
+ * Take a node OUT of its element style (a part a recipe wants unstyled — a migrated slider's bare chevrons, CC3):
295
+ * every owned trait still holding the style's value goes back to `baseline` (a state trait is removed); a trait the
296
+ * author changed is kept. The node then wears no style (no `iox-es` marker). Returns the kept traits.
297
+ */
298
+ export function releaseElementStyle(
299
+ target: ElementStyleTarget,
300
+ fromId: string | null | undefined,
301
+ baseline: Readonly<Record<string, unknown>> = {},
302
+ ): OwnedTrait[] {
303
+ const kept: OwnedTrait[] = [];
304
+ const from = elementStyleById(fromId);
305
+ if (from) {
306
+ for (const status of ownedTraitStatus(target, from.id)) {
307
+ const ref: OwnedTrait = status.state ? { trait: status.trait, state: status.state } : { trait: status.trait };
308
+ if (status.status === OwnedTraitLink.Changed) {
309
+ if (status.actual !== undefined) kept.push(ref);
310
+ continue;
311
+ }
312
+ if (ref.state) deleteState(target, ref.state, ref.trait);
313
+ else writeBase(target, ref.trait, baseline[ref.trait]);
314
+ }
315
+ }
316
+ delete (target as { elementStyle?: string | null }).elementStyle;
317
+ return kept;
318
+ }
319
+
293
320
  /**
294
321
  * The element-style MARKER (design-system §3b-bis, spec `elementStyleMarker`). A node that wears an
295
322
  * element style renders `iox-es iox-es-<id>` in BOTH renderers, through the one node-class helper