@vectoriox/iox-ui 4.25.0 → 4.26.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.
@@ -1506,1868 +1506,2050 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
1506
1506
  args: ['childrenHost', { read: ViewContainerRef, static: true }]
1507
1507
  }] } });
1508
1508
 
1509
- class ClientSliderContainerComponent {
1510
- constructor(platformId) {
1511
- this.platformId = platformId;
1512
- this.nodeId = '';
1513
- /** Language (writing) direction. Rendered as the `dir` attribute so it survives SSR and drives
1514
- * Swiper's RTL layout — must match the builder's app-slider-container, which had `dir` while
1515
- * this engine-runtime component did not (RTL showed in the editor but never on the live site). */
1516
- this.dir = 'ltr';
1517
- this.direction = 'horizontal';
1518
- this.loop = false;
1519
- this.slidesPerView = 1;
1520
- this.spaceBetween = 0;
1521
- this.speed = 300;
1522
- this.effect = 'slide';
1523
- this.grabCursor = true;
1524
- this.centeredSlides = false;
1525
- this.initialSlide = 0;
1526
- this.enableNavigation = false;
1527
- this.enablePagination = false;
1528
- this.enableScrollbar = false;
1529
- this.enableAutoplay = false;
1530
- this.enableKeyboard = false;
1531
- this.enableMousewheel = false;
1532
- this.enableZoom = false;
1533
- this.enableGrid = false;
1534
- this.enableParallax = false;
1535
- this.autoplayDelay = 3000;
1536
- this.autoplayPauseOnMouseEnter = true;
1537
- this.autoplayDisableOnInteraction = false;
1538
- this.paginationType = 'bullets';
1539
- this.paginationClickable = true;
1540
- this.gridRows = 1;
1541
- this.breakpoints = '';
1542
- this._swiper = null;
1543
- this.instanceRegistry = inject(ComponentInstanceRegistryService);
1509
+ // Virtual trait → composed CSS property helpers.
1510
+ //
1511
+ // Several style traits are "virtual" — they don't map 1:1 to a CSS property but
1512
+ // are components of a multi-function CSS value. composeVirtualTraits() strips
1513
+ // these virtual keys from the style map and replaces them with the correctly
1514
+ // composed CSS properties (filter, backdrop-filter, transform, transition).
1515
+ //
1516
+ // SHARED (iox-ui): consumed by BOTH the builder (render.directive + style panel)
1517
+ // and the SSR engine (LayoutRendererService). It MUST live here, not in iox-builder,
1518
+ // because the engine does not depend on iox-builder — without it the engine emitted
1519
+ // the raw virtual keys (`translate-x`, `filter-blur`, `scale-x`…) as invalid CSS.
1520
+ // Run this BEFORE compileDeclarations so the CSS output is identical on both sides.
1521
+ // ─── Filter ──────────────────────────────────────────────────────────────────
1522
+ const FILTER_FUNS = [
1523
+ ['filterBlur', 'blur', '0px'],
1524
+ ['filterBrightness', 'brightness', '1'],
1525
+ ['filterContrast', 'contrast', '1'],
1526
+ ['filterGrayscale', 'grayscale', '0'],
1527
+ ['filterSaturate', 'saturate', '1'],
1528
+ ['filterHueRotate', 'hue-rotate', '0deg'],
1529
+ ['filterSepia', 'sepia', '0'],
1530
+ ['filterInvert', 'invert', '0'],
1531
+ ];
1532
+ // ─── Backdrop-filter ─────────────────────────────────────────────────────────
1533
+ const BACKDROP_FUNS = [
1534
+ ['backdropBlur', 'blur', '0px'],
1535
+ ['backdropBrightness', 'brightness', '1'],
1536
+ ['backdropContrast', 'contrast', '1'],
1537
+ ['backdropSaturate', 'saturate', '1'],
1538
+ ];
1539
+ // ─── Transform ───────────────────────────────────────────────────────────────
1540
+ const TRANSFORM_FUNS = [
1541
+ ['translateX', 'translateX', '0px'],
1542
+ ['translateY', 'translateY', '0px'],
1543
+ ['scaleX', 'scaleX', '1'],
1544
+ ['scaleY', 'scaleY', '1'],
1545
+ ['rotate', 'rotate', '0deg'],
1546
+ ['skewX', 'skewX', '0deg'],
1547
+ ['skewY', 'skewY', '0deg'],
1548
+ ];
1549
+ // ─── ::marker pseudo-element ─────────────────────────────────────────────────
1550
+ const MARKER_FUNS = [
1551
+ ['markerColor', 'color'],
1552
+ ['markerFontSize', 'fontSize'],
1553
+ ];
1554
+ const TRANSITION_KEYS = ['transitionDuration', 'transitionTimingFunction', 'transitionDelay'];
1555
+ const VIRTUAL_TRAIT_GROUPS = Object.freeze([
1556
+ { output: 'filter', keys: FILTER_FUNS.map(([t]) => t), reset: 'none' },
1557
+ { output: 'backdropFilter', keys: BACKDROP_FUNS.map(([t]) => t), reset: 'none' },
1558
+ { output: 'transform', keys: TRANSFORM_FUNS.map(([t]) => t), reset: 'none' },
1559
+ { output: 'transition', keys: [...TRANSITION_KEYS], reset: 'none' },
1560
+ { output: '__markerStyles', keys: MARKER_FUNS.map(([t]) => t), reset: null },
1561
+ ]);
1562
+ // ─── All virtual trait names ──────────────────────────────────────────────────
1563
+ /** Every virtual trait key — derived from the groups so there is one list to maintain. */
1564
+ const VIRTUAL_TRAIT_KEYS = new Set(VIRTUAL_TRAIT_GROUPS.flatMap(g => g.keys));
1565
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
1566
+ function buildFns(funs, src) {
1567
+ return funs
1568
+ .filter(([t, , d]) => src[t] != null && src[t] !== '' && src[t] !== d)
1569
+ .map(([t, fn]) => `${fn}(${src[t]})`)
1570
+ .join(' ');
1571
+ }
1572
+ // ─── Public API ───────────────────────────────────────────────────────────────
1573
+ /**
1574
+ * Strip virtual trait keys from `raw` and emit their composed CSS equivalents.
1575
+ *
1576
+ * When composing a partial state-override map (hover, active…), pass the full
1577
+ * base style map as `base` so non-overridden components (e.g. filterBrightness
1578
+ * when only filterBlur is overridden) are preserved in the composed output.
1579
+ * When operating on a complete style map, omit `base` — it defaults to `raw`.
1580
+ */
1581
+ function composeVirtualTraits(raw, base = raw) {
1582
+ // Copy all non-virtual properties as-is
1583
+ const result = {};
1584
+ for (const [k, v] of Object.entries(raw)) {
1585
+ if (!VIRTUAL_TRAIT_KEYS.has(k))
1586
+ result[k] = v;
1544
1587
  }
1545
- ngOnInit() { }
1546
- ngAfterViewInit() {
1547
- if (isPlatformBrowser(this.platformId)) {
1548
- // Defer so all child slides are rendered first
1549
- requestAnimationFrame(() => this._initSwiper());
1550
- }
1588
+ // Merge base values for any virtual key absent from the partial override map
1589
+ const merged = (funs) => {
1590
+ const m = {};
1591
+ for (const [t] of funs)
1592
+ m[t] = raw[t] ?? base[t];
1593
+ return m;
1594
+ };
1595
+ // filter
1596
+ const filterStr = buildFns(FILTER_FUNS, merged(FILTER_FUNS));
1597
+ if (filterStr)
1598
+ result['filter'] = filterStr;
1599
+ // backdrop-filter
1600
+ const backdropStr = buildFns(BACKDROP_FUNS, merged(BACKDROP_FUNS));
1601
+ if (backdropStr)
1602
+ result['backdropFilter'] = backdropStr;
1603
+ // transform
1604
+ const transformStr = buildFns(TRANSFORM_FUNS, merged(TRANSFORM_FUNS));
1605
+ if (transformStr)
1606
+ result['transform'] = transformStr;
1607
+ // transition — only emit when duration is non-zero
1608
+ const dur = raw['transitionDuration'] ?? base['transitionDuration'];
1609
+ const ease = raw['transitionTimingFunction'] ?? base['transitionTimingFunction'];
1610
+ const del = raw['transitionDelay'] ?? base['transitionDelay'];
1611
+ if (dur && ease && dur !== '0ms' && dur !== '0s') {
1612
+ const delPart = del && del !== '0ms' && del !== '0s' ? ` ${del}` : '';
1613
+ result['transition'] = `all ${dur} ${ease}${delPart}`;
1551
1614
  }
1552
- ngOnDestroy() {
1553
- if (this._swiper) {
1554
- this._swiper.destroy(true, true);
1555
- this._swiper = null;
1556
- this.instanceRegistry.unregister(this.nodeId);
1557
- }
1615
+ // ::marker pseudo-element — collected into __markerStyles so StyleRegistryService
1616
+ // can emit a separate `.iox-node-{id}::marker { … }` rule.
1617
+ const markerStyles = {};
1618
+ for (const [trait, cssProp] of MARKER_FUNS) {
1619
+ const val = raw[trait] ?? base[trait];
1620
+ if (val != null && val !== '')
1621
+ markerStyles[cssProp] = val;
1558
1622
  }
1559
- async _initSwiper() {
1560
- const el = this.swiperEl?.nativeElement;
1561
- if (!el)
1562
- return;
1563
- const [{ default: Swiper }, swiperModules] = await Promise.all([
1564
- import('swiper'),
1565
- import('swiper/modules'),
1566
- ]);
1567
- const { Navigation, Pagination, Scrollbar, Autoplay, Keyboard, Mousewheel, Zoom, Grid, Parallax, EffectFade, EffectCube, EffectFlip, EffectCoverflow, EffectCards, EffectCreative, } = swiperModules;
1568
- const modules = [];
1569
- if (this.enableNavigation)
1570
- modules.push(Navigation);
1571
- if (this.enablePagination)
1572
- modules.push(Pagination);
1573
- if (this.enableScrollbar)
1574
- modules.push(Scrollbar);
1575
- if (this.enableAutoplay)
1576
- modules.push(Autoplay);
1577
- if (this.enableKeyboard)
1578
- modules.push(Keyboard);
1579
- if (this.enableMousewheel)
1580
- modules.push(Mousewheel);
1581
- if (this.enableZoom)
1582
- modules.push(Zoom);
1583
- if (this.enableGrid)
1584
- modules.push(Grid);
1585
- if (this.enableParallax)
1586
- modules.push(Parallax);
1587
- const effectMap = {
1588
- fade: EffectFade, cube: EffectCube, flip: EffectFlip,
1589
- coverflow: EffectCoverflow, cards: EffectCards, creative: EffectCreative,
1590
- };
1591
- if (this.effect !== 'slide' && effectMap[this.effect]) {
1592
- modules.push(effectMap[this.effect]);
1593
- }
1594
- const config = {
1595
- modules,
1596
- // Swiper reads the container's `dir` attribute, but pass it explicitly so RTL is applied
1597
- // on init regardless of attribute timing.
1598
- dir: this.dir,
1599
- direction: this.direction,
1600
- loop: this.loop,
1601
- slidesPerView: this.slidesPerView,
1602
- spaceBetween: this.spaceBetween,
1603
- speed: this.speed,
1604
- effect: this.effect,
1605
- grabCursor: this.grabCursor,
1606
- centeredSlides: this.centeredSlides,
1607
- initialSlide: this.initialSlide,
1608
- };
1609
- if (this.enableNavigation)
1610
- config['navigation'] = { prevEl: '.swiper-button-prev', nextEl: '.swiper-button-next' };
1611
- if (this.enablePagination)
1612
- config['pagination'] = { el: '.swiper-pagination', type: this.paginationType, clickable: this.paginationClickable };
1613
- if (this.enableScrollbar)
1614
- config['scrollbar'] = { el: '.swiper-scrollbar', draggable: true };
1615
- if (this.enableAutoplay)
1616
- config['autoplay'] = { delay: this.autoplayDelay, pauseOnMouseEnter: this.autoplayPauseOnMouseEnter, disableOnInteraction: this.autoplayDisableOnInteraction };
1617
- if (this.enableGrid && this.gridRows > 1)
1618
- config['grid'] = { rows: this.gridRows };
1619
- if (this.breakpoints) {
1620
- try {
1621
- config['breakpoints'] = JSON.parse(this.breakpoints);
1622
- }
1623
- catch { /* ignore */ }
1624
- }
1625
- this._swiper = new Swiper(el, config);
1626
- this.instanceRegistry.register(this.nodeId, this._swiper);
1623
+ if (Object.keys(markerStyles).length) {
1624
+ result['__markerStyles'] = markerStyles;
1627
1625
  }
1628
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderContainerComponent, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Component }); }
1629
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientSliderContainerComponent, isStandalone: false, selector: "iox-slider-container", inputs: { nodeId: "nodeId", dir: "dir", direction: "direction", loop: "loop", slidesPerView: "slidesPerView", spaceBetween: "spaceBetween", speed: "speed", effect: "effect", grabCursor: "grabCursor", centeredSlides: "centeredSlides", initialSlide: "initialSlide", enableNavigation: "enableNavigation", enablePagination: "enablePagination", enableScrollbar: "enableScrollbar", enableAutoplay: "enableAutoplay", enableKeyboard: "enableKeyboard", enableMousewheel: "enableMousewheel", enableZoom: "enableZoom", enableGrid: "enableGrid", enableParallax: "enableParallax", autoplayDelay: "autoplayDelay", autoplayPauseOnMouseEnter: "autoplayPauseOnMouseEnter", autoplayDisableOnInteraction: "autoplayDisableOnInteraction", paginationType: "paginationType", paginationClickable: "paginationClickable", gridRows: "gridRows", breakpoints: "breakpoints" }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }, { propertyName: "swiperEl", first: true, predicate: ["swiperEl"], descendants: true, static: true }], ngImport: i0, template: `
1630
- <div class="swiper" [class]="'iox-node-' + nodeId" [attr.dir]="dir" [style.direction]="dir" #swiperEl>
1631
- <div class="swiper-wrapper">
1632
- <ng-container #childrenHost></ng-container>
1633
- </div>
1634
- <div *ngIf="enableNavigation" class="swiper-button-prev"></div>
1635
- <div *ngIf="enableNavigation" class="swiper-button-next"></div>
1636
- <div *ngIf="enablePagination" class="swiper-pagination"></div>
1637
- <div *ngIf="enableScrollbar" class="swiper-scrollbar"></div>
1638
- </div>
1639
- `, isInline: true, styles: [".swiper{width:100%;height:100%;box-sizing:border-box}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); }
1626
+ return result;
1640
1627
  }
1641
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderContainerComponent, decorators: [{
1642
- type: Component,
1643
- args: [{ selector: 'iox-slider-container', template: `
1644
- <div class="swiper" [class]="'iox-node-' + nodeId" [attr.dir]="dir" [style.direction]="dir" #swiperEl>
1645
- <div class="swiper-wrapper">
1646
- <ng-container #childrenHost></ng-container>
1647
- </div>
1648
- <div *ngIf="enableNavigation" class="swiper-button-prev"></div>
1649
- <div *ngIf="enableNavigation" class="swiper-button-next"></div>
1650
- <div *ngIf="enablePagination" class="swiper-pagination"></div>
1651
- <div *ngIf="enableScrollbar" class="swiper-scrollbar"></div>
1652
- </div>
1653
- `, standalone: false, styles: [".swiper{width:100%;height:100%;box-sizing:border-box}\n"] }]
1654
- }], ctorParameters: () => [{ type: undefined, decorators: [{
1655
- type: Inject,
1656
- args: [PLATFORM_ID]
1657
- }] }], propDecorators: { nodeId: [{
1658
- type: Input
1659
- }], dir: [{
1660
- type: Input
1661
- }], direction: [{
1662
- type: Input
1663
- }], loop: [{
1664
- type: Input
1665
- }], slidesPerView: [{
1666
- type: Input
1667
- }], spaceBetween: [{
1668
- type: Input
1669
- }], speed: [{
1670
- type: Input
1671
- }], effect: [{
1672
- type: Input
1673
- }], grabCursor: [{
1674
- type: Input
1675
- }], centeredSlides: [{
1676
- type: Input
1677
- }], initialSlide: [{
1678
- type: Input
1679
- }], enableNavigation: [{
1680
- type: Input
1681
- }], enablePagination: [{
1682
- type: Input
1683
- }], enableScrollbar: [{
1684
- type: Input
1685
- }], enableAutoplay: [{
1686
- type: Input
1687
- }], enableKeyboard: [{
1688
- type: Input
1689
- }], enableMousewheel: [{
1690
- type: Input
1691
- }], enableZoom: [{
1692
- type: Input
1693
- }], enableGrid: [{
1694
- type: Input
1695
- }], enableParallax: [{
1696
- type: Input
1697
- }], autoplayDelay: [{
1698
- type: Input
1699
- }], autoplayPauseOnMouseEnter: [{
1700
- type: Input
1701
- }], autoplayDisableOnInteraction: [{
1702
- type: Input
1703
- }], paginationType: [{
1704
- type: Input
1705
- }], paginationClickable: [{
1706
- type: Input
1707
- }], gridRows: [{
1708
- type: Input
1709
- }], breakpoints: [{
1710
- type: Input
1711
- }], childrenHost: [{
1712
- type: ViewChild,
1713
- args: ['childrenHost', { read: ViewContainerRef, static: true }]
1714
- }], swiperEl: [{
1715
- type: ViewChild,
1716
- args: ['swiperEl', { static: true }]
1717
- }] } });
1718
1628
 
1629
+ // Responsive (per-breakpoint) styles — the shared model, cascade and emission plan.
1630
+ //
1631
+ // SHARED (iox-ui): the builder canvas AND the SSR engine both call this. The builder
1632
+ // FLATTENS the active breakpoint (its canvas is a transformed frame inside a desktop
1633
+ // browser, so a real `@media` query would read the editor's width); the engine WRAPS
1634
+ // each planned layer in `@media`. Same decisions, different transport — never fork them.
1635
+ //
1636
+ // Storage is a sparse layer beside the existing keys, shaped like `stateStyles`:
1637
+ //
1638
+ // styleProps — base, full resolved set (unchanged)
1639
+ // stateStyles — sparse per-state overrides (unchanged)
1640
+ // responsive — sparse per-breakpoint overrides (this file)
1641
+ // { mobile: { styleProps: {…}, stateStyles: { hover: {…} } } }
1642
+ //
1643
+ // A node with no `responsive` key renders exactly as before — no migration.
1644
+ // See iox-ai-guidance `architecture/builder/responsive-breakpoints-plan.md`.
1645
+ // ─── Breakpoints ─────────────────────────────────────────────────────────────
1646
+ /** The five fixed breakpoints (plan decision D2). Desktop-first: each narrower one
1647
+ * overrides the wider ones through `max-width`. */
1648
+ var Breakpoint;
1649
+ (function (Breakpoint) {
1650
+ Breakpoint["Base"] = "base";
1651
+ Breakpoint["Laptop"] = "laptop";
1652
+ Breakpoint["Tablet"] = "tablet";
1653
+ Breakpoint["Mobile"] = "mobile";
1654
+ Breakpoint["Small"] = "small";
1655
+ })(Breakpoint || (Breakpoint = {}));
1656
+ /**
1657
+ * Widest → narrowest. This order IS the cascade order and the `@media` emission order.
1658
+ *
1659
+ * ⚠️ Phones in portrait (360–430px) land in `Small`, NOT `Mobile` — `Mobile` is 480–767,
1660
+ * a phone held landscape. The labels say so (as Webflow's do). The ids stay short because
1661
+ * they are storage keys; the cascade still makes a `Mobile` override reach every phone.
1662
+ */
1663
+ const BREAKPOINTS = Object.freeze([
1664
+ { id: Breakpoint.Base, label: 'Base', maxWidth: null, previewWidth: 1440 },
1665
+ { id: Breakpoint.Laptop, label: 'Laptop', maxWidth: 1200, previewWidth: 1200 },
1666
+ { id: Breakpoint.Tablet, label: 'Tablet', maxWidth: 991, previewWidth: 768 },
1667
+ { id: Breakpoint.Mobile, label: 'Mobile landscape', maxWidth: 767, previewWidth: 568 },
1668
+ { id: Breakpoint.Small, label: 'Mobile portrait', maxWidth: 479, previewWidth: 390 },
1669
+ ]);
1670
+ const BREAKPOINT_INDEX = new Map(BREAKPOINTS.map((d, i) => [d.id, i]));
1671
+ /** The definition for `bp`, or `undefined` for an unknown id (e.g. hand-edited JSON). */
1672
+ function breakpointDef(bp) {
1673
+ const i = BREAKPOINT_INDEX.get(bp);
1674
+ return i === undefined ? undefined : BREAKPOINTS[i];
1675
+ }
1676
+ /** Base → `bp`, inclusive — the layers whose values apply at `bp`. Unknown → `[Base]`. */
1677
+ function cascadeChain(bp) {
1678
+ const i = BREAKPOINT_INDEX.get(bp);
1679
+ return BREAKPOINTS.slice(0, (i ?? 0) + 1).map(d => d.id);
1680
+ }
1719
1681
  /**
1720
- * Client-engine slide. The HOST element (`iox-slider-slide`) itself carries the
1721
- * `swiper-slide` class so it is a DIRECT child of `.swiper-wrapper` — Swiper only
1722
- * recognises slides that are direct children (the `.swiper-slide` direct-child
1723
- * invariant; see iox-ai-guidance architecture/builder/slider.md). A wrapper div
1724
- * would nest `.swiper-slide` one level too deep and Swiper would ignore the slides.
1682
+ * The breakpoint a viewport (or canvas) of `width` CSS px falls in — the narrowest one whose
1683
+ * `maxWidth` it fits under, else Base. Mirrors how the `max-width` media queries resolve, so the
1684
+ * builder's editing scope is always the breakpoint the canvas is actually showing.
1685
+ */
1686
+ function breakpointForWidth(width) {
1687
+ for (let i = BREAKPOINTS.length - 1; i > 0; i--) {
1688
+ const max = BREAKPOINTS[i].maxWidth;
1689
+ if (max !== null && width <= max)
1690
+ return BREAKPOINTS[i].id;
1691
+ }
1692
+ return Breakpoint.Base;
1693
+ }
1694
+ /** The next-wider breakpoint (what `bp` inherits from), or `null` for Base / unknown. */
1695
+ function parentBreakpoint(bp) {
1696
+ const i = BREAKPOINT_INDEX.get(bp);
1697
+ return i ? BREAKPOINTS[i - 1].id : null;
1698
+ }
1699
+ /**
1700
+ * What a breakpoint's width is measured against.
1725
1701
  *
1726
- * The shared `iox-node-{id}` class is applied to the same host so the page-level
1727
- * CSS rules match (parity with the string renderer / builder).
1702
+ * `Media` — the browser viewport. The SSR engine: a visitor's window IS the page.
1703
+ * `Container` — a named CSS size container. The builder: its canvas is a fixed-width frame inside a
1704
+ * desktop browser (and `transform: scale`d for zoom), so a media query would read the EDITOR's width.
1705
+ * A container query reads the frame's own layout width — which equals the simulated viewport and
1706
+ * ignores the zoom transform — so the builder emits the same structure as the engine.
1728
1707
  */
1729
- class ClientSliderSlideComponent {
1730
- constructor() {
1731
- this.nodeId = '';
1708
+ var BreakpointQueryKind;
1709
+ (function (BreakpointQueryKind) {
1710
+ BreakpointQueryKind["Media"] = "media";
1711
+ BreakpointQueryKind["Container"] = "container";
1712
+ })(BreakpointQueryKind || (BreakpointQueryKind = {}));
1713
+ /**
1714
+ * The at-rule prelude for `bp` — `@media (max-width: 767px)`, or with `Container`
1715
+ * `@container iox-canvas (max-width: 767px)`. `null` for Base or an unknown id.
1716
+ * `containerName` is required for `Container` (an unnamed container query would match
1717
+ * whichever ancestor container is nearest, which is not guaranteed to be the canvas).
1718
+ */
1719
+ function breakpointQueryFor(bp, kind = BreakpointQueryKind.Media, containerName) {
1720
+ const max = breakpointDef(bp)?.maxWidth;
1721
+ if (max == null)
1722
+ return null;
1723
+ if (kind === BreakpointQueryKind.Media)
1724
+ return `@media (max-width: ${max}px)`;
1725
+ if (!containerName)
1726
+ throw new Error('A container breakpoint query needs a container name');
1727
+ return `@container ${containerName} (max-width: ${max}px)`;
1728
+ }
1729
+ /** Wrap already-compiled rules for `bp`. Base (and blank css) passes through unwrapped. */
1730
+ function wrapInBreakpointQuery(bp, css, kind = BreakpointQueryKind.Media, containerName) {
1731
+ if (!css.trim())
1732
+ return '';
1733
+ const query = breakpointQueryFor(bp, kind, containerName);
1734
+ return query ? `${query} {\n${css}\n}` : css;
1735
+ }
1736
+ /** `@media (max-width: 767px)` for `bp`; `null` for Base or an unknown id. */
1737
+ function mediaQueryFor(bp) {
1738
+ return breakpointQueryFor(bp, BreakpointQueryKind.Media);
1739
+ }
1740
+ /** Wrap already-compiled rules for `bp` in its media query. Base (and blank css) passes through. */
1741
+ function wrapInMediaQuery(bp, css) {
1742
+ return wrapInBreakpointQuery(bp, css, BreakpointQueryKind.Media);
1743
+ }
1744
+ function layerAt(responsive, bp) {
1745
+ return bp === Breakpoint.Base ? undefined : responsive?.[bp];
1746
+ }
1747
+ /** Copy defined entries of `src` onto `target`. `undefined` never overwrites a value. */
1748
+ function assignDefined(target, src) {
1749
+ if (src)
1750
+ for (const [k, v] of Object.entries(src))
1751
+ if (v !== undefined)
1752
+ target[k] = v;
1753
+ return target;
1754
+ }
1755
+ function hasKeys(map) {
1756
+ return !!map && Object.keys(map).length > 0;
1757
+ }
1758
+ // ─── Cascade resolution ──────────────────────────────────────────────────────
1759
+ /** The full effective style map at `bp`: base, then each layer down the cascade. */
1760
+ function resolveStyleProps(styleProps, responsive, bp) {
1761
+ const out = assignDefined({}, styleProps ?? undefined);
1762
+ for (const id of cascadeChain(bp))
1763
+ assignDefined(out, layerAt(responsive, id)?.styleProps);
1764
+ return out;
1765
+ }
1766
+ /**
1767
+ * The full effective TRAIT map at `bp`: the node's own inputs, then each layer down the cascade.
1768
+ * Deliberately identical in shape and cascade order to `resolveStyleProps`, so "how does a value
1769
+ * at this width get decided" has ONE answer on both sides of the renderer split.
1770
+ */
1771
+ function resolveInputs(inputs, responsive, bp) {
1772
+ const out = assignDefined({}, inputs ?? undefined);
1773
+ for (const id of cascadeChain(bp))
1774
+ assignDefined(out, layerAt(responsive, id)?.inputs);
1775
+ return out;
1776
+ }
1777
+ /** True when `bp` itself sets trait `name` — the panel's override indicator. Never true for Base. */
1778
+ function isInputOverriddenAt(responsive, bp, name) {
1779
+ return layerAt(responsive, bp)?.inputs?.[name] !== undefined;
1780
+ }
1781
+ /**
1782
+ * Every level from Base down to `bp` that sets trait `name`, in cascade order — the trait-row
1783
+ * equivalent of `resolveValueChain`, driving the same "Base 4 → Tablet 2 → Mobile 1" source chain.
1784
+ */
1785
+ function resolveInputChain(name, inputs, responsive, bp) {
1786
+ const chain = [];
1787
+ for (const id of cascadeChain(bp)) {
1788
+ const value = id === Breakpoint.Base ? inputs?.[name] : layerAt(responsive, id)?.inputs?.[name];
1789
+ if (value !== undefined)
1790
+ chain.push({ breakpoint: id, value });
1732
1791
  }
1733
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderSlideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1734
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientSliderSlideComponent, isStandalone: false, selector: "iox-slider-slide", inputs: { nodeId: "nodeId" }, host: { properties: { "class": "'swiper-slide iox-node-' + nodeId" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{display:block;box-sizing:border-box}\n"] }); }
1792
+ return chain;
1793
+ }
1794
+ /** The effective per-state maps at `bp`. States with no values anywhere are omitted. */
1795
+ function resolveStateStyles(stateStyles, responsive, bp) {
1796
+ const out = {};
1797
+ const merge = (src) => {
1798
+ for (const [state, map] of Object.entries(src ?? {})) {
1799
+ out[state] = assignDefined(out[state] ?? {}, map);
1800
+ }
1801
+ };
1802
+ merge(stateStyles ?? undefined);
1803
+ for (const id of cascadeChain(bp))
1804
+ merge(layerAt(responsive, id)?.stateStyles);
1805
+ for (const state of Object.keys(out))
1806
+ if (!hasKeys(out[state]))
1807
+ delete out[state];
1808
+ return out;
1735
1809
  }
1736
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderSlideComponent, decorators: [{
1737
- type: Component,
1738
- args: [{ selector: 'iox-slider-slide', template: `<ng-container #childrenHost></ng-container>`, host: { '[class]': `'swiper-slide iox-node-' + nodeId` }, standalone: false, styles: [":host{display:block;box-sizing:border-box}\n"] }]
1739
- }], propDecorators: { nodeId: [{
1740
- type: Input
1741
- }], childrenHost: [{
1742
- type: ViewChild,
1743
- args: ['childrenHost', { read: ViewContainerRef, static: true }]
1744
- }] } });
1745
-
1746
1810
  /**
1747
- * Directive-host List: the real `<ul>`/`<ol>` IS the host — no `iox-list` wrapper, no
1748
- * inner/outer split. The tag is chosen by whoever CREATES the component (engine passes
1749
- * `hostElement: document.createElement(htmlTag)`), NOT an internal `*ngIf`. Children render
1750
- * into the host via `childrenHost`. `start`/`reversed` are host attrs (only meaningful on `<ol>`).
1811
+ * Every state that has a value anywhere — at base or at any breakpoint — in a stable order
1812
+ * (base states first, then any that exist only at a breakpoint). A renderer iterates this, not
1813
+ * `Object.keys(stateStyles)`, so a state defined ONLY at a breakpoint (a Mobile-only hover) still
1814
+ * gets its selector scope resolved.
1751
1815
  */
1752
- class ClientListComponent {
1753
- constructor() {
1754
- this.htmlTag = 'ul';
1755
- this.start = '';
1756
- this.reversed = false;
1757
- this.nodeId = '';
1816
+ function stateNamesIn(stateStyles, responsive) {
1817
+ const names = new Set();
1818
+ const add = (src) => {
1819
+ for (const [state, map] of Object.entries(src ?? {})) {
1820
+ if (Object.values(map ?? {}).some(v => v !== undefined))
1821
+ names.add(state);
1822
+ }
1823
+ };
1824
+ add(stateStyles);
1825
+ for (const { id } of BREAKPOINTS)
1826
+ add(layerAt(responsive, id)?.stateStyles);
1827
+ return [...names];
1828
+ }
1829
+ /**
1830
+ * Every level from Base down to `bp` that sets `prop` (optionally within `state`), in
1831
+ * cascade order. The LAST entry is the effective value; an empty array means unset.
1832
+ * Drives the style panel's source chain ("Base 34 → Tablet 28 → Mobile 22").
1833
+ */
1834
+ function resolveValueChain(prop, styleProps, stateStyles, responsive, bp, state) {
1835
+ const read = (props, states) => (state ? states?.[state] : props)?.[prop];
1836
+ const chain = [];
1837
+ for (const id of cascadeChain(bp)) {
1838
+ const layer = layerAt(responsive, id);
1839
+ const value = id === Breakpoint.Base
1840
+ ? read(styleProps ?? undefined, stateStyles ?? undefined)
1841
+ : read(layer?.styleProps, layer?.stateStyles);
1842
+ if (value !== undefined)
1843
+ chain.push({ breakpoint: id, value });
1758
1844
  }
1759
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1760
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientListComponent, isStandalone: false, selector: "ul[iox-list],ol[iox-list]", inputs: { htmlTag: "htmlTag", start: "start", reversed: "reversed", nodeId: "nodeId" }, host: { properties: { "class": "'iox-node-' + nodeId", "attr.start": "htmlTag === 'ol' && start !== '' && start != null ? start : null", "attr.reversed": "htmlTag === 'ol' && reversed ? '' : null" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{margin:0;box-sizing:border-box}\n"] }); }
1845
+ return chain;
1846
+ }
1847
+ /** True when `bp` itself sets `prop` (the style panel's orange edge). Never true for Base. */
1848
+ function isOverriddenAt(responsive, bp, prop, state) {
1849
+ const layer = layerAt(responsive, bp);
1850
+ const map = state ? layer?.stateStyles?.[state] : layer?.styleProps;
1851
+ return map?.[prop] !== undefined;
1852
+ }
1853
+ /** Number of overridden values — at `bp`, or across every breakpoint when omitted. */
1854
+ function countOverrides(responsive, bp) {
1855
+ const count = (map) => Object.values(map ?? {}).filter(v => v !== undefined).length;
1856
+ const ids = bp ? [bp] : BREAKPOINTS.map(d => d.id);
1857
+ let n = 0;
1858
+ for (const id of ids) {
1859
+ const layer = layerAt(responsive, id);
1860
+ n += count(layer?.styleProps);
1861
+ n += count(layer?.inputs);
1862
+ for (const map of Object.values(layer?.stateStyles ?? {}))
1863
+ n += count(map);
1864
+ }
1865
+ return n;
1866
+ }
1867
+ // ─── Editing (immutable) ─────────────────────────────────────────────────────
1868
+ function assertOverrideBreakpoint(bp) {
1869
+ if (bp === Breakpoint.Base) {
1870
+ throw new Error('Base styles live in styleProps / stateStyles, not in responsive overrides');
1871
+ }
1872
+ if (!BREAKPOINT_INDEX.has(bp))
1873
+ throw new Error(`Unknown breakpoint "${bp}"`);
1874
+ }
1875
+ /** Returns a new overrides object with `prop` set at `bp` (optionally within `state`). */
1876
+ function setOverride(responsive, bp, prop, value, state) {
1877
+ assertOverrideBreakpoint(bp);
1878
+ const layer = { ...(responsive?.[bp] ?? {}) };
1879
+ if (state) {
1880
+ layer.stateStyles = { ...(layer.stateStyles ?? {}), [state]: { ...(layer.stateStyles?.[state] ?? {}), [prop]: value } };
1881
+ }
1882
+ else {
1883
+ layer.styleProps = { ...(layer.styleProps ?? {}), [prop]: value };
1884
+ }
1885
+ return { ...(responsive ?? {}), [bp]: layer };
1886
+ }
1887
+ /** Returns a new overrides object with trait `name` set at `bp`. */
1888
+ function setInputOverride(responsive, bp, name, value) {
1889
+ assertOverrideBreakpoint(bp);
1890
+ const layer = { ...(responsive?.[bp] ?? {}) };
1891
+ layer.inputs = { ...(layer.inputs ?? {}), [name]: value };
1892
+ return { ...(responsive ?? {}), [bp]: layer };
1761
1893
  }
1762
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListComponent, decorators: [{
1763
- type: Component,
1764
- args: [{ selector: 'ul[iox-list],ol[iox-list]', template: `<ng-container #childrenHost></ng-container>`, host: {
1765
- '[class]': "'iox-node-' + nodeId",
1766
- '[attr.start]': "htmlTag === 'ol' && start !== '' && start != null ? start : null",
1767
- '[attr.reversed]': "htmlTag === 'ol' && reversed ? '' : null",
1768
- }, standalone: false, styles: [":host{margin:0;box-sizing:border-box}\n"] }]
1769
- }], propDecorators: { htmlTag: [{
1770
- type: Input
1771
- }], start: [{
1772
- type: Input
1773
- }], reversed: [{
1774
- type: Input
1775
- }], nodeId: [{
1776
- type: Input
1777
- }], childrenHost: [{
1778
- type: ViewChild,
1779
- args: ['childrenHost', { read: ViewContainerRef, static: true }]
1780
- }] } });
1781
-
1782
1894
  /**
1783
- * Directive-host List item: the real `<li>` IS the host — no `iox-list-item` wrapper, no
1784
- * inner/outer split. Children render into the `<li>` via `childrenHost`. Fixed tag (`<li>`),
1785
- * so the engine creates it with `vcr.createComponent` (no `hostElement` needed).
1895
+ * Remove trait overrides at `bp`, pruning empties exactly like `clearOverride` — omit `name` to
1896
+ * drop every trait override at that width. Returns `undefined` when nothing is left anywhere.
1786
1897
  */
1787
- class ClientListItemComponent {
1788
- constructor() {
1789
- this.nodeId = '';
1898
+ function clearInputOverride(responsive, bp, name) {
1899
+ assertOverrideBreakpoint(bp);
1900
+ const next = { ...(responsive ?? {}) };
1901
+ const current = next[bp];
1902
+ if (!current?.inputs)
1903
+ return hasKeys(next) ? next : undefined;
1904
+ const layer = { ...current };
1905
+ if (name === undefined) {
1906
+ delete layer.inputs;
1790
1907
  }
1791
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1792
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientListItemComponent, isStandalone: false, selector: "li[iox-list-item]", inputs: { nodeId: "nodeId" }, host: { properties: { "class": "'iox-node-' + nodeId" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{box-sizing:border-box}\n"] }); }
1908
+ else {
1909
+ const { [name]: _dropped, ...rest } = layer.inputs;
1910
+ layer.inputs = hasKeys(rest) ? rest : undefined;
1911
+ if (!layer.inputs)
1912
+ delete layer.inputs;
1913
+ }
1914
+ next[bp] = hasKeys(layer) ? layer : undefined;
1915
+ if (!next[bp])
1916
+ delete next[bp];
1917
+ return hasKeys(next) ? next : undefined;
1793
1918
  }
1794
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListItemComponent, decorators: [{
1795
- type: Component,
1796
- args: [{ selector: 'li[iox-list-item]', template: `<ng-container #childrenHost></ng-container>`, host: { '[class]': "'iox-node-' + nodeId" }, standalone: false, styles: [":host{box-sizing:border-box}\n"] }]
1797
- }], propDecorators: { nodeId: [{
1798
- type: Input
1799
- }], childrenHost: [{
1800
- type: ViewChild,
1801
- args: ['childrenHost', { read: ViewContainerRef, static: true }]
1802
- }] } });
1803
-
1804
1919
  /**
1805
- * Shared data-source resolving pattern — the SINGLE source of truth for how an IoxDataSource
1806
- * becomes a backend fetch, consumed by BOTH the page builder (iox-cms-client bindings panel) and
1807
- * the SSR client engine (iox-client-engine DataSourceResolverService). There must be no second
1808
- * copy of this decision logic; only the transport differs between the two:
1920
+ * Remove overrides at `bp` and PRUNE whatever becomes empty, so "reset" and "never
1921
+ * overridden" are the same state on disk — no tombstones, no nulls.
1809
1922
  *
1810
- * builder → authenticated org-scoped controllers (admin session):
1811
- * single by id → GET admin.../resources/:type/:id (ContentService.getContentById)
1812
- * collection → GET admin.../resources/:type (ContentService.getContent)
1813
- * engine → @Public() controllers (no auth, isPublished filter applied server-side):
1814
- * single by id → GET api/:type/:id (UserResourcesController)
1815
- * collection → GET api/:type?filters (UserResourcesController)
1923
+ * clearOverride(r, bp) — the whole breakpoint
1924
+ * clearOverride(r, bp, undefined, st) — one state at that breakpoint
1925
+ * clearOverride(r, bp, prop) — one base-state value
1926
+ * clearOverride(r, bp, prop, st) — one value within a state
1816
1927
  *
1817
- * The DECISION (by-id vs collection, which id, which filters, how to pick the single result) is
1818
- * identical for both — that's what this module owns. Each app maps the returned {@link DataSourcePlan}
1819
- * onto its own transport. A single item is ALWAYS fetched by its id as a path segment — never a
1820
- * `?_id=` query — to match the builder's getContentById.
1821
- */
1822
- /**
1823
- * Match a page route template against an actual path and extract named params.
1824
- * matchRouteParams('/project/:projectId', 'project/68abc') → { projectId: '68abc' }
1825
- * Both sides are normalised (leading slash stripped) and split on '/'; values are URL-decoded.
1928
+ * Returns `undefined` when nothing is left, so a serialiser omits the key entirely.
1826
1929
  */
1827
- function matchRouteParams(template, actual) {
1828
- const t = (template ?? '').replace(/^\/+/, '').split('/');
1829
- const a = (actual ?? '').replace(/^\/+/, '').split('/');
1830
- const params = {};
1831
- for (let i = 0; i < t.length; i++) {
1832
- const seg = t[i];
1833
- if (seg && seg.startsWith(':'))
1834
- params[seg.slice(1)] = decodeURIComponent(a[i] ?? '');
1930
+ function clearOverride(responsive, bp, prop, state) {
1931
+ assertOverrideBreakpoint(bp);
1932
+ const next = { ...(responsive ?? {}) };
1933
+ const current = next[bp];
1934
+ if (current && (prop !== undefined || state !== undefined)) {
1935
+ const layer = { ...current };
1936
+ if (state) {
1937
+ const states = { ...(layer.stateStyles ?? {}) };
1938
+ if (prop !== undefined && states[state]) {
1939
+ const { [prop]: _dropped, ...rest } = states[state];
1940
+ states[state] = rest;
1941
+ }
1942
+ if (prop === undefined || !hasKeys(states[state]))
1943
+ delete states[state];
1944
+ layer.stateStyles = hasKeys(states) ? states : undefined;
1945
+ }
1946
+ else if (prop !== undefined && layer.styleProps) {
1947
+ const { [prop]: _dropped, ...rest } = layer.styleProps;
1948
+ layer.styleProps = hasKeys(rest) ? rest : undefined;
1949
+ }
1950
+ if (!layer.stateStyles)
1951
+ delete layer.stateStyles;
1952
+ if (!layer.styleProps)
1953
+ delete layer.styleProps;
1954
+ next[bp] = hasKeys(layer) ? layer : undefined;
1835
1955
  }
1836
- return params;
1837
- }
1838
- /**
1839
- * Build the query filter for a data-source request from its filterByRouteParam / filterByQueryParams
1840
- * config. `field` is filtered by the route-param value (falling back to `defaultId`); query filters
1841
- * map each `field` to a query-param value (falling back to `defaultValue`), and a filter with NO
1842
- * `queryParam` is a constant on `defaultValue`. Empty values are omitted so the source isn't
1843
- * accidentally filtered to nothing.
1844
- */
1845
- function buildDataSourceFilter(req, routeParams = {}, queryParams = {}) {
1846
- const params = {};
1847
- const byRoute = req?.filterByRouteParam;
1848
- if (byRoute?.field && byRoute?.routeParam) {
1849
- const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
1850
- if (value !== undefined && value !== null && value !== '')
1851
- params[byRoute.field] = value;
1956
+ else {
1957
+ next[bp] = undefined;
1852
1958
  }
1853
- for (const q of req?.filterByQueryParams ?? []) {
1854
- if (!q?.field)
1855
- continue;
1856
- // No queryParam → a CONSTANT filter: defaultValue is the value, in the builder preview and
1857
- // on the live site alike. With a queryParam the URL wins and defaultValue is the fallback.
1858
- const value = q.queryParam ? (queryParams[q.queryParam] ?? q.defaultValue) : q.defaultValue;
1859
- if (value !== undefined && value !== null && value !== '')
1860
- params[q.field] = value;
1959
+ if (!next[bp])
1960
+ delete next[bp];
1961
+ return hasKeys(next) ? next : undefined;
1962
+ }
1963
+ const ZERO_DURATIONS = new Set(['0ms', '0s']);
1964
+ function groupsTouchedBy(map) {
1965
+ const touched = new Set();
1966
+ if (!map)
1967
+ return touched;
1968
+ for (const group of VIRTUAL_TRAIT_GROUPS) {
1969
+ if (group.keys.some(k => map[k] !== undefined))
1970
+ touched.add(group);
1861
1971
  }
1862
- return params;
1972
+ return touched;
1863
1973
  }
1864
1974
  /**
1865
- * Mirrors the builder's getContentById decision. When a SINGLE source is filtered by the item id
1866
- * (`_id`) via a route param, the item is fetched by PATH (`.../:type/:id`), not a `?_id=` query.
1867
- * Returns that id (route-param value, else `defaultId`), or `null` when the source isn't a
1868
- * single-by-id fetch — in which case the caller uses the collection endpoint + filters. The
1869
- * path-based form only applies to the id field; a single source filtered by any other field still
1870
- * goes through the collection endpoint.
1975
+ * Should a touched group that composed to nothing emit its cancelling value?
1976
+ * Transition is the exception: setting only the easing (no duration anywhere) composes to
1977
+ * nothing too, and cancelling there would also kill the renderer's default hover transition
1978
+ * — so transition cancels only when the merged duration is explicitly zero.
1871
1979
  */
1872
- function resolveSingleItemId(req, routeParams = {}) {
1873
- const byRoute = req?.filterByRouteParam;
1874
- if (!byRoute?.routeParam)
1875
- return null;
1876
- if (byRoute.field && byRoute.field !== '_id')
1877
- return null;
1878
- const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
1879
- return value !== undefined && value !== null && value !== '' ? String(value) : null;
1980
+ function shouldReset(group, merged) {
1981
+ if (group.reset === null)
1982
+ return false;
1983
+ if (group.output === 'transition')
1984
+ return ZERO_DURATIONS.has(String(merged['transitionDuration']));
1985
+ return true;
1880
1986
  }
1881
1987
  /**
1882
- * Resolve an IoxDataSource into a transport-agnostic {@link DataSourcePlan}. This is the shared
1883
- * decision both the builder and the SSR engine run; each then executes the plan with its own
1884
- * HTTP layer. Single sources with a route-param id resolve to a `by-id` (path) fetch — exactly like
1885
- * the builder's getContentById.
1988
+ * Compose a sparse override against everything wider than it, keeping ONLY what the
1989
+ * override changes: its own plain props, plus each virtual group it touches (or that
1990
+ * group's cancelling value when it now composes to nothing). Untouched groups are dropped
1991
+ * — re-emitting the wider rule's value inside a media block is noise at best.
1886
1992
  */
1887
- function planDataSource(ds, routeParams = {}, queryParams = {}) {
1888
- const mode = ds?.mode === 'single' ? 'single' : 'list';
1889
- switch (ds?.type) {
1890
- case 'cms':
1891
- case 'shop': {
1892
- const domain = ds.type;
1893
- const req = domain === 'cms' ? ds.request?.cms : ds.request?.shop;
1894
- const resource = req?.contentType;
1895
- if (!resource)
1896
- return { kind: 'none' };
1897
- const id = mode === 'single' ? resolveSingleItemId(req, routeParams) : null;
1898
- if (id)
1899
- return { kind: 'by-id', domain, resource, id, mode };
1900
- const filter = buildDataSourceFilter(req, routeParams, queryParams);
1901
- const plan = { kind: 'collection', domain, resource, filter, mode };
1902
- if (req?.limit)
1903
- plan.limit = req.limit;
1904
- return plan;
1905
- }
1906
- case 'external-api': {
1907
- const endpoint = ds.request?.endpoint;
1908
- return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
1909
- }
1910
- case 'static':
1911
- return { kind: 'static', data: ds.request?.data ?? null, mode };
1912
- case 'derived': {
1913
- const d = ds.request?.derived;
1914
- return d?.from ? { kind: 'derived', from: d.from, path: d.path ?? '', mode } : { kind: 'none' };
1915
- }
1916
- case 'variable':
1917
- // A VARIABLE is a page-local resource whose value is WRITTEN at runtime by an interaction
1918
- // (setContext), never fetched. It resolves to nothing here on purpose: the page renders
1919
- // its empty state until something writes it. `request.variable.itemsFrom` only names the
1920
- // source whose items it holds, so the bind picker can suggest fields.
1921
- return { kind: 'none' };
1922
- default:
1923
- return { kind: 'none' };
1993
+ function composeOverride(own, wider, touched) {
1994
+ const composed = composeVirtualTraits(own, wider);
1995
+ const groupOutputs = new Set(VIRTUAL_TRAIT_GROUPS.map(g => g.output));
1996
+ const out = {};
1997
+ for (const [k, v] of Object.entries(composed)) {
1998
+ if (!groupOutputs.has(k) && v !== undefined)
1999
+ out[k] = v;
2000
+ }
2001
+ const merged = { ...wider, ...own };
2002
+ for (const group of touched) {
2003
+ const value = composed[group.output];
2004
+ if (value !== undefined)
2005
+ out[group.output] = value;
2006
+ else if (shouldReset(group, merged))
2007
+ out[group.output] = group.reset;
1924
2008
  }
2009
+ return out;
1925
2010
  }
1926
2011
  /**
1927
- * The aliases on a page that are VARIABLES — runtime-written resources, not fetched data.
2012
+ * The per-breakpoint rule sets a node needs, widest → narrowest (the order they must be
2013
+ * emitted in, AFTER every base rule — equal specificity means source order decides).
2014
+ * Breakpoints with nothing to emit are omitted.
1928
2015
  *
1929
- * Shared so the builder and the SSR engine agree on which aliases start empty and are filled by
1930
- * interactions. See element-context-state-plan.md.
2016
+ * Handles the cross-axis case: a breakpoint that changes a virtual group (say
2017
+ * `translateX`) also re-emits that group for every state the node has, because the wider
2018
+ * state rule was composed against the wider value and would otherwise snap back to it on
2019
+ * hover.
1931
2020
  */
1932
- function variableAliases(dataSources) {
1933
- const out = new Set();
1934
- for (const ds of dataSources ?? []) {
1935
- if (ds?.type === 'variable' && ds.alias)
1936
- out.add(ds.alias);
1937
- }
1938
- return out;
1939
- }
1940
- /** The source alias whose items a variable holds, if it declares one (for field suggestions). */
1941
- function variableItemsFrom(dataSources, alias) {
1942
- for (const ds of dataSources ?? []) {
1943
- if (ds?.type === 'variable' && ds.alias === alias)
1944
- return ds.request?.variable?.itemsFrom || undefined;
2021
+ function planResponsiveRules(styleProps, stateStyles, responsive) {
2022
+ if (!hasKeys(responsive))
2023
+ return [];
2024
+ const plan = [];
2025
+ for (const def of BREAKPOINTS) {
2026
+ const layer = layerAt(responsive, def.id);
2027
+ if (!layer || def.maxWidth == null)
2028
+ continue;
2029
+ const bp = def.id;
2030
+ const wider = parentBreakpoint(bp);
2031
+ const own = layer.styleProps ?? {};
2032
+ const widerProps = resolveStyleProps(styleProps, responsive, wider);
2033
+ const touchedByProps = groupsTouchedBy(own);
2034
+ const composedProps = composeOverride(own, widerProps, touchedByProps);
2035
+ const propsHere = { ...widerProps, ...own };
2036
+ const statesHere = resolveStateStyles(stateStyles, responsive, bp);
2037
+ const statesWider = resolveStateStyles(stateStyles, responsive, wider);
2038
+ const composedStates = {};
2039
+ for (const state of Object.keys(statesHere)) {
2040
+ const ownState = layer.stateStyles?.[state] ?? {};
2041
+ const touched = groupsTouchedBy(ownState);
2042
+ for (const group of touchedByProps)
2043
+ touched.add(group);
2044
+ const composed = composeOverride(ownState, { ...propsHere, ...(statesWider[state] ?? {}) }, touched);
2045
+ if (hasKeys(composed))
2046
+ composedStates[state] = composed;
2047
+ }
2048
+ if (hasKeys(composedProps) || hasKeys(composedStates)) {
2049
+ plan.push({
2050
+ breakpoint: bp,
2051
+ mediaQuery: mediaQueryFor(bp),
2052
+ styleProps: composedProps,
2053
+ stateStyles: composedStates,
2054
+ });
2055
+ }
1945
2056
  }
1946
- return undefined;
2057
+ return plan;
1947
2058
  }
2059
+
2060
+ // Swiper breakpoints, derived from a node's per-breakpoint TRAIT overrides.
2061
+ //
2062
+ // SHARED (iox-ui): the builder canvas and the SSR engine both hand Swiper the same config, so a
2063
+ // slider that goes 1-up on a phone in the editor does the same on the live site.
2064
+ //
2065
+ // ⚠️ THE INVERSION IS THE WHOLE POINT OF THIS FILE.
2066
+ // IOX is desktop-first: `max-width`, and a layer applies at its width AND NARROWER.
2067
+ // Swiper is mobile-first: `breakpoints` keys are MIN-widths, and the LARGEST matching key wins.
2068
+ // Converting by hand at each call site is how the two drift, so it happens once, here, tested.
2069
+ //
2070
+ // See iox-ai-guidance `architecture/builder/builder-quality-pass-plan.md` (P3-G2).
2071
+ /** Trait names whose value Swiper reads per breakpoint. Anything else is config-wide. */
2072
+ const SWIPER_RESPONSIVE_TRAITS = Object.freeze(['slidesPerView', 'slidesPerGroup', 'spaceBetween']);
1948
2073
  /**
1949
- * Normalise a collection response into the shape a source expects: unwraps `{items}`/`{data}`
1950
- * envelopes, then returns the first item for a `single` source or the whole array for a `list`.
1951
- * A `by-id` response is already the item, so callers pass it through unchanged.
2074
+ * Build Swiper's mobile-first `breakpoints` map from IOX's desktop-first layers.
2075
+ *
2076
+ * Each IOX breakpoint covers `[nextNarrower.maxWidth + 1 … this.maxWidth]`, so the Swiper key for a
2077
+ * layer is **one px above the next-narrower breakpoint's max-width** — the low edge of the band the
2078
+ * layer governs. Base becomes the key above the widest bounded breakpoint.
2079
+ *
2080
+ * Values are the RESOLVED ones at each width (base + every wider layer), never the raw sparse layer:
2081
+ * Swiper does not cascade between its own breakpoints the way CSS does — the largest matching key
2082
+ * wins outright and is not merged with smaller ones — so each entry must be self-sufficient.
2083
+ *
2084
+ * Only `SWIPER_RESPONSIVE_TRAITS` are emitted, and a band is omitted when it matches the band below
2085
+ * it, keeping the config as small as the author's actual overrides.
1952
2086
  */
1953
- function normalizeCollectionResult(mode, data) {
1954
- const items = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
1955
- return mode === 'single' ? (items[0] ?? null) : items;
1956
- }
1957
- /** Navigate a dot/bracket path into an object, e.g. `gallery`, `details.images`, `variants[0].skus`.
1958
- * Returns undefined if any segment is missing. Kept minimal + self-contained so it is shareable by
1959
- * both the builder and the SSR engine (the two have divergent local `resolvePath` copies). */
1960
- function getByPath(obj, path) {
1961
- if (obj == null || !path)
1962
- return obj;
1963
- // Turn `a[0].b` into ['a','0','b'].
1964
- const segments = path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
1965
- let cur = obj;
1966
- for (const seg of segments) {
1967
- if (cur == null)
1968
- return undefined;
1969
- cur = cur[seg];
2087
+ function buildSwiperBreakpoints(inputs, responsive) {
2088
+ const bands = bandsWidestFirst();
2089
+ const map = {};
2090
+ let previous = null;
2091
+ // Narrowest → widest, so "same as the band below" is a comparison against what we just emitted.
2092
+ for (const band of [...bands].reverse()) {
2093
+ const resolved = pick(resolveInputs(inputs, responsive, band.id));
2094
+ if (previous && sameValues(previous, resolved))
2095
+ continue;
2096
+ map[band.minWidth] = resolved;
2097
+ previous = resolved;
1970
2098
  }
1971
- return cur;
2099
+ return map;
1972
2100
  }
1973
2101
  /**
1974
- * The array a Repeater (or a repeating Slider) iterates over — the SHARED decision so the builder
1975
- * canvas and the SSR engine agree (see data-source-resolving.md).
1976
- * - No `sourcePath`: the resolved source IS the list. An array passes through; a lone object degrades
1977
- * to a single-row list (back-compat with the old `fetchItems`); null/undefined → `[]`.
1978
- * - With `sourcePath`: the source resolved to a SINGLE resource (e.g. a product); dive into the
1979
- * nested array property (e.g. `gallery`). Non-array / missing → `[]`.
1980
- */
1981
- /**
1982
- * Fill in `derived` aliases in an already-resolved map. Given the data sources and a map of resolved
1983
- * NON-derived values (by alias), compute each `type:'derived'` alias as `getByPath(parent, path)` in
1984
- * dependency order (chains resolved by iterating until stable). Mutates + returns the map. Used by the
1985
- * SSR engine after it resolves the root sources; the builder resolves derived lazily per alias.
2102
+ * The full `breakpoints` value to hand Swiper: the derived map, with any hand-written advanced JSON
2103
+ * merged OVER it so an author's explicit escape hatch always wins.
1986
2104
  */
1987
- function resolveDerivedInto(dataSources, resolved) {
1988
- const derived = (dataSources ?? []).filter(d => d?.type === 'derived' && !!d.alias);
1989
- let changed = true, guard = 0;
1990
- while (changed && guard++ < 25) {
1991
- changed = false;
1992
- for (const d of derived) {
1993
- if (d.alias in resolved)
1994
- continue;
1995
- const from = d.request?.derived?.from;
1996
- if (from && from in resolved) {
1997
- resolved[d.alias] = getByPath(resolved[from], d.request.derived.path ?? '');
1998
- changed = true;
1999
- }
2105
+ function mergeSwiperBreakpoints(derived, advancedJson) {
2106
+ let advanced;
2107
+ if (advancedJson) {
2108
+ try {
2109
+ advanced = JSON.parse(advancedJson);
2110
+ }
2111
+ catch {
2112
+ advanced = undefined;
2000
2113
  }
2001
2114
  }
2002
- return resolved;
2003
- }
2004
- function resolveRepeaterItems(resolved, sourcePath) {
2005
- if (sourcePath) {
2006
- const nested = getByPath(resolved, sourcePath);
2007
- return Array.isArray(nested) ? nested : [];
2115
+ if (!advanced)
2116
+ return Object.keys(derived).length ? derived : undefined;
2117
+ const out = { ...derived };
2118
+ for (const [key, value] of Object.entries(advanced)) {
2119
+ const min = Number(key);
2120
+ if (!Number.isFinite(min))
2121
+ continue;
2122
+ out[min] = { ...(out[min] ?? {}), ...(value ?? {}) };
2008
2123
  }
2009
- if (Array.isArray(resolved))
2010
- return resolved;
2011
- return resolved == null ? [] : [resolved];
2124
+ return Object.keys(out).length ? out : undefined;
2012
2125
  }
2013
- // ── Referenced data sources (shared: builder-save + SSR resolve) ─────────────────────────────────
2014
- // Which data-source aliases a node tree uses, so a consumer resolves ONLY what a page/global needs
2015
- // (not the whole org library). Used by cms-client (persist globals' sources) and the SSR engine
2016
- // (resolve the referenced subset per page). See org-data-sources-plan.md.
2017
- /** Aliases referenced anywhere in a node tree: a repeater/slider `source` (a `bindings` entry with
2018
- * `prop === 'source'`, or `props.source` for the slider) plus every binding's `source`. */
2019
- function collectReferencedAliases(nodes, acc = new Set()) {
2020
- for (const n of nodes ?? []) {
2021
- for (const b of n?.bindings ?? []) {
2022
- if (b?.source)
2023
- acc.add(b.source);
2024
- }
2025
- if (n?.props?.source)
2026
- acc.add(n.props.source);
2027
- if (n?.children?.length)
2028
- collectReferencedAliases(n.children, acc);
2126
+ /** Each IOX breakpoint with the MIN width at which it starts applying. Widest first. */
2127
+ function bandsWidestFirst() {
2128
+ return BREAKPOINTS.map((def, i) => {
2129
+ const narrower = BREAKPOINTS[i + 1];
2130
+ // Base starts one px above the widest bounded breakpoint; every other band starts one px
2131
+ // above the breakpoint below it. The narrowest band starts at 0 — it has nothing below.
2132
+ const minWidth = narrower?.maxWidth != null ? narrower.maxWidth + 1 : 0;
2133
+ return { id: def.id, minWidth };
2134
+ });
2135
+ }
2136
+ function pick(resolved) {
2137
+ const out = {};
2138
+ for (const name of SWIPER_RESPONSIVE_TRAITS) {
2139
+ if (resolved[name] !== undefined)
2140
+ out[name] = resolved[name];
2029
2141
  }
2030
- return acc;
2142
+ return out;
2031
2143
  }
2032
- /** The subset of `all` whose `alias` is referenced by `nodes` (shallow — direct references only). */
2033
- function referencedDataSources(nodes, all) {
2034
- const aliases = collectReferencedAliases(nodes);
2035
- return (all ?? []).filter(ds => aliases.has(ds?.alias));
2144
+ function sameValues(a, b) {
2145
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
2146
+ for (const key of keys)
2147
+ if (a[key] !== b[key])
2148
+ return false;
2149
+ return true;
2036
2150
  }
2037
- /** Like `referencedDataSources`, but also pulls in a referenced source's DEPENDENCIES (transitively),
2038
- * none of which the layout names directly:
2039
- * - a `derived` alias needs its `request.derived.from` parent;
2040
- * - a `relative` (route-context) alias needs its `request.relative.subjectFrom` (the current item)
2041
- * and `request.relative.from` (the collection it navigates).
2042
- * Use this when RESOLVING (the engine) so derived views and siblings/related don't resolve to nothing. */
2043
- function referencedDataSourcesDeep(nodes, all) {
2044
- const byAlias = new Map((all ?? []).map(d => [d?.alias, d]));
2045
- const wanted = collectReferencedAliases(nodes);
2046
- const queue = [...wanted];
2047
- const enqueue = (alias) => {
2048
- if (alias && !wanted.has(alias)) {
2049
- wanted.add(alias);
2050
- queue.push(alias);
2151
+
2152
+ class ClientSliderContainerComponent {
2153
+ /** The base (Base-breakpoint) values of the traits Swiper reads per width. Read from the live
2154
+ * inputs rather than the node, so the builder's direct-assignment edits are picked up too. */
2155
+ collectSwiperInputs() {
2156
+ const all = {
2157
+ slidesPerView: this.slidesPerView,
2158
+ slidesPerGroup: this.slidesPerGroup,
2159
+ spaceBetween: this.spaceBetween,
2160
+ };
2161
+ const out = {};
2162
+ for (const name of SWIPER_RESPONSIVE_TRAITS)
2163
+ if (all[name] !== undefined)
2164
+ out[name] = all[name];
2165
+ return out;
2166
+ }
2167
+ constructor(platformId) {
2168
+ this.platformId = platformId;
2169
+ this.nodeId = '';
2170
+ /** Language (writing) direction. Rendered as the `dir` attribute so it survives SSR and drives
2171
+ * Swiper's RTL layout — must match the builder's app-slider-container, which had `dir` while
2172
+ * this engine-runtime component did not (RTL showed in the editor but never on the live site). */
2173
+ this.dir = 'ltr';
2174
+ this.direction = 'horizontal';
2175
+ this.loop = false;
2176
+ this.slidesPerView = 1;
2177
+ /** Declared by the config since the slider shipped, but never declared HERE — so `safeSetInput`
2178
+ * dropped it and the panel control did nothing on either renderer. */
2179
+ this.slidesPerGroup = 1;
2180
+ this.spaceBetween = 0;
2181
+ this.speed = 300;
2182
+ this.effect = 'slide';
2183
+ this.grabCursor = true;
2184
+ this.centeredSlides = false;
2185
+ this.initialSlide = 0;
2186
+ this.enableNavigation = false;
2187
+ this.enablePagination = false;
2188
+ this.enableScrollbar = false;
2189
+ this.enableAutoplay = false;
2190
+ this.enableKeyboard = false;
2191
+ this.enableMousewheel = false;
2192
+ this.enableZoom = false;
2193
+ this.enableGrid = false;
2194
+ this.enableParallax = false;
2195
+ this.autoplayDelay = 3000;
2196
+ this.autoplayPauseOnMouseEnter = true;
2197
+ this.autoplayDisableOnInteraction = false;
2198
+ this.paginationType = 'bullets';
2199
+ this.paginationClickable = true;
2200
+ this.gridRows = 1;
2201
+ /** Advanced escape hatch: raw Swiper `breakpoints` JSON. Merged OVER the map derived from the
2202
+ * node's per-breakpoint traits, so an explicit hand-written value always wins. */
2203
+ this.breakpoints = '';
2204
+ /** The node's per-breakpoint overrides. Only its `inputs` layer is read here — the style layers
2205
+ * are compiled to CSS by the style engine, not handed to Swiper. */
2206
+ this.responsive = null;
2207
+ this._swiper = null;
2208
+ this.instanceRegistry = inject(ComponentInstanceRegistryService);
2209
+ }
2210
+ ngOnInit() { }
2211
+ ngAfterViewInit() {
2212
+ if (isPlatformBrowser(this.platformId)) {
2213
+ // Defer so all child slides are rendered first
2214
+ requestAnimationFrame(() => this._initSwiper());
2051
2215
  }
2052
- };
2053
- while (queue.length) {
2054
- const ds = byAlias.get(queue.pop());
2055
- enqueue(ds?.request?.derived?.from);
2056
- enqueue(ds?.request?.relative?.subjectFrom);
2057
- enqueue(ds?.request?.relative?.from);
2058
2216
  }
2059
- return (all ?? []).filter(d => wanted.has(d?.alias));
2060
- }
2061
- /**
2062
- * Turn a resolved {@link DataSourcePlan} into the concrete public HTTP request (url + params) — the
2063
- * SINGLE place that decides the URL shape, shared by the builder binding preview and the SSR engine
2064
- * so both hit the SAME public endpoints (no admin, no duplication). Returns `null` for `static` /
2065
- * `none` plans (no HTTP call). A single item is a PATH segment (`/:resource/:id`), never `?_id=`.
2066
- */
2067
- function dataSourcePlanToRequest(plan, endpoints) {
2068
- const baseFor = (domain) => (domain === 'shop' ? endpoints.shopBase : endpoints.cmsBase);
2069
- switch (plan.kind) {
2070
- case 'by-id':
2071
- return { url: `${baseFor(plan.domain)}${plan.resource}/${encodeURIComponent(plan.id)}`, params: {} };
2072
- case 'collection': {
2073
- const params = { ...plan.filter };
2074
- if (plan.limit)
2075
- params['limit'] = plan.limit;
2076
- return { url: `${baseFor(plan.domain)}${plan.resource}`, params };
2217
+ ngOnDestroy() {
2218
+ if (this._swiper) {
2219
+ this._swiper.destroy(true, true);
2220
+ this._swiper = null;
2221
+ this.instanceRegistry.unregister(this.nodeId);
2077
2222
  }
2078
- case 'external':
2079
- return { url: plan.endpoint, params: {} };
2080
- default:
2081
- return null;
2082
2223
  }
2224
+ async _initSwiper() {
2225
+ const el = this.swiperEl?.nativeElement;
2226
+ if (!el)
2227
+ return;
2228
+ const [{ default: Swiper }, swiperModules] = await Promise.all([
2229
+ import('swiper'),
2230
+ import('swiper/modules'),
2231
+ ]);
2232
+ const { Navigation, Pagination, Scrollbar, Autoplay, Keyboard, Mousewheel, Zoom, Grid, Parallax, EffectFade, EffectCube, EffectFlip, EffectCoverflow, EffectCards, EffectCreative, } = swiperModules;
2233
+ const modules = [];
2234
+ if (this.enableNavigation)
2235
+ modules.push(Navigation);
2236
+ if (this.enablePagination)
2237
+ modules.push(Pagination);
2238
+ if (this.enableScrollbar)
2239
+ modules.push(Scrollbar);
2240
+ if (this.enableAutoplay)
2241
+ modules.push(Autoplay);
2242
+ if (this.enableKeyboard)
2243
+ modules.push(Keyboard);
2244
+ if (this.enableMousewheel)
2245
+ modules.push(Mousewheel);
2246
+ if (this.enableZoom)
2247
+ modules.push(Zoom);
2248
+ if (this.enableGrid)
2249
+ modules.push(Grid);
2250
+ if (this.enableParallax)
2251
+ modules.push(Parallax);
2252
+ const effectMap = {
2253
+ fade: EffectFade, cube: EffectCube, flip: EffectFlip,
2254
+ coverflow: EffectCoverflow, cards: EffectCards, creative: EffectCreative,
2255
+ };
2256
+ if (this.effect !== 'slide' && effectMap[this.effect]) {
2257
+ modules.push(effectMap[this.effect]);
2258
+ }
2259
+ const config = {
2260
+ modules,
2261
+ // Swiper reads the container's `dir` attribute, but pass it explicitly so RTL is applied
2262
+ // on init regardless of attribute timing.
2263
+ dir: this.dir,
2264
+ direction: this.direction,
2265
+ loop: this.loop,
2266
+ slidesPerView: this.slidesPerView,
2267
+ slidesPerGroup: this.slidesPerGroup,
2268
+ spaceBetween: this.spaceBetween,
2269
+ speed: this.speed,
2270
+ effect: this.effect,
2271
+ grabCursor: this.grabCursor,
2272
+ centeredSlides: this.centeredSlides,
2273
+ initialSlide: this.initialSlide,
2274
+ };
2275
+ if (this.enableNavigation)
2276
+ config['navigation'] = { prevEl: '.swiper-button-prev', nextEl: '.swiper-button-next' };
2277
+ if (this.enablePagination)
2278
+ config['pagination'] = { el: '.swiper-pagination', type: this.paginationType, clickable: this.paginationClickable };
2279
+ if (this.enableScrollbar)
2280
+ config['scrollbar'] = { el: '.swiper-scrollbar', draggable: true };
2281
+ if (this.enableAutoplay)
2282
+ config['autoplay'] = { delay: this.autoplayDelay, pauseOnMouseEnter: this.autoplayPauseOnMouseEnter, disableOnInteraction: this.autoplayDisableOnInteraction };
2283
+ if (this.enableGrid && this.gridRows > 1)
2284
+ config['grid'] = { rows: this.gridRows };
2285
+ // Per-breakpoint traits (slidesPerView/slidesPerGroup/spaceBetween) become Swiper's own
2286
+ // mobile-first `breakpoints` map via the SHARED converter — the desktop-first → mobile-first
2287
+ // inversion lives there, tested, not at each call site. The hand-written `breakpoints` JSON
2288
+ // stays as an advanced escape hatch and is merged OVER the derived map.
2289
+ const derived = buildSwiperBreakpoints(this.collectSwiperInputs(), this.responsive);
2290
+ const breakpointMap = mergeSwiperBreakpoints(derived, this.breakpoints);
2291
+ if (breakpointMap)
2292
+ config['breakpoints'] = breakpointMap;
2293
+ this._swiper = new Swiper(el, config);
2294
+ this.instanceRegistry.register(this.nodeId, this._swiper);
2295
+ }
2296
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderContainerComponent, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Component }); }
2297
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientSliderContainerComponent, isStandalone: false, selector: "iox-slider-container", inputs: { nodeId: "nodeId", dir: "dir", direction: "direction", loop: "loop", slidesPerView: "slidesPerView", slidesPerGroup: "slidesPerGroup", spaceBetween: "spaceBetween", speed: "speed", effect: "effect", grabCursor: "grabCursor", centeredSlides: "centeredSlides", initialSlide: "initialSlide", enableNavigation: "enableNavigation", enablePagination: "enablePagination", enableScrollbar: "enableScrollbar", enableAutoplay: "enableAutoplay", enableKeyboard: "enableKeyboard", enableMousewheel: "enableMousewheel", enableZoom: "enableZoom", enableGrid: "enableGrid", enableParallax: "enableParallax", autoplayDelay: "autoplayDelay", autoplayPauseOnMouseEnter: "autoplayPauseOnMouseEnter", autoplayDisableOnInteraction: "autoplayDisableOnInteraction", paginationType: "paginationType", paginationClickable: "paginationClickable", gridRows: "gridRows", breakpoints: "breakpoints", responsive: "responsive" }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }, { propertyName: "swiperEl", first: true, predicate: ["swiperEl"], descendants: true, static: true }], ngImport: i0, template: `
2298
+ <div class="swiper" [class]="'iox-node-' + nodeId" [attr.dir]="dir" [style.direction]="dir" #swiperEl>
2299
+ <div class="swiper-wrapper">
2300
+ <ng-container #childrenHost></ng-container>
2301
+ </div>
2302
+ <div *ngIf="enableNavigation" class="swiper-button-prev"></div>
2303
+ <div *ngIf="enableNavigation" class="swiper-button-next"></div>
2304
+ <div *ngIf="enablePagination" class="swiper-pagination"></div>
2305
+ <div *ngIf="enableScrollbar" class="swiper-scrollbar"></div>
2306
+ </div>
2307
+ `, isInline: true, styles: [".swiper{width:100%;height:100%;box-sizing:border-box}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); }
2083
2308
  }
2309
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderContainerComponent, decorators: [{
2310
+ type: Component,
2311
+ args: [{ selector: 'iox-slider-container', template: `
2312
+ <div class="swiper" [class]="'iox-node-' + nodeId" [attr.dir]="dir" [style.direction]="dir" #swiperEl>
2313
+ <div class="swiper-wrapper">
2314
+ <ng-container #childrenHost></ng-container>
2315
+ </div>
2316
+ <div *ngIf="enableNavigation" class="swiper-button-prev"></div>
2317
+ <div *ngIf="enableNavigation" class="swiper-button-next"></div>
2318
+ <div *ngIf="enablePagination" class="swiper-pagination"></div>
2319
+ <div *ngIf="enableScrollbar" class="swiper-scrollbar"></div>
2320
+ </div>
2321
+ `, standalone: false, styles: [".swiper{width:100%;height:100%;box-sizing:border-box}\n"] }]
2322
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
2323
+ type: Inject,
2324
+ args: [PLATFORM_ID]
2325
+ }] }], propDecorators: { nodeId: [{
2326
+ type: Input
2327
+ }], dir: [{
2328
+ type: Input
2329
+ }], direction: [{
2330
+ type: Input
2331
+ }], loop: [{
2332
+ type: Input
2333
+ }], slidesPerView: [{
2334
+ type: Input
2335
+ }], slidesPerGroup: [{
2336
+ type: Input
2337
+ }], spaceBetween: [{
2338
+ type: Input
2339
+ }], speed: [{
2340
+ type: Input
2341
+ }], effect: [{
2342
+ type: Input
2343
+ }], grabCursor: [{
2344
+ type: Input
2345
+ }], centeredSlides: [{
2346
+ type: Input
2347
+ }], initialSlide: [{
2348
+ type: Input
2349
+ }], enableNavigation: [{
2350
+ type: Input
2351
+ }], enablePagination: [{
2352
+ type: Input
2353
+ }], enableScrollbar: [{
2354
+ type: Input
2355
+ }], enableAutoplay: [{
2356
+ type: Input
2357
+ }], enableKeyboard: [{
2358
+ type: Input
2359
+ }], enableMousewheel: [{
2360
+ type: Input
2361
+ }], enableZoom: [{
2362
+ type: Input
2363
+ }], enableGrid: [{
2364
+ type: Input
2365
+ }], enableParallax: [{
2366
+ type: Input
2367
+ }], autoplayDelay: [{
2368
+ type: Input
2369
+ }], autoplayPauseOnMouseEnter: [{
2370
+ type: Input
2371
+ }], autoplayDisableOnInteraction: [{
2372
+ type: Input
2373
+ }], paginationType: [{
2374
+ type: Input
2375
+ }], paginationClickable: [{
2376
+ type: Input
2377
+ }], gridRows: [{
2378
+ type: Input
2379
+ }], breakpoints: [{
2380
+ type: Input
2381
+ }], responsive: [{
2382
+ type: Input
2383
+ }], childrenHost: [{
2384
+ type: ViewChild,
2385
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
2386
+ }], swiperEl: [{
2387
+ type: ViewChild,
2388
+ args: ['swiperEl', { static: true }]
2389
+ }] } });
2084
2390
 
2085
2391
  /**
2086
- * Gallery — data model + the SHARED "what are the image URLs" resolver.
2087
- *
2088
- * A Gallery is populated one of two ways (see gallery-component-plan.md):
2089
- * - **Manual**: an ordered `GalleryImage[]` trait. Each item is a raw/filebox URL OR a per-item
2090
- * binding path resolved against the item/route context.
2091
- * - **Bound**: the whole gallery repeats over a data source (a collection, or an array property
2092
- * inside one resource via `sourcePath`) — the items come from the shared `resolveRepeaterItems`,
2093
- * and one field of each record (`imageField`) is the URL.
2094
- *
2095
- * `resolveGalleryUrls` is the SINGLE place both the builder canvas and the SSR/runtime engine turn a
2096
- * node's config into the final `string[]` the crossfade loop consumes — so both sides show the same
2097
- * images. It is pure and transport-agnostic; never fork it. It reuses `getByPath` (the same path
2098
- * reader used by data-source resolving) so binding paths behave identically everywhere.
2099
- */
2100
- /**
2101
- * Resolve a gallery's configured images to the flat, display-ready `string[]` the loop renders.
2102
- * Pure. Empty/whitespace URLs are dropped so a half-filled manual list never yields blank frames.
2392
+ * Client-engine slide. The HOST element (`iox-slider-slide`) itself carries the
2393
+ * `swiper-slide` class so it is a DIRECT child of `.swiper-wrapper` — Swiper only
2394
+ * recognises slides that are direct children (the `.swiper-slide` direct-child
2395
+ * invariant; see iox-ai-guidance architecture/builder/slider.md). A wrapper div
2396
+ * would nest `.swiper-slide` one level too deep and Swiper would ignore the slides.
2103
2397
  *
2104
- * @param images manual `GalleryImage[]` (manual mode). Ignored when `bound` is provided.
2105
- * @param bound bound-mode records + image field. When present, takes precedence over `images`.
2106
- * @param ctx render context for per-item bindings (item/route data). Defaults to `{}`.
2398
+ * The shared `iox-node-{id}` class is applied to the same host so the page-level
2399
+ * CSS rules match (parity with the string renderer / builder).
2107
2400
  */
2108
- function resolveGalleryUrls(images, bound, ctx = {}) {
2109
- if (bound) {
2110
- const field = bound.imageField;
2111
- return (bound.items ?? [])
2112
- .map(item => (field ? getByPath(item, field) : item))
2113
- .map(coerceUrl)
2114
- .filter((u) => !!u);
2401
+ class ClientSliderSlideComponent {
2402
+ constructor() {
2403
+ this.nodeId = '';
2115
2404
  }
2116
- return (images ?? [])
2117
- .map(normalizeGalleryImage)
2118
- .map(img => (img?.binding ? getByPath(ctx, img.binding) : img?.url))
2119
- .map(coerceUrl)
2120
- .filter((u) => !!u);
2121
- }
2122
- /**
2123
- * A plain string is a valid entry — a pasted URL list, an older saved node, or a bound field that
2124
- * yielded a URL directly. Accepting it HERE (not in one consumer) is what keeps the two render paths
2125
- * honest: `ClientGalleryComponent` used to normalise privately, so the engine's string-renderer
2126
- * fallback — which calls this function directly — silently dropped every string-shaped image and
2127
- * rendered an empty gallery. One resolver, one answer. See gallery-component-plan.md.
2128
- */
2129
- function normalizeGalleryImage(image) {
2130
- if (typeof image === 'string')
2131
- return { url: image };
2132
- return image ?? undefined;
2133
- }
2134
- /** A resolved value becomes a URL only if it's a non-empty string; everything else drops out. */
2135
- function coerceUrl(value) {
2136
- if (typeof value !== 'string')
2137
- return null;
2138
- const trimmed = value.trim();
2139
- return trimmed ? trimmed : null;
2140
- }
2141
- /**
2142
- * Parse a pasted block of URLs (newline- and/or comma-separated) into `GalleryImage[]`. Used by the
2143
- * MediaList trait editor's "paste URL list" affordance. Pure so it can be unit-tested + reused.
2144
- */
2145
- function parseUrlList(text) {
2146
- if (!text)
2147
- return [];
2148
- return text
2149
- .split(/[\n,]+/)
2150
- .map(s => s.trim())
2151
- .filter(Boolean)
2152
- .map(url => ({ url }));
2405
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderSlideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2406
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientSliderSlideComponent, isStandalone: false, selector: "iox-slider-slide", inputs: { nodeId: "nodeId" }, host: { properties: { "class": "'swiper-slide iox-node-' + nodeId" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{display:block;box-sizing:border-box}\n"] }); }
2153
2407
  }
2408
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderSlideComponent, decorators: [{
2409
+ type: Component,
2410
+ args: [{ selector: 'iox-slider-slide', template: `<ng-container #childrenHost></ng-container>`, host: { '[class]': `'swiper-slide iox-node-' + nodeId` }, standalone: false, styles: [":host{display:block;box-sizing:border-box}\n"] }]
2411
+ }], propDecorators: { nodeId: [{
2412
+ type: Input
2413
+ }], childrenHost: [{
2414
+ type: ViewChild,
2415
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
2416
+ }] } });
2154
2417
 
2155
2418
  /**
2156
- * Gallery effect registry — the SHARED, effect-agnostic description of how each gallery slide looks
2157
- * and animates in a given crossfade state. Modeled on `binding-pipes.ts` / `PAGE_TRANSITION_PRESETS`:
2158
- * a descriptor map + a resolve fn, all pure, consumed by BOTH the builder twin and the engine twin so
2159
- * the fade looks identical on the canvas and the live site.
2160
- *
2161
- * v1 ships only `fade` (the look ported from `sites/interior-design` fade-gallery). Adding `slide`,
2162
- * `zoom`, `kenburns`, … later is a new entry here + its CSS — NO component refactor, because the
2163
- * component only ever asks `resolveGalleryEffect(id).buildSlideStyle(state)` for per-slide inline CSS.
2164
- *
2165
- * See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2419
+ * Directive-host List: the real `<ul>`/`<ol>` IS the host — no `iox-list` wrapper, no
2420
+ * inner/outer split. The tag is chosen by whoever CREATES the component (engine passes
2421
+ * `hostElement: document.createElement(htmlTag)`), NOT an internal `*ngIf`. Children render
2422
+ * into the host via `childrenHost`. `start`/`reversed` are host attrs (only meaningful on `<ol>`).
2166
2423
  */
2167
- /** The ken-burns zoom baked into the reference fade look (scale 1 → 1.05 over show+fade). */
2168
- const FADE_ZOOM_EXTRA_MS = 1000;
2169
- const fadeEffect = {
2170
- id: 'fade',
2171
- label: 'Fade',
2172
- defaults: { showMs: 4000, fadeMs: 1000 },
2173
- buildSlideStyle(state) {
2174
- const { active, fadingOut, firstCycleActive, durations } = state;
2175
- const zoomMs = durations.showMs + durations.fadeMs + FADE_ZOOM_EXTRA_MS;
2176
- // Before the first cycle activates, everything is flat + instant so there is no entry flash.
2177
- if (!firstCycleActive) {
2178
- return { opacity: '0', transform: 'scale(1)', transition: 'opacity 0ms, transform 0ms' };
2179
- }
2180
- const visible = active && !fadingOut;
2181
- const engaged = active || fadingOut; // current or outgoing slide is zoomed-in
2182
- return {
2183
- opacity: visible ? '1' : '0',
2184
- transform: engaged ? 'scale(1.05)' : 'scale(1)',
2185
- transition: `opacity ${durations.fadeMs}ms cubic-bezier(0.77, 0, 0.175, 1), ` +
2186
- `transform ${zoomMs}ms linear`,
2187
- };
2188
- },
2189
- };
2190
- /** The registry — one entry per effect. Keyed by id; unknown ids fall back to `fade`. */
2191
- const GALLERY_EFFECTS = {
2192
- fade: fadeEffect,
2193
- };
2194
- /** Resolve an effect id to its descriptor, defaulting to `fade` (forward-compatible with new ids). */
2195
- function resolveGalleryEffect(id) {
2196
- return (id && GALLERY_EFFECTS[id]) || GALLERY_EFFECTS['fade'];
2424
+ class ClientListComponent {
2425
+ constructor() {
2426
+ this.htmlTag = 'ul';
2427
+ this.start = '';
2428
+ this.reversed = false;
2429
+ this.nodeId = '';
2430
+ }
2431
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2432
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientListComponent, isStandalone: false, selector: "ul[iox-list],ol[iox-list]", inputs: { htmlTag: "htmlTag", start: "start", reversed: "reversed", nodeId: "nodeId" }, host: { properties: { "class": "'iox-node-' + nodeId", "attr.start": "htmlTag === 'ol' && start !== '' && start != null ? start : null", "attr.reversed": "htmlTag === 'ol' && reversed ? '' : null" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{margin:0;box-sizing:border-box}\n"] }); }
2433
+ }
2434
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListComponent, decorators: [{
2435
+ type: Component,
2436
+ args: [{ selector: 'ul[iox-list],ol[iox-list]', template: `<ng-container #childrenHost></ng-container>`, host: {
2437
+ '[class]': "'iox-node-' + nodeId",
2438
+ '[attr.start]': "htmlTag === 'ol' && start !== '' && start != null ? start : null",
2439
+ '[attr.reversed]': "htmlTag === 'ol' && reversed ? '' : null",
2440
+ }, standalone: false, styles: [":host{margin:0;box-sizing:border-box}\n"] }]
2441
+ }], propDecorators: { htmlTag: [{
2442
+ type: Input
2443
+ }], start: [{
2444
+ type: Input
2445
+ }], reversed: [{
2446
+ type: Input
2447
+ }], nodeId: [{
2448
+ type: Input
2449
+ }], childrenHost: [{
2450
+ type: ViewChild,
2451
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
2452
+ }] } });
2453
+
2454
+ /**
2455
+ * Directive-host List item: the real `<li>` IS the host — no `iox-list-item` wrapper, no
2456
+ * inner/outer split. Children render into the `<li>` via `childrenHost`. Fixed tag (`<li>`),
2457
+ * so the engine creates it with `vcr.createComponent` (no `hostElement` needed).
2458
+ */
2459
+ class ClientListItemComponent {
2460
+ constructor() {
2461
+ this.nodeId = '';
2462
+ }
2463
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2464
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientListItemComponent, isStandalone: false, selector: "li[iox-list-item]", inputs: { nodeId: "nodeId" }, host: { properties: { "class": "'iox-node-' + nodeId" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{box-sizing:border-box}\n"] }); }
2197
2465
  }
2198
- const GALLERY_EFFECT_DESCRIPTORS = Object.values(GALLERY_EFFECTS).map(e => ({ id: e.id, label: e.label }));
2466
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientListItemComponent, decorators: [{
2467
+ type: Component,
2468
+ args: [{ selector: 'li[iox-list-item]', template: `<ng-container #childrenHost></ng-container>`, host: { '[class]': "'iox-node-' + nodeId" }, standalone: false, styles: [":host{box-sizing:border-box}\n"] }]
2469
+ }], propDecorators: { nodeId: [{
2470
+ type: Input
2471
+ }], childrenHost: [{
2472
+ type: ViewChild,
2473
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
2474
+ }] } });
2199
2475
 
2200
2476
  /**
2201
- * Gallery crossfade loop — the SHARED, framework-free state machine that drives which image is
2202
- * visible over time. Ported from the reference fade-gallery's `scheduleCycle` / `tryStartLoop` /
2203
- * first-cycle-activation logic, with Angular specifics removed so BOTH the builder twin and the
2204
- * engine/runtime twin (`iox-gallery`) run ONE implementation — never a forked copy that drifts.
2477
+ * Shared data-source resolving pattern — the SINGLE source of truth for how an IoxDataSource
2478
+ * becomes a backend fetch, consumed by BOTH the page builder (iox-cms-client bindings panel) and
2479
+ * the SSR client engine (iox-client-engine DataSourceResolverService). There must be no second
2480
+ * copy of this decision logic; only the transport differs between the two:
2205
2481
  *
2206
- * The loop owns only *timing + index* state and emits it through `onChange`; the component maps that
2207
- * state to CSS via the effect registry (`gallery-effects.ts`). Timers are injected so the loop is
2208
- * deterministic under fake timers in tests and platform-guardable in the engine.
2482
+ * builder → authenticated org-scoped controllers (admin session):
2483
+ * single by id → GET admin.../resources/:type/:id (ContentService.getContentById)
2484
+ * collection → GET admin.../resources/:type (ContentService.getContent)
2485
+ * engine → @Public() controllers (no auth, isPublished filter applied server-side):
2486
+ * single by id → GET api/:type/:id (UserResourcesController)
2487
+ * collection → GET api/:type?filters (UserResourcesController)
2209
2488
  *
2210
- * See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2489
+ * The DECISION (by-id vs collection, which id, which filters, how to pick the single result) is
2490
+ * identical for both — that's what this module owns. Each app maps the returned {@link DataSourcePlan}
2491
+ * onto its own transport. A single item is ALWAYS fetched by its id as a path segment — never a
2492
+ * `?_id=` query — to match the builder's getContentById.
2211
2493
  */
2212
- /** Default scheduler over the platform's `setTimeout` / `requestAnimationFrame` (with a timer fallback). */
2213
- const DEFAULT_GALLERY_SCHEDULER = {
2214
- setTimer: (fn, ms) => setTimeout(fn, ms),
2215
- clearTimer: h => clearTimeout(h),
2216
- nextFrame: fn => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(fn) : setTimeout(fn, 16),
2217
- cancelFrame: h => {
2218
- if (typeof cancelAnimationFrame === 'function')
2219
- cancelAnimationFrame(h);
2220
- else
2221
- clearTimeout(h);
2222
- },
2223
- };
2224
- class GalleryLoop {
2225
- constructor(opts) {
2226
- this.count = 0;
2227
- this.currentIndex = 0;
2228
- this.fadingOutIndex = null;
2229
- this.firstCycleActive = false;
2230
- this.cycleTimer = null;
2231
- this.fadeTimer = null;
2232
- this.frameHandle = null;
2233
- /** Bumped on every (re)start so a stale timer callback from a previous run is ignored. */
2234
- this.runToken = 0;
2235
- this.running = false;
2236
- this.scheduler = opts.scheduler ?? DEFAULT_GALLERY_SCHEDULER;
2237
- this.showMs = opts.showMs;
2238
- this.fadeMs = opts.fadeMs;
2239
- this.onChange = opts.onChange;
2240
- }
2241
- get state() {
2242
- return {
2243
- currentIndex: this.currentIndex,
2244
- fadingOutIndex: this.fadingOutIndex,
2245
- firstCycleActive: this.firstCycleActive,
2246
- };
2494
+ /**
2495
+ * Match a page route template against an actual path and extract named params.
2496
+ * matchRouteParams('/project/:projectId', 'project/68abc') → { projectId: '68abc' }
2497
+ * Both sides are normalised (leading slash stripped) and split on '/'; values are URL-decoded.
2498
+ */
2499
+ function matchRouteParams(template, actual) {
2500
+ const t = (template ?? '').replace(/^\/+/, '').split('/');
2501
+ const a = (actual ?? '').replace(/^\/+/, '').split('/');
2502
+ const params = {};
2503
+ for (let i = 0; i < t.length; i++) {
2504
+ const seg = t[i];
2505
+ if (seg && seg.startsWith(':'))
2506
+ params[seg.slice(1)] = decodeURIComponent(a[i] ?? '');
2247
2507
  }
2248
- /** Start (or restart) the loop for `count` images. A count < 2 shows the single image without cycling. */
2249
- start(count) {
2250
- this.stop();
2251
- this.count = Math.max(0, count | 0);
2252
- this.currentIndex = 0;
2253
- this.fadingOutIndex = null;
2254
- this.firstCycleActive = false;
2255
- if (this.count === 0) {
2256
- this.emit();
2257
- return;
2258
- }
2259
- this.running = true;
2260
- const token = ++this.runToken;
2261
- // Activate on the next frame so the first paint is flat (no entry-transition flash), then
2262
- // begin cycling only when there is more than one image to advance through.
2263
- this.frameHandle = this.scheduler.nextFrame(() => {
2264
- if (token !== this.runToken || !this.running)
2265
- return;
2266
- this.firstCycleActive = true;
2267
- this.emit();
2268
- if (this.count > 1)
2269
- this.scheduleCycle(token);
2270
- });
2508
+ return params;
2509
+ }
2510
+ /**
2511
+ * Build the query filter for a data-source request from its filterByRouteParam / filterByQueryParams
2512
+ * config. `field` is filtered by the route-param value (falling back to `defaultId`); query filters
2513
+ * map each `field` to a query-param value (falling back to `defaultValue`), and a filter with NO
2514
+ * `queryParam` is a constant on `defaultValue`. Empty values are omitted so the source isn't
2515
+ * accidentally filtered to nothing.
2516
+ */
2517
+ function buildDataSourceFilter(req, routeParams = {}, queryParams = {}) {
2518
+ const params = {};
2519
+ const byRoute = req?.filterByRouteParam;
2520
+ if (byRoute?.field && byRoute?.routeParam) {
2521
+ const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
2522
+ if (value !== undefined && value !== null && value !== '')
2523
+ params[byRoute.field] = value;
2271
2524
  }
2272
- /** Update timing live. Takes effect on the next scheduled cycle; does not restart the loop. */
2273
- setDurations(showMs, fadeMs) {
2274
- this.showMs = showMs;
2275
- this.fadeMs = fadeMs;
2525
+ for (const q of req?.filterByQueryParams ?? []) {
2526
+ if (!q?.field)
2527
+ continue;
2528
+ // No queryParam → a CONSTANT filter: defaultValue is the value, in the builder preview and
2529
+ // on the live site alike. With a queryParam the URL wins and defaultValue is the fallback.
2530
+ const value = q.queryParam ? (queryParams[q.queryParam] ?? q.defaultValue) : q.defaultValue;
2531
+ if (value !== undefined && value !== null && value !== '')
2532
+ params[q.field] = value;
2276
2533
  }
2277
- /** Stop and clear all timers. Idempotent. State is retained (call `start` to reset). */
2278
- stop() {
2279
- this.running = false;
2280
- this.runToken++;
2281
- if (this.cycleTimer != null) {
2282
- this.scheduler.clearTimer(this.cycleTimer);
2283
- this.cycleTimer = null;
2534
+ return params;
2535
+ }
2536
+ /**
2537
+ * Mirrors the builder's getContentById decision. When a SINGLE source is filtered by the item id
2538
+ * (`_id`) via a route param, the item is fetched by PATH (`.../:type/:id`), not a `?_id=` query.
2539
+ * Returns that id (route-param value, else `defaultId`), or `null` when the source isn't a
2540
+ * single-by-id fetch — in which case the caller uses the collection endpoint + filters. The
2541
+ * path-based form only applies to the id field; a single source filtered by any other field still
2542
+ * goes through the collection endpoint.
2543
+ */
2544
+ function resolveSingleItemId(req, routeParams = {}) {
2545
+ const byRoute = req?.filterByRouteParam;
2546
+ if (!byRoute?.routeParam)
2547
+ return null;
2548
+ if (byRoute.field && byRoute.field !== '_id')
2549
+ return null;
2550
+ const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
2551
+ return value !== undefined && value !== null && value !== '' ? String(value) : null;
2552
+ }
2553
+ /**
2554
+ * Resolve an IoxDataSource into a transport-agnostic {@link DataSourcePlan}. This is the shared
2555
+ * decision both the builder and the SSR engine run; each then executes the plan with its own
2556
+ * HTTP layer. Single sources with a route-param id resolve to a `by-id` (path) fetch — exactly like
2557
+ * the builder's getContentById.
2558
+ */
2559
+ function planDataSource(ds, routeParams = {}, queryParams = {}) {
2560
+ const mode = ds?.mode === 'single' ? 'single' : 'list';
2561
+ switch (ds?.type) {
2562
+ case 'cms':
2563
+ case 'shop': {
2564
+ const domain = ds.type;
2565
+ const req = domain === 'cms' ? ds.request?.cms : ds.request?.shop;
2566
+ const resource = req?.contentType;
2567
+ if (!resource)
2568
+ return { kind: 'none' };
2569
+ const id = mode === 'single' ? resolveSingleItemId(req, routeParams) : null;
2570
+ if (id)
2571
+ return { kind: 'by-id', domain, resource, id, mode };
2572
+ const filter = buildDataSourceFilter(req, routeParams, queryParams);
2573
+ const plan = { kind: 'collection', domain, resource, filter, mode };
2574
+ if (req?.limit)
2575
+ plan.limit = req.limit;
2576
+ return plan;
2284
2577
  }
2285
- if (this.fadeTimer != null) {
2286
- this.scheduler.clearTimer(this.fadeTimer);
2287
- this.fadeTimer = null;
2578
+ case 'external-api': {
2579
+ const endpoint = ds.request?.endpoint;
2580
+ return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
2288
2581
  }
2289
- if (this.frameHandle != null) {
2290
- this.scheduler.cancelFrame(this.frameHandle);
2291
- this.frameHandle = null;
2582
+ case 'static':
2583
+ return { kind: 'static', data: ds.request?.data ?? null, mode };
2584
+ case 'derived': {
2585
+ const d = ds.request?.derived;
2586
+ return d?.from ? { kind: 'derived', from: d.from, path: d.path ?? '', mode } : { kind: 'none' };
2292
2587
  }
2588
+ case 'variable':
2589
+ // A VARIABLE is a page-local resource whose value is WRITTEN at runtime by an interaction
2590
+ // (setContext), never fetched. It resolves to nothing here on purpose: the page renders
2591
+ // its empty state until something writes it. `request.variable.itemsFrom` only names the
2592
+ // source whose items it holds, so the bind picker can suggest fields.
2593
+ return { kind: 'none' };
2594
+ default:
2595
+ return { kind: 'none' };
2293
2596
  }
2294
- scheduleCycle(token) {
2295
- if (token !== this.runToken || !this.running || this.count < 2)
2296
- return;
2297
- this.cycleTimer = this.scheduler.setTimer(() => {
2298
- if (token !== this.runToken || !this.running)
2299
- return;
2300
- this.fadingOutIndex = this.currentIndex;
2301
- this.currentIndex = (this.currentIndex + 1) % this.count;
2302
- this.emit();
2303
- this.fadeTimer = this.scheduler.setTimer(() => {
2304
- if (token !== this.runToken || !this.running)
2305
- return;
2306
- this.fadingOutIndex = null;
2307
- this.emit();
2308
- this.scheduleCycle(token);
2309
- }, this.fadeMs);
2310
- }, this.showMs);
2597
+ }
2598
+ /**
2599
+ * The aliases on a page that are VARIABLES — runtime-written resources, not fetched data.
2600
+ *
2601
+ * Shared so the builder and the SSR engine agree on which aliases start empty and are filled by
2602
+ * interactions. See element-context-state-plan.md.
2603
+ */
2604
+ function variableAliases(dataSources) {
2605
+ const out = new Set();
2606
+ for (const ds of dataSources ?? []) {
2607
+ if (ds?.type === 'variable' && ds.alias)
2608
+ out.add(ds.alias);
2311
2609
  }
2312
- emit() {
2313
- this.onChange(this.state);
2610
+ return out;
2611
+ }
2612
+ /** The source alias whose items a variable holds, if it declares one (for field suggestions). */
2613
+ function variableItemsFrom(dataSources, alias) {
2614
+ for (const ds of dataSources ?? []) {
2615
+ if (ds?.type === 'variable' && ds.alias === alias)
2616
+ return ds.request?.variable?.itemsFrom || undefined;
2314
2617
  }
2618
+ return undefined;
2315
2619
  }
2316
-
2317
2620
  /**
2318
- * `iox-gallery` — the engine/runtime twin of the Gallery builder component (the builder twin
2319
- * `app-builder-gallery` arrives in Phase 2). It runs the SHARED crossfade loop + effect registry from
2320
- * `@vectoriox/iox-ui`, so the fade behaves identically on the canvas and the live site — no forked
2321
- * animation logic. See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2322
- *
2323
- * Population: v1 drives the loop from the `images` input (manual mode). Data-source binding
2324
- * (`source`/`sourcePath`) is Phase 3 — this component already resolves through `resolveGalleryUrls`,
2325
- * so that only adds a bound-source input, not new render logic.
2326
- *
2327
- * SSR / no-JS: on the server the loop never starts, so the **first image renders visible** as a static
2328
- * frame (SEO + no-JS baseline). In the browser `ngAfterViewInit` starts the loop, which begins on the
2329
- * same index 0 — so the static frame and the first live frame match and there is no hydration flash.
2621
+ * Normalise a collection response into the shape a source expects: unwraps `{items}`/`{data}`
2622
+ * envelopes, then returns the first item for a `single` source or the whole array for a `list`.
2623
+ * A `by-id` response is already the item, so callers pass it through unchanged.
2330
2624
  */
2331
- class ClientGalleryComponent {
2332
- /** Manual images — `GalleryImage[]` or a plain `string[]` of URLs (normalised on set). */
2333
- set images(v) {
2334
- this._images = v ?? [];
2335
- this.urls = resolveGalleryUrls(this._images, null);
2336
- // A live edit (image added/removed/reordered) while the loop runs → restart on the new set so
2337
- // the canvas reflects it and the loop cycles through the new count.
2338
- if (this.loopActive)
2339
- this.startLoop();
2340
- }
2341
- get images() { return this._images; }
2342
- set showDuration(v) { this._showDuration = v; this.loop?.setDurations(v, this._fadeDuration); }
2343
- get showDuration() { return this._showDuration; }
2344
- set fadeDuration(v) { this._fadeDuration = v; this.loop?.setDurations(this._showDuration, v); }
2345
- get fadeDuration() { return this._fadeDuration; }
2346
- constructor(platformId, cdr) {
2347
- this.cdr = cdr;
2348
- this.nodeId = '';
2349
- this.effect = 'fade';
2350
- this.autoStart = true;
2351
- /** Layout mode — only `'stack'` is rendered in v1 (`'grid'` scaffolded for later). */
2352
- this.layout = 'stack';
2353
- // ── Inputs that mutate live state use SETTERS, not ngOnChanges ──────────────────────────────
2354
- // The builder trait panel pushes edits via DIRECT property assignment (`instance.images = v`),
2355
- // which does NOT trigger ngOnChanges — so a gallery that recomputed in ngOnChanges never refreshed
2356
- // when you added an image on the canvas. A setter fires on BOTH direct assignment (builder panel)
2357
- // and setInput (SSR engine / render.directive), so it's the one path that works everywhere.
2358
- this._images = [];
2359
- this._showDuration = 4000;
2360
- this._fadeDuration = 1000;
2361
- /** The flat, display-ready URLs the loop cycles. Recomputed whenever `images` is set. */
2362
- this.urls = [];
2363
- this.currentIndex = 0;
2364
- this.fadingOutIndex = null;
2365
- this.started = false;
2366
- /** True once the loop has been started (in ngAfterViewInit) — gates the setter-driven restart. */
2367
- this.loopActive = false;
2368
- this.loop = null;
2369
- this.isBrowser = isPlatformBrowser(platformId);
2370
- }
2371
- ngAfterViewInit() {
2372
- if (this.isBrowser && this.autoStart) {
2373
- this.startLoop();
2374
- }
2375
- }
2376
- ngOnDestroy() {
2377
- this.loop?.stop();
2378
- this.loop = null;
2379
- this.loopActive = false;
2625
+ function normalizeCollectionResult(mode, data) {
2626
+ const items = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
2627
+ return mode === 'single' ? (items[0] ?? null) : items;
2628
+ }
2629
+ /** Navigate a dot/bracket path into an object, e.g. `gallery`, `details.images`, `variants[0].skus`.
2630
+ * Returns undefined if any segment is missing. Kept minimal + self-contained so it is shareable by
2631
+ * both the builder and the SSR engine (the two have divergent local `resolvePath` copies). */
2632
+ function getByPath(obj, path) {
2633
+ if (obj == null || !path)
2634
+ return obj;
2635
+ // Turn `a[0].b` into ['a','0','b'].
2636
+ const segments = path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
2637
+ let cur = obj;
2638
+ for (const seg of segments) {
2639
+ if (cur == null)
2640
+ return undefined;
2641
+ cur = cur[seg];
2380
2642
  }
2381
- /** Per-slide inline style. Before the loop activates (server + no-JS + pre-first-frame) the first
2382
- * image is the visible static frame; once running, the effect owns each slide's look. */
2383
- slideStyle(i) {
2384
- if (!this.started) {
2385
- return i === 0
2386
- ? { opacity: '1', transform: 'scale(1)', transition: 'none' }
2387
- : { opacity: '0', transform: 'scale(1)', transition: 'none' };
2643
+ return cur;
2644
+ }
2645
+ /**
2646
+ * The array a Repeater (or a repeating Slider) iterates over — the SHARED decision so the builder
2647
+ * canvas and the SSR engine agree (see data-source-resolving.md).
2648
+ * - No `sourcePath`: the resolved source IS the list. An array passes through; a lone object degrades
2649
+ * to a single-row list (back-compat with the old `fetchItems`); null/undefined → `[]`.
2650
+ * - With `sourcePath`: the source resolved to a SINGLE resource (e.g. a product); dive into the
2651
+ * nested array property (e.g. `gallery`). Non-array / missing → `[]`.
2652
+ */
2653
+ /**
2654
+ * Fill in `derived` aliases in an already-resolved map. Given the data sources and a map of resolved
2655
+ * NON-derived values (by alias), compute each `type:'derived'` alias as `getByPath(parent, path)` in
2656
+ * dependency order (chains resolved by iterating until stable). Mutates + returns the map. Used by the
2657
+ * SSR engine after it resolves the root sources; the builder resolves derived lazily per alias.
2658
+ */
2659
+ function resolveDerivedInto(dataSources, resolved) {
2660
+ const derived = (dataSources ?? []).filter(d => d?.type === 'derived' && !!d.alias);
2661
+ let changed = true, guard = 0;
2662
+ while (changed && guard++ < 25) {
2663
+ changed = false;
2664
+ for (const d of derived) {
2665
+ if (d.alias in resolved)
2666
+ continue;
2667
+ const from = d.request?.derived?.from;
2668
+ if (from && from in resolved) {
2669
+ resolved[d.alias] = getByPath(resolved[from], d.request.derived.path ?? '');
2670
+ changed = true;
2671
+ }
2388
2672
  }
2389
- return resolveGalleryEffect(this.effect).buildSlideStyle({
2390
- active: i === this.currentIndex,
2391
- fadingOut: i === this.fadingOutIndex,
2392
- firstCycleActive: true,
2393
- durations: { showMs: this.showDuration, fadeMs: this.fadeDuration },
2394
- });
2395
2673
  }
2396
- startLoop() {
2397
- if (!this.isBrowser)
2398
- return;
2399
- this.loopActive = true;
2400
- this.loop?.stop();
2401
- this.loop = new GalleryLoop({
2402
- showMs: this.showDuration,
2403
- fadeMs: this.fadeDuration,
2404
- onChange: state => {
2405
- this.currentIndex = state.currentIndex;
2406
- this.fadingOutIndex = state.fadingOutIndex;
2407
- this.started = state.firstCycleActive;
2408
- this.cdr.markForCheck();
2409
- },
2410
- });
2411
- this.loop.start(this.urls.length);
2674
+ return resolved;
2675
+ }
2676
+ function resolveRepeaterItems(resolved, sourcePath) {
2677
+ if (sourcePath) {
2678
+ const nested = getByPath(resolved, sourcePath);
2679
+ return Array.isArray(nested) ? nested : [];
2412
2680
  }
2413
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientGalleryComponent, deps: [{ token: PLATFORM_ID }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); }
2414
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientGalleryComponent, isStandalone: false, selector: "iox-gallery", inputs: { nodeId: "nodeId", effect: "effect", autoStart: "autoStart", layout: "layout", images: "images", showDuration: "showDuration", fadeDuration: "fadeDuration" }, ngImport: i0, template: `
2415
- <div class="iox-gallery" [class]="'iox-node-' + nodeId">
2416
- <div *ngFor="let url of urls; index as i"
2417
- class="iox-gallery__img"
2418
- [style.background-image]="'url(' + url + ')'"
2419
- [ngStyle]="slideStyle(i)"></div>
2420
- <div class="iox-gallery__overlay"></div>
2421
- </div>
2422
- `, isInline: true, styles: [":host{display:block}.iox-gallery{position:relative;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); }
2681
+ if (Array.isArray(resolved))
2682
+ return resolved;
2683
+ return resolved == null ? [] : [resolved];
2423
2684
  }
2424
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientGalleryComponent, decorators: [{
2425
- type: Component,
2426
- args: [{ selector: 'iox-gallery', template: `
2427
- <div class="iox-gallery" [class]="'iox-node-' + nodeId">
2428
- <div *ngFor="let url of urls; index as i"
2429
- class="iox-gallery__img"
2430
- [style.background-image]="'url(' + url + ')'"
2431
- [ngStyle]="slideStyle(i)"></div>
2432
- <div class="iox-gallery__overlay"></div>
2433
- </div>
2434
- `, standalone: false, styles: [":host{display:block}.iox-gallery{position:relative;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"] }]
2435
- }], ctorParameters: () => [{ type: undefined, decorators: [{
2436
- type: Inject,
2437
- args: [PLATFORM_ID]
2438
- }] }, { type: i0.ChangeDetectorRef }], propDecorators: { nodeId: [{
2439
- type: Input
2440
- }], effect: [{
2441
- type: Input
2442
- }], autoStart: [{
2443
- type: Input
2444
- }], layout: [{
2445
- type: Input
2446
- }], images: [{
2447
- type: Input
2448
- }], showDuration: [{
2449
- type: Input
2450
- }], fadeDuration: [{
2451
- type: Input
2452
- }] } });
2453
-
2454
- const COMPONENTS = [
2455
- BuilderImageComponent,
2456
- BuilderSpacerComponent,
2457
- CardComponent,
2458
- BuilderDividerComponent,
2459
- BuilderIconComponent,
2460
- BuilderLinkComponent,
2461
- BuilderButtonComponent,
2462
- BuilderHeadingComponent,
2463
- TextBlockComponent,
2464
- SectionComponent,
2465
- ContainerComponent,
2466
- LinkedContainerComponent,
2467
- ClientRepeaterComponent,
2468
- ButtonBlockComponent,
2469
- ClientSliderContainerComponent,
2470
- ClientSliderSlideComponent,
2471
- ClientListComponent,
2472
- ClientListItemComponent,
2473
- ClientGalleryComponent,
2474
- ];
2475
- class IoxBuilderComponentsModule {
2476
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
2477
- static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, declarations: [BuilderImageComponent,
2478
- BuilderSpacerComponent,
2479
- CardComponent,
2480
- BuilderDividerComponent,
2481
- BuilderIconComponent,
2482
- BuilderLinkComponent,
2483
- BuilderButtonComponent,
2484
- BuilderHeadingComponent,
2485
- TextBlockComponent,
2486
- SectionComponent,
2487
- ContainerComponent,
2488
- LinkedContainerComponent,
2489
- ClientRepeaterComponent,
2490
- ButtonBlockComponent,
2491
- ClientSliderContainerComponent,
2492
- ClientSliderSlideComponent,
2493
- ClientListComponent,
2494
- ClientListItemComponent,
2495
- ClientGalleryComponent], imports: [CommonModule], exports: [BuilderImageComponent,
2496
- BuilderSpacerComponent,
2497
- CardComponent,
2498
- BuilderDividerComponent,
2499
- BuilderIconComponent,
2500
- BuilderLinkComponent,
2501
- BuilderButtonComponent,
2502
- BuilderHeadingComponent,
2503
- TextBlockComponent,
2504
- SectionComponent,
2505
- ContainerComponent,
2506
- LinkedContainerComponent,
2507
- ClientRepeaterComponent,
2508
- ButtonBlockComponent,
2509
- ClientSliderContainerComponent,
2510
- ClientSliderSlideComponent,
2511
- ClientListComponent,
2512
- ClientListItemComponent,
2513
- ClientGalleryComponent] }); }
2514
- static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, imports: [CommonModule] }); }
2685
+ // ── Referenced data sources (shared: builder-save + SSR resolve) ─────────────────────────────────
2686
+ // Which data-source aliases a node tree uses, so a consumer resolves ONLY what a page/global needs
2687
+ // (not the whole org library). Used by cms-client (persist globals' sources) and the SSR engine
2688
+ // (resolve the referenced subset per page). See org-data-sources-plan.md.
2689
+ /** Aliases referenced anywhere in a node tree: a repeater/slider `source` (a `bindings` entry with
2690
+ * `prop === 'source'`, or `props.source` for the slider) plus every binding's `source`. */
2691
+ function collectReferencedAliases(nodes, acc = new Set()) {
2692
+ for (const n of nodes ?? []) {
2693
+ for (const b of n?.bindings ?? []) {
2694
+ if (b?.source)
2695
+ acc.add(b.source);
2696
+ }
2697
+ if (n?.props?.source)
2698
+ acc.add(n.props.source);
2699
+ if (n?.children?.length)
2700
+ collectReferencedAliases(n.children, acc);
2701
+ }
2702
+ return acc;
2703
+ }
2704
+ /** The subset of `all` whose `alias` is referenced by `nodes` (shallow — direct references only). */
2705
+ function referencedDataSources(nodes, all) {
2706
+ const aliases = collectReferencedAliases(nodes);
2707
+ return (all ?? []).filter(ds => aliases.has(ds?.alias));
2708
+ }
2709
+ /** Like `referencedDataSources`, but also pulls in a referenced source's DEPENDENCIES (transitively),
2710
+ * none of which the layout names directly:
2711
+ * - a `derived` alias needs its `request.derived.from` parent;
2712
+ * - a `relative` (route-context) alias needs its `request.relative.subjectFrom` (the current item)
2713
+ * and `request.relative.from` (the collection it navigates).
2714
+ * Use this when RESOLVING (the engine) so derived views and siblings/related don't resolve to nothing. */
2715
+ function referencedDataSourcesDeep(nodes, all) {
2716
+ const byAlias = new Map((all ?? []).map(d => [d?.alias, d]));
2717
+ const wanted = collectReferencedAliases(nodes);
2718
+ const queue = [...wanted];
2719
+ const enqueue = (alias) => {
2720
+ if (alias && !wanted.has(alias)) {
2721
+ wanted.add(alias);
2722
+ queue.push(alias);
2723
+ }
2724
+ };
2725
+ while (queue.length) {
2726
+ const ds = byAlias.get(queue.pop());
2727
+ enqueue(ds?.request?.derived?.from);
2728
+ enqueue(ds?.request?.relative?.subjectFrom);
2729
+ enqueue(ds?.request?.relative?.from);
2730
+ }
2731
+ return (all ?? []).filter(d => wanted.has(d?.alias));
2515
2732
  }
2516
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, decorators: [{
2517
- type: NgModule,
2518
- args: [{
2519
- declarations: COMPONENTS,
2520
- imports: [CommonModule],
2521
- exports: COMPONENTS,
2522
- }]
2523
- }] });
2524
-
2525
2733
  /**
2526
- * Single source of truth for the IOX component type → Angular class mapping.
2527
- * Used by the client engine (iox-client-engine) to resolve component classes
2528
- * from IoxLayoutNode.type strings at render time.
2529
- *
2530
- * All 13 builder components are registered here:
2531
- * - Leaf (Phase 2a): Image, Spacer, Card, Divider, Icon
2532
- * - Inline-editable (Phase 2b): Link, Button, Heading, Text
2533
- * - Layout shells (Phase 2c): Section, Container, LinkedContainer
2534
- * - Repeater (Phase 2d): ClientRepeaterComponent
2535
- *
2536
- * Section/Container/LinkedContainer/Repeater expose childrenHost: ViewContainerRef
2537
- * for ClientRenderDirective to insert child components into.
2734
+ * Turn a resolved {@link DataSourcePlan} into the concrete public HTTP request (url + params) — the
2735
+ * SINGLE place that decides the URL shape, shared by the builder binding preview and the SSR engine
2736
+ * so both hit the SAME public endpoints (no admin, no duplication). Returns `null` for `static` /
2737
+ * `none` plans (no HTTP call). A single item is a PATH segment (`/:resource/:id`), never `?_id=`.
2538
2738
  */
2539
- class IoxComponentRegistryService {
2540
- constructor() {
2541
- this.map = new Map([
2542
- ['Image', BuilderImageComponent],
2543
- ['Spacer', BuilderSpacerComponent],
2544
- ['Card', CardComponent],
2545
- ['Divider', BuilderDividerComponent],
2546
- ['Icon', BuilderIconComponent],
2547
- ['Link', BuilderLinkComponent],
2548
- ['Button', BuilderButtonComponent],
2549
- ['Heading', BuilderHeadingComponent],
2550
- ['Text', TextBlockComponent],
2551
- ['Section', SectionComponent],
2552
- ['Container', ContainerComponent],
2553
- ['LinkedContainer', LinkedContainerComponent],
2554
- ['Repeater', ClientRepeaterComponent],
2555
- ['ButtonBlock', ButtonBlockComponent],
2556
- ['SliderContainer', ClientSliderContainerComponent],
2557
- ['SliderSlide', ClientSliderSlideComponent],
2558
- ['List', ClientListComponent],
2559
- ['ListItem', ClientListItemComponent],
2560
- ['Gallery', ClientGalleryComponent],
2561
- ]);
2562
- }
2563
- getComponent(type) {
2564
- return this.map.get(type);
2565
- }
2566
- hasComponent(type) {
2567
- return this.map.has(type);
2739
+ function dataSourcePlanToRequest(plan, endpoints) {
2740
+ const baseFor = (domain) => (domain === 'shop' ? endpoints.shopBase : endpoints.cmsBase);
2741
+ switch (plan.kind) {
2742
+ case 'by-id':
2743
+ return { url: `${baseFor(plan.domain)}${plan.resource}/${encodeURIComponent(plan.id)}`, params: {} };
2744
+ case 'collection': {
2745
+ const params = { ...plan.filter };
2746
+ if (plan.limit)
2747
+ params['limit'] = plan.limit;
2748
+ return { url: `${baseFor(plan.domain)}${plan.resource}`, params };
2749
+ }
2750
+ case 'external':
2751
+ return { url: plan.endpoint, params: {} };
2752
+ default:
2753
+ return null;
2568
2754
  }
2569
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2570
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, providedIn: 'root' }); }
2571
2755
  }
2572
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, decorators: [{
2573
- type: Injectable,
2574
- args: [{ providedIn: 'root' }]
2575
- }] });
2576
2756
 
2577
2757
  /**
2578
- * style-compiler — the SINGLE source of truth for turning a node's style map into
2579
- * CSS declarations. Framework-agnostic (no Angular): consumed by both renderers.
2758
+ * Gallery — data model + the SHARED "what are the image URLs" resolver.
2580
2759
  *
2581
- * - Builder canvas: `StyleRegistryService` (@vectoriox/iox-builder) → live `<style>` tag.
2582
- * - SSR site: `LayoutRendererService` (iox-client-engine) → HTML string CSS.
2760
+ * A Gallery is populated one of two ways (see gallery-component-plan.md):
2761
+ * - **Manual**: an ordered `GalleryImage[]` trait. Each item is a raw/filebox URL OR a per-item
2762
+ * binding path resolved against the item/route context.
2763
+ * - **Bound**: the whole gallery repeats over a data source (a collection, or an array property
2764
+ * inside one resource via `sourcePath`) — the items come from the shared `resolveRepeaterItems`,
2765
+ * and one field of each record (`imageField`) is the URL.
2583
2766
  *
2584
- * Lives in @vectoriox/iox-ui because BOTH consumers depend on iox-ui (the engine does
2585
- * NOT depend on iox-builder). Keeping it here — not duplicated per renderer — is what
2586
- * prevents the builder and the live site from drifting.
2587
- * See iox-ai-guidance `architecture/builder/render-parity.md`.
2767
+ * `resolveGalleryUrls` is the SINGLE place both the builder canvas and the SSR/runtime engine turn a
2768
+ * node's config into the final `string[]` the crossfade loop consumes — so both sides show the same
2769
+ * images. It is pure and transport-agnostic; never fork it. It reuses `getByPath` (the same path
2770
+ * reader used by data-source resolving) so binding paths behave identically everywhere.
2588
2771
  */
2589
2772
  /**
2590
- * Rewrite viewport-relative units so they resolve to the simulated canvas dimensions
2591
- * in the builder, and to the real viewport in production.
2773
+ * Resolve a gallery's configured images to the flat, display-ready `string[]` the loop renders.
2774
+ * Pure. Empty/whitespace URLs are dropped so a half-filled manual list never yields blank frames.
2592
2775
  *
2593
- * `--preview-vh` / `--preview-vw` are set by BuilderComponent (ResizeObserver +
2594
- * viewport-state subscription). In production those variables are undefined, so the
2595
- * `1vh` / `1vw` fallbacks restore native browser behaviour.
2776
+ * @param images manual `GalleryImage[]` (manual mode). Ignored when `bound` is provided.
2777
+ * @param bound bound-mode records + image field. When present, takes precedence over `images`.
2778
+ * @param ctx render context for per-item bindings (item/route data). Defaults to `{}`.
2596
2779
  */
2597
- function rewriteViewportUnits(value) {
2598
- return value
2599
- .replace(/(-?\d*\.?\d+)vh/g, 'calc($1 * var(--preview-vh, 1vh))')
2600
- .replace(/(-?\d*\.?\d+)vw/g, 'calc($1 * var(--preview-vw, 1vw))');
2601
- }
2602
- /** camelCase style key → kebab-case CSS property. */
2603
- function styleKeyToKebab(key) {
2604
- return key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
2780
+ function resolveGalleryUrls(images, bound, ctx = {}) {
2781
+ if (bound) {
2782
+ const field = bound.imageField;
2783
+ return (bound.items ?? [])
2784
+ .map(item => (field ? getByPath(item, field) : item))
2785
+ .map(coerceUrl)
2786
+ .filter((u) => !!u);
2787
+ }
2788
+ return (images ?? [])
2789
+ .map(normalizeGalleryImage)
2790
+ .map(img => (img?.binding ? getByPath(ctx, img.binding) : img?.url))
2791
+ .map(coerceUrl)
2792
+ .filter((u) => !!u);
2605
2793
  }
2606
2794
  /**
2607
- * kebab CSS property → the value that equals its CSS initial AND has no UA-stylesheet override for
2608
- * any element the builder renders (div/section/span/i/a/img/h1–h6/p/ul). Emitting these is a pure
2609
- * no-op, so they're dropped to keep the compiled CSS lean (self-describing nodes serialise every
2610
- * trait, many at their default).
2611
- *
2612
- * Deliberately EXCLUDED, because a value equal to the initial can still be meaningful there:
2613
- * - inherited properties (line-height, color, font-family/size/weight, text-align, letter-spacing…)
2614
- * — the value may override an inherited ancestor value.
2615
- * - margin / padding — UA stylesheets set them on h1–h6, p, ul, so `0` depends on a reset.
2616
- * - border-* — the initial `medium` width + interaction with border-style makes it non-trivial.
2617
- */
2618
- const NOOP_INITIAL_VALUES = {
2619
- 'width': ['auto'], 'height': ['auto'],
2620
- 'min-width': ['auto'], 'min-height': ['auto'],
2621
- 'max-width': ['none', 'auto'], 'max-height': ['none', 'auto'], // `auto` is invalid for max-* → inert
2622
- 'position': ['static'],
2623
- 'top': ['auto'], 'right': ['auto'], 'bottom': ['auto'], 'left': ['auto'],
2624
- 'opacity': ['1'], 'z-index': ['auto'],
2625
- 'flex-grow': ['0'], 'flex-shrink': ['1'], 'flex-basis': ['auto'],
2626
- 'flex-direction': ['row'], 'flex-wrap': ['nowrap'],
2627
- 'gap': ['0', '0px', 'normal'],
2628
- 'border-radius': ['0', '0px'],
2629
- 'aspect-ratio': ['auto'],
2630
- 'mix-blend-mode': ['normal'],
2631
- 'transform': ['none'], 'box-shadow': ['none'],
2632
- // overflow initial is `visible`. A self-describing node serialises the Overflow trait's default,
2633
- // so WITHOUT this the emitted `overflow: visible` would override component baselines that clip
2634
- // (e.g. `.iox-card__img-wrap`, `.iox-slider`, `.iox-gallery` all set `overflow: hidden`), breaking
2635
- // object-fit/aspect-ratio image clipping everywhere. A user-set hidden/scroll/auto/clip is not the
2636
- // initial, so it still emits and wins.
2637
- 'overflow': ['visible'], 'overflow-x': ['visible'], 'overflow-y': ['visible'],
2638
- 'clip-path': ['none'],
2639
- };
2640
- /**
2641
- * Style props a type's render-default fully REPLACES. Emitting them would produce a dead
2642
- * declaration that is immediately overridden in the same rule (e.g. an Icon's serialised
2643
- * `lineHeight: 1.5` followed by the forced `line-height: 1`), so callers strip them first via
2644
- * {@link stripSuppressedProps} and let renderDefaultDeclarations supply the real value.
2795
+ * A plain string is a valid entry — a pasted URL list, an older saved node, or a bound field that
2796
+ * yielded a URL directly. Accepting it HERE (not in one consumer) is what keeps the two render paths
2797
+ * honest: `ClientGalleryComponent` used to normalise privately, so the engine's string-renderer
2798
+ * fallback — which calls this function directly — silently dropped every string-shaped image and
2799
+ * rendered an empty gallery. One resolver, one answer. See gallery-component-plan.md.
2645
2800
  */
2646
- const SUPPRESSED_STYLE_PROPS_BY_TYPE = {
2647
- // An icon's line box is structurally forced to 1 — see the Icon branch of renderDefaultDeclarations.
2648
- Icon: ['lineHeight'],
2649
- };
2650
- /** Remove the style props whose value a type's render-default always overrides. Returns the map
2651
- * unchanged (same reference) when the type suppresses nothing. */
2652
- function stripSuppressedProps(type, styleProps) {
2653
- const suppressed = SUPPRESSED_STYLE_PROPS_BY_TYPE[type];
2654
- if (!suppressed?.length)
2655
- return styleProps;
2656
- const out = { ...styleProps };
2657
- for (const key of suppressed)
2658
- delete out[key];
2659
- return out;
2801
+ function normalizeGalleryImage(image) {
2802
+ if (typeof image === 'string')
2803
+ return { url: image };
2804
+ return image ?? undefined;
2660
2805
  }
2661
- /**
2662
- * What a compiled rule is FOR — decides whether an initial value is a no-op.
2663
- *
2664
- * In a `Base` rule nothing is being overridden, so `width: auto` or `transform: none` changes
2665
- * nothing and is dropped (see NOOP_INITIAL_VALUES). In an `Override` rule — a breakpoint layer —
2666
- * the initial value is exactly how you UNDO a wider rule (`width: 520px` → `auto` on mobile), so
2667
- * dropping it would silently keep the desktop value.
2668
- */
2669
- var DeclarationContext;
2670
- (function (DeclarationContext) {
2671
- DeclarationContext["Base"] = "base";
2672
- DeclarationContext["Override"] = "override";
2673
- })(DeclarationContext || (DeclarationContext = {}));
2674
- /**
2675
- * Compile a flat, camelCase style map into an array of `"prop: value"` CSS
2676
- * declarations (kebab-cased, viewport-units rewritten, empties dropped, no-op initials dropped
2677
- * in a `Base` context only, optional `!important`). Callers wrap these in whatever selector
2678
- * they emit.
2679
- *
2680
- * @param props Flat camelCase style map.
2681
- * @param importantProps Keys that must carry `!important` (e.g. `position`, to beat
2682
- * Angular's scoped-CSS specificity boost in the builder). A prop forced
2683
- * `!important` is never dropped as a no-op — the `!important` exists to win
2684
- * the cascade, so its value must always be emitted.
2685
- * @param context `Override` for a rule that overrides a wider one (a breakpoint layer):
2686
- * initial values are kept, because there they reset rather than no-op.
2806
+ /** A resolved value becomes a URL only if it's a non-empty string; everything else drops out. */
2807
+ function coerceUrl(value) {
2808
+ if (typeof value !== 'string')
2809
+ return null;
2810
+ const trimmed = value.trim();
2811
+ return trimmed ? trimmed : null;
2812
+ }
2813
+ /**
2814
+ * Parse a pasted block of URLs (newline- and/or comma-separated) into `GalleryImage[]`. Used by the
2815
+ * MediaList trait editor's "paste URL list" affordance. Pure so it can be unit-tested + reused.
2687
2816
  */
2688
- function compileDeclarations(props, importantProps, context = DeclarationContext.Base) {
2689
- const out = [];
2690
- for (const [k, v] of Object.entries(props)) {
2691
- if (v === undefined || v === null || v === '')
2692
- continue;
2693
- const prop = styleKeyToKebab(k);
2694
- const val = rewriteViewportUnits(String(v));
2695
- const isImportant = importantProps?.has(k) ?? false;
2696
- const dropsNoOps = context === DeclarationContext.Base && !isImportant;
2697
- if (dropsNoOps && NOOP_INITIAL_VALUES[prop]?.includes(val.trim().toLowerCase()))
2698
- continue;
2699
- out.push(`${prop}: ${val}${isImportant ? ' !important' : ''}`);
2700
- }
2701
- return out;
2817
+ function parseUrlList(text) {
2818
+ if (!text)
2819
+ return [];
2820
+ return text
2821
+ .split(/[\n,]+/)
2822
+ .map(s => s.trim())
2823
+ .filter(Boolean)
2824
+ .map(url => ({ url }));
2702
2825
  }
2826
+
2703
2827
  /**
2704
- * Per-type default `width` that the builder applies from each component's config
2705
- * (NOT persisted in styleProps — see render-parity.md "saved node is not
2706
- * self-describing"). The engine has no config classes, so it resolves the same
2707
- * default from THIS map at render time. Keeping the map here (not re-typed in the
2708
- * engine) is the "shared default map" decision.
2828
+ * Gallery effect registry — the SHARED, effect-agnostic description of how each gallery slide looks
2829
+ * and animates in a given crossfade state. Modeled on `binding-pipes.ts` / `PAGE_TRANSITION_PRESETS`:
2830
+ * a descriptor map + a resolve fn, all pure, consumed by BOTH the builder twin and the engine twin so
2831
+ * the fade looks identical on the canvas and the live site.
2709
2832
  *
2710
- * ONLY the `100%` container-type defaults live here. Fixed-size config defaults
2711
- * (Card 200px, Image 300px, SliderSlide 33%) are starter placeholders real content
2712
- * almost always overrides — forcing them onto width-less nodes breaks layout.
2833
+ * v1 ships only `fade` (the look ported from `sites/interior-design` fade-gallery). Adding `slide`,
2834
+ * `zoom`, `kenburns`, … later is a new entry here + its CSS — NO component refactor, because the
2835
+ * component only ever asks `resolveGalleryEffect(id).buildSlideStyle(state)` for per-slide inline CSS.
2836
+ *
2837
+ * See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2713
2838
  */
2714
- const DEFAULT_WIDTH_BY_TYPE = {
2715
- Container: '100%', Repeater: '100%', SliderContainer: '100%',
2716
- List: '100%', ListItem: '100%', Text: '100%', Divider: '100%', Spacer: '100%',
2717
- // Section, Heading, LinkedContainer, Card, Image, SliderSlide, Button, ButtonBlock,
2718
- // Icon → no forced default (their config defaults are content sizes set explicitly).
2839
+ /** The ken-burns zoom baked into the reference fade look (scale 1 → 1.05 over show+fade). */
2840
+ const FADE_ZOOM_EXTRA_MS = 1000;
2841
+ const fadeEffect = {
2842
+ id: 'fade',
2843
+ label: 'Fade',
2844
+ defaults: { showMs: 4000, fadeMs: 1000 },
2845
+ buildSlideStyle(state) {
2846
+ const { active, fadingOut, firstCycleActive, durations } = state;
2847
+ const zoomMs = durations.showMs + durations.fadeMs + FADE_ZOOM_EXTRA_MS;
2848
+ // Before the first cycle activates, everything is flat + instant so there is no entry flash.
2849
+ if (!firstCycleActive) {
2850
+ return { opacity: '0', transform: 'scale(1)', transition: 'opacity 0ms, transform 0ms' };
2851
+ }
2852
+ const visible = active && !fadingOut;
2853
+ const engaged = active || fadingOut; // current or outgoing slide is zoomed-in
2854
+ return {
2855
+ opacity: visible ? '1' : '0',
2856
+ transform: engaged ? 'scale(1.05)' : 'scale(1)',
2857
+ transition: `opacity ${durations.fadeMs}ms cubic-bezier(0.77, 0, 0.175, 1), ` +
2858
+ `transform ${zoomMs}ms linear`,
2859
+ };
2860
+ },
2861
+ };
2862
+ /** The registry — one entry per effect. Keyed by id; unknown ids fall back to `fade`. */
2863
+ const GALLERY_EFFECTS = {
2864
+ fade: fadeEffect,
2719
2865
  };
2866
+ /** Resolve an effect id to its descriptor, defaulting to `fade` (forward-compatible with new ids). */
2867
+ function resolveGalleryEffect(id) {
2868
+ return (id && GALLERY_EFFECTS[id]) || GALLERY_EFFECTS['fade'];
2869
+ }
2870
+ const GALLERY_EFFECT_DESCRIPTORS = Object.values(GALLERY_EFFECTS).map(e => ({ id: e.id, label: e.label }));
2871
+
2720
2872
  /**
2721
- * Extra "render default" declarations the builder gets implicitly from live component
2722
- * behaviour, which the engine must re-apply explicitly for parity. Returns `"prop: value"`
2723
- * declarations to append AFTER the node's own compiled declarations.
2873
+ * Gallery crossfade loop — the SHARED, framework-free state machine that drives which image is
2874
+ * visible over time. Ported from the reference fade-gallery's `scheduleCycle` / `tryStartLoop` /
2875
+ * first-cycle-activation logic, with Angular specifics removed so BOTH the builder twin and the
2876
+ * engine/runtime twin (`iox-gallery`) run ONE implementation — never a forked copy that drifts.
2724
2877
  *
2725
- * @param type Node type (registry key).
2726
- * @param styleProps The node's persisted styleProps.
2727
- * @param hasHoverState Whether the node has a non-empty hover / parent-hover state.
2878
+ * The loop owns only *timing + index* state and emits it through `onChange`; the component maps that
2879
+ * state to CSS via the effect registry (`gallery-effects.ts`). Timers are injected so the loop is
2880
+ * deterministic under fake timers in tests and platform-guardable in the engine.
2881
+ *
2882
+ * See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2728
2883
  */
2729
- function renderDefaultDeclarations(type, styleProps, hasHoverState) {
2730
- const sp = styleProps ?? {};
2731
- const out = [];
2732
- const isOutOfFlow = sp['position'] === 'fixed' || sp['position'] === 'absolute';
2733
- // Container-type default width so it fills its track. Only when width is unset — once a page
2734
- // is saved with fully-resolved styleProps (self-describing nodes), width comes through
2735
- // compileDeclarations and this is a no-op.
2736
- if (sp['width'] === undefined) {
2737
- if (isOutOfFlow) {
2738
- out.push('width: 100%');
2884
+ /** Default scheduler over the platform's `setTimeout` / `requestAnimationFrame` (with a timer fallback). */
2885
+ const DEFAULT_GALLERY_SCHEDULER = {
2886
+ setTimer: (fn, ms) => setTimeout(fn, ms),
2887
+ clearTimer: h => clearTimeout(h),
2888
+ nextFrame: fn => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(fn) : setTimeout(fn, 16),
2889
+ cancelFrame: h => {
2890
+ if (typeof cancelAnimationFrame === 'function')
2891
+ cancelAnimationFrame(h);
2892
+ else
2893
+ clearTimeout(h);
2894
+ },
2895
+ };
2896
+ class GalleryLoop {
2897
+ constructor(opts) {
2898
+ this.count = 0;
2899
+ this.currentIndex = 0;
2900
+ this.fadingOutIndex = null;
2901
+ this.firstCycleActive = false;
2902
+ this.cycleTimer = null;
2903
+ this.fadeTimer = null;
2904
+ this.frameHandle = null;
2905
+ /** Bumped on every (re)start so a stale timer callback from a previous run is ignored. */
2906
+ this.runToken = 0;
2907
+ this.running = false;
2908
+ this.scheduler = opts.scheduler ?? DEFAULT_GALLERY_SCHEDULER;
2909
+ this.showMs = opts.showMs;
2910
+ this.fadeMs = opts.fadeMs;
2911
+ this.onChange = opts.onChange;
2912
+ }
2913
+ get state() {
2914
+ return {
2915
+ currentIndex: this.currentIndex,
2916
+ fadingOutIndex: this.fadingOutIndex,
2917
+ firstCycleActive: this.firstCycleActive,
2918
+ };
2919
+ }
2920
+ /** Start (or restart) the loop for `count` images. A count < 2 shows the single image without cycling. */
2921
+ start(count) {
2922
+ this.stop();
2923
+ this.count = Math.max(0, count | 0);
2924
+ this.currentIndex = 0;
2925
+ this.fadingOutIndex = null;
2926
+ this.firstCycleActive = false;
2927
+ if (this.count === 0) {
2928
+ this.emit();
2929
+ return;
2739
2930
  }
2740
- else if (DEFAULT_WIDTH_BY_TYPE[type]) {
2741
- out.push(`width: ${DEFAULT_WIDTH_BY_TYPE[type]}`);
2931
+ this.running = true;
2932
+ const token = ++this.runToken;
2933
+ // Activate on the next frame so the first paint is flat (no entry-transition flash), then
2934
+ // begin cycling only when there is more than one image to advance through.
2935
+ this.frameHandle = this.scheduler.nextFrame(() => {
2936
+ if (token !== this.runToken || !this.running)
2937
+ return;
2938
+ this.firstCycleActive = true;
2939
+ this.emit();
2940
+ if (this.count > 1)
2941
+ this.scheduleCycle(token);
2942
+ });
2943
+ }
2944
+ /** Update timing live. Takes effect on the next scheduled cycle; does not restart the loop. */
2945
+ setDurations(showMs, fadeMs) {
2946
+ this.showMs = showMs;
2947
+ this.fadeMs = fadeMs;
2948
+ }
2949
+ /** Stop and clear all timers. Idempotent. State is retained (call `start` to reset). */
2950
+ stop() {
2951
+ this.running = false;
2952
+ this.runToken++;
2953
+ if (this.cycleTimer != null) {
2954
+ this.scheduler.clearTimer(this.cycleTimer);
2955
+ this.cycleTimer = null;
2956
+ }
2957
+ if (this.fadeTimer != null) {
2958
+ this.scheduler.clearTimer(this.fadeTimer);
2959
+ this.fadeTimer = null;
2960
+ }
2961
+ if (this.frameHandle != null) {
2962
+ this.scheduler.cancelFrame(this.frameHandle);
2963
+ this.frameHandle = null;
2742
2964
  }
2743
2965
  }
2744
- // min-width:0 lets container flex children shrink below content and equal-split. Emit it for
2745
- // container types INDEPENDENT of where width came from (styleProps vs the default above) —
2746
- // otherwise a saved page whose width is persisted would lose the equal-split. In the builder the
2747
- // same rule comes from the `.container-child` wrapper; here it lands on `.iox-node` directly.
2748
- if (DEFAULT_WIDTH_BY_TYPE[type] && sp['minWidth'] === undefined && !isOutOfFlow) {
2749
- out.push('min-width: 0');
2966
+ scheduleCycle(token) {
2967
+ if (token !== this.runToken || !this.running || this.count < 2)
2968
+ return;
2969
+ this.cycleTimer = this.scheduler.setTimer(() => {
2970
+ if (token !== this.runToken || !this.running)
2971
+ return;
2972
+ this.fadingOutIndex = this.currentIndex;
2973
+ this.currentIndex = (this.currentIndex + 1) % this.count;
2974
+ this.emit();
2975
+ this.fadeTimer = this.scheduler.setTimer(() => {
2976
+ if (token !== this.runToken || !this.running)
2977
+ return;
2978
+ this.fadingOutIndex = null;
2979
+ this.emit();
2980
+ this.scheduleCycle(token);
2981
+ }, this.fadeMs);
2982
+ }, this.showMs);
2750
2983
  }
2751
- // A border only renders when there is a WIDTH. Gate border-style on borderWidth — NOT on
2752
- // borderColor: a node with only a border colour and no width would otherwise get the
2753
- // browser's default `medium` width and show a phantom border. Absent borderWidth = no border.
2754
- if (sp['borderWidth'] && sp['borderStyle'] === undefined) {
2755
- out.push('border-style: solid');
2984
+ emit() {
2985
+ this.onChange(this.state);
2756
2986
  }
2757
- // Default transition so own/parent hover animates smoothly (parity with the builder).
2758
- if (hasHoverState && sp['transition'] === undefined) {
2759
- out.push('transition: all 0.2s ease');
2987
+ }
2988
+
2989
+ /**
2990
+ * `iox-gallery` — the engine/runtime twin of the Gallery builder component (the builder twin
2991
+ * `app-builder-gallery` arrives in Phase 2). It runs the SHARED crossfade loop + effect registry from
2992
+ * `@vectoriox/iox-ui`, so the fade behaves identically on the canvas and the live site — no forked
2993
+ * animation logic. See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2994
+ *
2995
+ * Population: v1 drives the loop from the `images` input (manual mode). Data-source binding
2996
+ * (`source`/`sourcePath`) is Phase 3 — this component already resolves through `resolveGalleryUrls`,
2997
+ * so that only adds a bound-source input, not new render logic.
2998
+ *
2999
+ * SSR / no-JS: on the server the loop never starts, so the **first image renders visible** as a static
3000
+ * frame (SEO + no-JS baseline). In the browser `ngAfterViewInit` starts the loop, which begins on the
3001
+ * same index 0 — so the static frame and the first live frame match and there is no hydration flash.
3002
+ */
3003
+ class ClientGalleryComponent {
3004
+ /** Manual images — `GalleryImage[]` or a plain `string[]` of URLs (normalised on set). */
3005
+ set images(v) {
3006
+ this._images = v ?? [];
3007
+ this.urls = resolveGalleryUrls(this._images, null);
3008
+ // A live edit (image added/removed/reordered) while the loop runs → restart on the new set so
3009
+ // the canvas reflects it and the loop cycles through the new count.
3010
+ if (this.loopActive)
3011
+ this.startLoop();
3012
+ }
3013
+ get images() { return this._images; }
3014
+ set showDuration(v) { this._showDuration = v; this.loop?.setDurations(v, this._fadeDuration); }
3015
+ get showDuration() { return this._showDuration; }
3016
+ set fadeDuration(v) { this._fadeDuration = v; this.loop?.setDurations(this._showDuration, v); }
3017
+ get fadeDuration() { return this._fadeDuration; }
3018
+ constructor(platformId, cdr) {
3019
+ this.cdr = cdr;
3020
+ this.nodeId = '';
3021
+ this.effect = 'fade';
3022
+ this.autoStart = true;
3023
+ /** Layout mode — only `'stack'` is rendered in v1 (`'grid'` scaffolded for later). */
3024
+ this.layout = 'stack';
3025
+ // ── Inputs that mutate live state use SETTERS, not ngOnChanges ──────────────────────────────
3026
+ // The builder trait panel pushes edits via DIRECT property assignment (`instance.images = v`),
3027
+ // which does NOT trigger ngOnChanges — so a gallery that recomputed in ngOnChanges never refreshed
3028
+ // when you added an image on the canvas. A setter fires on BOTH direct assignment (builder panel)
3029
+ // and setInput (SSR engine / render.directive), so it's the one path that works everywhere.
3030
+ this._images = [];
3031
+ this._showDuration = 4000;
3032
+ this._fadeDuration = 1000;
3033
+ /** The flat, display-ready URLs the loop cycles. Recomputed whenever `images` is set. */
3034
+ this.urls = [];
3035
+ this.currentIndex = 0;
3036
+ this.fadingOutIndex = null;
3037
+ this.started = false;
3038
+ /** True once the loop has been started (in ngAfterViewInit) — gates the setter-driven restart. */
3039
+ this.loopActive = false;
3040
+ this.loop = null;
3041
+ this.isBrowser = isPlatformBrowser(platformId);
2760
3042
  }
2761
- // Image defaults reapplied when unset (the config defaults aren't always persisted). height:auto
2762
- // is only a DEFAULT — when the user sets an explicit height (e.g. 100%) it must WIN, so guard it
2763
- // on `sp['height'] === undefined` (matching the builder, which respects a configured height). An
2764
- // unset height + aspect-ratio lets the ratio drive the box.
2765
- if (type === 'Image') {
2766
- if (sp['aspectRatio'] === undefined)
2767
- out.push('aspect-ratio: 1/1');
2768
- // object-fit is a Size style trait with config default 'cover'; that default isn't persisted,
2769
- // so reapply it here (matching the builder) when the node has none.
2770
- if (sp['objectFit'] === undefined)
2771
- out.push('object-fit: cover');
2772
- // NOTE: no `height: auto` — when height is unset, `auto` is already the CSS initial value, so
2773
- // emitting it is a pure no-op. A configured height comes through compileDeclarations and wins.
3043
+ ngAfterViewInit() {
3044
+ if (this.isBrowser && this.autoStart) {
3045
+ this.startLoop();
3046
+ }
2774
3047
  }
2775
- // Containers default to a positioning context (for absolutely-positioned children). These live
2776
- // in `.iox-node` (not the component `:host`) so an explicit styleProps value always wins.
2777
- if ((type === 'Container' || type === 'Section' || type === 'LinkedContainer') && sp['position'] === undefined) {
2778
- out.push('position: relative');
3048
+ ngOnDestroy() {
3049
+ this.loop?.stop();
3050
+ this.loop = null;
3051
+ this.loopActive = false;
2779
3052
  }
2780
- // LinkedContainer is an <a> (inline by default) → default it to block so children lay out.
2781
- if (type === 'LinkedContainer' && sp['display'] === undefined) {
2782
- out.push('display: block');
3053
+ /** Per-slide inline style. Before the loop activates (server + no-JS + pre-first-frame) the first
3054
+ * image is the visible static frame; once running, the effect owns each slide's look. */
3055
+ slideStyle(i) {
3056
+ if (!this.started) {
3057
+ return i === 0
3058
+ ? { opacity: '1', transform: 'scale(1)', transition: 'none' }
3059
+ : { opacity: '0', transform: 'scale(1)', transition: 'none' };
3060
+ }
3061
+ return resolveGalleryEffect(this.effect).buildSlideStyle({
3062
+ active: i === this.currentIndex,
3063
+ fadingOut: i === this.fadingOutIndex,
3064
+ firstCycleActive: true,
3065
+ durations: { showMs: this.showDuration, fadeMs: this.fadeDuration },
3066
+ });
2783
3067
  }
2784
- // An Icon is a single FONT GLYPH, not a paragraph: it must contribute no line-box height of its
2785
- // own (spacing belongs to padding/margin). But every node serialises the Typography group's
2786
- // `lineHeight` default of 1.5, so icons carry `line-height: 1.5` — a 24px glyph then gets a 36px
2787
- // line box and pushes the surrounding layout around.
2788
- //
2789
- // This is emitted UNCONDITIONALLY (not guarded on `undefined`) and callers append render-defaults
2790
- // AFTER the node's own compiled declarations in the SAME rule, so it overrides a stale serialised
2791
- // value for EXISTING icons too — no re-save needed. line-height is inherited, so simply omitting
2792
- // it is not enough: the icon would inherit 1.5 from its container instead.
2793
- if (type === 'Icon')
2794
- out.push('line-height: 0');
2795
- // An icon's `<i>` must shrink-wrap its glyph so it grows with font-size and doesn't overflow its
2796
- // parent. Phosphor forces `display:inline-flex` on its own class; generated (fantasticon) fonts
2797
- // don't, so the engine's plain `<i class="bi-x iox-node-…">` would fall back to inline and the
2798
- // glyph would overflow. Emit it here (guarded so a user-set display still wins) — mirrors the
2799
- // builder Icon component's `:host{display:inline-flex}`.
2800
- if (type === 'Icon' && sp['display'] === undefined) {
2801
- out.push('display: inline-flex', 'align-items: center', 'justify-content: center');
3068
+ startLoop() {
3069
+ if (!this.isBrowser)
3070
+ return;
3071
+ this.loopActive = true;
3072
+ this.loop?.stop();
3073
+ this.loop = new GalleryLoop({
3074
+ showMs: this.showDuration,
3075
+ fadeMs: this.fadeDuration,
3076
+ onChange: state => {
3077
+ this.currentIndex = state.currentIndex;
3078
+ this.fadingOutIndex = state.fadingOutIndex;
3079
+ this.started = state.firstCycleActive;
3080
+ this.cdr.markForCheck();
3081
+ },
3082
+ });
3083
+ this.loop.start(this.urls.length);
2802
3084
  }
2803
- return out;
3085
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientGalleryComponent, deps: [{ token: PLATFORM_ID }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); }
3086
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: ClientGalleryComponent, isStandalone: false, selector: "iox-gallery", inputs: { nodeId: "nodeId", effect: "effect", autoStart: "autoStart", layout: "layout", images: "images", showDuration: "showDuration", fadeDuration: "fadeDuration" }, ngImport: i0, template: `
3087
+ <div class="iox-gallery" [class]="'iox-node-' + nodeId">
3088
+ <div *ngFor="let url of urls; index as i"
3089
+ class="iox-gallery__img"
3090
+ [style.background-image]="'url(' + url + ')'"
3091
+ [ngStyle]="slideStyle(i)"></div>
3092
+ <div class="iox-gallery__overlay"></div>
3093
+ </div>
3094
+ `, isInline: true, styles: [":host{display:block}.iox-gallery{position:relative;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); }
2804
3095
  }
3096
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientGalleryComponent, decorators: [{
3097
+ type: Component,
3098
+ args: [{ selector: 'iox-gallery', template: `
3099
+ <div class="iox-gallery" [class]="'iox-node-' + nodeId">
3100
+ <div *ngFor="let url of urls; index as i"
3101
+ class="iox-gallery__img"
3102
+ [style.background-image]="'url(' + url + ')'"
3103
+ [ngStyle]="slideStyle(i)"></div>
3104
+ <div class="iox-gallery__overlay"></div>
3105
+ </div>
3106
+ `, standalone: false, styles: [":host{display:block}.iox-gallery{position:relative;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"] }]
3107
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
3108
+ type: Inject,
3109
+ args: [PLATFORM_ID]
3110
+ }] }, { type: i0.ChangeDetectorRef }], propDecorators: { nodeId: [{
3111
+ type: Input
3112
+ }], effect: [{
3113
+ type: Input
3114
+ }], autoStart: [{
3115
+ type: Input
3116
+ }], layout: [{
3117
+ type: Input
3118
+ }], images: [{
3119
+ type: Input
3120
+ }], showDuration: [{
3121
+ type: Input
3122
+ }], fadeDuration: [{
3123
+ type: Input
3124
+ }] } });
2805
3125
 
2806
- // Virtual trait → composed CSS property helpers.
2807
- //
2808
- // Several style traits are "virtual" — they don't map 1:1 to a CSS property but
2809
- // are components of a multi-function CSS value. composeVirtualTraits() strips
2810
- // these virtual keys from the style map and replaces them with the correctly
2811
- // composed CSS properties (filter, backdrop-filter, transform, transition).
2812
- //
2813
- // SHARED (iox-ui): consumed by BOTH the builder (render.directive + style panel)
2814
- // and the SSR engine (LayoutRendererService). It MUST live here, not in iox-builder,
2815
- // because the engine does not depend on iox-builder — without it the engine emitted
2816
- // the raw virtual keys (`translate-x`, `filter-blur`, `scale-x`…) as invalid CSS.
2817
- // Run this BEFORE compileDeclarations so the CSS output is identical on both sides.
2818
- // ─── Filter ──────────────────────────────────────────────────────────────────
2819
- const FILTER_FUNS = [
2820
- ['filterBlur', 'blur', '0px'],
2821
- ['filterBrightness', 'brightness', '1'],
2822
- ['filterContrast', 'contrast', '1'],
2823
- ['filterGrayscale', 'grayscale', '0'],
2824
- ['filterSaturate', 'saturate', '1'],
2825
- ['filterHueRotate', 'hue-rotate', '0deg'],
2826
- ['filterSepia', 'sepia', '0'],
2827
- ['filterInvert', 'invert', '0'],
2828
- ];
2829
- // ─── Backdrop-filter ─────────────────────────────────────────────────────────
2830
- const BACKDROP_FUNS = [
2831
- ['backdropBlur', 'blur', '0px'],
2832
- ['backdropBrightness', 'brightness', '1'],
2833
- ['backdropContrast', 'contrast', '1'],
2834
- ['backdropSaturate', 'saturate', '1'],
2835
- ];
2836
- // ─── Transform ───────────────────────────────────────────────────────────────
2837
- const TRANSFORM_FUNS = [
2838
- ['translateX', 'translateX', '0px'],
2839
- ['translateY', 'translateY', '0px'],
2840
- ['scaleX', 'scaleX', '1'],
2841
- ['scaleY', 'scaleY', '1'],
2842
- ['rotate', 'rotate', '0deg'],
2843
- ['skewX', 'skewX', '0deg'],
2844
- ['skewY', 'skewY', '0deg'],
2845
- ];
2846
- // ─── ::marker pseudo-element ─────────────────────────────────────────────────
2847
- const MARKER_FUNS = [
2848
- ['markerColor', 'color'],
2849
- ['markerFontSize', 'fontSize'],
3126
+ const COMPONENTS = [
3127
+ BuilderImageComponent,
3128
+ BuilderSpacerComponent,
3129
+ CardComponent,
3130
+ BuilderDividerComponent,
3131
+ BuilderIconComponent,
3132
+ BuilderLinkComponent,
3133
+ BuilderButtonComponent,
3134
+ BuilderHeadingComponent,
3135
+ TextBlockComponent,
3136
+ SectionComponent,
3137
+ ContainerComponent,
3138
+ LinkedContainerComponent,
3139
+ ClientRepeaterComponent,
3140
+ ButtonBlockComponent,
3141
+ ClientSliderContainerComponent,
3142
+ ClientSliderSlideComponent,
3143
+ ClientListComponent,
3144
+ ClientListItemComponent,
3145
+ ClientGalleryComponent,
2850
3146
  ];
2851
- const TRANSITION_KEYS = ['transitionDuration', 'transitionTimingFunction', 'transitionDelay'];
2852
- const VIRTUAL_TRAIT_GROUPS = Object.freeze([
2853
- { output: 'filter', keys: FILTER_FUNS.map(([t]) => t), reset: 'none' },
2854
- { output: 'backdropFilter', keys: BACKDROP_FUNS.map(([t]) => t), reset: 'none' },
2855
- { output: 'transform', keys: TRANSFORM_FUNS.map(([t]) => t), reset: 'none' },
2856
- { output: 'transition', keys: [...TRANSITION_KEYS], reset: 'none' },
2857
- { output: '__markerStyles', keys: MARKER_FUNS.map(([t]) => t), reset: null },
2858
- ]);
2859
- // ─── All virtual trait names ──────────────────────────────────────────────────
2860
- /** Every virtual trait key — derived from the groups so there is one list to maintain. */
2861
- const VIRTUAL_TRAIT_KEYS = new Set(VIRTUAL_TRAIT_GROUPS.flatMap(g => g.keys));
2862
- // ─── Helpers ─────────────────────────────────────────────────────────────────
2863
- function buildFns(funs, src) {
2864
- return funs
2865
- .filter(([t, , d]) => src[t] != null && src[t] !== '' && src[t] !== d)
2866
- .map(([t, fn]) => `${fn}(${src[t]})`)
2867
- .join(' ');
3147
+ class IoxBuilderComponentsModule {
3148
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
3149
+ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, declarations: [BuilderImageComponent,
3150
+ BuilderSpacerComponent,
3151
+ CardComponent,
3152
+ BuilderDividerComponent,
3153
+ BuilderIconComponent,
3154
+ BuilderLinkComponent,
3155
+ BuilderButtonComponent,
3156
+ BuilderHeadingComponent,
3157
+ TextBlockComponent,
3158
+ SectionComponent,
3159
+ ContainerComponent,
3160
+ LinkedContainerComponent,
3161
+ ClientRepeaterComponent,
3162
+ ButtonBlockComponent,
3163
+ ClientSliderContainerComponent,
3164
+ ClientSliderSlideComponent,
3165
+ ClientListComponent,
3166
+ ClientListItemComponent,
3167
+ ClientGalleryComponent], imports: [CommonModule], exports: [BuilderImageComponent,
3168
+ BuilderSpacerComponent,
3169
+ CardComponent,
3170
+ BuilderDividerComponent,
3171
+ BuilderIconComponent,
3172
+ BuilderLinkComponent,
3173
+ BuilderButtonComponent,
3174
+ BuilderHeadingComponent,
3175
+ TextBlockComponent,
3176
+ SectionComponent,
3177
+ ContainerComponent,
3178
+ LinkedContainerComponent,
3179
+ ClientRepeaterComponent,
3180
+ ButtonBlockComponent,
3181
+ ClientSliderContainerComponent,
3182
+ ClientSliderSlideComponent,
3183
+ ClientListComponent,
3184
+ ClientListItemComponent,
3185
+ ClientGalleryComponent] }); }
3186
+ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, imports: [CommonModule] }); }
2868
3187
  }
2869
- // ─── Public API ───────────────────────────────────────────────────────────────
3188
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, decorators: [{
3189
+ type: NgModule,
3190
+ args: [{
3191
+ declarations: COMPONENTS,
3192
+ imports: [CommonModule],
3193
+ exports: COMPONENTS,
3194
+ }]
3195
+ }] });
3196
+
2870
3197
  /**
2871
- * Strip virtual trait keys from `raw` and emit their composed CSS equivalents.
3198
+ * Single source of truth for the IOX component type → Angular class mapping.
3199
+ * Used by the client engine (iox-client-engine) to resolve component classes
3200
+ * from IoxLayoutNode.type strings at render time.
2872
3201
  *
2873
- * When composing a partial state-override map (hover, active…), pass the full
2874
- * base style map as `base` so non-overridden components (e.g. filterBrightness
2875
- * when only filterBlur is overridden) are preserved in the composed output.
2876
- * When operating on a complete style map, omit `base` — it defaults to `raw`.
3202
+ * All 13 builder components are registered here:
3203
+ * - Leaf (Phase 2a): Image, Spacer, Card, Divider, Icon
3204
+ * - Inline-editable (Phase 2b): Link, Button, Heading, Text
3205
+ * - Layout shells (Phase 2c): Section, Container, LinkedContainer
3206
+ * - Repeater (Phase 2d): ClientRepeaterComponent
3207
+ *
3208
+ * Section/Container/LinkedContainer/Repeater expose childrenHost: ViewContainerRef
3209
+ * for ClientRenderDirective to insert child components into.
2877
3210
  */
2878
- function composeVirtualTraits(raw, base = raw) {
2879
- // Copy all non-virtual properties as-is
2880
- const result = {};
2881
- for (const [k, v] of Object.entries(raw)) {
2882
- if (!VIRTUAL_TRAIT_KEYS.has(k))
2883
- result[k] = v;
2884
- }
2885
- // Merge base values for any virtual key absent from the partial override map
2886
- const merged = (funs) => {
2887
- const m = {};
2888
- for (const [t] of funs)
2889
- m[t] = raw[t] ?? base[t];
2890
- return m;
2891
- };
2892
- // filter
2893
- const filterStr = buildFns(FILTER_FUNS, merged(FILTER_FUNS));
2894
- if (filterStr)
2895
- result['filter'] = filterStr;
2896
- // backdrop-filter
2897
- const backdropStr = buildFns(BACKDROP_FUNS, merged(BACKDROP_FUNS));
2898
- if (backdropStr)
2899
- result['backdropFilter'] = backdropStr;
2900
- // transform
2901
- const transformStr = buildFns(TRANSFORM_FUNS, merged(TRANSFORM_FUNS));
2902
- if (transformStr)
2903
- result['transform'] = transformStr;
2904
- // transition — only emit when duration is non-zero
2905
- const dur = raw['transitionDuration'] ?? base['transitionDuration'];
2906
- const ease = raw['transitionTimingFunction'] ?? base['transitionTimingFunction'];
2907
- const del = raw['transitionDelay'] ?? base['transitionDelay'];
2908
- if (dur && ease && dur !== '0ms' && dur !== '0s') {
2909
- const delPart = del && del !== '0ms' && del !== '0s' ? ` ${del}` : '';
2910
- result['transition'] = `all ${dur} ${ease}${delPart}`;
3211
+ class IoxComponentRegistryService {
3212
+ constructor() {
3213
+ this.map = new Map([
3214
+ ['Image', BuilderImageComponent],
3215
+ ['Spacer', BuilderSpacerComponent],
3216
+ ['Card', CardComponent],
3217
+ ['Divider', BuilderDividerComponent],
3218
+ ['Icon', BuilderIconComponent],
3219
+ ['Link', BuilderLinkComponent],
3220
+ ['Button', BuilderButtonComponent],
3221
+ ['Heading', BuilderHeadingComponent],
3222
+ ['Text', TextBlockComponent],
3223
+ ['Section', SectionComponent],
3224
+ ['Container', ContainerComponent],
3225
+ ['LinkedContainer', LinkedContainerComponent],
3226
+ ['Repeater', ClientRepeaterComponent],
3227
+ ['ButtonBlock', ButtonBlockComponent],
3228
+ ['SliderContainer', ClientSliderContainerComponent],
3229
+ ['SliderSlide', ClientSliderSlideComponent],
3230
+ ['List', ClientListComponent],
3231
+ ['ListItem', ClientListItemComponent],
3232
+ ['Gallery', ClientGalleryComponent],
3233
+ ]);
2911
3234
  }
2912
- // ::marker pseudo-element — collected into __markerStyles so StyleRegistryService
2913
- // can emit a separate `.iox-node-{id}::marker { … }` rule.
2914
- const markerStyles = {};
2915
- for (const [trait, cssProp] of MARKER_FUNS) {
2916
- const val = raw[trait] ?? base[trait];
2917
- if (val != null && val !== '')
2918
- markerStyles[cssProp] = val;
3235
+ getComponent(type) {
3236
+ return this.map.get(type);
2919
3237
  }
2920
- if (Object.keys(markerStyles).length) {
2921
- result['__markerStyles'] = markerStyles;
3238
+ hasComponent(type) {
3239
+ return this.map.has(type);
2922
3240
  }
2923
- return result;
3241
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
3242
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, providedIn: 'root' }); }
2924
3243
  }
3244
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, decorators: [{
3245
+ type: Injectable,
3246
+ args: [{ providedIn: 'root' }]
3247
+ }] });
2925
3248
 
2926
3249
  /**
2927
- * tree-relations — framework-agnostic helpers for reasoning about a rendered node
2928
- * tree's structural relationships. Lives in @vectoriox/iox-ui because BOTH renderers
2929
- * depend on iox-ui (the engine does NOT depend on iox-builder), so the same logic
2930
- * drives the builder canvas AND the SSR site — no fork.
2931
- * See iox-ai-guidance `architecture/builder/targeted-hover-state-plan.md`.
2932
- */
2933
- /** The css id a node contributes to selectors: its clone `styleId`, else its own `id`. */
2934
- function nodeCssId(node) {
2935
- return node.styleId ?? node.id;
2936
- }
2937
- /**
2938
- * Path of css ids from the root down to (and including) the node whose css id === `cssId`.
2939
- * Returns `null` when no node matches. First match wins (depth-first, pre-order).
2940
- */
2941
- function pathToCssId(nodes, cssId) {
2942
- for (const node of nodes) {
2943
- const ownId = nodeCssId(node);
2944
- if (ownId === cssId)
2945
- return [ownId];
2946
- if (node.children?.length) {
2947
- const childPath = pathToCssId(node.children, cssId);
2948
- if (childPath)
2949
- return ownId ? [ownId, ...childPath] : childPath;
2950
- }
2951
- }
2952
- return null;
2953
- }
2954
- /**
2955
- * The css id of the NEAREST COMMON ANCESTOR of two nodes (identified by their css ids),
2956
- * used to scope a `:has()` targeted-state selector so it stays repetition-safe.
2957
- *
2958
- * The result may equal `styledCssId` (the trigger sits INSIDE the styled node) or
2959
- * `triggerCssId` (the styled node sits inside the trigger) — both are valid scopes;
2960
- * `buildTargetedStateSelector` picks the right selector shape for each. Returns
2961
- * `undefined` only when a valid scope can't exist: an id is blank, the two ids are the
2962
- * same node, or they share no ancestor in `root`. Callers MUST skip emitting the rule
2963
- * when this is `undefined` — never emit an unscoped (page-global) `:has()`, which would
2964
- * break under repetition.
2965
- */
2966
- function nearestCommonAncestorId(root, styledCssId, triggerCssId) {
2967
- if (!styledCssId || !triggerCssId || styledCssId === triggerCssId)
2968
- return undefined;
2969
- const styledPath = pathToCssId(root, styledCssId);
2970
- const triggerPath = pathToCssId(root, triggerCssId);
2971
- if (!styledPath || !triggerPath)
2972
- return undefined;
2973
- // Walk both paths together; the last shared prefix element is the common ancestor.
2974
- let common;
2975
- const shorter = Math.min(styledPath.length, triggerPath.length);
2976
- for (let i = 0; i < shorter; i++) {
2977
- if (styledPath[i] !== triggerPath[i])
2978
- break;
2979
- common = styledPath[i];
2980
- }
2981
- return common;
2982
- }
2983
- /**
2984
- * Build the selector BODY (no leading `.`, matching the builder's `compile()` convention)
2985
- * for a targeted state, given the resolved scope. Three topologies, one shared rule:
3250
+ * style-compiler — the SINGLE source of truth for turning a node's style map into
3251
+ * CSS declarations. Framework-agnostic (no Angular): consumed by both renderers.
2986
3252
  *
2987
- * - trigger INSIDE styled (scope === styled): `iox-node-{styled}:has(.iox-node-{trigger}:{base})`
2988
- * - styled INSIDE trigger (scope === trigger): `iox-node-{trigger}:{base} .iox-node-{styled}`
2989
- * - separate subtrees (scope is above both): `iox-node-{scope}:has(.iox-node-{trigger}:{base}) .iox-node-{styled}`
3253
+ * - Builder canvas: `StyleRegistryService` (@vectoriox/iox-builder) → live `<style>` tag.
3254
+ * - SSR site: `LayoutRendererService` (iox-client-engine) → HTML string CSS.
2990
3255
  *
2991
- * `scopeCssId` MUST be a value returned by `nearestCommonAncestorId` for the same pair.
3256
+ * Lives in @vectoriox/iox-ui because BOTH consumers depend on iox-ui (the engine does
3257
+ * NOT depend on iox-builder). Keeping it here — not duplicated per renderer — is what
3258
+ * prevents the builder and the live site from drifting.
3259
+ * See iox-ai-guidance `architecture/builder/render-parity.md`.
2992
3260
  */
2993
- function buildTargetedStateSelector(scopeCssId, triggerCssId, styledCssId, base) {
2994
- if (scopeCssId === styledCssId) {
2995
- return `iox-node-${styledCssId}:has(.iox-node-${triggerCssId}:${base})`;
2996
- }
2997
- if (scopeCssId === triggerCssId) {
2998
- return `iox-node-${triggerCssId}:${base} .iox-node-${styledCssId}`;
2999
- }
3000
- return `iox-node-${scopeCssId}:has(.iox-node-${triggerCssId}:${base}) .iox-node-${styledCssId}`;
3001
- }
3002
-
3003
- // Responsive (per-breakpoint) styles — the shared model, cascade and emission plan.
3004
- //
3005
- // SHARED (iox-ui): the builder canvas AND the SSR engine both call this. The builder
3006
- // FLATTENS the active breakpoint (its canvas is a transformed frame inside a desktop
3007
- // browser, so a real `@media` query would read the editor's width); the engine WRAPS
3008
- // each planned layer in `@media`. Same decisions, different transport — never fork them.
3009
- //
3010
- // Storage is a sparse layer beside the existing keys, shaped like `stateStyles`:
3011
- //
3012
- // styleProps — base, full resolved set (unchanged)
3013
- // stateStyles — sparse per-state overrides (unchanged)
3014
- // responsive — sparse per-breakpoint overrides (this file)
3015
- // { mobile: { styleProps: {…}, stateStyles: { hover: {…} } } }
3016
- //
3017
- // A node with no `responsive` key renders exactly as before — no migration.
3018
- // See iox-ai-guidance `architecture/builder/responsive-breakpoints-plan.md`.
3019
- // ─── Breakpoints ─────────────────────────────────────────────────────────────
3020
- /** The five fixed breakpoints (plan decision D2). Desktop-first: each narrower one
3021
- * overrides the wider ones through `max-width`. */
3022
- var Breakpoint;
3023
- (function (Breakpoint) {
3024
- Breakpoint["Base"] = "base";
3025
- Breakpoint["Laptop"] = "laptop";
3026
- Breakpoint["Tablet"] = "tablet";
3027
- Breakpoint["Mobile"] = "mobile";
3028
- Breakpoint["Small"] = "small";
3029
- })(Breakpoint || (Breakpoint = {}));
3030
3261
  /**
3031
- * Widest → narrowest. This order IS the cascade order and the `@media` emission order.
3262
+ * Rewrite viewport-relative units so they resolve to the simulated canvas dimensions
3263
+ * in the builder, and to the real viewport in production.
3032
3264
  *
3033
- * ⚠️ Phones in portrait (360–430px) land in `Small`, NOT `Mobile` — `Mobile` is 480–767,
3034
- * a phone held landscape. The labels say so (as Webflow's do). The ids stay short because
3035
- * they are storage keys; the cascade still makes a `Mobile` override reach every phone.
3036
- */
3037
- const BREAKPOINTS = Object.freeze([
3038
- { id: Breakpoint.Base, label: 'Base', maxWidth: null, previewWidth: 1440 },
3039
- { id: Breakpoint.Laptop, label: 'Laptop', maxWidth: 1200, previewWidth: 1200 },
3040
- { id: Breakpoint.Tablet, label: 'Tablet', maxWidth: 991, previewWidth: 768 },
3041
- { id: Breakpoint.Mobile, label: 'Mobile landscape', maxWidth: 767, previewWidth: 568 },
3042
- { id: Breakpoint.Small, label: 'Mobile portrait', maxWidth: 479, previewWidth: 390 },
3043
- ]);
3044
- const BREAKPOINT_INDEX = new Map(BREAKPOINTS.map((d, i) => [d.id, i]));
3045
- /** The definition for `bp`, or `undefined` for an unknown id (e.g. hand-edited JSON). */
3046
- function breakpointDef(bp) {
3047
- const i = BREAKPOINT_INDEX.get(bp);
3048
- return i === undefined ? undefined : BREAKPOINTS[i];
3049
- }
3050
- /** Base → `bp`, inclusive — the layers whose values apply at `bp`. Unknown → `[Base]`. */
3051
- function cascadeChain(bp) {
3052
- const i = BREAKPOINT_INDEX.get(bp);
3053
- return BREAKPOINTS.slice(0, (i ?? 0) + 1).map(d => d.id);
3054
- }
3055
- /**
3056
- * The breakpoint a viewport (or canvas) of `width` CSS px falls in — the narrowest one whose
3057
- * `maxWidth` it fits under, else Base. Mirrors how the `max-width` media queries resolve, so the
3058
- * builder's editing scope is always the breakpoint the canvas is actually showing.
3265
+ * `--preview-vh` / `--preview-vw` are set by BuilderComponent (ResizeObserver +
3266
+ * viewport-state subscription). In production those variables are undefined, so the
3267
+ * `1vh` / `1vw` fallbacks restore native browser behaviour.
3059
3268
  */
3060
- function breakpointForWidth(width) {
3061
- for (let i = BREAKPOINTS.length - 1; i > 0; i--) {
3062
- const max = BREAKPOINTS[i].maxWidth;
3063
- if (max !== null && width <= max)
3064
- return BREAKPOINTS[i].id;
3065
- }
3066
- return Breakpoint.Base;
3269
+ function rewriteViewportUnits(value) {
3270
+ return value
3271
+ .replace(/(-?\d*\.?\d+)vh/g, 'calc($1 * var(--preview-vh, 1vh))')
3272
+ .replace(/(-?\d*\.?\d+)vw/g, 'calc($1 * var(--preview-vw, 1vw))');
3067
3273
  }
3068
- /** The next-wider breakpoint (what `bp` inherits from), or `null` for Base / unknown. */
3069
- function parentBreakpoint(bp) {
3070
- const i = BREAKPOINT_INDEX.get(bp);
3071
- return i ? BREAKPOINTS[i - 1].id : null;
3274
+ /** camelCase style key → kebab-case CSS property. */
3275
+ function styleKeyToKebab(key) {
3276
+ return key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
3072
3277
  }
3073
3278
  /**
3074
- * What a breakpoint's width is measured against.
3279
+ * kebab CSS property → the value that equals its CSS initial AND has no UA-stylesheet override for
3280
+ * any element the builder renders (div/section/span/i/a/img/h1–h6/p/ul). Emitting these is a pure
3281
+ * no-op, so they're dropped to keep the compiled CSS lean (self-describing nodes serialise every
3282
+ * trait, many at their default).
3075
3283
  *
3076
- * `Media` — the browser viewport. The SSR engine: a visitor's window IS the page.
3077
- * `Container` — a named CSS size container. The builder: its canvas is a fixed-width frame inside a
3078
- * desktop browser (and `transform: scale`d for zoom), so a media query would read the EDITOR's width.
3079
- * A container query reads the frame's own layout width — which equals the simulated viewport and
3080
- * ignores the zoom transform — so the builder emits the same structure as the engine.
3284
+ * Deliberately EXCLUDED, because a value equal to the initial can still be meaningful there:
3285
+ * - inherited properties (line-height, color, font-family/size/weight, text-align, letter-spacing…)
3286
+ * — the value may override an inherited ancestor value.
3287
+ * - margin / padding — UA stylesheets set them on h1–h6, p, ul, so `0` depends on a reset.
3288
+ * - border-* — the initial `medium` width + interaction with border-style makes it non-trivial.
3081
3289
  */
3082
- var BreakpointQueryKind;
3083
- (function (BreakpointQueryKind) {
3084
- BreakpointQueryKind["Media"] = "media";
3085
- BreakpointQueryKind["Container"] = "container";
3086
- })(BreakpointQueryKind || (BreakpointQueryKind = {}));
3290
+ const NOOP_INITIAL_VALUES = {
3291
+ 'width': ['auto'], 'height': ['auto'],
3292
+ 'min-width': ['auto'], 'min-height': ['auto'],
3293
+ 'max-width': ['none', 'auto'], 'max-height': ['none', 'auto'], // `auto` is invalid for max-* → inert
3294
+ 'position': ['static'],
3295
+ 'top': ['auto'], 'right': ['auto'], 'bottom': ['auto'], 'left': ['auto'],
3296
+ 'opacity': ['1'], 'z-index': ['auto'],
3297
+ 'flex-grow': ['0'], 'flex-shrink': ['1'], 'flex-basis': ['auto'],
3298
+ 'flex-direction': ['row'], 'flex-wrap': ['nowrap'],
3299
+ 'gap': ['0', '0px', 'normal'],
3300
+ 'border-radius': ['0', '0px'],
3301
+ 'aspect-ratio': ['auto'],
3302
+ 'mix-blend-mode': ['normal'],
3303
+ 'transform': ['none'], 'box-shadow': ['none'],
3304
+ // overflow initial is `visible`. A self-describing node serialises the Overflow trait's default,
3305
+ // so WITHOUT this the emitted `overflow: visible` would override component baselines that clip
3306
+ // (e.g. `.iox-card__img-wrap`, `.iox-slider`, `.iox-gallery` all set `overflow: hidden`), breaking
3307
+ // object-fit/aspect-ratio image clipping everywhere. A user-set hidden/scroll/auto/clip is not the
3308
+ // initial, so it still emits and wins.
3309
+ 'overflow': ['visible'], 'overflow-x': ['visible'], 'overflow-y': ['visible'],
3310
+ 'clip-path': ['none'],
3311
+ };
3087
3312
  /**
3088
- * The at-rule prelude for `bp` — `@media (max-width: 767px)`, or with `Container`
3089
- * `@container iox-canvas (max-width: 767px)`. `null` for Base or an unknown id.
3090
- * `containerName` is required for `Container` (an unnamed container query would match
3091
- * whichever ancestor container is nearest, which is not guaranteed to be the canvas).
3313
+ * Style props a type's render-default fully REPLACES. Emitting them would produce a dead
3314
+ * declaration that is immediately overridden in the same rule (e.g. an Icon's serialised
3315
+ * `lineHeight: 1.5` followed by the forced `line-height: 1`), so callers strip them first via
3316
+ * {@link stripSuppressedProps} and let renderDefaultDeclarations supply the real value.
3092
3317
  */
3093
- function breakpointQueryFor(bp, kind = BreakpointQueryKind.Media, containerName) {
3094
- const max = breakpointDef(bp)?.maxWidth;
3095
- if (max == null)
3096
- return null;
3097
- if (kind === BreakpointQueryKind.Media)
3098
- return `@media (max-width: ${max}px)`;
3099
- if (!containerName)
3100
- throw new Error('A container breakpoint query needs a container name');
3101
- return `@container ${containerName} (max-width: ${max}px)`;
3102
- }
3103
- /** Wrap already-compiled rules for `bp`. Base (and blank css) passes through unwrapped. */
3104
- function wrapInBreakpointQuery(bp, css, kind = BreakpointQueryKind.Media, containerName) {
3105
- if (!css.trim())
3106
- return '';
3107
- const query = breakpointQueryFor(bp, kind, containerName);
3108
- return query ? `${query} {\n${css}\n}` : css;
3109
- }
3110
- /** `@media (max-width: 767px)` for `bp`; `null` for Base or an unknown id. */
3111
- function mediaQueryFor(bp) {
3112
- return breakpointQueryFor(bp, BreakpointQueryKind.Media);
3113
- }
3114
- /** Wrap already-compiled rules for `bp` in its media query. Base (and blank css) passes through. */
3115
- function wrapInMediaQuery(bp, css) {
3116
- return wrapInBreakpointQuery(bp, css, BreakpointQueryKind.Media);
3117
- }
3118
- function layerAt(responsive, bp) {
3119
- return bp === Breakpoint.Base ? undefined : responsive?.[bp];
3120
- }
3121
- /** Copy defined entries of `src` onto `target`. `undefined` never overwrites a value. */
3122
- function assignDefined(target, src) {
3123
- if (src)
3124
- for (const [k, v] of Object.entries(src))
3125
- if (v !== undefined)
3126
- target[k] = v;
3127
- return target;
3128
- }
3129
- function hasKeys(map) {
3130
- return !!map && Object.keys(map).length > 0;
3131
- }
3132
- // ─── Cascade resolution ──────────────────────────────────────────────────────
3133
- /** The full effective style map at `bp`: base, then each layer down the cascade. */
3134
- function resolveStyleProps(styleProps, responsive, bp) {
3135
- const out = assignDefined({}, styleProps ?? undefined);
3136
- for (const id of cascadeChain(bp))
3137
- assignDefined(out, layerAt(responsive, id)?.styleProps);
3138
- return out;
3139
- }
3140
- /** The effective per-state maps at `bp`. States with no values anywhere are omitted. */
3141
- function resolveStateStyles(stateStyles, responsive, bp) {
3142
- const out = {};
3143
- const merge = (src) => {
3144
- for (const [state, map] of Object.entries(src ?? {})) {
3145
- out[state] = assignDefined(out[state] ?? {}, map);
3146
- }
3147
- };
3148
- merge(stateStyles ?? undefined);
3149
- for (const id of cascadeChain(bp))
3150
- merge(layerAt(responsive, id)?.stateStyles);
3151
- for (const state of Object.keys(out))
3152
- if (!hasKeys(out[state]))
3153
- delete out[state];
3318
+ const SUPPRESSED_STYLE_PROPS_BY_TYPE = {
3319
+ // An icon's line box is structurally forced to 1 — see the Icon branch of renderDefaultDeclarations.
3320
+ Icon: ['lineHeight'],
3321
+ };
3322
+ /** Remove the style props whose value a type's render-default always overrides. Returns the map
3323
+ * unchanged (same reference) when the type suppresses nothing. */
3324
+ function stripSuppressedProps(type, styleProps) {
3325
+ const suppressed = SUPPRESSED_STYLE_PROPS_BY_TYPE[type];
3326
+ if (!suppressed?.length)
3327
+ return styleProps;
3328
+ const out = { ...styleProps };
3329
+ for (const key of suppressed)
3330
+ delete out[key];
3154
3331
  return out;
3155
3332
  }
3156
3333
  /**
3157
- * Every state that has a value anywhere — at base or at any breakpoint — in a stable order
3158
- * (base states first, then any that exist only at a breakpoint). A renderer iterates this, not
3159
- * `Object.keys(stateStyles)`, so a state defined ONLY at a breakpoint (a Mobile-only hover) still
3160
- * gets its selector scope resolved.
3334
+ * What a compiled rule is FOR — decides whether an initial value is a no-op.
3335
+ *
3336
+ * In a `Base` rule nothing is being overridden, so `width: auto` or `transform: none` changes
3337
+ * nothing and is dropped (see NOOP_INITIAL_VALUES). In an `Override` rule — a breakpoint layer —
3338
+ * the initial value is exactly how you UNDO a wider rule (`width: 520px` → `auto` on mobile), so
3339
+ * dropping it would silently keep the desktop value.
3161
3340
  */
3162
- function stateNamesIn(stateStyles, responsive) {
3163
- const names = new Set();
3164
- const add = (src) => {
3165
- for (const [state, map] of Object.entries(src ?? {})) {
3166
- if (Object.values(map ?? {}).some(v => v !== undefined))
3167
- names.add(state);
3168
- }
3169
- };
3170
- add(stateStyles);
3171
- for (const { id } of BREAKPOINTS)
3172
- add(layerAt(responsive, id)?.stateStyles);
3173
- return [...names];
3174
- }
3341
+ var DeclarationContext;
3342
+ (function (DeclarationContext) {
3343
+ DeclarationContext["Base"] = "base";
3344
+ DeclarationContext["Override"] = "override";
3345
+ })(DeclarationContext || (DeclarationContext = {}));
3175
3346
  /**
3176
- * Every level from Base down to `bp` that sets `prop` (optionally within `state`), in
3177
- * cascade order. The LAST entry is the effective value; an empty array means unset.
3178
- * Drives the style panel's source chain ("Base 34 → Tablet 28 → Mobile 22").
3347
+ * Compile a flat, camelCase style map into an array of `"prop: value"` CSS
3348
+ * declarations (kebab-cased, viewport-units rewritten, empties dropped, no-op initials dropped
3349
+ * in a `Base` context only, optional `!important`). Callers wrap these in whatever selector
3350
+ * they emit.
3351
+ *
3352
+ * @param props Flat camelCase style map.
3353
+ * @param importantProps Keys that must carry `!important` (e.g. `position`, to beat
3354
+ * Angular's scoped-CSS specificity boost in the builder). A prop forced
3355
+ * `!important` is never dropped as a no-op — the `!important` exists to win
3356
+ * the cascade, so its value must always be emitted.
3357
+ * @param context `Override` for a rule that overrides a wider one (a breakpoint layer):
3358
+ * initial values are kept, because there they reset rather than no-op.
3179
3359
  */
3180
- function resolveValueChain(prop, styleProps, stateStyles, responsive, bp, state) {
3181
- const read = (props, states) => (state ? states?.[state] : props)?.[prop];
3182
- const chain = [];
3183
- for (const id of cascadeChain(bp)) {
3184
- const layer = layerAt(responsive, id);
3185
- const value = id === Breakpoint.Base
3186
- ? read(styleProps ?? undefined, stateStyles ?? undefined)
3187
- : read(layer?.styleProps, layer?.stateStyles);
3188
- if (value !== undefined)
3189
- chain.push({ breakpoint: id, value });
3190
- }
3191
- return chain;
3192
- }
3193
- /** True when `bp` itself sets `prop` (the style panel's orange edge). Never true for Base. */
3194
- function isOverriddenAt(responsive, bp, prop, state) {
3195
- const layer = layerAt(responsive, bp);
3196
- const map = state ? layer?.stateStyles?.[state] : layer?.styleProps;
3197
- return map?.[prop] !== undefined;
3198
- }
3199
- /** Number of overridden values — at `bp`, or across every breakpoint when omitted. */
3200
- function countOverrides(responsive, bp) {
3201
- const count = (map) => Object.values(map ?? {}).filter(v => v !== undefined).length;
3202
- const ids = bp ? [bp] : BREAKPOINTS.map(d => d.id);
3203
- let n = 0;
3204
- for (const id of ids) {
3205
- const layer = layerAt(responsive, id);
3206
- n += count(layer?.styleProps);
3207
- for (const map of Object.values(layer?.stateStyles ?? {}))
3208
- n += count(map);
3209
- }
3210
- return n;
3211
- }
3212
- // ─── Editing (immutable) ─────────────────────────────────────────────────────
3213
- function assertOverrideBreakpoint(bp) {
3214
- if (bp === Breakpoint.Base) {
3215
- throw new Error('Base styles live in styleProps / stateStyles, not in responsive overrides');
3216
- }
3217
- if (!BREAKPOINT_INDEX.has(bp))
3218
- throw new Error(`Unknown breakpoint "${bp}"`);
3219
- }
3220
- /** Returns a new overrides object with `prop` set at `bp` (optionally within `state`). */
3221
- function setOverride(responsive, bp, prop, value, state) {
3222
- assertOverrideBreakpoint(bp);
3223
- const layer = { ...(responsive?.[bp] ?? {}) };
3224
- if (state) {
3225
- layer.stateStyles = { ...(layer.stateStyles ?? {}), [state]: { ...(layer.stateStyles?.[state] ?? {}), [prop]: value } };
3226
- }
3227
- else {
3228
- layer.styleProps = { ...(layer.styleProps ?? {}), [prop]: value };
3360
+ function compileDeclarations(props, importantProps, context = DeclarationContext.Base) {
3361
+ const out = [];
3362
+ for (const [k, v] of Object.entries(props)) {
3363
+ if (v === undefined || v === null || v === '')
3364
+ continue;
3365
+ const prop = styleKeyToKebab(k);
3366
+ const val = rewriteViewportUnits(String(v));
3367
+ const isImportant = importantProps?.has(k) ?? false;
3368
+ const dropsNoOps = context === DeclarationContext.Base && !isImportant;
3369
+ if (dropsNoOps && NOOP_INITIAL_VALUES[prop]?.includes(val.trim().toLowerCase()))
3370
+ continue;
3371
+ out.push(`${prop}: ${val}${isImportant ? ' !important' : ''}`);
3229
3372
  }
3230
- return { ...(responsive ?? {}), [bp]: layer };
3373
+ return out;
3231
3374
  }
3232
3375
  /**
3233
- * Remove overrides at `bp` and PRUNE whatever becomes empty, so "reset" and "never
3234
- * overridden" are the same state on disk — no tombstones, no nulls.
3376
+ * Per-type default `width` that the builder applies from each component's config
3377
+ * (NOT persisted in styleProps — see render-parity.md "saved node is not
3378
+ * self-describing"). The engine has no config classes, so it resolves the same
3379
+ * default from THIS map at render time. Keeping the map here (not re-typed in the
3380
+ * engine) is the "shared default map" decision.
3235
3381
  *
3236
- * clearOverride(r, bp) — the whole breakpoint
3237
- * clearOverride(r, bp, undefined, st) — one state at that breakpoint
3238
- * clearOverride(r, bp, prop) — one base-state value
3239
- * clearOverride(r, bp, prop, st) — one value within a state
3382
+ * ONLY the `100%` container-type defaults live here. Fixed-size config defaults
3383
+ * (Card 200px, Image 300px, SliderSlide 33%) are starter placeholders real content
3384
+ * almost always overrides — forcing them onto width-less nodes breaks layout.
3385
+ */
3386
+ const DEFAULT_WIDTH_BY_TYPE = {
3387
+ Container: '100%', Repeater: '100%', SliderContainer: '100%',
3388
+ List: '100%', ListItem: '100%', Text: '100%', Divider: '100%', Spacer: '100%',
3389
+ // Section, Heading, LinkedContainer, Card, Image, SliderSlide, Button, ButtonBlock,
3390
+ // Icon → no forced default (their config defaults are content sizes set explicitly).
3391
+ };
3392
+ /**
3393
+ * Extra "render default" declarations the builder gets implicitly from live component
3394
+ * behaviour, which the engine must re-apply explicitly for parity. Returns `"prop: value"`
3395
+ * declarations to append AFTER the node's own compiled declarations.
3240
3396
  *
3241
- * Returns `undefined` when nothing is left, so a serialiser omits the key entirely.
3397
+ * @param type Node type (registry key).
3398
+ * @param styleProps The node's persisted styleProps.
3399
+ * @param hasHoverState Whether the node has a non-empty hover / parent-hover state.
3242
3400
  */
3243
- function clearOverride(responsive, bp, prop, state) {
3244
- assertOverrideBreakpoint(bp);
3245
- const next = { ...(responsive ?? {}) };
3246
- const current = next[bp];
3247
- if (current && (prop !== undefined || state !== undefined)) {
3248
- const layer = { ...current };
3249
- if (state) {
3250
- const states = { ...(layer.stateStyles ?? {}) };
3251
- if (prop !== undefined && states[state]) {
3252
- const { [prop]: _dropped, ...rest } = states[state];
3253
- states[state] = rest;
3254
- }
3255
- if (prop === undefined || !hasKeys(states[state]))
3256
- delete states[state];
3257
- layer.stateStyles = hasKeys(states) ? states : undefined;
3401
+ function renderDefaultDeclarations(type, styleProps, hasHoverState) {
3402
+ const sp = styleProps ?? {};
3403
+ const out = [];
3404
+ const isOutOfFlow = sp['position'] === 'fixed' || sp['position'] === 'absolute';
3405
+ // Container-type default width so it fills its track. Only when width is unset — once a page
3406
+ // is saved with fully-resolved styleProps (self-describing nodes), width comes through
3407
+ // compileDeclarations and this is a no-op.
3408
+ if (sp['width'] === undefined) {
3409
+ if (isOutOfFlow) {
3410
+ out.push('width: 100%');
3258
3411
  }
3259
- else if (prop !== undefined && layer.styleProps) {
3260
- const { [prop]: _dropped, ...rest } = layer.styleProps;
3261
- layer.styleProps = hasKeys(rest) ? rest : undefined;
3412
+ else if (DEFAULT_WIDTH_BY_TYPE[type]) {
3413
+ out.push(`width: ${DEFAULT_WIDTH_BY_TYPE[type]}`);
3262
3414
  }
3263
- if (!layer.stateStyles)
3264
- delete layer.stateStyles;
3265
- if (!layer.styleProps)
3266
- delete layer.styleProps;
3267
- next[bp] = hasKeys(layer) ? layer : undefined;
3268
3415
  }
3269
- else {
3270
- next[bp] = undefined;
3416
+ // min-width:0 lets container flex children shrink below content and equal-split. Emit it for
3417
+ // container types INDEPENDENT of where width came from (styleProps vs the default above) —
3418
+ // otherwise a saved page whose width is persisted would lose the equal-split. In the builder the
3419
+ // same rule comes from the `.container-child` wrapper; here it lands on `.iox-node` directly.
3420
+ if (DEFAULT_WIDTH_BY_TYPE[type] && sp['minWidth'] === undefined && !isOutOfFlow) {
3421
+ out.push('min-width: 0');
3271
3422
  }
3272
- if (!next[bp])
3273
- delete next[bp];
3274
- return hasKeys(next) ? next : undefined;
3275
- }
3276
- const ZERO_DURATIONS = new Set(['0ms', '0s']);
3277
- function groupsTouchedBy(map) {
3278
- const touched = new Set();
3279
- if (!map)
3280
- return touched;
3281
- for (const group of VIRTUAL_TRAIT_GROUPS) {
3282
- if (group.keys.some(k => map[k] !== undefined))
3283
- touched.add(group);
3423
+ // A border only renders when there is a WIDTH. Gate border-style on borderWidth — NOT on
3424
+ // borderColor: a node with only a border colour and no width would otherwise get the
3425
+ // browser's default `medium` width and show a phantom border. Absent borderWidth = no border.
3426
+ if (sp['borderWidth'] && sp['borderStyle'] === undefined) {
3427
+ out.push('border-style: solid');
3284
3428
  }
3285
- return touched;
3429
+ // Default transition so own/parent hover animates smoothly (parity with the builder).
3430
+ if (hasHoverState && sp['transition'] === undefined) {
3431
+ out.push('transition: all 0.2s ease');
3432
+ }
3433
+ // Image defaults reapplied when unset (the config defaults aren't always persisted). height:auto
3434
+ // is only a DEFAULT — when the user sets an explicit height (e.g. 100%) it must WIN, so guard it
3435
+ // on `sp['height'] === undefined` (matching the builder, which respects a configured height). An
3436
+ // unset height + aspect-ratio lets the ratio drive the box.
3437
+ if (type === 'Image') {
3438
+ if (sp['aspectRatio'] === undefined)
3439
+ out.push('aspect-ratio: 1/1');
3440
+ // object-fit is a Size style trait with config default 'cover'; that default isn't persisted,
3441
+ // so reapply it here (matching the builder) when the node has none.
3442
+ if (sp['objectFit'] === undefined)
3443
+ out.push('object-fit: cover');
3444
+ // NOTE: no `height: auto` — when height is unset, `auto` is already the CSS initial value, so
3445
+ // emitting it is a pure no-op. A configured height comes through compileDeclarations and wins.
3446
+ }
3447
+ // Containers default to a positioning context (for absolutely-positioned children). These live
3448
+ // in `.iox-node` (not the component `:host`) so an explicit styleProps value always wins.
3449
+ if ((type === 'Container' || type === 'Section' || type === 'LinkedContainer') && sp['position'] === undefined) {
3450
+ out.push('position: relative');
3451
+ }
3452
+ // LinkedContainer is an <a> (inline by default) → default it to block so children lay out.
3453
+ if (type === 'LinkedContainer' && sp['display'] === undefined) {
3454
+ out.push('display: block');
3455
+ }
3456
+ // An Icon is a single FONT GLYPH, not a paragraph: it must contribute no line-box height of its
3457
+ // own (spacing belongs to padding/margin). But every node serialises the Typography group's
3458
+ // `lineHeight` default of 1.5, so icons carry `line-height: 1.5` — a 24px glyph then gets a 36px
3459
+ // line box and pushes the surrounding layout around.
3460
+ //
3461
+ // This is emitted UNCONDITIONALLY (not guarded on `undefined`) and callers append render-defaults
3462
+ // AFTER the node's own compiled declarations in the SAME rule, so it overrides a stale serialised
3463
+ // value for EXISTING icons too — no re-save needed. line-height is inherited, so simply omitting
3464
+ // it is not enough: the icon would inherit 1.5 from its container instead.
3465
+ if (type === 'Icon')
3466
+ out.push('line-height: 0');
3467
+ // An icon's `<i>` must shrink-wrap its glyph so it grows with font-size and doesn't overflow its
3468
+ // parent. Phosphor forces `display:inline-flex` on its own class; generated (fantasticon) fonts
3469
+ // don't, so the engine's plain `<i class="bi-x iox-node-…">` would fall back to inline and the
3470
+ // glyph would overflow. Emit it here (guarded so a user-set display still wins) — mirrors the
3471
+ // builder Icon component's `:host{display:inline-flex}`.
3472
+ if (type === 'Icon' && sp['display'] === undefined) {
3473
+ out.push('display: inline-flex', 'align-items: center', 'justify-content: center');
3474
+ }
3475
+ return out;
3286
3476
  }
3477
+
3287
3478
  /**
3288
- * Should a touched group that composed to nothing emit its cancelling value?
3289
- * Transition is the exception: setting only the easing (no duration anywhere) composes to
3290
- * nothing too, and cancelling there would also kill the renderer's default hover transition
3291
- * — so transition cancels only when the merged duration is explicitly zero.
3479
+ * tree-relations — framework-agnostic helpers for reasoning about a rendered node
3480
+ * tree's structural relationships. Lives in @vectoriox/iox-ui because BOTH renderers
3481
+ * depend on iox-ui (the engine does NOT depend on iox-builder), so the same logic
3482
+ * drives the builder canvas AND the SSR site — no fork.
3483
+ * See iox-ai-guidance `architecture/builder/targeted-hover-state-plan.md`.
3292
3484
  */
3293
- function shouldReset(group, merged) {
3294
- if (group.reset === null)
3295
- return false;
3296
- if (group.output === 'transition')
3297
- return ZERO_DURATIONS.has(String(merged['transitionDuration']));
3298
- return true;
3485
+ /** The css id a node contributes to selectors: its clone `styleId`, else its own `id`. */
3486
+ function nodeCssId(node) {
3487
+ return node.styleId ?? node.id;
3299
3488
  }
3300
3489
  /**
3301
- * Compose a sparse override against everything wider than it, keeping ONLY what the
3302
- * override changes: its own plain props, plus each virtual group it touches (or that
3303
- * group's cancelling value when it now composes to nothing). Untouched groups are dropped
3304
- * — re-emitting the wider rule's value inside a media block is noise at best.
3490
+ * Path of css ids from the root down to (and including) the node whose css id === `cssId`.
3491
+ * Returns `null` when no node matches. First match wins (depth-first, pre-order).
3305
3492
  */
3306
- function composeOverride(own, wider, touched) {
3307
- const composed = composeVirtualTraits(own, wider);
3308
- const groupOutputs = new Set(VIRTUAL_TRAIT_GROUPS.map(g => g.output));
3309
- const out = {};
3310
- for (const [k, v] of Object.entries(composed)) {
3311
- if (!groupOutputs.has(k) && v !== undefined)
3312
- out[k] = v;
3493
+ function pathToCssId(nodes, cssId) {
3494
+ for (const node of nodes) {
3495
+ const ownId = nodeCssId(node);
3496
+ if (ownId === cssId)
3497
+ return [ownId];
3498
+ if (node.children?.length) {
3499
+ const childPath = pathToCssId(node.children, cssId);
3500
+ if (childPath)
3501
+ return ownId ? [ownId, ...childPath] : childPath;
3502
+ }
3313
3503
  }
3314
- const merged = { ...wider, ...own };
3315
- for (const group of touched) {
3316
- const value = composed[group.output];
3317
- if (value !== undefined)
3318
- out[group.output] = value;
3319
- else if (shouldReset(group, merged))
3320
- out[group.output] = group.reset;
3504
+ return null;
3505
+ }
3506
+ /**
3507
+ * The css id of the NEAREST COMMON ANCESTOR of two nodes (identified by their css ids),
3508
+ * used to scope a `:has()` targeted-state selector so it stays repetition-safe.
3509
+ *
3510
+ * The result may equal `styledCssId` (the trigger sits INSIDE the styled node) or
3511
+ * `triggerCssId` (the styled node sits inside the trigger) — both are valid scopes;
3512
+ * `buildTargetedStateSelector` picks the right selector shape for each. Returns
3513
+ * `undefined` only when a valid scope can't exist: an id is blank, the two ids are the
3514
+ * same node, or they share no ancestor in `root`. Callers MUST skip emitting the rule
3515
+ * when this is `undefined` — never emit an unscoped (page-global) `:has()`, which would
3516
+ * break under repetition.
3517
+ */
3518
+ function nearestCommonAncestorId(root, styledCssId, triggerCssId) {
3519
+ if (!styledCssId || !triggerCssId || styledCssId === triggerCssId)
3520
+ return undefined;
3521
+ const styledPath = pathToCssId(root, styledCssId);
3522
+ const triggerPath = pathToCssId(root, triggerCssId);
3523
+ if (!styledPath || !triggerPath)
3524
+ return undefined;
3525
+ // Walk both paths together; the last shared prefix element is the common ancestor.
3526
+ let common;
3527
+ const shorter = Math.min(styledPath.length, triggerPath.length);
3528
+ for (let i = 0; i < shorter; i++) {
3529
+ if (styledPath[i] !== triggerPath[i])
3530
+ break;
3531
+ common = styledPath[i];
3321
3532
  }
3322
- return out;
3533
+ return common;
3323
3534
  }
3324
3535
  /**
3325
- * The per-breakpoint rule sets a node needs, widest → narrowest (the order they must be
3326
- * emitted in, AFTER every base rule — equal specificity means source order decides).
3327
- * Breakpoints with nothing to emit are omitted.
3536
+ * Build the selector BODY (no leading `.`, matching the builder's `compile()` convention)
3537
+ * for a targeted state, given the resolved scope. Three topologies, one shared rule:
3328
3538
  *
3329
- * Handles the cross-axis case: a breakpoint that changes a virtual group (say
3330
- * `translateX`) also re-emits that group for every state the node has, because the wider
3331
- * state rule was composed against the wider value and would otherwise snap back to it on
3332
- * hover.
3539
+ * - trigger INSIDE styled (scope === styled): `iox-node-{styled}:has(.iox-node-{trigger}:{base})`
3540
+ * - styled INSIDE trigger (scope === trigger): `iox-node-{trigger}:{base} .iox-node-{styled}`
3541
+ * - separate subtrees (scope is above both): `iox-node-{scope}:has(.iox-node-{trigger}:{base}) .iox-node-{styled}`
3542
+ *
3543
+ * `scopeCssId` MUST be a value returned by `nearestCommonAncestorId` for the same pair.
3333
3544
  */
3334
- function planResponsiveRules(styleProps, stateStyles, responsive) {
3335
- if (!hasKeys(responsive))
3336
- return [];
3337
- const plan = [];
3338
- for (const def of BREAKPOINTS) {
3339
- const layer = layerAt(responsive, def.id);
3340
- if (!layer || def.maxWidth == null)
3341
- continue;
3342
- const bp = def.id;
3343
- const wider = parentBreakpoint(bp);
3344
- const own = layer.styleProps ?? {};
3345
- const widerProps = resolveStyleProps(styleProps, responsive, wider);
3346
- const touchedByProps = groupsTouchedBy(own);
3347
- const composedProps = composeOverride(own, widerProps, touchedByProps);
3348
- const propsHere = { ...widerProps, ...own };
3349
- const statesHere = resolveStateStyles(stateStyles, responsive, bp);
3350
- const statesWider = resolveStateStyles(stateStyles, responsive, wider);
3351
- const composedStates = {};
3352
- for (const state of Object.keys(statesHere)) {
3353
- const ownState = layer.stateStyles?.[state] ?? {};
3354
- const touched = groupsTouchedBy(ownState);
3355
- for (const group of touchedByProps)
3356
- touched.add(group);
3357
- const composed = composeOverride(ownState, { ...propsHere, ...(statesWider[state] ?? {}) }, touched);
3358
- if (hasKeys(composed))
3359
- composedStates[state] = composed;
3360
- }
3361
- if (hasKeys(composedProps) || hasKeys(composedStates)) {
3362
- plan.push({
3363
- breakpoint: bp,
3364
- mediaQuery: mediaQueryFor(bp),
3365
- styleProps: composedProps,
3366
- stateStyles: composedStates,
3367
- });
3368
- }
3545
+ function buildTargetedStateSelector(scopeCssId, triggerCssId, styledCssId, base) {
3546
+ if (scopeCssId === styledCssId) {
3547
+ return `iox-node-${styledCssId}:has(.iox-node-${triggerCssId}:${base})`;
3369
3548
  }
3370
- return plan;
3549
+ if (scopeCssId === triggerCssId) {
3550
+ return `iox-node-${triggerCssId}:${base} .iox-node-${styledCssId}`;
3551
+ }
3552
+ return `iox-node-${scopeCssId}:has(.iox-node-${triggerCssId}:${base}) .iox-node-${styledCssId}`;
3371
3553
  }
3372
3554
 
3373
3555
  /**
@@ -4404,5 +4586,5 @@ function effectiveTransitionId(anim) {
4404
4586
  * Generated bundle index. Do not edit.
4405
4587
  */
4406
4588
 
4407
- export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
4589
+ export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
4408
4590
  //# sourceMappingURL=vectoriox-iox-ui.mjs.map