@vectoriox/iox-ui 4.24.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,1858 +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);
1544
- }
1545
- ngOnInit() { }
1546
- ngAfterViewInit() {
1547
- if (isPlatformBrowser(this.platformId)) {
1548
- // Defer so all child slides are rendered first
1549
- requestAnimationFrame(() => this._initSwiper());
1550
- }
1551
- }
1552
- ngOnDestroy() {
1553
- if (this._swiper) {
1554
- this._swiper.destroy(true, true);
1555
- this._swiper = null;
1556
- this.instanceRegistry.unregister(this.nodeId);
1557
- }
1558
- }
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);
1627
- }
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"] }] }); }
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(' ');
1640
1571
  }
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
-
1572
+ // ─── Public API ───────────────────────────────────────────────────────────────
1719
1573
  /**
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.
1574
+ * Strip virtual trait keys from `raw` and emit their composed CSS equivalents.
1725
1575
  *
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).
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`.
1728
1580
  */
1729
- class ClientSliderSlideComponent {
1730
- constructor() {
1731
- this.nodeId = '';
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;
1732
1587
  }
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"] }); }
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}`;
1614
+ }
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;
1622
+ }
1623
+ if (Object.keys(markerStyles).length) {
1624
+ result['__markerStyles'] = markerStyles;
1625
+ }
1626
+ return result;
1735
1627
  }
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
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 = {}));
1746
1656
  /**
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>`).
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.
1751
1662
  */
1752
- class ClientListComponent {
1753
- constructor() {
1754
- this.htmlTag = 'ul';
1755
- this.start = '';
1756
- this.reversed = false;
1757
- this.nodeId = '';
1758
- }
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"] }); }
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);
1761
1680
  }
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
1681
  /**
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).
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.
1786
1685
  */
1787
- class ClientListItemComponent {
1788
- constructor() {
1789
- this.nodeId = '';
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;
1790
1691
  }
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"] }); }
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;
1793
1698
  }
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
1699
  /**
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:
1809
- *
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)
1700
+ * What a breakpoint's width is measured against.
1816
1701
  *
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.
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.
1821
1707
  */
1708
+ var BreakpointQueryKind;
1709
+ (function (BreakpointQueryKind) {
1710
+ BreakpointQueryKind["Media"] = "media";
1711
+ BreakpointQueryKind["Container"] = "container";
1712
+ })(BreakpointQueryKind || (BreakpointQueryKind = {}));
1822
1713
  /**
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.
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).
1826
1718
  */
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] ?? '');
1835
- }
1836
- return params;
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;
1837
1765
  }
1838
1766
  /**
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.
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.
1844
1770
  */
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;
1852
- }
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;
1861
- }
1862
- return params;
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;
1863
1780
  }
1864
1781
  /**
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.
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.
1871
1784
  */
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;
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 });
1791
+ }
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;
1880
1809
  }
1881
1810
  /**
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.
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.
1886
1815
  */
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' };
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);
1915
1822
  }
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' };
1924
- }
1823
+ };
1824
+ add(stateStyles);
1825
+ for (const { id } of BREAKPOINTS)
1826
+ add(layerAt(responsive, id)?.stateStyles);
1827
+ return [...names];
1925
1828
  }
1926
1829
  /**
1927
- * The aliases on a page that are VARIABLES — runtime-written resources, not fetched data.
1928
- *
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.
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").
1931
1833
  */
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;
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 });
1945
1844
  }
1946
- return undefined;
1845
+ return chain;
1947
1846
  }
1948
- /**
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.
1952
- */
1953
- function normalizeCollectionResult(mode, data) {
1954
- const items = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
1955
- return mode === 'single' ? (items[0] ?? null) : items;
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;
1956
1852
  }
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];
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);
1970
1864
  }
1971
- return cur;
1865
+ return n;
1972
1866
  }
1973
- /**
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.
1986
- */
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
- }
2000
- }
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');
2001
1871
  }
2002
- return resolved;
1872
+ if (!BREAKPOINT_INDEX.has(bp))
1873
+ throw new Error(`Unknown breakpoint "${bp}"`);
2003
1874
  }
2004
- function resolveRepeaterItems(resolved, sourcePath) {
2005
- if (sourcePath) {
2006
- const nested = getByPath(resolved, sourcePath);
2007
- return Array.isArray(nested) ? nested : [];
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 } };
2008
1881
  }
2009
- if (Array.isArray(resolved))
2010
- return resolved;
2011
- return resolved == null ? [] : [resolved];
2012
- }
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);
1882
+ else {
1883
+ layer.styleProps = { ...(layer.styleProps ?? {}), [prop]: value };
2029
1884
  }
2030
- return acc;
2031
- }
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));
1885
+ return { ...(responsive ?? {}), [bp]: layer };
2036
1886
  }
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);
2051
- }
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
- }
2059
- return (all ?? []).filter(d => wanted.has(d?.alias));
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 };
2060
1893
  }
2061
1894
  /**
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=`.
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.
2066
1897
  */
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 };
2077
- }
2078
- case 'external':
2079
- return { url: plan.endpoint, params: {} };
2080
- default:
2081
- return null;
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;
2082
1907
  }
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;
2083
1918
  }
2084
-
2085
1919
  /**
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.
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.
2094
1922
  *
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.
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
2103
1927
  *
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 `{}`.
1928
+ * Returns `undefined` when nothing is left, so a serialiser omits the key entirely.
2107
1929
  */
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);
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;
2115
1955
  }
2116
- return (images ?? [])
2117
- .map(img => (img?.binding ? getByPath(ctx, img.binding) : img?.url))
2118
- .map(coerceUrl)
2119
- .filter((u) => !!u);
1956
+ else {
1957
+ next[bp] = undefined;
1958
+ }
1959
+ if (!next[bp])
1960
+ delete next[bp];
1961
+ return hasKeys(next) ? next : undefined;
2120
1962
  }
2121
- /** A resolved value becomes a URL only if it's a non-empty string; everything else drops out. */
2122
- function coerceUrl(value) {
2123
- if (typeof value !== 'string')
2124
- return null;
2125
- const trimmed = value.trim();
2126
- return trimmed ? trimmed : null;
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);
1971
+ }
1972
+ return touched;
2127
1973
  }
2128
1974
  /**
2129
- * Parse a pasted block of URLs (newline- and/or comma-separated) into `GalleryImage[]`. Used by the
2130
- * MediaList trait editor's "paste URL list" affordance. Pure so it can be unit-tested + reused.
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.
2131
1979
  */
2132
- function parseUrlList(text) {
2133
- if (!text)
2134
- return [];
2135
- return text
2136
- .split(/[\n,]+/)
2137
- .map(s => s.trim())
2138
- .filter(Boolean)
2139
- .map(url => ({ url }));
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;
2140
1986
  }
2141
-
2142
1987
  /**
2143
- * Gallery effect registry — the SHARED, effect-agnostic description of how each gallery slide looks
2144
- * and animates in a given crossfade state. Modeled on `binding-pipes.ts` / `PAGE_TRANSITION_PRESETS`:
2145
- * a descriptor map + a resolve fn, all pure, consumed by BOTH the builder twin and the engine twin so
2146
- * the fade looks identical on the canvas and the live site.
2147
- *
2148
- * v1 ships only `fade` (the look ported from `sites/interior-design` fade-gallery). Adding `slide`,
2149
- * `zoom`, `kenburns`, … later is a new entry here + its CSS — NO component refactor, because the
2150
- * component only ever asks `resolveGalleryEffect(id).buildSlideStyle(state)` for per-slide inline CSS.
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.
1992
+ */
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;
2008
+ }
2009
+ return out;
2010
+ }
2011
+ /**
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.
2151
2015
  *
2152
- * See iox-ai-guidance/architecture/builder/gallery-component-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.
2153
2020
  */
2154
- /** The ken-burns zoom baked into the reference fade look (scale 1 → 1.05 over show+fade). */
2155
- const FADE_ZOOM_EXTRA_MS = 1000;
2156
- const fadeEffect = {
2157
- id: 'fade',
2158
- label: 'Fade',
2159
- defaults: { showMs: 4000, fadeMs: 1000 },
2160
- buildSlideStyle(state) {
2161
- const { active, fadingOut, firstCycleActive, durations } = state;
2162
- const zoomMs = durations.showMs + durations.fadeMs + FADE_ZOOM_EXTRA_MS;
2163
- // Before the first cycle activates, everything is flat + instant so there is no entry flash.
2164
- if (!firstCycleActive) {
2165
- return { opacity: '0', transform: 'scale(1)', transition: 'opacity 0ms, transform 0ms' };
2166
- }
2167
- const visible = active && !fadingOut;
2168
- const engaged = active || fadingOut; // current or outgoing slide is zoomed-in
2169
- return {
2170
- opacity: visible ? '1' : '0',
2171
- transform: engaged ? 'scale(1.05)' : 'scale(1)',
2172
- transition: `opacity ${durations.fadeMs}ms cubic-bezier(0.77, 0, 0.175, 1), ` +
2173
- `transform ${zoomMs}ms linear`,
2174
- };
2175
- },
2176
- };
2177
- /** The registry — one entry per effect. Keyed by id; unknown ids fall back to `fade`. */
2178
- const GALLERY_EFFECTS = {
2179
- fade: fadeEffect,
2180
- };
2181
- /** Resolve an effect id to its descriptor, defaulting to `fade` (forward-compatible with new ids). */
2182
- function resolveGalleryEffect(id) {
2183
- return (id && GALLERY_EFFECTS[id]) || GALLERY_EFFECTS['fade'];
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
+ }
2056
+ }
2057
+ return plan;
2184
2058
  }
2185
- const GALLERY_EFFECT_DESCRIPTORS = Object.values(GALLERY_EFFECTS).map(e => ({ id: e.id, label: e.label }));
2186
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']);
2187
2073
  /**
2188
- * Gallery crossfade loop — the SHARED, framework-free state machine that drives which image is
2189
- * visible over time. Ported from the reference fade-gallery's `scheduleCycle` / `tryStartLoop` /
2190
- * first-cycle-activation logic, with Angular specifics removed so BOTH the builder twin and the
2191
- * engine/runtime twin (`iox-gallery`) run ONE implementation — never a forked copy that drifts.
2074
+ * Build Swiper's mobile-first `breakpoints` map from IOX's desktop-first layers.
2192
2075
  *
2193
- * The loop owns only *timing + index* state and emits it through `onChange`; the component maps that
2194
- * state to CSS via the effect registry (`gallery-effects.ts`). Timers are injected so the loop is
2195
- * deterministic under fake timers in tests and platform-guardable in the engine.
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.
2196
2079
  *
2197
- * See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
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.
2198
2086
  */
2199
- /** Default scheduler over the platform's `setTimeout` / `requestAnimationFrame` (with a timer fallback). */
2200
- const DEFAULT_GALLERY_SCHEDULER = {
2201
- setTimer: (fn, ms) => setTimeout(fn, ms),
2202
- clearTimer: h => clearTimeout(h),
2203
- nextFrame: fn => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(fn) : setTimeout(fn, 16),
2204
- cancelFrame: h => {
2205
- if (typeof cancelAnimationFrame === 'function')
2206
- cancelAnimationFrame(h);
2207
- else
2208
- clearTimeout(h);
2209
- },
2210
- };
2211
- class GalleryLoop {
2212
- constructor(opts) {
2213
- this.count = 0;
2214
- this.currentIndex = 0;
2215
- this.fadingOutIndex = null;
2216
- this.firstCycleActive = false;
2217
- this.cycleTimer = null;
2218
- this.fadeTimer = null;
2219
- this.frameHandle = null;
2220
- /** Bumped on every (re)start so a stale timer callback from a previous run is ignored. */
2221
- this.runToken = 0;
2222
- this.running = false;
2223
- this.scheduler = opts.scheduler ?? DEFAULT_GALLERY_SCHEDULER;
2224
- this.showMs = opts.showMs;
2225
- this.fadeMs = opts.fadeMs;
2226
- this.onChange = opts.onChange;
2227
- }
2228
- get state() {
2229
- return {
2230
- currentIndex: this.currentIndex,
2231
- fadingOutIndex: this.fadingOutIndex,
2232
- firstCycleActive: this.firstCycleActive,
2233
- };
2234
- }
2235
- /** Start (or restart) the loop for `count` images. A count < 2 shows the single image without cycling. */
2236
- start(count) {
2237
- this.stop();
2238
- this.count = Math.max(0, count | 0);
2239
- this.currentIndex = 0;
2240
- this.fadingOutIndex = null;
2241
- this.firstCycleActive = false;
2242
- if (this.count === 0) {
2243
- this.emit();
2244
- return;
2245
- }
2246
- this.running = true;
2247
- const token = ++this.runToken;
2248
- // Activate on the next frame so the first paint is flat (no entry-transition flash), then
2249
- // begin cycling only when there is more than one image to advance through.
2250
- this.frameHandle = this.scheduler.nextFrame(() => {
2251
- if (token !== this.runToken || !this.running)
2252
- return;
2253
- this.firstCycleActive = true;
2254
- this.emit();
2255
- if (this.count > 1)
2256
- this.scheduleCycle(token);
2257
- });
2258
- }
2259
- /** Update timing live. Takes effect on the next scheduled cycle; does not restart the loop. */
2260
- setDurations(showMs, fadeMs) {
2261
- this.showMs = showMs;
2262
- this.fadeMs = fadeMs;
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;
2263
2098
  }
2264
- /** Stop and clear all timers. Idempotent. State is retained (call `start` to reset). */
2265
- stop() {
2266
- this.running = false;
2267
- this.runToken++;
2268
- if (this.cycleTimer != null) {
2269
- this.scheduler.clearTimer(this.cycleTimer);
2270
- this.cycleTimer = null;
2271
- }
2272
- if (this.fadeTimer != null) {
2273
- this.scheduler.clearTimer(this.fadeTimer);
2274
- this.fadeTimer = null;
2099
+ return map;
2100
+ }
2101
+ /**
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.
2104
+ */
2105
+ function mergeSwiperBreakpoints(derived, advancedJson) {
2106
+ let advanced;
2107
+ if (advancedJson) {
2108
+ try {
2109
+ advanced = JSON.parse(advancedJson);
2275
2110
  }
2276
- if (this.frameHandle != null) {
2277
- this.scheduler.cancelFrame(this.frameHandle);
2278
- this.frameHandle = null;
2111
+ catch {
2112
+ advanced = undefined;
2279
2113
  }
2280
2114
  }
2281
- scheduleCycle(token) {
2282
- if (token !== this.runToken || !this.running || this.count < 2)
2283
- return;
2284
- this.cycleTimer = this.scheduler.setTimer(() => {
2285
- if (token !== this.runToken || !this.running)
2286
- return;
2287
- this.fadingOutIndex = this.currentIndex;
2288
- this.currentIndex = (this.currentIndex + 1) % this.count;
2289
- this.emit();
2290
- this.fadeTimer = this.scheduler.setTimer(() => {
2291
- if (token !== this.runToken || !this.running)
2292
- return;
2293
- this.fadingOutIndex = null;
2294
- this.emit();
2295
- this.scheduleCycle(token);
2296
- }, this.fadeMs);
2297
- }, this.showMs);
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 ?? {}) };
2298
2123
  }
2299
- emit() {
2300
- this.onChange(this.state);
2124
+ return Object.keys(out).length ? out : undefined;
2125
+ }
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];
2301
2141
  }
2142
+ return out;
2143
+ }
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;
2302
2150
  }
2303
2151
 
2304
- /**
2305
- * `iox-gallery` — the engine/runtime twin of the Gallery builder component (the builder twin
2306
- * `app-builder-gallery` arrives in Phase 2). It runs the SHARED crossfade loop + effect registry from
2307
- * `@vectoriox/iox-ui`, so the fade behaves identically on the canvas and the live site — no forked
2308
- * animation logic. See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
2309
- *
2310
- * Population: v1 drives the loop from the `images` input (manual mode). Data-source binding
2311
- * (`source`/`sourcePath`) is Phase 3 — this component already resolves through `resolveGalleryUrls`,
2312
- * so that only adds a bound-source input, not new render logic.
2313
- *
2314
- * SSR / no-JS: on the server the loop never starts, so the **first image renders visible** as a static
2315
- * frame (SEO + no-JS baseline). In the browser `ngAfterViewInit` starts the loop, which begins on the
2316
- * same index 0 — so the static frame and the first live frame match and there is no hydration flash.
2317
- */
2318
- class ClientGalleryComponent {
2319
- /** Manual images — `GalleryImage[]` or a plain `string[]` of URLs (normalised on set). */
2320
- set images(v) {
2321
- this._images = v ?? [];
2322
- this.urls = resolveGalleryUrls(this.normalizeImages(), null);
2323
- // A live edit (image added/removed/reordered) while the loop runs → restart on the new set so
2324
- // the canvas reflects it and the loop cycles through the new count.
2325
- if (this.loopActive)
2326
- this.startLoop();
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;
2327
2166
  }
2328
- get images() { return this._images; }
2329
- set showDuration(v) { this._showDuration = v; this.loop?.setDurations(v, this._fadeDuration); }
2330
- get showDuration() { return this._showDuration; }
2331
- set fadeDuration(v) { this._fadeDuration = v; this.loop?.setDurations(this._showDuration, v); }
2332
- get fadeDuration() { return this._fadeDuration; }
2333
- constructor(platformId, cdr) {
2334
- this.cdr = cdr;
2167
+ constructor(platformId) {
2168
+ this.platformId = platformId;
2335
2169
  this.nodeId = '';
2336
- this.effect = 'fade';
2337
- this.autoStart = true;
2338
- /** Layout mode — only `'stack'` is rendered in v1 (`'grid'` scaffolded for later). */
2339
- this.layout = 'stack';
2340
- // ── Inputs that mutate live state use SETTERS, not ngOnChanges ──────────────────────────────
2341
- // The builder trait panel pushes edits via DIRECT property assignment (`instance.images = v`),
2342
- // which does NOT trigger ngOnChanges — so a gallery that recomputed in ngOnChanges never refreshed
2343
- // when you added an image on the canvas. A setter fires on BOTH direct assignment (builder panel)
2344
- // and setInput (SSR engine / render.directive), so it's the one path that works everywhere.
2345
- this._images = [];
2346
- this._showDuration = 4000;
2347
- this._fadeDuration = 1000;
2348
- /** The flat, display-ready URLs the loop cycles. Recomputed whenever `images` is set. */
2349
- this.urls = [];
2350
- this.currentIndex = 0;
2351
- this.fadingOutIndex = null;
2352
- this.started = false;
2353
- /** True once the loop has been started (in ngAfterViewInit) — gates the setter-driven restart. */
2354
- this.loopActive = false;
2355
- this.loop = null;
2356
- this.isBrowser = isPlatformBrowser(platformId);
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);
2357
2209
  }
2210
+ ngOnInit() { }
2358
2211
  ngAfterViewInit() {
2359
- if (this.isBrowser && this.autoStart) {
2360
- this.startLoop();
2212
+ if (isPlatformBrowser(this.platformId)) {
2213
+ // Defer so all child slides are rendered first
2214
+ requestAnimationFrame(() => this._initSwiper());
2361
2215
  }
2362
2216
  }
2363
2217
  ngOnDestroy() {
2364
- this.loop?.stop();
2365
- this.loop = null;
2366
- this.loopActive = false;
2367
- }
2368
- /** Per-slide inline style. Before the loop activates (server + no-JS + pre-first-frame) the first
2369
- * image is the visible static frame; once running, the effect owns each slide's look. */
2370
- slideStyle(i) {
2371
- if (!this.started) {
2372
- return i === 0
2373
- ? { opacity: '1', transform: 'scale(1)', transition: 'none' }
2374
- : { opacity: '0', transform: 'scale(1)', transition: 'none' };
2218
+ if (this._swiper) {
2219
+ this._swiper.destroy(true, true);
2220
+ this._swiper = null;
2221
+ this.instanceRegistry.unregister(this.nodeId);
2375
2222
  }
2376
- return resolveGalleryEffect(this.effect).buildSlideStyle({
2377
- active: i === this.currentIndex,
2378
- fadingOut: i === this.fadingOutIndex,
2379
- firstCycleActive: true,
2380
- durations: { showMs: this.showDuration, fadeMs: this.fadeDuration },
2381
- });
2382
2223
  }
2383
- startLoop() {
2384
- if (!this.isBrowser)
2224
+ async _initSwiper() {
2225
+ const el = this.swiperEl?.nativeElement;
2226
+ if (!el)
2385
2227
  return;
2386
- this.loopActive = true;
2387
- this.loop?.stop();
2388
- this.loop = new GalleryLoop({
2389
- showMs: this.showDuration,
2390
- fadeMs: this.fadeDuration,
2391
- onChange: state => {
2392
- this.currentIndex = state.currentIndex;
2393
- this.fadingOutIndex = state.fadingOutIndex;
2394
- this.started = state.firstCycleActive;
2395
- this.cdr.markForCheck();
2396
- },
2397
- });
2398
- this.loop.start(this.urls.length);
2399
- }
2400
- normalizeImages() {
2401
- return (this.images ?? []).map(img => (typeof img === 'string' ? { url: img } : img));
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);
2402
2295
  }
2403
- 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 }); }
2404
- 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: `
2405
- <div class="iox-gallery" [class]="'iox-node-' + nodeId">
2406
- <div *ngFor="let url of urls; index as i"
2407
- class="iox-gallery__img"
2408
- [style.background-image]="'url(' + url + ')'"
2409
- [ngStyle]="slideStyle(i)"></div>
2410
- <div class="iox-gallery__overlay"></div>
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>
2411
2306
  </div>
2412
- `, 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"] }] }); }
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"] }] }); }
2413
2308
  }
2414
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientGalleryComponent, decorators: [{
2309
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: ClientSliderContainerComponent, decorators: [{
2415
2310
  type: Component,
2416
- args: [{ selector: 'iox-gallery', template: `
2417
- <div class="iox-gallery" [class]="'iox-node-' + nodeId">
2418
- <div *ngFor="let url of urls; index as i"
2419
- class="iox-gallery__img"
2420
- [style.background-image]="'url(' + url + ')'"
2421
- [ngStyle]="slideStyle(i)"></div>
2422
- <div class="iox-gallery__overlay"></div>
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>
2423
2320
  </div>
2424
- `, 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"] }]
2321
+ `, standalone: false, styles: [".swiper{width:100%;height:100%;box-sizing:border-box}\n"] }]
2425
2322
  }], ctorParameters: () => [{ type: undefined, decorators: [{
2426
2323
  type: Inject,
2427
2324
  args: [PLATFORM_ID]
2428
- }] }, { type: i0.ChangeDetectorRef }], propDecorators: { nodeId: [{
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: [{
2429
2340
  type: Input
2430
2341
  }], effect: [{
2431
2342
  type: Input
2432
- }], autoStart: [{
2343
+ }], grabCursor: [{
2433
2344
  type: Input
2434
- }], layout: [{
2345
+ }], centeredSlides: [{
2435
2346
  type: Input
2436
- }], images: [{
2347
+ }], initialSlide: [{
2437
2348
  type: Input
2438
- }], showDuration: [{
2349
+ }], enableNavigation: [{
2439
2350
  type: Input
2440
- }], fadeDuration: [{
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
+ }] } });
2390
+
2391
+ /**
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.
2397
+ *
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).
2400
+ */
2401
+ class ClientSliderSlideComponent {
2402
+ constructor() {
2403
+ this.nodeId = '';
2404
+ }
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"] }); }
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
+ }] } });
2417
+
2418
+ /**
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>`).
2423
+ */
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"] }); }
2465
+ }
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: [{
2441
2470
  type: Input
2471
+ }], childrenHost: [{
2472
+ type: ViewChild,
2473
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
2442
2474
  }] } });
2443
2475
 
2444
- const COMPONENTS = [
2445
- BuilderImageComponent,
2446
- BuilderSpacerComponent,
2447
- CardComponent,
2448
- BuilderDividerComponent,
2449
- BuilderIconComponent,
2450
- BuilderLinkComponent,
2451
- BuilderButtonComponent,
2452
- BuilderHeadingComponent,
2453
- TextBlockComponent,
2454
- SectionComponent,
2455
- ContainerComponent,
2456
- LinkedContainerComponent,
2457
- ClientRepeaterComponent,
2458
- ButtonBlockComponent,
2459
- ClientSliderContainerComponent,
2460
- ClientSliderSlideComponent,
2461
- ClientListComponent,
2462
- ClientListItemComponent,
2463
- ClientGalleryComponent,
2464
- ];
2465
- class IoxBuilderComponentsModule {
2466
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
2467
- static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, declarations: [BuilderImageComponent,
2468
- BuilderSpacerComponent,
2469
- CardComponent,
2470
- BuilderDividerComponent,
2471
- BuilderIconComponent,
2472
- BuilderLinkComponent,
2473
- BuilderButtonComponent,
2474
- BuilderHeadingComponent,
2475
- TextBlockComponent,
2476
- SectionComponent,
2477
- ContainerComponent,
2478
- LinkedContainerComponent,
2479
- ClientRepeaterComponent,
2480
- ButtonBlockComponent,
2481
- ClientSliderContainerComponent,
2482
- ClientSliderSlideComponent,
2483
- ClientListComponent,
2484
- ClientListItemComponent,
2485
- ClientGalleryComponent], imports: [CommonModule], exports: [BuilderImageComponent,
2486
- BuilderSpacerComponent,
2487
- CardComponent,
2488
- BuilderDividerComponent,
2489
- BuilderIconComponent,
2490
- BuilderLinkComponent,
2491
- BuilderButtonComponent,
2492
- BuilderHeadingComponent,
2493
- TextBlockComponent,
2494
- SectionComponent,
2495
- ContainerComponent,
2496
- LinkedContainerComponent,
2497
- ClientRepeaterComponent,
2498
- ButtonBlockComponent,
2499
- ClientSliderContainerComponent,
2500
- ClientSliderSlideComponent,
2501
- ClientListComponent,
2502
- ClientListItemComponent,
2503
- ClientGalleryComponent] }); }
2504
- static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, imports: [CommonModule] }); }
2505
- }
2506
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, decorators: [{
2507
- type: NgModule,
2508
- args: [{
2509
- declarations: COMPONENTS,
2510
- imports: [CommonModule],
2511
- exports: COMPONENTS,
2512
- }]
2513
- }] });
2514
-
2515
2476
  /**
2516
- * Single source of truth for the IOX component type → Angular class mapping.
2517
- * Used by the client engine (iox-client-engine) to resolve component classes
2518
- * from IoxLayoutNode.type strings at render time.
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:
2519
2481
  *
2520
- * All 13 builder components are registered here:
2521
- * - Leaf (Phase 2a): Image, Spacer, Card, Divider, Icon
2522
- * - Inline-editable (Phase 2b): Link, Button, Heading, Text
2523
- * - Layout shells (Phase 2c): Section, Container, LinkedContainer
2524
- * - Repeater (Phase 2d): ClientRepeaterComponent
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)
2525
2488
  *
2526
- * Section/Container/LinkedContainer/Repeater expose childrenHost: ViewContainerRef
2527
- * for ClientRenderDirective to insert child components into.
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.
2528
2493
  */
2529
- class IoxComponentRegistryService {
2530
- constructor() {
2531
- this.map = new Map([
2532
- ['Image', BuilderImageComponent],
2533
- ['Spacer', BuilderSpacerComponent],
2534
- ['Card', CardComponent],
2535
- ['Divider', BuilderDividerComponent],
2536
- ['Icon', BuilderIconComponent],
2537
- ['Link', BuilderLinkComponent],
2538
- ['Button', BuilderButtonComponent],
2539
- ['Heading', BuilderHeadingComponent],
2540
- ['Text', TextBlockComponent],
2541
- ['Section', SectionComponent],
2542
- ['Container', ContainerComponent],
2543
- ['LinkedContainer', LinkedContainerComponent],
2544
- ['Repeater', ClientRepeaterComponent],
2545
- ['ButtonBlock', ButtonBlockComponent],
2546
- ['SliderContainer', ClientSliderContainerComponent],
2547
- ['SliderSlide', ClientSliderSlideComponent],
2548
- ['List', ClientListComponent],
2549
- ['ListItem', ClientListItemComponent],
2550
- ['Gallery', ClientGalleryComponent],
2551
- ]);
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] ?? '');
2552
2507
  }
2553
- getComponent(type) {
2554
- return this.map.get(type);
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;
2555
2524
  }
2556
- hasComponent(type) {
2557
- return this.map.has(type);
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;
2533
+ }
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;
2577
+ }
2578
+ case 'external-api': {
2579
+ const endpoint = ds.request?.endpoint;
2580
+ return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
2581
+ }
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' };
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' };
2596
+ }
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);
2609
+ }
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;
2617
+ }
2618
+ return undefined;
2619
+ }
2620
+ /**
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.
2624
+ */
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];
2558
2642
  }
2559
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2560
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, providedIn: 'root' }); }
2643
+ return cur;
2561
2644
  }
2562
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxComponentRegistryService, decorators: [{
2563
- type: Injectable,
2564
- args: [{ providedIn: 'root' }]
2565
- }] });
2566
-
2567
2645
  /**
2568
- * style-compiler — the SINGLE source of truth for turning a node's style map into
2569
- * CSS declarations. Framework-agnostic (no Angular): consumed by both renderers.
2570
- *
2571
- * - Builder canvas: `StyleRegistryService` (@vectoriox/iox-builder) → live `<style>` tag.
2572
- * - SSR site: `LayoutRendererService` (iox-client-engine) → HTML string CSS.
2573
- *
2574
- * Lives in @vectoriox/iox-ui because BOTH consumers depend on iox-ui (the engine does
2575
- * NOT depend on iox-builder). Keeping it here — not duplicated per renderer — is what
2576
- * prevents the builder and the live site from drifting.
2577
- * See iox-ai-guidance `architecture/builder/render-parity.md`.
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 → `[]`.
2578
2652
  */
2579
2653
  /**
2580
- * Rewrite viewport-relative units so they resolve to the simulated canvas dimensions
2581
- * in the builder, and to the real viewport in production.
2582
- *
2583
- * `--preview-vh` / `--preview-vw` are set by BuilderComponent (ResizeObserver +
2584
- * viewport-state subscription). In production those variables are undefined, so the
2585
- * `1vh` / `1vw` fallbacks restore native browser behaviour.
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.
2586
2658
  */
2587
- function rewriteViewportUnits(value) {
2588
- return value
2589
- .replace(/(-?\d*\.?\d+)vh/g, 'calc($1 * var(--preview-vh, 1vh))')
2590
- .replace(/(-?\d*\.?\d+)vw/g, 'calc($1 * var(--preview-vw, 1vw))');
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
+ }
2672
+ }
2673
+ }
2674
+ return resolved;
2591
2675
  }
2592
- /** camelCase style key → kebab-case CSS property. */
2593
- function styleKeyToKebab(key) {
2594
- return key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
2676
+ function resolveRepeaterItems(resolved, sourcePath) {
2677
+ if (sourcePath) {
2678
+ const nested = getByPath(resolved, sourcePath);
2679
+ return Array.isArray(nested) ? nested : [];
2680
+ }
2681
+ if (Array.isArray(resolved))
2682
+ return resolved;
2683
+ return resolved == null ? [] : [resolved];
2684
+ }
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));
2595
2732
  }
2596
2733
  /**
2597
- * kebab CSS property → the value that equals its CSS initial AND has no UA-stylesheet override for
2598
- * any element the builder renders (div/section/span/i/a/img/h1–h6/p/ul). Emitting these is a pure
2599
- * no-op, so they're dropped to keep the compiled CSS lean (self-describing nodes serialise every
2600
- * trait, many at their default).
2601
- *
2602
- * Deliberately EXCLUDED, because a value equal to the initial can still be meaningful there:
2603
- * - inherited properties (line-height, color, font-family/size/weight, text-align, letter-spacing…)
2604
- * — the value may override an inherited ancestor value.
2605
- * - margin / padding — UA stylesheets set them on h1–h6, p, ul, so `0` depends on a reset.
2606
- * - border-* — the initial `medium` width + interaction with border-style makes it non-trivial.
2607
- */
2608
- const NOOP_INITIAL_VALUES = {
2609
- 'width': ['auto'], 'height': ['auto'],
2610
- 'min-width': ['auto'], 'min-height': ['auto'],
2611
- 'max-width': ['none', 'auto'], 'max-height': ['none', 'auto'], // `auto` is invalid for max-* → inert
2612
- 'position': ['static'],
2613
- 'top': ['auto'], 'right': ['auto'], 'bottom': ['auto'], 'left': ['auto'],
2614
- 'opacity': ['1'], 'z-index': ['auto'],
2615
- 'flex-grow': ['0'], 'flex-shrink': ['1'], 'flex-basis': ['auto'],
2616
- 'flex-direction': ['row'], 'flex-wrap': ['nowrap'],
2617
- 'gap': ['0', '0px', 'normal'],
2618
- 'border-radius': ['0', '0px'],
2619
- 'aspect-ratio': ['auto'],
2620
- 'mix-blend-mode': ['normal'],
2621
- 'transform': ['none'], 'box-shadow': ['none'],
2622
- // overflow initial is `visible`. A self-describing node serialises the Overflow trait's default,
2623
- // so WITHOUT this the emitted `overflow: visible` would override component baselines that clip
2624
- // (e.g. `.iox-card__img-wrap`, `.iox-slider`, `.iox-gallery` all set `overflow: hidden`), breaking
2625
- // object-fit/aspect-ratio image clipping everywhere. A user-set hidden/scroll/auto/clip is not the
2626
- // initial, so it still emits and wins.
2627
- 'overflow': ['visible'], 'overflow-x': ['visible'], 'overflow-y': ['visible'],
2628
- 'clip-path': ['none'],
2629
- };
2630
- /**
2631
- * Style props a type's render-default fully REPLACES. Emitting them would produce a dead
2632
- * declaration that is immediately overridden in the same rule (e.g. an Icon's serialised
2633
- * `lineHeight: 1.5` followed by the forced `line-height: 1`), so callers strip them first via
2634
- * {@link stripSuppressedProps} and let renderDefaultDeclarations supply the real value.
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=`.
2635
2738
  */
2636
- const SUPPRESSED_STYLE_PROPS_BY_TYPE = {
2637
- // An icon's line box is structurally forced to 1 — see the Icon branch of renderDefaultDeclarations.
2638
- Icon: ['lineHeight'],
2639
- };
2640
- /** Remove the style props whose value a type's render-default always overrides. Returns the map
2641
- * unchanged (same reference) when the type suppresses nothing. */
2642
- function stripSuppressedProps(type, styleProps) {
2643
- const suppressed = SUPPRESSED_STYLE_PROPS_BY_TYPE[type];
2644
- if (!suppressed?.length)
2645
- return styleProps;
2646
- const out = { ...styleProps };
2647
- for (const key of suppressed)
2648
- delete out[key];
2649
- return out;
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;
2754
+ }
2650
2755
  }
2756
+
2651
2757
  /**
2652
- * What a compiled rule is FOR — decides whether an initial value is a no-op.
2758
+ * Gallery — data model + the SHARED "what are the image URLs" resolver.
2653
2759
  *
2654
- * In a `Base` rule nothing is being overridden, so `width: auto` or `transform: none` changes
2655
- * nothing and is dropped (see NOOP_INITIAL_VALUES). In an `Override` rule — a breakpoint layer —
2656
- * the initial value is exactly how you UNDO a wider rule (`width: 520px` → `auto` on mobile), so
2657
- * dropping it would silently keep the desktop value.
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.
2766
+ *
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.
2658
2771
  */
2659
- var DeclarationContext;
2660
- (function (DeclarationContext) {
2661
- DeclarationContext["Base"] = "base";
2662
- DeclarationContext["Override"] = "override";
2663
- })(DeclarationContext || (DeclarationContext = {}));
2664
2772
  /**
2665
- * Compile a flat, camelCase style map into an array of `"prop: value"` CSS
2666
- * declarations (kebab-cased, viewport-units rewritten, empties dropped, no-op initials dropped
2667
- * in a `Base` context only, optional `!important`). Callers wrap these in whatever selector
2668
- * they emit.
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.
2669
2775
  *
2670
- * @param props Flat camelCase style map.
2671
- * @param importantProps Keys that must carry `!important` (e.g. `position`, to beat
2672
- * Angular's scoped-CSS specificity boost in the builder). A prop forced
2673
- * `!important` is never dropped as a no-op — the `!important` exists to win
2674
- * the cascade, so its value must always be emitted.
2675
- * @param context `Override` for a rule that overrides a wider one (a breakpoint layer):
2676
- * initial values are kept, because there they reset rather than no-op.
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 `{}`.
2677
2779
  */
2678
- function compileDeclarations(props, importantProps, context = DeclarationContext.Base) {
2679
- const out = [];
2680
- for (const [k, v] of Object.entries(props)) {
2681
- if (v === undefined || v === null || v === '')
2682
- continue;
2683
- const prop = styleKeyToKebab(k);
2684
- const val = rewriteViewportUnits(String(v));
2685
- const isImportant = importantProps?.has(k) ?? false;
2686
- const dropsNoOps = context === DeclarationContext.Base && !isImportant;
2687
- if (dropsNoOps && NOOP_INITIAL_VALUES[prop]?.includes(val.trim().toLowerCase()))
2688
- continue;
2689
- out.push(`${prop}: ${val}${isImportant ? ' !important' : ''}`);
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);
2690
2787
  }
2691
- return out;
2788
+ return (images ?? [])
2789
+ .map(normalizeGalleryImage)
2790
+ .map(img => (img?.binding ? getByPath(ctx, img.binding) : img?.url))
2791
+ .map(coerceUrl)
2792
+ .filter((u) => !!u);
2692
2793
  }
2693
2794
  /**
2694
- * Per-type default `width` that the builder applies from each component's config
2695
- * (NOT persisted in styleProps — see render-parity.md "saved node is not
2696
- * self-describing"). The engine has no config classes, so it resolves the same
2697
- * default from THIS map at render time. Keeping the map here (not re-typed in the
2698
- * engine) is the "shared default map" decision.
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.
2800
+ */
2801
+ function normalizeGalleryImage(image) {
2802
+ if (typeof image === 'string')
2803
+ return { url: image };
2804
+ return image ?? undefined;
2805
+ }
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.
2816
+ */
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 }));
2825
+ }
2826
+
2827
+ /**
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.
2699
2832
  *
2700
- * ONLY the `100%` container-type defaults live here. Fixed-size config defaults
2701
- * (Card 200px, Image 300px, SliderSlide 33%) are starter placeholders real content
2702
- * 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.
2703
2838
  */
2704
- const DEFAULT_WIDTH_BY_TYPE = {
2705
- Container: '100%', Repeater: '100%', SliderContainer: '100%',
2706
- List: '100%', ListItem: '100%', Text: '100%', Divider: '100%', Spacer: '100%',
2707
- // Section, Heading, LinkedContainer, Card, Image, SliderSlide, Button, ButtonBlock,
2708
- // 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,
2709
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
+
2710
2872
  /**
2711
- * Extra "render default" declarations the builder gets implicitly from live component
2712
- * behaviour, which the engine must re-apply explicitly for parity. Returns `"prop: value"`
2713
- * 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.
2714
2877
  *
2715
- * @param type Node type (registry key).
2716
- * @param styleProps The node's persisted styleProps.
2717
- * @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.
2718
2883
  */
2719
- function renderDefaultDeclarations(type, styleProps, hasHoverState) {
2720
- const sp = styleProps ?? {};
2721
- const out = [];
2722
- const isOutOfFlow = sp['position'] === 'fixed' || sp['position'] === 'absolute';
2723
- // Container-type default width so it fills its track. Only when width is unset — once a page
2724
- // is saved with fully-resolved styleProps (self-describing nodes), width comes through
2725
- // compileDeclarations and this is a no-op.
2726
- if (sp['width'] === undefined) {
2727
- if (isOutOfFlow) {
2728
- 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;
2729
2930
  }
2730
- else if (DEFAULT_WIDTH_BY_TYPE[type]) {
2731
- 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;
2732
2964
  }
2733
2965
  }
2734
- // min-width:0 lets container flex children shrink below content and equal-split. Emit it for
2735
- // container types INDEPENDENT of where width came from (styleProps vs the default above) —
2736
- // otherwise a saved page whose width is persisted would lose the equal-split. In the builder the
2737
- // same rule comes from the `.container-child` wrapper; here it lands on `.iox-node` directly.
2738
- if (DEFAULT_WIDTH_BY_TYPE[type] && sp['minWidth'] === undefined && !isOutOfFlow) {
2739
- 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);
2740
2983
  }
2741
- // A border only renders when there is a WIDTH. Gate border-style on borderWidth — NOT on
2742
- // borderColor: a node with only a border colour and no width would otherwise get the
2743
- // browser's default `medium` width and show a phantom border. Absent borderWidth = no border.
2744
- if (sp['borderWidth'] && sp['borderStyle'] === undefined) {
2745
- out.push('border-style: solid');
2984
+ emit() {
2985
+ this.onChange(this.state);
2746
2986
  }
2747
- // Default transition so own/parent hover animates smoothly (parity with the builder).
2748
- if (hasHoverState && sp['transition'] === undefined) {
2749
- 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);
2750
3042
  }
2751
- // Image defaults reapplied when unset (the config defaults aren't always persisted). height:auto
2752
- // is only a DEFAULT — when the user sets an explicit height (e.g. 100%) it must WIN, so guard it
2753
- // on `sp['height'] === undefined` (matching the builder, which respects a configured height). An
2754
- // unset height + aspect-ratio lets the ratio drive the box.
2755
- if (type === 'Image') {
2756
- if (sp['aspectRatio'] === undefined)
2757
- out.push('aspect-ratio: 1/1');
2758
- // object-fit is a Size style trait with config default 'cover'; that default isn't persisted,
2759
- // so reapply it here (matching the builder) when the node has none.
2760
- if (sp['objectFit'] === undefined)
2761
- out.push('object-fit: cover');
2762
- // NOTE: no `height: auto` — when height is unset, `auto` is already the CSS initial value, so
2763
- // 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
+ }
2764
3047
  }
2765
- // Containers default to a positioning context (for absolutely-positioned children). These live
2766
- // in `.iox-node` (not the component `:host`) so an explicit styleProps value always wins.
2767
- if ((type === 'Container' || type === 'Section' || type === 'LinkedContainer') && sp['position'] === undefined) {
2768
- out.push('position: relative');
3048
+ ngOnDestroy() {
3049
+ this.loop?.stop();
3050
+ this.loop = null;
3051
+ this.loopActive = false;
2769
3052
  }
2770
- // LinkedContainer is an <a> (inline by default) → default it to block so children lay out.
2771
- if (type === 'LinkedContainer' && sp['display'] === undefined) {
2772
- 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
+ });
2773
3067
  }
2774
- // An Icon is a single FONT GLYPH, not a paragraph: it must contribute no line-box height of its
2775
- // own (spacing belongs to padding/margin). But every node serialises the Typography group's
2776
- // `lineHeight` default of 1.5, so icons carry `line-height: 1.5` — a 24px glyph then gets a 36px
2777
- // line box and pushes the surrounding layout around.
2778
- //
2779
- // This is emitted UNCONDITIONALLY (not guarded on `undefined`) and callers append render-defaults
2780
- // AFTER the node's own compiled declarations in the SAME rule, so it overrides a stale serialised
2781
- // value for EXISTING icons too — no re-save needed. line-height is inherited, so simply omitting
2782
- // it is not enough: the icon would inherit 1.5 from its container instead.
2783
- if (type === 'Icon')
2784
- out.push('line-height: 0');
2785
- // An icon's `<i>` must shrink-wrap its glyph so it grows with font-size and doesn't overflow its
2786
- // parent. Phosphor forces `display:inline-flex` on its own class; generated (fantasticon) fonts
2787
- // don't, so the engine's plain `<i class="bi-x iox-node-…">` would fall back to inline and the
2788
- // glyph would overflow. Emit it here (guarded so a user-set display still wins) — mirrors the
2789
- // builder Icon component's `:host{display:inline-flex}`.
2790
- if (type === 'Icon' && sp['display'] === undefined) {
2791
- 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);
2792
3084
  }
2793
- 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"] }] }); }
2794
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
+ }] } });
2795
3125
 
2796
- // Virtual trait → composed CSS property helpers.
2797
- //
2798
- // Several style traits are "virtual" — they don't map 1:1 to a CSS property but
2799
- // are components of a multi-function CSS value. composeVirtualTraits() strips
2800
- // these virtual keys from the style map and replaces them with the correctly
2801
- // composed CSS properties (filter, backdrop-filter, transform, transition).
2802
- //
2803
- // SHARED (iox-ui): consumed by BOTH the builder (render.directive + style panel)
2804
- // and the SSR engine (LayoutRendererService). It MUST live here, not in iox-builder,
2805
- // because the engine does not depend on iox-builder — without it the engine emitted
2806
- // the raw virtual keys (`translate-x`, `filter-blur`, `scale-x`…) as invalid CSS.
2807
- // Run this BEFORE compileDeclarations so the CSS output is identical on both sides.
2808
- // ─── Filter ──────────────────────────────────────────────────────────────────
2809
- const FILTER_FUNS = [
2810
- ['filterBlur', 'blur', '0px'],
2811
- ['filterBrightness', 'brightness', '1'],
2812
- ['filterContrast', 'contrast', '1'],
2813
- ['filterGrayscale', 'grayscale', '0'],
2814
- ['filterSaturate', 'saturate', '1'],
2815
- ['filterHueRotate', 'hue-rotate', '0deg'],
2816
- ['filterSepia', 'sepia', '0'],
2817
- ['filterInvert', 'invert', '0'],
2818
- ];
2819
- // ─── Backdrop-filter ─────────────────────────────────────────────────────────
2820
- const BACKDROP_FUNS = [
2821
- ['backdropBlur', 'blur', '0px'],
2822
- ['backdropBrightness', 'brightness', '1'],
2823
- ['backdropContrast', 'contrast', '1'],
2824
- ['backdropSaturate', 'saturate', '1'],
2825
- ];
2826
- // ─── Transform ───────────────────────────────────────────────────────────────
2827
- const TRANSFORM_FUNS = [
2828
- ['translateX', 'translateX', '0px'],
2829
- ['translateY', 'translateY', '0px'],
2830
- ['scaleX', 'scaleX', '1'],
2831
- ['scaleY', 'scaleY', '1'],
2832
- ['rotate', 'rotate', '0deg'],
2833
- ['skewX', 'skewX', '0deg'],
2834
- ['skewY', 'skewY', '0deg'],
2835
- ];
2836
- // ─── ::marker pseudo-element ─────────────────────────────────────────────────
2837
- const MARKER_FUNS = [
2838
- ['markerColor', 'color'],
2839
- ['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,
2840
3146
  ];
2841
- const TRANSITION_KEYS = ['transitionDuration', 'transitionTimingFunction', 'transitionDelay'];
2842
- const VIRTUAL_TRAIT_GROUPS = Object.freeze([
2843
- { output: 'filter', keys: FILTER_FUNS.map(([t]) => t), reset: 'none' },
2844
- { output: 'backdropFilter', keys: BACKDROP_FUNS.map(([t]) => t), reset: 'none' },
2845
- { output: 'transform', keys: TRANSFORM_FUNS.map(([t]) => t), reset: 'none' },
2846
- { output: 'transition', keys: [...TRANSITION_KEYS], reset: 'none' },
2847
- { output: '__markerStyles', keys: MARKER_FUNS.map(([t]) => t), reset: null },
2848
- ]);
2849
- // ─── All virtual trait names ──────────────────────────────────────────────────
2850
- /** Every virtual trait key — derived from the groups so there is one list to maintain. */
2851
- const VIRTUAL_TRAIT_KEYS = new Set(VIRTUAL_TRAIT_GROUPS.flatMap(g => g.keys));
2852
- // ─── Helpers ─────────────────────────────────────────────────────────────────
2853
- function buildFns(funs, src) {
2854
- return funs
2855
- .filter(([t, , d]) => src[t] != null && src[t] !== '' && src[t] !== d)
2856
- .map(([t, fn]) => `${fn}(${src[t]})`)
2857
- .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] }); }
2858
3187
  }
2859
- // ─── 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
+
2860
3197
  /**
2861
- * 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.
2862
3201
  *
2863
- * When composing a partial state-override map (hover, active…), pass the full
2864
- * base style map as `base` so non-overridden components (e.g. filterBrightness
2865
- * when only filterBlur is overridden) are preserved in the composed output.
2866
- * 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.
2867
3210
  */
2868
- function composeVirtualTraits(raw, base = raw) {
2869
- // Copy all non-virtual properties as-is
2870
- const result = {};
2871
- for (const [k, v] of Object.entries(raw)) {
2872
- if (!VIRTUAL_TRAIT_KEYS.has(k))
2873
- result[k] = v;
2874
- }
2875
- // Merge base values for any virtual key absent from the partial override map
2876
- const merged = (funs) => {
2877
- const m = {};
2878
- for (const [t] of funs)
2879
- m[t] = raw[t] ?? base[t];
2880
- return m;
2881
- };
2882
- // filter
2883
- const filterStr = buildFns(FILTER_FUNS, merged(FILTER_FUNS));
2884
- if (filterStr)
2885
- result['filter'] = filterStr;
2886
- // backdrop-filter
2887
- const backdropStr = buildFns(BACKDROP_FUNS, merged(BACKDROP_FUNS));
2888
- if (backdropStr)
2889
- result['backdropFilter'] = backdropStr;
2890
- // transform
2891
- const transformStr = buildFns(TRANSFORM_FUNS, merged(TRANSFORM_FUNS));
2892
- if (transformStr)
2893
- result['transform'] = transformStr;
2894
- // transition — only emit when duration is non-zero
2895
- const dur = raw['transitionDuration'] ?? base['transitionDuration'];
2896
- const ease = raw['transitionTimingFunction'] ?? base['transitionTimingFunction'];
2897
- const del = raw['transitionDelay'] ?? base['transitionDelay'];
2898
- if (dur && ease && dur !== '0ms' && dur !== '0s') {
2899
- const delPart = del && del !== '0ms' && del !== '0s' ? ` ${del}` : '';
2900
- 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
+ ]);
2901
3234
  }
2902
- // ::marker pseudo-element — collected into __markerStyles so StyleRegistryService
2903
- // can emit a separate `.iox-node-{id}::marker { … }` rule.
2904
- const markerStyles = {};
2905
- for (const [trait, cssProp] of MARKER_FUNS) {
2906
- const val = raw[trait] ?? base[trait];
2907
- if (val != null && val !== '')
2908
- markerStyles[cssProp] = val;
3235
+ getComponent(type) {
3236
+ return this.map.get(type);
2909
3237
  }
2910
- if (Object.keys(markerStyles).length) {
2911
- result['__markerStyles'] = markerStyles;
3238
+ hasComponent(type) {
3239
+ return this.map.has(type);
2912
3240
  }
2913
- 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' }); }
2914
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
+ }] });
2915
3248
 
2916
3249
  /**
2917
- * tree-relations — framework-agnostic helpers for reasoning about a rendered node
2918
- * tree's structural relationships. Lives in @vectoriox/iox-ui because BOTH renderers
2919
- * depend on iox-ui (the engine does NOT depend on iox-builder), so the same logic
2920
- * drives the builder canvas AND the SSR site — no fork.
2921
- * See iox-ai-guidance `architecture/builder/targeted-hover-state-plan.md`.
2922
- */
2923
- /** The css id a node contributes to selectors: its clone `styleId`, else its own `id`. */
2924
- function nodeCssId(node) {
2925
- return node.styleId ?? node.id;
2926
- }
2927
- /**
2928
- * Path of css ids from the root down to (and including) the node whose css id === `cssId`.
2929
- * Returns `null` when no node matches. First match wins (depth-first, pre-order).
2930
- */
2931
- function pathToCssId(nodes, cssId) {
2932
- for (const node of nodes) {
2933
- const ownId = nodeCssId(node);
2934
- if (ownId === cssId)
2935
- return [ownId];
2936
- if (node.children?.length) {
2937
- const childPath = pathToCssId(node.children, cssId);
2938
- if (childPath)
2939
- return ownId ? [ownId, ...childPath] : childPath;
2940
- }
2941
- }
2942
- return null;
2943
- }
2944
- /**
2945
- * The css id of the NEAREST COMMON ANCESTOR of two nodes (identified by their css ids),
2946
- * used to scope a `:has()` targeted-state selector so it stays repetition-safe.
2947
- *
2948
- * The result may equal `styledCssId` (the trigger sits INSIDE the styled node) or
2949
- * `triggerCssId` (the styled node sits inside the trigger) — both are valid scopes;
2950
- * `buildTargetedStateSelector` picks the right selector shape for each. Returns
2951
- * `undefined` only when a valid scope can't exist: an id is blank, the two ids are the
2952
- * same node, or they share no ancestor in `root`. Callers MUST skip emitting the rule
2953
- * when this is `undefined` — never emit an unscoped (page-global) `:has()`, which would
2954
- * break under repetition.
2955
- */
2956
- function nearestCommonAncestorId(root, styledCssId, triggerCssId) {
2957
- if (!styledCssId || !triggerCssId || styledCssId === triggerCssId)
2958
- return undefined;
2959
- const styledPath = pathToCssId(root, styledCssId);
2960
- const triggerPath = pathToCssId(root, triggerCssId);
2961
- if (!styledPath || !triggerPath)
2962
- return undefined;
2963
- // Walk both paths together; the last shared prefix element is the common ancestor.
2964
- let common;
2965
- const shorter = Math.min(styledPath.length, triggerPath.length);
2966
- for (let i = 0; i < shorter; i++) {
2967
- if (styledPath[i] !== triggerPath[i])
2968
- break;
2969
- common = styledPath[i];
2970
- }
2971
- return common;
2972
- }
2973
- /**
2974
- * Build the selector BODY (no leading `.`, matching the builder's `compile()` convention)
2975
- * 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.
2976
3252
  *
2977
- * - trigger INSIDE styled (scope === styled): `iox-node-{styled}:has(.iox-node-{trigger}:{base})`
2978
- * - styled INSIDE trigger (scope === trigger): `iox-node-{trigger}:{base} .iox-node-{styled}`
2979
- * - 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.
2980
3255
  *
2981
- * `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`.
2982
3260
  */
2983
- function buildTargetedStateSelector(scopeCssId, triggerCssId, styledCssId, base) {
2984
- if (scopeCssId === styledCssId) {
2985
- return `iox-node-${styledCssId}:has(.iox-node-${triggerCssId}:${base})`;
2986
- }
2987
- if (scopeCssId === triggerCssId) {
2988
- return `iox-node-${triggerCssId}:${base} .iox-node-${styledCssId}`;
2989
- }
2990
- return `iox-node-${scopeCssId}:has(.iox-node-${triggerCssId}:${base}) .iox-node-${styledCssId}`;
2991
- }
2992
-
2993
- // Responsive (per-breakpoint) styles — the shared model, cascade and emission plan.
2994
- //
2995
- // SHARED (iox-ui): the builder canvas AND the SSR engine both call this. The builder
2996
- // FLATTENS the active breakpoint (its canvas is a transformed frame inside a desktop
2997
- // browser, so a real `@media` query would read the editor's width); the engine WRAPS
2998
- // each planned layer in `@media`. Same decisions, different transport — never fork them.
2999
- //
3000
- // Storage is a sparse layer beside the existing keys, shaped like `stateStyles`:
3001
- //
3002
- // styleProps — base, full resolved set (unchanged)
3003
- // stateStyles — sparse per-state overrides (unchanged)
3004
- // responsive — sparse per-breakpoint overrides (this file)
3005
- // { mobile: { styleProps: {…}, stateStyles: { hover: {…} } } }
3006
- //
3007
- // A node with no `responsive` key renders exactly as before — no migration.
3008
- // See iox-ai-guidance `architecture/builder/responsive-breakpoints-plan.md`.
3009
- // ─── Breakpoints ─────────────────────────────────────────────────────────────
3010
- /** The five fixed breakpoints (plan decision D2). Desktop-first: each narrower one
3011
- * overrides the wider ones through `max-width`. */
3012
- var Breakpoint;
3013
- (function (Breakpoint) {
3014
- Breakpoint["Base"] = "base";
3015
- Breakpoint["Laptop"] = "laptop";
3016
- Breakpoint["Tablet"] = "tablet";
3017
- Breakpoint["Mobile"] = "mobile";
3018
- Breakpoint["Small"] = "small";
3019
- })(Breakpoint || (Breakpoint = {}));
3020
3261
  /**
3021
- * 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.
3022
3264
  *
3023
- * ⚠️ Phones in portrait (360–430px) land in `Small`, NOT `Mobile` — `Mobile` is 480–767,
3024
- * a phone held landscape. The labels say so (as Webflow's do). The ids stay short because
3025
- * they are storage keys; the cascade still makes a `Mobile` override reach every phone.
3026
- */
3027
- const BREAKPOINTS = Object.freeze([
3028
- { id: Breakpoint.Base, label: 'Base', maxWidth: null, previewWidth: 1440 },
3029
- { id: Breakpoint.Laptop, label: 'Laptop', maxWidth: 1200, previewWidth: 1200 },
3030
- { id: Breakpoint.Tablet, label: 'Tablet', maxWidth: 991, previewWidth: 768 },
3031
- { id: Breakpoint.Mobile, label: 'Mobile landscape', maxWidth: 767, previewWidth: 568 },
3032
- { id: Breakpoint.Small, label: 'Mobile portrait', maxWidth: 479, previewWidth: 390 },
3033
- ]);
3034
- const BREAKPOINT_INDEX = new Map(BREAKPOINTS.map((d, i) => [d.id, i]));
3035
- /** The definition for `bp`, or `undefined` for an unknown id (e.g. hand-edited JSON). */
3036
- function breakpointDef(bp) {
3037
- const i = BREAKPOINT_INDEX.get(bp);
3038
- return i === undefined ? undefined : BREAKPOINTS[i];
3039
- }
3040
- /** Base → `bp`, inclusive — the layers whose values apply at `bp`. Unknown → `[Base]`. */
3041
- function cascadeChain(bp) {
3042
- const i = BREAKPOINT_INDEX.get(bp);
3043
- return BREAKPOINTS.slice(0, (i ?? 0) + 1).map(d => d.id);
3044
- }
3045
- /**
3046
- * The breakpoint a viewport (or canvas) of `width` CSS px falls in — the narrowest one whose
3047
- * `maxWidth` it fits under, else Base. Mirrors how the `max-width` media queries resolve, so the
3048
- * 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.
3049
3268
  */
3050
- function breakpointForWidth(width) {
3051
- for (let i = BREAKPOINTS.length - 1; i > 0; i--) {
3052
- const max = BREAKPOINTS[i].maxWidth;
3053
- if (max !== null && width <= max)
3054
- return BREAKPOINTS[i].id;
3055
- }
3056
- 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))');
3057
3273
  }
3058
- /** The next-wider breakpoint (what `bp` inherits from), or `null` for Base / unknown. */
3059
- function parentBreakpoint(bp) {
3060
- const i = BREAKPOINT_INDEX.get(bp);
3061
- 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()}`);
3062
3277
  }
3063
3278
  /**
3064
- * 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).
3065
3283
  *
3066
- * `Media` — the browser viewport. The SSR engine: a visitor's window IS the page.
3067
- * `Container` — a named CSS size container. The builder: its canvas is a fixed-width frame inside a
3068
- * desktop browser (and `transform: scale`d for zoom), so a media query would read the EDITOR's width.
3069
- * A container query reads the frame's own layout width — which equals the simulated viewport and
3070
- * 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.
3071
3289
  */
3072
- var BreakpointQueryKind;
3073
- (function (BreakpointQueryKind) {
3074
- BreakpointQueryKind["Media"] = "media";
3075
- BreakpointQueryKind["Container"] = "container";
3076
- })(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
+ };
3077
3312
  /**
3078
- * The at-rule prelude for `bp` — `@media (max-width: 767px)`, or with `Container`
3079
- * `@container iox-canvas (max-width: 767px)`. `null` for Base or an unknown id.
3080
- * `containerName` is required for `Container` (an unnamed container query would match
3081
- * 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.
3082
3317
  */
3083
- function breakpointQueryFor(bp, kind = BreakpointQueryKind.Media, containerName) {
3084
- const max = breakpointDef(bp)?.maxWidth;
3085
- if (max == null)
3086
- return null;
3087
- if (kind === BreakpointQueryKind.Media)
3088
- return `@media (max-width: ${max}px)`;
3089
- if (!containerName)
3090
- throw new Error('A container breakpoint query needs a container name');
3091
- return `@container ${containerName} (max-width: ${max}px)`;
3092
- }
3093
- /** Wrap already-compiled rules for `bp`. Base (and blank css) passes through unwrapped. */
3094
- function wrapInBreakpointQuery(bp, css, kind = BreakpointQueryKind.Media, containerName) {
3095
- if (!css.trim())
3096
- return '';
3097
- const query = breakpointQueryFor(bp, kind, containerName);
3098
- return query ? `${query} {\n${css}\n}` : css;
3099
- }
3100
- /** `@media (max-width: 767px)` for `bp`; `null` for Base or an unknown id. */
3101
- function mediaQueryFor(bp) {
3102
- return breakpointQueryFor(bp, BreakpointQueryKind.Media);
3103
- }
3104
- /** Wrap already-compiled rules for `bp` in its media query. Base (and blank css) passes through. */
3105
- function wrapInMediaQuery(bp, css) {
3106
- return wrapInBreakpointQuery(bp, css, BreakpointQueryKind.Media);
3107
- }
3108
- function layerAt(responsive, bp) {
3109
- return bp === Breakpoint.Base ? undefined : responsive?.[bp];
3110
- }
3111
- /** Copy defined entries of `src` onto `target`. `undefined` never overwrites a value. */
3112
- function assignDefined(target, src) {
3113
- if (src)
3114
- for (const [k, v] of Object.entries(src))
3115
- if (v !== undefined)
3116
- target[k] = v;
3117
- return target;
3118
- }
3119
- function hasKeys(map) {
3120
- return !!map && Object.keys(map).length > 0;
3121
- }
3122
- // ─── Cascade resolution ──────────────────────────────────────────────────────
3123
- /** The full effective style map at `bp`: base, then each layer down the cascade. */
3124
- function resolveStyleProps(styleProps, responsive, bp) {
3125
- const out = assignDefined({}, styleProps ?? undefined);
3126
- for (const id of cascadeChain(bp))
3127
- assignDefined(out, layerAt(responsive, id)?.styleProps);
3128
- return out;
3129
- }
3130
- /** The effective per-state maps at `bp`. States with no values anywhere are omitted. */
3131
- function resolveStateStyles(stateStyles, responsive, bp) {
3132
- const out = {};
3133
- const merge = (src) => {
3134
- for (const [state, map] of Object.entries(src ?? {})) {
3135
- out[state] = assignDefined(out[state] ?? {}, map);
3136
- }
3137
- };
3138
- merge(stateStyles ?? undefined);
3139
- for (const id of cascadeChain(bp))
3140
- merge(layerAt(responsive, id)?.stateStyles);
3141
- for (const state of Object.keys(out))
3142
- if (!hasKeys(out[state]))
3143
- 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];
3144
3331
  return out;
3145
3332
  }
3146
3333
  /**
3147
- * Every state that has a value anywhere — at base or at any breakpoint — in a stable order
3148
- * (base states first, then any that exist only at a breakpoint). A renderer iterates this, not
3149
- * `Object.keys(stateStyles)`, so a state defined ONLY at a breakpoint (a Mobile-only hover) still
3150
- * 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.
3151
3340
  */
3152
- function stateNamesIn(stateStyles, responsive) {
3153
- const names = new Set();
3154
- const add = (src) => {
3155
- for (const [state, map] of Object.entries(src ?? {})) {
3156
- if (Object.values(map ?? {}).some(v => v !== undefined))
3157
- names.add(state);
3158
- }
3159
- };
3160
- add(stateStyles);
3161
- for (const { id } of BREAKPOINTS)
3162
- add(layerAt(responsive, id)?.stateStyles);
3163
- return [...names];
3164
- }
3341
+ var DeclarationContext;
3342
+ (function (DeclarationContext) {
3343
+ DeclarationContext["Base"] = "base";
3344
+ DeclarationContext["Override"] = "override";
3345
+ })(DeclarationContext || (DeclarationContext = {}));
3165
3346
  /**
3166
- * Every level from Base down to `bp` that sets `prop` (optionally within `state`), in
3167
- * cascade order. The LAST entry is the effective value; an empty array means unset.
3168
- * 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.
3169
3359
  */
3170
- function resolveValueChain(prop, styleProps, stateStyles, responsive, bp, state) {
3171
- const read = (props, states) => (state ? states?.[state] : props)?.[prop];
3172
- const chain = [];
3173
- for (const id of cascadeChain(bp)) {
3174
- const layer = layerAt(responsive, id);
3175
- const value = id === Breakpoint.Base
3176
- ? read(styleProps ?? undefined, stateStyles ?? undefined)
3177
- : read(layer?.styleProps, layer?.stateStyles);
3178
- if (value !== undefined)
3179
- chain.push({ breakpoint: id, value });
3180
- }
3181
- return chain;
3182
- }
3183
- /** True when `bp` itself sets `prop` (the style panel's orange edge). Never true for Base. */
3184
- function isOverriddenAt(responsive, bp, prop, state) {
3185
- const layer = layerAt(responsive, bp);
3186
- const map = state ? layer?.stateStyles?.[state] : layer?.styleProps;
3187
- return map?.[prop] !== undefined;
3188
- }
3189
- /** Number of overridden values — at `bp`, or across every breakpoint when omitted. */
3190
- function countOverrides(responsive, bp) {
3191
- const count = (map) => Object.values(map ?? {}).filter(v => v !== undefined).length;
3192
- const ids = bp ? [bp] : BREAKPOINTS.map(d => d.id);
3193
- let n = 0;
3194
- for (const id of ids) {
3195
- const layer = layerAt(responsive, id);
3196
- n += count(layer?.styleProps);
3197
- for (const map of Object.values(layer?.stateStyles ?? {}))
3198
- n += count(map);
3199
- }
3200
- return n;
3201
- }
3202
- // ─── Editing (immutable) ─────────────────────────────────────────────────────
3203
- function assertOverrideBreakpoint(bp) {
3204
- if (bp === Breakpoint.Base) {
3205
- throw new Error('Base styles live in styleProps / stateStyles, not in responsive overrides');
3206
- }
3207
- if (!BREAKPOINT_INDEX.has(bp))
3208
- throw new Error(`Unknown breakpoint "${bp}"`);
3209
- }
3210
- /** Returns a new overrides object with `prop` set at `bp` (optionally within `state`). */
3211
- function setOverride(responsive, bp, prop, value, state) {
3212
- assertOverrideBreakpoint(bp);
3213
- const layer = { ...(responsive?.[bp] ?? {}) };
3214
- if (state) {
3215
- layer.stateStyles = { ...(layer.stateStyles ?? {}), [state]: { ...(layer.stateStyles?.[state] ?? {}), [prop]: value } };
3216
- }
3217
- else {
3218
- 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' : ''}`);
3219
3372
  }
3220
- return { ...(responsive ?? {}), [bp]: layer };
3373
+ return out;
3221
3374
  }
3222
3375
  /**
3223
- * Remove overrides at `bp` and PRUNE whatever becomes empty, so "reset" and "never
3224
- * 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.
3225
3381
  *
3226
- * clearOverride(r, bp) — the whole breakpoint
3227
- * clearOverride(r, bp, undefined, st) — one state at that breakpoint
3228
- * clearOverride(r, bp, prop) — one base-state value
3229
- * 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.
3230
3396
  *
3231
- * 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.
3232
3400
  */
3233
- function clearOverride(responsive, bp, prop, state) {
3234
- assertOverrideBreakpoint(bp);
3235
- const next = { ...(responsive ?? {}) };
3236
- const current = next[bp];
3237
- if (current && (prop !== undefined || state !== undefined)) {
3238
- const layer = { ...current };
3239
- if (state) {
3240
- const states = { ...(layer.stateStyles ?? {}) };
3241
- if (prop !== undefined && states[state]) {
3242
- const { [prop]: _dropped, ...rest } = states[state];
3243
- states[state] = rest;
3244
- }
3245
- if (prop === undefined || !hasKeys(states[state]))
3246
- delete states[state];
3247
- 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%');
3248
3411
  }
3249
- else if (prop !== undefined && layer.styleProps) {
3250
- const { [prop]: _dropped, ...rest } = layer.styleProps;
3251
- layer.styleProps = hasKeys(rest) ? rest : undefined;
3412
+ else if (DEFAULT_WIDTH_BY_TYPE[type]) {
3413
+ out.push(`width: ${DEFAULT_WIDTH_BY_TYPE[type]}`);
3252
3414
  }
3253
- if (!layer.stateStyles)
3254
- delete layer.stateStyles;
3255
- if (!layer.styleProps)
3256
- delete layer.styleProps;
3257
- next[bp] = hasKeys(layer) ? layer : undefined;
3258
3415
  }
3259
- else {
3260
- 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');
3261
3422
  }
3262
- if (!next[bp])
3263
- delete next[bp];
3264
- return hasKeys(next) ? next : undefined;
3265
- }
3266
- const ZERO_DURATIONS = new Set(['0ms', '0s']);
3267
- function groupsTouchedBy(map) {
3268
- const touched = new Set();
3269
- if (!map)
3270
- return touched;
3271
- for (const group of VIRTUAL_TRAIT_GROUPS) {
3272
- if (group.keys.some(k => map[k] !== undefined))
3273
- 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');
3274
3428
  }
3275
- 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;
3276
3476
  }
3477
+
3277
3478
  /**
3278
- * Should a touched group that composed to nothing emit its cancelling value?
3279
- * Transition is the exception: setting only the easing (no duration anywhere) composes to
3280
- * nothing too, and cancelling there would also kill the renderer's default hover transition
3281
- * — 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`.
3282
3484
  */
3283
- function shouldReset(group, merged) {
3284
- if (group.reset === null)
3285
- return false;
3286
- if (group.output === 'transition')
3287
- return ZERO_DURATIONS.has(String(merged['transitionDuration']));
3288
- 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;
3289
3488
  }
3290
3489
  /**
3291
- * Compose a sparse override against everything wider than it, keeping ONLY what the
3292
- * override changes: its own plain props, plus each virtual group it touches (or that
3293
- * group's cancelling value when it now composes to nothing). Untouched groups are dropped
3294
- * — 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).
3295
3492
  */
3296
- function composeOverride(own, wider, touched) {
3297
- const composed = composeVirtualTraits(own, wider);
3298
- const groupOutputs = new Set(VIRTUAL_TRAIT_GROUPS.map(g => g.output));
3299
- const out = {};
3300
- for (const [k, v] of Object.entries(composed)) {
3301
- if (!groupOutputs.has(k) && v !== undefined)
3302
- 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
+ }
3303
3503
  }
3304
- const merged = { ...wider, ...own };
3305
- for (const group of touched) {
3306
- const value = composed[group.output];
3307
- if (value !== undefined)
3308
- out[group.output] = value;
3309
- else if (shouldReset(group, merged))
3310
- 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];
3311
3532
  }
3312
- return out;
3533
+ return common;
3313
3534
  }
3314
3535
  /**
3315
- * The per-breakpoint rule sets a node needs, widest → narrowest (the order they must be
3316
- * emitted in, AFTER every base rule — equal specificity means source order decides).
3317
- * 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:
3318
3538
  *
3319
- * Handles the cross-axis case: a breakpoint that changes a virtual group (say
3320
- * `translateX`) also re-emits that group for every state the node has, because the wider
3321
- * state rule was composed against the wider value and would otherwise snap back to it on
3322
- * 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.
3323
3544
  */
3324
- function planResponsiveRules(styleProps, stateStyles, responsive) {
3325
- if (!hasKeys(responsive))
3326
- return [];
3327
- const plan = [];
3328
- for (const def of BREAKPOINTS) {
3329
- const layer = layerAt(responsive, def.id);
3330
- if (!layer || def.maxWidth == null)
3331
- continue;
3332
- const bp = def.id;
3333
- const wider = parentBreakpoint(bp);
3334
- const own = layer.styleProps ?? {};
3335
- const widerProps = resolveStyleProps(styleProps, responsive, wider);
3336
- const touchedByProps = groupsTouchedBy(own);
3337
- const composedProps = composeOverride(own, widerProps, touchedByProps);
3338
- const propsHere = { ...widerProps, ...own };
3339
- const statesHere = resolveStateStyles(stateStyles, responsive, bp);
3340
- const statesWider = resolveStateStyles(stateStyles, responsive, wider);
3341
- const composedStates = {};
3342
- for (const state of Object.keys(statesHere)) {
3343
- const ownState = layer.stateStyles?.[state] ?? {};
3344
- const touched = groupsTouchedBy(ownState);
3345
- for (const group of touchedByProps)
3346
- touched.add(group);
3347
- const composed = composeOverride(ownState, { ...propsHere, ...(statesWider[state] ?? {}) }, touched);
3348
- if (hasKeys(composed))
3349
- composedStates[state] = composed;
3350
- }
3351
- if (hasKeys(composedProps) || hasKeys(composedStates)) {
3352
- plan.push({
3353
- breakpoint: bp,
3354
- mediaQuery: mediaQueryFor(bp),
3355
- styleProps: composedProps,
3356
- stateStyles: composedStates,
3357
- });
3358
- }
3545
+ function buildTargetedStateSelector(scopeCssId, triggerCssId, styledCssId, base) {
3546
+ if (scopeCssId === styledCssId) {
3547
+ return `iox-node-${styledCssId}:has(.iox-node-${triggerCssId}:${base})`;
3359
3548
  }
3360
- 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}`;
3361
3553
  }
3362
3554
 
3363
3555
  /**
@@ -4394,5 +4586,5 @@ function effectiveTransitionId(anim) {
4394
4586
  * Generated bundle index. Do not edit.
4395
4587
  */
4396
4588
 
4397
- 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, 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 };
4398
4590
  //# sourceMappingURL=vectoriox-iox-ui.mjs.map