@antglobal/copilot-cards-web 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +60 -8
  2. package/dist/index.js +3358 -579
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getBuiltinIcon, cloneJsonData, createLifecycleManager, materializeCard, createA2UIParameterResolver, createExpressionContext, resolveActionRef, replaceRootContents, resolveA2UIDeep, hasExpression, resolveExpression, resolveDeep, resolveExpressionValue, isBoundRenderTreeNode, runActionSteps, normalizeSchema, validateSchema, requiresBindingMaterialization, parseSchema, StreamingParser, StreamingEngine, findAffectedRepeatOwners, bindingTopologyFingerprint, findTemplateRepeatOwners, extractPartialSchema, runActionStep, registry } from '@antglobal/copilot-cards-core';
1
+ import { getBuiltinIcon, cloneJsonData, createLifecycleManager, materializeCard, findAffectedRepeatOwners, createA2UIParameterResolver, createExpressionContext, replaceRootContents, resolveA2UIDeep, resolveDeep, resolveExpressionValue, resolveActionRef, isBoundRenderTreeNode, hasExpression, resolveExpression, runActionSteps, normalizeSchema, validateSchema, requiresBindingMaterialization, parseSchema, StreamingParser, StreamingEngine, bindingTopologyFingerprint, findTemplateRepeatOwners, extractPartialSchema, runActionStep, registry } from '@antglobal/copilot-cards-core';
2
2
  export { ActionRegistry, a2uiComponentToElement, a2uiToCommand, convertLegacySchema, createLifecycleManager, hasExpression, isA2UIEnvelope, isLegacySchema, normalizeSchema, parseSchema, registerActionHandler, registry, resolveActionRef, resolveDeep, resolveExpression, resolveExpressionValue, runActionStep, runActionSteps, validateSchema } from '@antglobal/copilot-cards-core';
3
3
  import * as echarts from 'echarts/core';
4
4
  import { LineChart, BarChart, PieChart, ScatterChart, FunnelChart, HeatmapChart } from 'echarts/charts';
@@ -332,11 +332,21 @@ function disconnectChartResize(chartRoot) {
332
332
  const registration = chartResizeRegistrations.get(chartRoot);
333
333
  if (!registration)
334
334
  return;
335
- registration.observer.disconnect();
335
+ chartResizeRegistrations.delete(chartRoot);
336
+ try {
337
+ registration.observer.disconnect();
338
+ }
339
+ catch (error) {
340
+ console.error('[renderCard] Chart observer cleanup failed', error);
341
+ }
336
342
  if (registration.frameID !== null) {
337
- cancelAnimationFrame(registration.frameID);
343
+ try {
344
+ cancelAnimationFrame(registration.frameID);
345
+ }
346
+ catch (error) {
347
+ console.error('[renderCard] Chart frame cleanup failed', error);
348
+ }
338
349
  }
339
- chartResizeRegistrations.delete(chartRoot);
340
350
  }
341
351
  function observeChartResize(chartRoot, chart) {
342
352
  disconnectChartResize(chartRoot);
@@ -376,12 +386,20 @@ function observeChartResize(chartRoot, chart) {
376
386
  * host node, so skipping this leaks one instance per chart per re-render.
377
387
  */
378
388
  function disposeChartsIn(container) {
379
- const roots = container.querySelectorAll(`[${CHART_ROOT_ATTR}]`);
389
+ const roots = [];
390
+ if (container.hasAttribute?.(CHART_ROOT_ATTR))
391
+ roots.push(container);
392
+ roots.push(...container.querySelectorAll(`[${CHART_ROOT_ATTR}]`));
380
393
  roots.forEach((node) => {
381
394
  disconnectChartResize(node);
382
- const inst = echarts.getInstanceByDom(node);
383
- if (inst)
384
- inst.dispose();
395
+ try {
396
+ const inst = echarts.getInstanceByDom(node);
397
+ if (inst)
398
+ inst.dispose();
399
+ }
400
+ catch (error) {
401
+ console.error('[renderCard] Chart instance cleanup failed', error);
402
+ }
385
403
  });
386
404
  }
387
405
  function renderChartSlot(container, slotContent, actionContext) {
@@ -706,6 +724,8 @@ function buildHeatmapOption(config) {
706
724
  */
707
725
  // ─── Slot Layout Constants ──────────────────────────────────────
708
726
  const SLOT_LAYOUT = {
727
+ DEFAULT: 'default',
728
+ FLEX: 'flex',
709
729
  COLUMNS: 'columns',
710
730
  GRID: 'grid',
711
731
  HORIZONTAL_SCROLL: 'horizontalScroll',
@@ -716,16 +736,411 @@ const SLOT_LAYOUT = {
716
736
  TABLE: 'table',
717
737
  CHART: 'chart',
718
738
  };
739
+ const EMPTY_HOST_PRESENTATION_OWNERSHIP = {
740
+ attributes: [],
741
+ styleProperties: [],
742
+ };
743
+ const CAROUSEL_SCROLL_ID_ATTRIBUTE = 'data-scroll-id';
744
+ function carouselHostStyles(gap) {
745
+ return {
746
+ display: 'flex',
747
+ flexDirection: 'row',
748
+ overflowX: 'auto',
749
+ scrollSnapType: 'x mandatory',
750
+ WebkitOverflowScrolling: 'touch',
751
+ gap: `${gap}px`,
752
+ paddingTop: '16px',
753
+ paddingBottom: '16px',
754
+ scrollbarWidth: 'none',
755
+ cursor: 'grab',
756
+ position: 'relative',
757
+ };
758
+ }
759
+ function toCSSPropertyName(property) {
760
+ return property.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
761
+ }
762
+ const CAROUSEL_HOST_PRESENTATION_OWNERSHIP = {
763
+ attributes: [CAROUSEL_SCROLL_ID_ATTRIBUTE],
764
+ styleProperties: Object.keys(carouselHostStyles(0)).map(toCSSPropertyName),
765
+ };
766
+ function findSpecialSlotKey(props) {
767
+ const slots = props.slots;
768
+ return slots && Object.keys(slots).find(key => key !== SLOT_LAYOUT.DEFAULT);
769
+ }
770
+ /** Host attributes/styles owned by the active slot renderer after shell render. */
771
+ function getSlotHostPresentationOwnership(props) {
772
+ return findSpecialSlotKey(props) === SLOT_LAYOUT.CAROUSEL
773
+ ? CAROUSEL_HOST_PRESENTATION_OWNERSHIP
774
+ : EMPTY_HOST_PRESENTATION_OWNERSHIP;
775
+ }
776
+ /** Resolve the exact carousel values consumed by the renderer. */
777
+ function resolveCarouselConfig(props, childCount) {
778
+ const slotContent = props.slots?.[SLOT_LAYOUT.CAROUSEL];
779
+ const config = slotContent?.config ?? {};
780
+ const itemWidthPx = config.itemWidthPx;
781
+ return {
782
+ scale: config.scale ?? props.carouselScale ?? 0.85,
783
+ opacity: config.opacity ?? props.carouselOpacity ?? 0.4,
784
+ overlayColor: config.overlayColor
785
+ ?? props.carouselOverlayColor
786
+ ?? 'rgba(255,255,255,0.5)',
787
+ gap: config.gap ?? props.carouselGap ?? 16,
788
+ itemWidthPercent: config.itemWidthPercent
789
+ ?? props.carouselItemWidth
790
+ ?? 70,
791
+ itemWidthPx: itemWidthPx ? itemWidthPx : null,
792
+ initialIndex: Math.max(0, Math.min(childCount - 1, config.initialIndex ?? props.carouselInitialIndex ?? 0)),
793
+ autoplay: config.autoplay ?? props.carouselAutoplay ?? false,
794
+ autoplayInterval: config.autoplayInterval
795
+ ?? props.carouselAutoplayInterval
796
+ ?? 3000,
797
+ };
798
+ }
799
+ function carouselNumericFingerprint(value) {
800
+ const numeric = Number(value);
801
+ return Number.isFinite(numeric) ? numeric : String(numeric);
802
+ }
803
+ function carouselLayoutFingerprint(config) {
804
+ const autoplay = Boolean(config.autoplay);
805
+ return {
806
+ scale: carouselNumericFingerprint(config.scale),
807
+ opacity: carouselNumericFingerprint(config.opacity),
808
+ overlayColor: String(config.overlayColor),
809
+ gap: `${config.gap}px`,
810
+ itemBasis: config.itemWidthPx
811
+ ? `${config.itemWidthPx}px`
812
+ : `${config.itemWidthPercent}%`,
813
+ initialIndex: carouselNumericFingerprint(config.initialIndex),
814
+ autoplay,
815
+ autoplayInterval: autoplay
816
+ ? carouselNumericFingerprint(config.autoplayInterval)
817
+ : null,
818
+ };
819
+ }
820
+ /** JSON-like layout dependencies used by the static incremental boundary. */
821
+ function getSlotLayoutFingerprintInput(props, children, resolveChildProps) {
822
+ const slotKey = findSpecialSlotKey(props);
823
+ if (slotKey === SLOT_LAYOUT.CAROUSEL) {
824
+ return {
825
+ slotKey,
826
+ childOccurrences: children.map((child, index) => ({
827
+ index,
828
+ id: child.id,
829
+ })),
830
+ carousel: carouselLayoutFingerprint(resolveCarouselConfig(props, children.length)),
831
+ };
832
+ }
833
+ const input = {
834
+ childIds: children.map(child => child.id),
835
+ slots: props.slots ?? null,
836
+ };
837
+ if (slotKey === SLOT_LAYOUT.GRID) {
838
+ input.gridPlacements = children.map((child, index) => {
839
+ const placement = gridPlacementStyles(resolveChildProps?.(child) ?? child.props);
840
+ return {
841
+ index,
842
+ id: child.id,
843
+ gridColumn: placement['grid-column'] ?? null,
844
+ gridRow: placement['grid-row'] ?? null,
845
+ gridArea: placement['grid-area'] ?? null,
846
+ };
847
+ });
848
+ }
849
+ return input;
850
+ }
851
+ const slotItemBindings = new WeakMap();
852
+ const slotOwnerBindings = new WeakMap();
853
+ function clearSlotOwnerBehavior(element) {
854
+ slotOwnerBindings.get(element)?.cleanup();
855
+ }
856
+ function createSlotOwnerCleanup(element) {
857
+ clearSlotOwnerBehavior(element);
858
+ const cleanups = [];
859
+ let disposed = false;
860
+ const binding = {
861
+ cleanup() {
862
+ if (disposed)
863
+ return;
864
+ disposed = true;
865
+ if (slotOwnerBindings.get(element) === binding) {
866
+ slotOwnerBindings.delete(element);
867
+ }
868
+ for (const cleanup of cleanups.splice(0).reverse()) {
869
+ try {
870
+ cleanup();
871
+ }
872
+ catch {
873
+ // Teardown is best-effort; one browser resource cannot block others.
874
+ }
875
+ }
876
+ },
877
+ };
878
+ slotOwnerBindings.set(element, binding);
879
+ return {
880
+ add(cleanup) {
881
+ if (disposed)
882
+ cleanup();
883
+ else
884
+ cleanups.push(cleanup);
885
+ },
886
+ dispose: binding.cleanup,
887
+ };
888
+ }
889
+ function addSlotOwnerListener(controller, target, type, listener, options) {
890
+ target.addEventListener(type, listener, options);
891
+ controller.add(() => target.removeEventListener(type, listener, options));
892
+ }
893
+ function clearSlotItemPresentation(element) {
894
+ const binding = slotItemBindings.get(element);
895
+ if (!binding)
896
+ return;
897
+ try {
898
+ binding.cleanup?.();
899
+ }
900
+ finally {
901
+ slotItemBindings.delete(element);
902
+ }
903
+ }
904
+ function bindSlotItemPresentation(element, descriptor) {
905
+ clearSlotItemPresentation(element);
906
+ const behavior = descriptor.apply(element) ?? {};
907
+ slotItemBindings.set(element, { descriptor, ...behavior });
908
+ }
909
+ function stylePropertyNames(style) {
910
+ const names = new Set();
911
+ for (let index = 0; index < style.length; index += 1) {
912
+ const name = style.item(index);
913
+ if (name)
914
+ names.add(name);
915
+ }
916
+ return names;
917
+ }
918
+ function captureSlotItemPresentation(element, ownership) {
919
+ const styleNames = stylePropertyNames(element.style);
920
+ return {
921
+ attributes: ownership.attributes.map(name => ({
922
+ name,
923
+ present: element.hasAttribute(name),
924
+ value: element.getAttribute(name) ?? '',
925
+ })),
926
+ styles: ownership.styleProperties.map(name => ({
927
+ name,
928
+ present: styleNames.has(name),
929
+ value: element.style.getPropertyValue(name),
930
+ priority: element.style.getPropertyPriority(name),
931
+ })),
932
+ };
933
+ }
934
+ function restoreSlotItemPresentation(element, snapshot) {
935
+ for (const attribute of snapshot.attributes) {
936
+ if (attribute.present)
937
+ element.setAttribute(attribute.name, attribute.value);
938
+ else
939
+ element.removeAttribute(attribute.name);
940
+ }
941
+ for (const style of snapshot.styles) {
942
+ if (style.present) {
943
+ element.style.setProperty(style.name, style.value, style.priority);
944
+ }
945
+ else {
946
+ element.style.removeProperty(style.name);
947
+ }
948
+ }
949
+ }
950
+ function slotItemOwnership(styles) {
951
+ return {
952
+ attributes: [],
953
+ styleProperties: Object.keys(styles),
954
+ };
955
+ }
956
+ function applySlotItemStyles(element, styles) {
957
+ for (const [name, value] of Object.entries(styles)) {
958
+ element.style.setProperty(name, value);
959
+ }
960
+ }
961
+ /** Copy an immediate parent's slot-item decoration onto a detached replacement. */
962
+ function transferSlotItemPresentation(current, replacement) {
963
+ const binding = slotItemBindings.get(current);
964
+ if (!binding)
965
+ return;
966
+ const resume = binding.suspend?.();
967
+ try {
968
+ const snapshot = captureSlotItemPresentation(current, binding.descriptor.ownership);
969
+ bindSlotItemPresentation(replacement, binding.descriptor);
970
+ restoreSlotItemPresentation(replacement, snapshot);
971
+ binding.descriptor.transfer?.(current, replacement);
972
+ }
973
+ finally {
974
+ resume?.();
975
+ }
976
+ }
977
+ /** Item styles owned by the live parent slot, used by shallow shell updates. */
978
+ function getSlotItemPresentationOwnership(element) {
979
+ return slotItemBindings.get(element)?.descriptor.ownership
980
+ ?? EMPTY_HOST_PRESENTATION_OWNERSHIP;
981
+ }
982
+ /** Preserve parent-owned presentation and transient behavior around an item update. */
983
+ function updateSlotItemShellPresentation(element, update, additionalOwnership = EMPTY_HOST_PRESENTATION_OWNERSHIP) {
984
+ const binding = slotItemBindings.get(element);
985
+ const ownership = mergePresentationOwnership(binding?.descriptor.ownership ?? EMPTY_HOST_PRESENTATION_OWNERSHIP, additionalOwnership);
986
+ if (!binding
987
+ && ownership.attributes.length === 0
988
+ && ownership.styleProperties.length === 0) {
989
+ update();
990
+ return;
991
+ }
992
+ const resume = binding?.suspend?.();
993
+ try {
994
+ const snapshot = captureSlotItemPresentation(element, ownership);
995
+ try {
996
+ update();
997
+ }
998
+ finally {
999
+ restoreSlotItemPresentation(element, snapshot);
1000
+ }
1001
+ }
1002
+ finally {
1003
+ resume?.();
1004
+ }
1005
+ }
1006
+ function mergePresentationOwnership(...ownership) {
1007
+ return {
1008
+ attributes: Array.from(new Set(ownership.flatMap(value => value.attributes))),
1009
+ styleProperties: Array.from(new Set(ownership.flatMap(value => value.styleProperties))),
1010
+ };
1011
+ }
1012
+ /** Patch renderer-owned shell presentation without overwriting layout state. */
1013
+ function patchElementShellPresentation(current, previous, desired, ...preservedOwnership) {
1014
+ const ownership = mergePresentationOwnership(...preservedOwnership);
1015
+ const layoutAttributes = new Set(ownership.attributes);
1016
+ const layoutStyleProperties = new Set(ownership.styleProperties);
1017
+ const attributeNames = new Set([
1018
+ ...Array.from(previous.attributes, attribute => attribute.name),
1019
+ ...Array.from(desired.attributes, attribute => attribute.name),
1020
+ ]);
1021
+ attributeNames.delete('style');
1022
+ for (const name of attributeNames) {
1023
+ if (layoutAttributes.has(name))
1024
+ continue;
1025
+ const previousHas = previous.hasAttribute(name);
1026
+ const desiredHas = desired.hasAttribute(name);
1027
+ const previousValue = previous.getAttribute(name);
1028
+ const desiredValue = desired.getAttribute(name);
1029
+ if (previousHas === desiredHas
1030
+ && previousValue === desiredValue) {
1031
+ continue;
1032
+ }
1033
+ if (previousHas) {
1034
+ if (!current.hasAttribute(name)
1035
+ || current.getAttribute(name) !== previousValue) {
1036
+ continue;
1037
+ }
1038
+ }
1039
+ else if (current.hasAttribute(name)) {
1040
+ continue;
1041
+ }
1042
+ if (desiredHas)
1043
+ current.setAttribute(name, desiredValue);
1044
+ else
1045
+ current.removeAttribute(name);
1046
+ }
1047
+ const previousPropertyNames = stylePropertyNames(previous.style);
1048
+ const desiredPropertyNames = stylePropertyNames(desired.style);
1049
+ const propertyNames = new Set([
1050
+ ...previousPropertyNames,
1051
+ ...desiredPropertyNames,
1052
+ ]);
1053
+ const currentPropertyNames = stylePropertyNames(current.style);
1054
+ for (const name of propertyNames) {
1055
+ if (layoutStyleProperties.has(name))
1056
+ continue;
1057
+ const previousHas = previousPropertyNames.has(name);
1058
+ const desiredHas = desiredPropertyNames.has(name);
1059
+ const previousValue = previous.style.getPropertyValue(name);
1060
+ const desiredValue = desired.style.getPropertyValue(name);
1061
+ const previousPriority = previous.style.getPropertyPriority(name);
1062
+ const desiredPriority = desired.style.getPropertyPriority(name);
1063
+ if (previousHas === desiredHas
1064
+ && previousValue === desiredValue
1065
+ && previousPriority === desiredPriority) {
1066
+ continue;
1067
+ }
1068
+ if (previousHas) {
1069
+ if (!currentPropertyNames.has(name)
1070
+ || current.style.getPropertyValue(name) !== previousValue
1071
+ || current.style.getPropertyPriority(name) !== previousPriority) {
1072
+ continue;
1073
+ }
1074
+ }
1075
+ else if (currentPropertyNames.has(name)) {
1076
+ continue;
1077
+ }
1078
+ if (desiredHas) {
1079
+ current.style.setProperty(name, desiredValue, desiredPriority);
1080
+ }
1081
+ else {
1082
+ current.style.removeProperty(name);
1083
+ }
1084
+ }
1085
+ }
1086
+ /** Release slot-owned item listeners within a disposed subtree. */
1087
+ function clearSlotItemPresentationsIn(root) {
1088
+ let hasError = false;
1089
+ let firstError;
1090
+ const clear = (element) => {
1091
+ try {
1092
+ clearSlotItemPresentation(element);
1093
+ }
1094
+ catch (error) {
1095
+ if (!hasError)
1096
+ firstError = error;
1097
+ hasError = true;
1098
+ }
1099
+ };
1100
+ clear(root);
1101
+ root.querySelectorAll('*').forEach(clear);
1102
+ if (hasError)
1103
+ throw firstError;
1104
+ }
1105
+ /** Release slot-owner listeners, timers, frames, and observers in a subtree. */
1106
+ function clearSlotOwnerBehaviorsIn(root) {
1107
+ clearSlotOwnerBehavior(root);
1108
+ root.querySelectorAll('*').forEach(clearSlotOwnerBehavior);
1109
+ }
1110
+ // ─── Dispatcher ─────────────────────────────────────────────────
1111
+ /**
1112
+ * Apply host-level layout styles based on the slot key.
1113
+ * Called from renderDefault BEFORE props.style so explicit styles can override.
1114
+ * Layouts that require wrapper DOM continue to render in renderSlotLayout.
1115
+ */
1116
+ function applySlotBaseStyle(container, props) {
1117
+ const flex = props.slots?.[SLOT_LAYOUT.FLEX];
1118
+ if (!flex || typeof flex !== 'object')
1119
+ return;
1120
+ const config = flex.config && typeof flex.config === 'object' ? flex.config : {};
1121
+ container.style.display = 'flex';
1122
+ container.style.flexDirection = config.direction ?? 'column';
1123
+ container.style.alignItems = config.align ?? 'flex-start';
1124
+ container.style.justifyContent = config.justify ?? 'flex-start';
1125
+ container.style.flexWrap = config.wrap ?? 'nowrap';
1126
+ container.style.gap = resolveFlexGap(config.gap);
1127
+ }
1128
+ function resolveFlexGap(value) {
1129
+ if (typeof value === 'number' && Number.isFinite(value)) {
1130
+ return `${value}px`;
1131
+ }
1132
+ return typeof value === 'string' && value.length > 0 ? value : '0px';
1133
+ }
719
1134
  /**
720
1135
  * Render children into a container using the slot layout strategy.
721
1136
  * Returns true if a special layout was applied (children already appended).
722
1137
  * Returns false for `default` — caller uses original append loop.
723
1138
  */
724
- function renderSlotLayout(container, children, props, renderChild, childrenMap, actionContext) {
1139
+ function renderSlotLayout(container, children, props, renderChild, childrenMap, actionContext, resolveChildProps) {
725
1140
  const slots = props.slots;
726
1141
  if (!slots)
727
1142
  return false;
728
- const slotKey = Object.keys(slots).find((k) => k !== 'default');
1143
+ const slotKey = findSpecialSlotKey(props);
729
1144
  if (!slotKey)
730
1145
  return false;
731
1146
  const slotContent = slots[slotKey];
@@ -734,7 +1149,7 @@ function renderSlotLayout(container, children, props, renderChild, childrenMap,
734
1149
  renderColumnsSlot(container, slotContent, childrenMap, renderChild);
735
1150
  return true;
736
1151
  case SLOT_LAYOUT.GRID:
737
- renderGridSlot(container, children, slotContent, renderChild);
1152
+ renderGridSlot(container, children, slotContent, renderChild, resolveChildProps);
738
1153
  return true;
739
1154
  case SLOT_LAYOUT.HORIZONTAL_SCROLL:
740
1155
  renderHorizontalScrollSlot(container, children, slotContent, renderChild);
@@ -808,7 +1223,7 @@ function renderColumnsSlot(container, slotContent, childrenMap, renderChild) {
808
1223
  *
809
1224
  * Schema: `slots: { grid: { children: [...], config: { columns: 2, gap: '8px', rows?: 3, rowHeight?: '48px' } } }`
810
1225
  */
811
- function renderGridSlot(container, children, slotContent, renderChild) {
1226
+ function renderGridSlot(container, children, slotContent, renderChild, resolveChildProps) {
812
1227
  const columns = slotContent?.config?.columns ?? 2;
813
1228
  const gap = slotContent?.config?.gap ?? '8px';
814
1229
  const rows = slotContent?.config?.rows;
@@ -826,7 +1241,7 @@ function renderGridSlot(container, children, slotContent, renderChild) {
826
1241
  // regardless of whether the child component applies props.style to its host
827
1242
  // or to an inner shadow-DOM node. No-op for children without placement, so
828
1243
  // auto-flow cards are unaffected.
829
- applyGridPlacement(el, child);
1244
+ bindSlotItemPresentation(el, gridItemDescriptor(resolveChildProps?.(child) ?? child.props));
830
1245
  grid.appendChild(el);
831
1246
  }
832
1247
  container.appendChild(grid);
@@ -839,54 +1254,41 @@ function renderGridSlot(container, children, slotContent, renderChild) {
839
1254
  * unresolved expression object) is ignored. Children that declare no placement
840
1255
  * are left untouched — preserving auto-flow behaviour for legacy cards.
841
1256
  */
842
- function applyGridPlacement(el, child) {
843
- const style = child?.props?.style;
844
- if (!style || typeof style !== 'object')
845
- return;
846
- if (isCssPlacement(style.gridColumn))
847
- el.style.gridColumn = String(style.gridColumn);
848
- if (isCssPlacement(style.gridRow))
849
- el.style.gridRow = String(style.gridRow);
850
- if (isCssPlacement(style.gridArea))
851
- el.style.gridArea = String(style.gridArea);
1257
+ function gridPlacementStyles(props) {
1258
+ const style = props?.style;
1259
+ const placement = {};
1260
+ if (style && typeof style === 'object') {
1261
+ if (isCssPlacement(style.gridColumn)) {
1262
+ placement['grid-column'] = String(style.gridColumn);
1263
+ }
1264
+ if (isCssPlacement(style.gridRow)) {
1265
+ placement['grid-row'] = String(style.gridRow);
1266
+ }
1267
+ if (isCssPlacement(style.gridArea)) {
1268
+ placement['grid-area'] = String(style.gridArea);
1269
+ }
1270
+ }
1271
+ return placement;
1272
+ }
1273
+ function gridItemDescriptor(props) {
1274
+ const placement = gridPlacementStyles(props);
1275
+ return {
1276
+ kind: 'grid',
1277
+ ownership: slotItemOwnership(placement),
1278
+ apply(element) {
1279
+ applySlotItemStyles(element, placement);
1280
+ },
1281
+ };
852
1282
  }
853
1283
  function isCssPlacement(value) {
854
1284
  return typeof value === 'string' || typeof value === 'number';
855
1285
  }
856
- // ─── Horizontal Scroll ──────────────────────────────────────────
857
- /**
858
- * Horizontal scrollable row with mouse drag support.
859
- *
860
- * Schema: `slots: { horizontalScroll: { children: [...], config: { gap, itemWidth, snap, arrows, arrowStyle, scrollStep, mask, maskWidth, maskColor, itemHoverStyle, autoScroll, autoScrollInterval, autoScrollLoop } } }`
861
- *
862
- * Optional config:
863
- * - `arrows` — show prev/next arrow buttons; each is only visible while
864
- * the track can actually scroll in that direction (default false)
865
- * - `arrowStyle` — free-form CSS object merged onto both arrow buttons; every
866
- * default (circle, background, shadow…) can be overridden
867
- * - `arrowIcon` — replaces the built-in chevron on both buttons: an image URL
868
- * (http/https/data:image) or a text glyph/emoji. The left
869
- * button gets a mirrored copy.
870
- * - `arrowIconLeft` / `arrowIconRight` — per-side icon, same formats, never
871
- * mirrored; takes precedence over `arrowIcon`
872
- * - `arrowOffset` — distance of each arrow from its own edge (default '8px');
873
- * number = px, negative values push the arrows outside /
874
- * straddling the container edge
875
- * - `scrollStep` — px scrolled per arrow click (default 80% of the visible width)
876
- * - `mask` — show translucent edge-fade masks hinting at more content;
877
- * follows the same can-scroll state as the arrows (default false)
878
- * - `maskWidth` — width of each edge mask (default '48px')
879
- * - `maskColor` — base color the mask fades out from; any CSS color incl.
880
- * rgba for translucency (default '#fff')
881
- * - `maskOpacity` — opacity of a mask while visible (default 1)
882
- * - `itemHoverStyle` — CSS object applied to an item on hover and reverted on
883
- * leave; pure inline-style swap, never triggers a re-render
884
- * - `autoScroll` — while the pointer hovers the track, auto-advance one item
885
- * every `autoScrollInterval` ms (default false)
886
- * - `autoScrollInterval` — ms each item stays before advancing (default 2000)
887
- * - `autoScrollLoop` — loop back to the first item after the last (default true);
888
- * when false it stops on the last item until re-hovered
889
- */
1286
+ function resolveScrollbarMode(value) {
1287
+ if (value === 'auto' || value === 'hidden' || value === 'visible') {
1288
+ return value;
1289
+ }
1290
+ return undefined;
1291
+ }
890
1292
  function renderHorizontalScrollSlot(container, children, slotContent, renderChild) {
891
1293
  const config = slotContent?.config ?? {};
892
1294
  const gap = config.gap ?? '8px';
@@ -898,13 +1300,23 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
898
1300
  const autoScroll = config.autoScroll === true;
899
1301
  const autoScrollInterval = typeof config.autoScrollInterval === 'number' ? config.autoScrollInterval : 2000;
900
1302
  const autoScrollLoop = config.autoScrollLoop !== false;
1303
+ const scrollbar = resolveScrollbarMode(config.scrollbar);
1304
+ const hidesScrollbar = scrollbar == null || scrollbar === 'hidden';
1305
+ const itemDescriptor = horizontalScrollItemDescriptor(itemWidth, snap, itemHoverStyle);
901
1306
  const track = document.createElement('div');
1307
+ const ownerCleanup = createSlotOwnerCleanup(track);
1308
+ let ownerDisposed = false;
1309
+ let wheelTimer = 0;
1310
+ let autoTimer = 0;
1311
+ let overlayFrame = 0;
1312
+ let resizeObserver = null;
902
1313
  track.style.cssText = [
903
1314
  'display:flex',
904
1315
  'overflow-x:auto',
905
1316
  'overflow-y:hidden',
906
1317
  '-webkit-overflow-scrolling:touch',
907
- 'scrollbar-width:none',
1318
+ hidesScrollbar ? 'scrollbar-width:none' : '',
1319
+ scrollbar === 'visible' ? 'scrollbar-width:auto' : '',
908
1320
  `gap:${gap}`,
909
1321
  'cursor:grab',
910
1322
  snap ? 'scroll-snap-type:x mandatory' : '',
@@ -913,27 +1325,21 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
913
1325
  // across variable-driven re-renders (see captureScrollPositions/restoreScrollPositions).
914
1326
  const cardId = container.getAttribute('data-card-id') ?? 'x';
915
1327
  track.setAttribute('data-scroll-id', cardId);
916
- // Hide scrollbar for WebKit
917
- const styleEl = document.createElement('style');
918
- const trackClass = `card-scroll-${cardId}`;
919
- styleEl.textContent = `.${trackClass}::-webkit-scrollbar{display:none}`;
920
- track.classList.add(trackClass);
1328
+ if (scrollbar)
1329
+ track.dataset.scrollbar = scrollbar;
921
1330
  for (const child of children) {
922
1331
  const item = renderChild(child);
923
- item.style.flexShrink = '0';
924
- if (itemWidth)
925
- item.style.width = itemWidth;
926
- if (snap)
927
- item.style.scrollSnapAlign = 'start';
928
- if (itemHoverStyle)
929
- applyHoverStyle(item, itemHoverStyle);
1332
+ bindSlotItemPresentation(item, itemDescriptor);
930
1333
  track.appendChild(item);
931
1334
  }
932
1335
  // Mouse drag (desktop)
933
1336
  let isDragging = false;
934
1337
  let startX = 0;
935
1338
  let scrollLeft = 0;
936
- track.addEventListener('mousedown', (e) => {
1339
+ const handleMouseDown = (event) => {
1340
+ if (ownerDisposed)
1341
+ return;
1342
+ const e = event;
937
1343
  if (e.button !== 0)
938
1344
  return; // left button only — keep middle/right clicks out of drag state
939
1345
  // Nested scrollers: consume the gesture so an enclosing horizontalScroll doesn't
@@ -947,8 +1353,11 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
947
1353
  track.style.userSelect = 'none';
948
1354
  if (snap)
949
1355
  track.style.scrollSnapType = 'none';
950
- });
951
- track.addEventListener('mousemove', (e) => {
1356
+ };
1357
+ const handleMouseMove = (event) => {
1358
+ if (ownerDisposed)
1359
+ return;
1360
+ const e = event;
952
1361
  if (!isDragging)
953
1362
  return;
954
1363
  // Only swallow the move while THIS track owns the drag — otherwise an in-progress
@@ -957,7 +1366,7 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
957
1366
  e.stopPropagation();
958
1367
  e.preventDefault();
959
1368
  track.scrollLeft = scrollLeft - (e.pageX - startX);
960
- });
1369
+ };
961
1370
  const stopDrag = () => {
962
1371
  if (!isDragging)
963
1372
  return;
@@ -967,15 +1376,19 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
967
1376
  if (snap)
968
1377
  track.style.scrollSnapType = 'x mandatory';
969
1378
  };
970
- track.addEventListener('mouseup', stopDrag);
971
- track.addEventListener('mouseleave', stopDrag);
1379
+ addSlotOwnerListener(ownerCleanup, track, 'mousedown', handleMouseDown);
1380
+ addSlotOwnerListener(ownerCleanup, track, 'mousemove', handleMouseMove);
1381
+ addSlotOwnerListener(ownerCleanup, track, 'mouseup', stopDrag);
1382
+ addSlotOwnerListener(ownerCleanup, track, 'mouseleave', stopDrag);
972
1383
  // Wheel / trackpad → horizontal scroll.
973
1384
  // Mandatory snap would swallow small wheel deltas (each increment snaps right
974
1385
  // back to the same item, so the wheel appears dead) — suspend snap while
975
1386
  // wheeling and restore it shortly after the last tick, which also re-aligns
976
1387
  // the track to the nearest snap point.
977
- let wheelTimer = 0;
978
- track.addEventListener('wheel', (e) => {
1388
+ const handleWheel = (event) => {
1389
+ if (ownerDisposed)
1390
+ return;
1391
+ const e = event;
979
1392
  e.preventDefault();
980
1393
  // Consume the wheel so a nested horizontalScroll scrolls only itself instead of
981
1394
  // also driving an enclosing scroller (simplest nested model — no edge hand-off
@@ -988,12 +1401,16 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
988
1401
  const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
989
1402
  track.scrollLeft += delta;
990
1403
  if (snap) {
991
- clearTimeout(wheelTimer);
1404
+ window.clearTimeout(wheelTimer);
992
1405
  wheelTimer = window.setTimeout(() => {
1406
+ wheelTimer = 0;
1407
+ if (ownerDisposed)
1408
+ return;
993
1409
  track.style.scrollSnapType = 'x mandatory';
994
1410
  }, 150);
995
1411
  }
996
- }, { passive: false });
1412
+ };
1413
+ addSlotOwnerListener(ownerCleanup, track, 'wheel', handleWheel, { passive: false });
997
1414
  // Hover autoplay (desktop): while the pointer is over the track, glide to the next
998
1415
  // item every `autoScrollInterval` ms, looping back to the first at the end when
999
1416
  // `autoScrollLoop`. Opt-in via `autoScroll`. Manual drag pauses it (resumes on the
@@ -1002,9 +1419,10 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1002
1419
  // chrome. Targets are item starts (= snap points), so mandatory snap won't fight the
1003
1420
  // glide — same reason the arrow buttons can scrollBy smoothly without suspending snap.
1004
1421
  if (autoScroll) {
1005
- let autoTimer = 0;
1006
1422
  const glideTo = (left) => track.scrollTo({ left, behavior: 'smooth' });
1007
1423
  const advance = () => {
1424
+ if (ownerDisposed)
1425
+ return stopAuto();
1008
1426
  // A variable-driven re-render rebuilds the track; bail and clear so the interval
1009
1427
  // doesn't keep driving a detached node (mirrors the ResizeObserver self-teardown).
1010
1428
  if (!track.isConnected)
@@ -1025,6 +1443,8 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1025
1443
  glideTo(track.scrollLeft + (next.getBoundingClientRect().left - trackLeft));
1026
1444
  };
1027
1445
  const startAuto = () => {
1446
+ if (ownerDisposed)
1447
+ return;
1028
1448
  if (autoTimer)
1029
1449
  return;
1030
1450
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
@@ -1034,14 +1454,45 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1034
1454
  function stopAuto() {
1035
1455
  if (!autoTimer)
1036
1456
  return;
1037
- clearInterval(autoTimer);
1457
+ window.clearInterval(autoTimer);
1038
1458
  autoTimer = 0;
1039
1459
  }
1040
- track.addEventListener('mouseenter', startAuto);
1041
- track.addEventListener('mouseleave', stopAuto);
1042
- track.addEventListener('mousedown', stopAuto);
1460
+ addSlotOwnerListener(ownerCleanup, track, 'mouseenter', startAuto);
1461
+ addSlotOwnerListener(ownerCleanup, track, 'mouseleave', stopAuto);
1462
+ addSlotOwnerListener(ownerCleanup, track, 'mousedown', stopAuto);
1463
+ }
1464
+ ownerCleanup.add(() => {
1465
+ ownerDisposed = true;
1466
+ stopDrag();
1467
+ if (wheelTimer) {
1468
+ window.clearTimeout(wheelTimer);
1469
+ wheelTimer = 0;
1470
+ }
1471
+ if (autoTimer) {
1472
+ window.clearInterval(autoTimer);
1473
+ autoTimer = 0;
1474
+ }
1475
+ if (resizeObserver) {
1476
+ resizeObserver.disconnect();
1477
+ resizeObserver = null;
1478
+ }
1479
+ if (overlayFrame) {
1480
+ cancelAnimationFrame(overlayFrame);
1481
+ overlayFrame = 0;
1482
+ }
1483
+ });
1484
+ if (hidesScrollbar) {
1485
+ const trackClass = `card-scroll-${cardId}`;
1486
+ track.classList.add(trackClass);
1487
+ const styleEl = document.createElement('style');
1488
+ styleEl.textContent = `.${trackClass}::-webkit-scrollbar{display:none}`;
1489
+ container.appendChild(styleEl);
1490
+ }
1491
+ else if (scrollbar === 'visible') {
1492
+ const styleEl = document.createElement('style');
1493
+ styleEl.textContent = '[data-scroll-id][data-scrollbar="visible"]::-webkit-scrollbar{display:block}';
1494
+ container.appendChild(styleEl);
1043
1495
  }
1044
- container.appendChild(styleEl);
1045
1496
  // Plain track — no overlay chrome requested
1046
1497
  if (!arrows && !mask) {
1047
1498
  container.appendChild(track);
@@ -1074,13 +1525,25 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1074
1525
  : typeof config.arrowOffset === 'number' ? `${config.arrowOffset}px` : String(config.arrowOffset);
1075
1526
  leftArrow = createArrowButton('left', arrowStyle, arrowOffset, config.arrowIconLeft ?? config.arrowIcon, config.arrowIconLeft == null);
1076
1527
  rightArrow = createArrowButton('right', arrowStyle, arrowOffset, config.arrowIconRight ?? config.arrowIcon, false);
1077
- leftArrow.addEventListener('click', () => track.scrollBy({ left: -step(), behavior: 'smooth' }));
1078
- rightArrow.addEventListener('click', () => track.scrollBy({ left: step(), behavior: 'smooth' }));
1528
+ const scrollLeft = () => {
1529
+ if (!ownerDisposed) {
1530
+ track.scrollBy({ left: -step(), behavior: 'smooth' });
1531
+ }
1532
+ };
1533
+ const scrollRight = () => {
1534
+ if (!ownerDisposed) {
1535
+ track.scrollBy({ left: step(), behavior: 'smooth' });
1536
+ }
1537
+ };
1538
+ addSlotOwnerListener(ownerCleanup, leftArrow, 'click', scrollLeft);
1539
+ addSlotOwnerListener(ownerCleanup, rightArrow, 'click', scrollRight);
1079
1540
  wrapper.appendChild(leftArrow);
1080
1541
  wrapper.appendChild(rightArrow);
1081
1542
  }
1082
1543
  // Show each arrow/mask only while the track can scroll in that direction.
1083
1544
  const updateOverlays = () => {
1545
+ if (ownerDisposed)
1546
+ return;
1084
1547
  const canLeft = track.scrollLeft > 1;
1085
1548
  const canRight = track.scrollLeft + track.clientWidth < track.scrollWidth - 1;
1086
1549
  if (leftArrow)
@@ -1092,37 +1555,125 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1092
1555
  if (rightMask)
1093
1556
  rightMask.style.opacity = canRight ? maskOpacity : '0';
1094
1557
  };
1095
- track.addEventListener('scroll', updateOverlays, { passive: true });
1558
+ addSlotOwnerListener(ownerCleanup, track, 'scroll', updateOverlays, { passive: true });
1096
1559
  // ResizeObserver fires once on observe (covers initial layout) and again on
1097
1560
  // any size change; it self-disconnects once the track leaves the DOM.
1098
1561
  if (typeof ResizeObserver !== 'undefined') {
1099
- const ro = new ResizeObserver(() => {
1562
+ resizeObserver = new ResizeObserver(() => {
1563
+ if (ownerDisposed)
1564
+ return;
1100
1565
  if (!track.isConnected) {
1101
- ro.disconnect();
1566
+ resizeObserver?.disconnect();
1567
+ resizeObserver = null;
1102
1568
  return;
1103
1569
  }
1104
1570
  updateOverlays();
1105
1571
  });
1106
- ro.observe(track);
1572
+ resizeObserver.observe(track);
1107
1573
  }
1108
1574
  else {
1109
- requestAnimationFrame(updateOverlays);
1575
+ overlayFrame = requestAnimationFrame(() => {
1576
+ overlayFrame = 0;
1577
+ updateOverlays();
1578
+ });
1110
1579
  }
1111
1580
  }
1581
+ function horizontalScrollItemDescriptor(itemWidth, snap, hoverStyle) {
1582
+ const styles = {
1583
+ 'flex-shrink': '0',
1584
+ };
1585
+ if (itemWidth)
1586
+ styles.width = itemWidth;
1587
+ if (snap)
1588
+ styles['scroll-snap-align'] = 'start';
1589
+ return {
1590
+ kind: 'horizontalScroll',
1591
+ ownership: slotItemOwnership(styles),
1592
+ apply(element) {
1593
+ applySlotItemStyles(element, styles);
1594
+ return hoverStyle ? applyHoverStyle(element, hoverStyle) : undefined;
1595
+ },
1596
+ };
1597
+ }
1112
1598
  /** Apply `hoverStyle` on mouseenter and restore the previous inline values on leave. */
1113
1599
  function applyHoverStyle(item, hoverStyle) {
1114
1600
  const previous = {};
1115
- item.addEventListener('mouseenter', () => {
1601
+ let hovered = false;
1602
+ const enter = () => {
1603
+ if (hovered)
1604
+ return;
1605
+ hovered = true;
1116
1606
  for (const [key, value] of Object.entries(hoverStyle)) {
1117
- previous[key] = item.style[key];
1118
- item.style[key] = value;
1607
+ if (key.startsWith('--')) {
1608
+ const previousValue = item.style.getPropertyValue(key);
1609
+ const previousPriority = item.style.getPropertyPriority(key);
1610
+ previous[key] = {
1611
+ value: previousValue,
1612
+ priority: previousPriority,
1613
+ wasPresent: previousValue !== '' || previousPriority !== '',
1614
+ isCustomProperty: true,
1615
+ };
1616
+ item.style.setProperty(key, String(value));
1617
+ }
1618
+ else {
1619
+ previous[key] = {
1620
+ value: item.style[key],
1621
+ priority: '',
1622
+ wasPresent: true,
1623
+ isCustomProperty: false,
1624
+ };
1625
+ item.style[key] = value;
1626
+ }
1119
1627
  }
1120
- });
1121
- item.addEventListener('mouseleave', () => {
1122
- for (const [key, value] of Object.entries(previous)) {
1123
- item.style[key] = value;
1628
+ };
1629
+ const leave = () => {
1630
+ if (!hovered)
1631
+ return;
1632
+ hovered = false;
1633
+ for (const [key, state] of Object.entries(previous)) {
1634
+ if (state.isCustomProperty) {
1635
+ if (state.wasPresent) {
1636
+ item.style.setProperty(key, state.value, state.priority);
1637
+ }
1638
+ else {
1639
+ item.style.removeProperty(key);
1640
+ }
1641
+ }
1642
+ else {
1643
+ item.style[key] = state.value;
1644
+ }
1645
+ delete previous[key];
1124
1646
  }
1125
- });
1647
+ };
1648
+ item.addEventListener('mouseenter', enter);
1649
+ item.addEventListener('mouseleave', leave);
1650
+ return {
1651
+ cleanup() {
1652
+ let hasError = false;
1653
+ let firstError;
1654
+ const cleanup = (operation) => {
1655
+ try {
1656
+ operation();
1657
+ }
1658
+ catch (error) {
1659
+ if (!hasError)
1660
+ firstError = error;
1661
+ hasError = true;
1662
+ }
1663
+ };
1664
+ cleanup(leave);
1665
+ cleanup(() => item.removeEventListener('mouseenter', enter));
1666
+ cleanup(() => item.removeEventListener('mouseleave', leave));
1667
+ if (hasError)
1668
+ throw firstError;
1669
+ },
1670
+ suspend() {
1671
+ if (!hovered)
1672
+ return;
1673
+ leave();
1674
+ return enter;
1675
+ },
1676
+ };
1126
1677
  }
1127
1678
  /** Translucent edge-fade mask hinting that more content is available in that direction. */
1128
1679
  function createEdgeMask(side, width, color) {
@@ -1202,6 +1753,66 @@ function createArrowButton(side, arrowStyle, offset, icon, mirror = false) {
1202
1753
  return btn;
1203
1754
  }
1204
1755
  // ─── Carousel ───────────────────────────────────────────────────
1756
+ function carouselOverlay(item) {
1757
+ for (let index = item.children.length - 1; index >= 0; index -= 1) {
1758
+ const child = item.children.item(index);
1759
+ if (child instanceof HTMLElement
1760
+ && child.classList.contains('carousel-overlay')) {
1761
+ return child;
1762
+ }
1763
+ }
1764
+ return undefined;
1765
+ }
1766
+ function carouselItems(container) {
1767
+ return Array.from(container.children).filter((child) => (child instanceof HTMLElement
1768
+ && slotItemBindings.get(child)?.descriptor.kind === 'carousel'));
1769
+ }
1770
+ function carouselItemDescriptor(index, itemCount, itemBasis, overlayColor) {
1771
+ const baseStyles = {
1772
+ 'flex-grow': '0',
1773
+ 'flex-shrink': '0',
1774
+ 'flex-basis': itemBasis,
1775
+ 'scroll-snap-align': 'center',
1776
+ 'transform-origin': 'center center',
1777
+ position: 'relative',
1778
+ overflow: 'hidden',
1779
+ };
1780
+ const ownedStyles = {
1781
+ ...baseStyles,
1782
+ transform: '',
1783
+ opacity: '',
1784
+ };
1785
+ if (index === 0)
1786
+ ownedStyles['margin-left'] = '';
1787
+ if (index === itemCount - 1)
1788
+ ownedStyles['margin-right'] = '';
1789
+ return {
1790
+ kind: 'carousel',
1791
+ ownership: slotItemOwnership(ownedStyles),
1792
+ apply(element) {
1793
+ applySlotItemStyles(element, baseStyles);
1794
+ const overlay = document.createElement('div');
1795
+ overlay.className = 'carousel-overlay';
1796
+ Object.assign(overlay.style, {
1797
+ position: 'absolute',
1798
+ inset: '0',
1799
+ background: overlayColor,
1800
+ opacity: '0',
1801
+ pointerEvents: 'none',
1802
+ borderRadius: 'inherit',
1803
+ zIndex: '1',
1804
+ });
1805
+ element.appendChild(overlay);
1806
+ },
1807
+ transfer(current, replacement) {
1808
+ const currentOverlay = carouselOverlay(current);
1809
+ const replacementOverlay = carouselOverlay(replacement);
1810
+ if (currentOverlay && replacementOverlay) {
1811
+ replacementOverlay.style.opacity = currentOverlay.style.opacity;
1812
+ }
1813
+ },
1814
+ };
1815
+ }
1205
1816
  /**
1206
1817
  * Center-focused carousel with scale/opacity/overlay effects and snap.
1207
1818
  *
@@ -1212,65 +1823,57 @@ function createArrowButton(side, arrowStyle, offset, icon, mirror = false) {
1212
1823
  * to the first after the last (default false)
1213
1824
  * - `autoplayInterval` — ms between auto-advances (default 3000)
1214
1825
  */
1215
- function renderCarouselSlot(container, children, slotContent, props, renderChild) {
1216
- const config = slotContent?.config ?? {};
1217
- const inactiveScale = config.scale ?? props.carouselScale ?? 0.85;
1218
- const inactiveOpacity = config.opacity ?? props.carouselOpacity ?? 0.4;
1219
- const overlayColor = config.overlayColor ?? props.carouselOverlayColor ?? 'rgba(255,255,255,0.5)';
1220
- const gap = config.gap ?? props.carouselGap ?? 16;
1221
- const itemWidth = config.itemWidthPercent ?? props.carouselItemWidth ?? 70;
1222
- const itemWidthPx = config.itemWidthPx;
1223
- const initialIndex = Math.max(0, Math.min(children.length - 1, config.initialIndex ?? props.carouselInitialIndex ?? 0));
1224
- const autoplay = config.autoplay ?? props.carouselAutoplay ?? false;
1225
- const autoplayInterval = config.autoplayInterval ?? props.carouselAutoplayInterval ?? 3000;
1226
- Object.assign(container.style, {
1227
- display: 'flex',
1228
- flexDirection: 'row',
1229
- overflowX: 'auto',
1230
- scrollSnapType: 'x mandatory',
1231
- WebkitOverflowScrolling: 'touch',
1232
- gap: `${gap}px`,
1233
- paddingTop: '16px',
1234
- paddingBottom: '16px',
1235
- scrollbarWidth: 'none',
1236
- cursor: 'grab',
1237
- position: 'relative',
1238
- });
1239
- // Hide scrollbar
1240
- const styleEl = document.createElement('style');
1826
+ function renderCarouselSlot(container, children, _slotContent, props, renderChild) {
1827
+ const effectiveConfig = resolveCarouselConfig(props, children.length);
1828
+ const inactiveScale = effectiveConfig.scale;
1829
+ const inactiveOpacity = effectiveConfig.opacity;
1830
+ const overlayColor = effectiveConfig.overlayColor;
1831
+ const gap = effectiveConfig.gap;
1832
+ const itemWidth = effectiveConfig.itemWidthPercent;
1833
+ const itemWidthPx = effectiveConfig.itemWidthPx;
1834
+ const initialIndex = effectiveConfig.initialIndex;
1835
+ const autoplay = effectiveConfig.autoplay;
1836
+ const autoplayInterval = effectiveConfig.autoplayInterval;
1837
+ const ownerCleanup = createSlotOwnerCleanup(container);
1838
+ let ownerDisposed = false;
1839
+ const pendingFrames = new Set();
1840
+ const requestOwnerFrame = (callback) => {
1841
+ let frameId = 0;
1842
+ let completedSynchronously = false;
1843
+ frameId = requestAnimationFrame((timestamp) => {
1844
+ completedSynchronously = true;
1845
+ pendingFrames.delete(frameId);
1846
+ if (!ownerDisposed)
1847
+ callback(timestamp);
1848
+ });
1849
+ if (!completedSynchronously)
1850
+ pendingFrames.add(frameId);
1851
+ return completedSynchronously ? 0 : frameId;
1852
+ };
1853
+ const cancelOwnerFrame = (frameId) => {
1854
+ if (!frameId)
1855
+ return;
1856
+ cancelAnimationFrame(frameId);
1857
+ pendingFrames.delete(frameId);
1858
+ };
1859
+ Object.assign(container.style, carouselHostStyles(gap));
1860
+ // Hide scrollbar
1861
+ const styleEl = document.createElement('style');
1241
1862
  const id = container.getAttribute('data-card-id') ?? '';
1242
1863
  styleEl.textContent = `[data-card-id="${id}"]{scrollbar-width:none}[data-card-id="${id}"]::-webkit-scrollbar{display:none}`;
1243
1864
  container.appendChild(styleEl);
1244
1865
  // The carousel element scrolls itself — tag it so renderCard can snapshot/restore
1245
1866
  // its scrollLeft across variable-driven re-renders (see captureScrollPositions/restoreScrollPositions).
1246
- container.setAttribute('data-scroll-id', id || 'x');
1867
+ container.setAttribute(CAROUSEL_SCROLL_ID_ATTRIBUTE, id || 'x');
1247
1868
  // Render children
1248
- const items = [];
1249
1869
  for (let i = 0; i < children.length; i++) {
1250
1870
  const child = children[i];
1251
1871
  const item = renderChild(child);
1252
- item.style.flex = itemWidthPx ? `0 0 ${itemWidthPx}px` : `0 0 ${itemWidth}%`;
1253
- item.style.scrollSnapAlign = 'center';
1254
- item.style.transformOrigin = 'center center';
1255
- item.style.position = 'relative';
1256
- item.style.overflow = 'hidden';
1257
- const overlay = document.createElement('div');
1258
- overlay.className = 'carousel-overlay';
1259
- Object.assign(overlay.style, {
1260
- position: 'absolute',
1261
- inset: '0',
1262
- background: overlayColor,
1263
- opacity: '0',
1264
- pointerEvents: 'none',
1265
- borderRadius: 'inherit',
1266
- zIndex: '1',
1267
- });
1268
- item.appendChild(overlay);
1872
+ bindSlotItemPresentation(item, carouselItemDescriptor(i, children.length, itemWidthPx ? `${itemWidthPx}px` : `${itemWidth}%`, overlayColor));
1269
1873
  container.appendChild(item);
1270
- items.push(item);
1271
1874
  }
1272
1875
  // Index of the item whose center is closest to the viewport center
1273
- const nearestIndex = () => {
1876
+ const nearestIndex = (items) => {
1274
1877
  const centerX = container.scrollLeft + container.offsetWidth / 2;
1275
1878
  let idx = 0;
1276
1879
  let minDist = Infinity;
@@ -1284,9 +1887,29 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1284
1887
  });
1285
1888
  return idx;
1286
1889
  };
1890
+ let pendingSnapScrollEnd = null;
1891
+ let pendingSnapTimer = 0;
1892
+ const clearPendingSnap = () => {
1893
+ if (pendingSnapScrollEnd) {
1894
+ container.removeEventListener('scrollend', pendingSnapScrollEnd);
1895
+ pendingSnapScrollEnd = null;
1896
+ }
1897
+ if (pendingSnapTimer) {
1898
+ window.clearTimeout(pendingSnapTimer);
1899
+ pendingSnapTimer = 0;
1900
+ }
1901
+ };
1902
+ const restoreSnap = () => {
1903
+ clearPendingSnap();
1904
+ if (!ownerDisposed)
1905
+ container.style.scrollSnapType = 'x mandatory';
1906
+ };
1287
1907
  // Center a given item. 'smooth' animates and restores CSS snap afterwards;
1288
1908
  // 'auto' jumps instantly (used for initial positioning).
1289
1909
  const centerItem = (item, behavior = 'smooth') => {
1910
+ if (ownerDisposed)
1911
+ return;
1912
+ clearPendingSnap();
1290
1913
  const target = item.offsetLeft - (container.offsetWidth - item.offsetWidth) / 2;
1291
1914
  if (behavior === 'auto') {
1292
1915
  // Disable mandatory snap for the instant jump — a mandatory-snap container
@@ -1294,7 +1917,7 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1294
1917
  // Restore snap next frame once the scroll offset is committed.
1295
1918
  container.style.scrollSnapType = 'none';
1296
1919
  container.scrollLeft = target;
1297
- requestAnimationFrame(() => {
1920
+ requestOwnerFrame(() => {
1298
1921
  container.style.scrollSnapType = 'x mandatory';
1299
1922
  });
1300
1923
  return;
@@ -1302,12 +1925,12 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1302
1925
  // Disable CSS snap during the smooth animation, restore after for pixel-perfect alignment
1303
1926
  container.style.scrollSnapType = 'none';
1304
1927
  container.scrollTo({ left: target, behavior: 'smooth' });
1305
- container.addEventListener('scrollend', () => {
1306
- container.style.scrollSnapType = 'x mandatory';
1307
- }, { once: true });
1928
+ pendingSnapScrollEnd = restoreSnap;
1929
+ container.addEventListener('scrollend', pendingSnapScrollEnd, { once: true });
1308
1930
  // Fallback for browsers without scrollend event
1309
- setTimeout(() => {
1310
- container.style.scrollSnapType = 'x mandatory';
1931
+ pendingSnapTimer = window.setTimeout(() => {
1932
+ pendingSnapTimer = 0;
1933
+ restoreSnap();
1311
1934
  }, 400);
1312
1935
  };
1313
1936
  // Snap helper
@@ -1316,7 +1939,10 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1316
1939
  let isDragging = false;
1317
1940
  let startX = 0;
1318
1941
  let scrollStart = 0;
1319
- container.addEventListener('mousedown', (e) => {
1942
+ const handleMouseDown = (event) => {
1943
+ if (ownerDisposed)
1944
+ return;
1945
+ const e = event;
1320
1946
  if (e.button !== 0)
1321
1947
  return; // left button only — keep middle/right clicks out of drag state
1322
1948
  isDragging = true;
@@ -1325,35 +1951,52 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1325
1951
  container.style.cursor = 'grabbing';
1326
1952
  container.style.scrollSnapType = 'none';
1327
1953
  e.preventDefault();
1328
- });
1329
- document.addEventListener('mousemove', (e) => {
1330
- if (!isDragging)
1954
+ };
1955
+ const handleMouseMove = (event) => {
1956
+ if (ownerDisposed || !isDragging)
1331
1957
  return;
1958
+ const e = event;
1332
1959
  container.scrollLeft = scrollStart - (e.pageX - startX);
1333
- });
1334
- document.addEventListener('mouseup', () => {
1335
- if (!isDragging)
1960
+ };
1961
+ const handleMouseUp = () => {
1962
+ if (ownerDisposed || !isDragging)
1336
1963
  return;
1337
1964
  isDragging = false;
1338
1965
  container.style.cursor = 'grab';
1339
1966
  snapToNearest();
1340
- });
1341
- container.addEventListener('touchend', () => {
1342
- snapToNearest();
1343
- }, { passive: true });
1967
+ };
1968
+ const handleTouchEnd = () => {
1969
+ if (!ownerDisposed)
1970
+ snapToNearest();
1971
+ };
1972
+ addSlotOwnerListener(ownerCleanup, container, 'mousedown', handleMouseDown);
1973
+ addSlotOwnerListener(ownerCleanup, document, 'mousemove', handleMouseMove);
1974
+ addSlotOwnerListener(ownerCleanup, document, 'mouseup', handleMouseUp);
1975
+ addSlotOwnerListener(ownerCleanup, container, 'touchend', handleTouchEnd, { passive: true });
1344
1976
  // Wheel support
1345
1977
  let wheelTimer = 0;
1346
- container.addEventListener('wheel', (e) => {
1978
+ const handleWheel = (event) => {
1979
+ if (ownerDisposed)
1980
+ return;
1981
+ const e = event;
1347
1982
  if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) {
1348
1983
  e.preventDefault();
1349
1984
  container.style.scrollSnapType = 'none';
1350
1985
  container.scrollLeft += e.deltaY;
1351
- clearTimeout(wheelTimer);
1352
- wheelTimer = window.setTimeout(() => snapToNearest(), 150);
1986
+ window.clearTimeout(wheelTimer);
1987
+ wheelTimer = window.setTimeout(() => {
1988
+ wheelTimer = 0;
1989
+ if (!ownerDisposed)
1990
+ snapToNearest();
1991
+ }, 150);
1353
1992
  }
1354
- }, { passive: false });
1993
+ };
1994
+ addSlotOwnerListener(ownerCleanup, container, 'wheel', handleWheel, { passive: false });
1355
1995
  // Scale/opacity effects
1356
1996
  const updateEffects = () => {
1997
+ if (ownerDisposed)
1998
+ return;
1999
+ const items = carouselItems(container);
1357
2000
  const containerWidth = container.offsetWidth;
1358
2001
  const scrollCenter = container.scrollLeft + containerWidth / 2;
1359
2002
  items.forEach((item) => {
@@ -1366,20 +2009,27 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1366
2009
  const opacity = 1 - ratio * (1 - inactiveOpacity);
1367
2010
  item.style.transform = `scale3d(${scale},${scale},1)`;
1368
2011
  item.style.opacity = `${opacity}`;
1369
- const overlayEl = item.querySelector('.carousel-overlay');
2012
+ const overlayEl = carouselOverlay(item);
1370
2013
  if (overlayEl)
1371
2014
  overlayEl.style.opacity = `${ratio}`;
1372
2015
  });
1373
2016
  };
1374
2017
  let rafId = 0;
1375
- container.addEventListener('scroll', () => {
1376
- cancelAnimationFrame(rafId);
1377
- rafId = requestAnimationFrame(updateEffects);
1378
- }, { passive: true });
2018
+ const handleScroll = () => {
2019
+ if (ownerDisposed)
2020
+ return;
2021
+ cancelOwnerFrame(rafId);
2022
+ rafId = requestOwnerFrame(() => {
2023
+ rafId = 0;
2024
+ updateEffects();
2025
+ });
2026
+ };
2027
+ addSlotOwnerListener(ownerCleanup, container, 'scroll', handleScroll, { passive: true });
1379
2028
  // Smooth snap to nearest item center + restore scrollSnapType for pixel-perfect final position
1380
2029
  snapToNearest = () => {
2030
+ const items = carouselItems(container);
1381
2031
  if (items.length > 0)
1382
- centerItem(items[nearestIndex()]);
2032
+ centerItem(items[nearestIndex(items)]);
1383
2033
  };
1384
2034
  // Autoplay — advance to the next item on an interval, looping back to the
1385
2035
  // first after the last. Pauses while the user hovers or interacts.
@@ -1387,33 +2037,64 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1387
2037
  let autoplayTimer = 0;
1388
2038
  const stopAutoplay = () => {
1389
2039
  if (autoplayTimer) {
1390
- clearInterval(autoplayTimer);
2040
+ window.clearInterval(autoplayTimer);
1391
2041
  autoplayTimer = 0;
1392
2042
  }
1393
2043
  };
1394
2044
  const startAutoplay = () => {
1395
- if (!autoplay || items.length < 2)
2045
+ const items = carouselItems(container);
2046
+ if (ownerDisposed
2047
+ || !container.isConnected
2048
+ || !autoplay
2049
+ || items.length < 2)
1396
2050
  return;
1397
2051
  stopAutoplay();
1398
2052
  autoplayTimer = window.setInterval(() => {
1399
- // Self-clean once the carousel leaves the DOM (no teardown hook is available here)
1400
- if (!container.isConnected) {
2053
+ if (ownerDisposed || !container.isConnected) {
1401
2054
  stopAutoplay();
1402
2055
  return;
1403
2056
  }
1404
2057
  if (paused || isDragging)
1405
2058
  return;
1406
- centerItem(items[(nearestIndex() + 1) % items.length]);
2059
+ const items = carouselItems(container);
2060
+ if (items.length < 2)
2061
+ return;
2062
+ centerItem(items[(nearestIndex(items) + 1) % items.length]);
1407
2063
  }, autoplayInterval);
1408
2064
  };
1409
2065
  if (autoplay) {
1410
- container.addEventListener('mouseenter', () => { paused = true; });
1411
- container.addEventListener('mouseleave', () => { paused = false; });
1412
- container.addEventListener('touchstart', () => { paused = true; }, { passive: true });
1413
- container.addEventListener('touchend', () => { paused = false; }, { passive: true });
1414
- }
2066
+ const pauseAutoplay = () => {
2067
+ if (!ownerDisposed)
2068
+ paused = true;
2069
+ };
2070
+ const resumeAutoplay = () => {
2071
+ if (!ownerDisposed)
2072
+ paused = false;
2073
+ };
2074
+ addSlotOwnerListener(ownerCleanup, container, 'mouseenter', pauseAutoplay);
2075
+ addSlotOwnerListener(ownerCleanup, container, 'mouseleave', resumeAutoplay);
2076
+ addSlotOwnerListener(ownerCleanup, container, 'touchstart', pauseAutoplay, { passive: true });
2077
+ addSlotOwnerListener(ownerCleanup, container, 'touchend', resumeAutoplay, { passive: true });
2078
+ }
2079
+ ownerCleanup.add(() => {
2080
+ ownerDisposed = true;
2081
+ paused = true;
2082
+ isDragging = false;
2083
+ container.style.cursor = 'grab';
2084
+ if (wheelTimer) {
2085
+ window.clearTimeout(wheelTimer);
2086
+ wheelTimer = 0;
2087
+ }
2088
+ clearPendingSnap();
2089
+ stopAutoplay();
2090
+ for (const frameId of pendingFrames)
2091
+ cancelAnimationFrame(frameId);
2092
+ pendingFrames.clear();
2093
+ rafId = 0;
2094
+ });
1415
2095
  // Initial position & effects — deferred until element is in DOM and laid out
1416
- requestAnimationFrame(() => {
2096
+ requestOwnerFrame(() => {
2097
+ const items = carouselItems(container);
1417
2098
  if (items.length > 0) {
1418
2099
  // Use ACTUAL rendered width (includes padding in content-box mode) for perfect centering
1419
2100
  const containerWidth = container.offsetWidth;
@@ -1428,13 +2109,13 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1428
2109
  delete container.dataset.restoreScrollLeft;
1429
2110
  container.style.scrollSnapType = 'none';
1430
2111
  container.scrollLeft = parseFloat(saved);
1431
- requestAnimationFrame(() => {
2112
+ requestOwnerFrame(() => {
1432
2113
  container.style.scrollSnapType = 'x mandatory';
1433
2114
  });
1434
2115
  }
1435
2116
  else {
1436
2117
  // Jump to the configured initial item (defaults to the first) — CSS snap maintains alignment
1437
- centerItem(items[initialIndex], 'auto');
2118
+ centerItem(items[Math.min(initialIndex, items.length - 1)], 'auto');
1438
2119
  }
1439
2120
  }
1440
2121
  updateEffects();
@@ -1721,6 +2402,97 @@ class BaseElement extends HTMLElement {
1721
2402
  this.shadowRoot.innerHTML = html;
1722
2403
  applyResponsiveStyles(this.shadowRoot, this._responsive);
1723
2404
  }
2405
+ /**
2406
+ * Patch freshly rendered markup around an existing interactive element.
2407
+ *
2408
+ * Replacing a focused native control through `shadowRoot.innerHTML` resets
2409
+ * its selection and, on mobile WebViews, can also recreate the soft
2410
+ * keyboard with a different layout. This helper keeps the selected element
2411
+ * connected while reconciling its surrounding markup.
2412
+ */
2413
+ patchShadowHTMLPreservingElement(html, preserved, selector) {
2414
+ if (!this.shadowRoot || !this.shadowRoot.contains(preserved))
2415
+ return false;
2416
+ const template = document.createElement('template');
2417
+ template.innerHTML = html;
2418
+ const desiredPreserved = template.content.querySelector(selector);
2419
+ if (!desiredPreserved
2420
+ || desiredPreserved.tagName !== preserved.tagName
2421
+ || (preserved instanceof HTMLInputElement
2422
+ && desiredPreserved instanceof HTMLInputElement
2423
+ && desiredPreserved.type !== preserved.type)) {
2424
+ return false;
2425
+ }
2426
+ this.reconcilePreservedTree(this.shadowRoot, template.content, preserved);
2427
+ applyResponsiveStyles(this.shadowRoot, this._responsive);
2428
+ return this.shadowRoot.contains(preserved);
2429
+ }
2430
+ reconcilePreservedTree(currentParent, desiredParent, preserved) {
2431
+ const desiredChildren = Array.from(desiredParent.children);
2432
+ const currentChildren = Array.from(currentParent.children);
2433
+ const matches = new Map();
2434
+ const claimed = new Set();
2435
+ for (const desired of desiredChildren) {
2436
+ const desiredKey = this.reconcileKey(desired);
2437
+ const current = currentChildren.find(candidate => (!claimed.has(candidate)
2438
+ && this.reconcileKey(candidate) === desiredKey));
2439
+ if (current) {
2440
+ claimed.add(current);
2441
+ matches.set(desired, current);
2442
+ }
2443
+ }
2444
+ for (const current of currentChildren) {
2445
+ if (!claimed.has(current))
2446
+ current.remove();
2447
+ }
2448
+ desiredChildren.forEach((desired, index) => {
2449
+ let current = matches.get(desired);
2450
+ if (!current) {
2451
+ current = desired.cloneNode(true);
2452
+ }
2453
+ const childAtIndex = currentParent.children.item(index);
2454
+ if (childAtIndex !== current) {
2455
+ currentParent.insertBefore(current, childAtIndex);
2456
+ }
2457
+ this.patchPreservedElement(current, desired, preserved);
2458
+ });
2459
+ }
2460
+ patchPreservedElement(current, desired, preserved) {
2461
+ for (const attribute of Array.from(current.attributes)) {
2462
+ if (!desired.hasAttribute(attribute.name)) {
2463
+ current.removeAttribute(attribute.name);
2464
+ }
2465
+ }
2466
+ for (const attribute of Array.from(desired.attributes)) {
2467
+ current.setAttribute(attribute.name, attribute.value);
2468
+ }
2469
+ if (current === preserved)
2470
+ return;
2471
+ if (current.contains(preserved)) {
2472
+ this.reconcilePreservedTree(current, desired, preserved);
2473
+ return;
2474
+ }
2475
+ current.innerHTML = desired.innerHTML;
2476
+ }
2477
+ reconcileKey(element) {
2478
+ if (element.classList.contains('card-input'))
2479
+ return 'card-input';
2480
+ if (element.classList.contains('card-input-wrapper')) {
2481
+ return 'card-input-wrapper';
2482
+ }
2483
+ if (element.classList.contains('card-input-label')) {
2484
+ return 'card-input-label';
2485
+ }
2486
+ if (element.classList.contains('input-control'))
2487
+ return 'input-control';
2488
+ if (element.classList.contains('input-prefix'))
2489
+ return 'input-prefix';
2490
+ if (element.classList.contains('input-suffix'))
2491
+ return 'input-suffix';
2492
+ if (element.classList.contains('number-stepper'))
2493
+ return 'number-stepper';
2494
+ return element.tagName;
2495
+ }
1724
2496
  /**
1725
2497
  * Escape a value before interpolating it into a double-quoted HTML
1726
2498
  * attribute. Browsers decode the entities before parsing inline CSS, so
@@ -2336,6 +3108,30 @@ function sanitizeIconHtml(icon) {
2336
3108
  walk(tpl.content);
2337
3109
  return tpl.innerHTML;
2338
3110
  }
3111
+ function escapeIconAttribute(value) {
3112
+ return value
3113
+ .replace(/&/g, '&amp;')
3114
+ .replace(/"/g, '&quot;')
3115
+ .replace(/</g, '&lt;')
3116
+ .replace(/>/g, '&gt;');
3117
+ }
3118
+ function isDirectImageUrl(icon) {
3119
+ const normalized = icon.replace(/[\s\x00-\x1f]/g, '').toLowerCase();
3120
+ return /^(?:https?:\/\/|\/\/|data:image\/)/.test(normalized)
3121
+ || /^(?:\.{0,2}\/).+\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i.test(icon);
3122
+ }
3123
+ /**
3124
+ * Button.icon historically accepts sanitized HTML or text. A direct image URL
3125
+ * is a common shorthand, so convert only recognizable image URLs to markup and
3126
+ * leave inline SVG, <img> HTML, emoji, and other text on the existing path.
3127
+ */
3128
+ function renderButtonIcon(icon) {
3129
+ const value = String(icon ?? '').trim();
3130
+ if (isDirectImageUrl(value)) {
3131
+ return `<img class="card-btn-url-icon" src="${escapeIconAttribute(value)}" alt="" aria-hidden="true" />`;
3132
+ }
3133
+ return sanitizeIconHtml(value);
3134
+ }
2339
3135
  class CardButton extends BaseElement {
2340
3136
  render() {
2341
3137
  if (!this.shadowRoot || !this._node)
@@ -2490,12 +3286,18 @@ class CardButton extends BaseElement {
2490
3286
  width: 1em;
2491
3287
  height: 1em;
2492
3288
  }
3289
+ .card-btn-url-icon {
3290
+ display: block;
3291
+ width: 100%;
3292
+ height: 100%;
3293
+ object-fit: contain;
3294
+ }
2493
3295
  </style>
2494
3296
  <button
2495
3297
  class="card-btn ${variant} ${isMobile ? 'card-mobile' : 'card-desktop'}"
2496
3298
  ${disabled ? 'disabled' : ''}
2497
3299
  style="${inlineStyle}"
2498
- ><span class="card-btn-content">${icon ? `<span class="card-btn-icon">${sanitizeIconHtml(String(icon))}</span>` : ''}${displayText}</span></button>
3300
+ ><span class="card-btn-content">${icon ? `<span class="card-btn-icon">${renderButtonIcon(icon)}</span>` : ''}${displayText}</span></button>
2499
3301
  `);
2500
3302
  }
2501
3303
  }
@@ -2679,10 +3481,18 @@ function normalizeNumberAttribute(value, options = {}) {
2679
3481
  return numeric;
2680
3482
  }
2681
3483
  class CardInput extends BaseElement {
3484
+ constructor() {
3485
+ super(...arguments);
3486
+ this.wiredInputs = new WeakSet();
3487
+ this.wiredNumberInputs = new WeakSet();
3488
+ }
2682
3489
  render() {
2683
3490
  if (!this.shadowRoot || !this._node)
2684
3491
  return;
2685
- const { placeholder = '', autoFocus = false, inputType = 'text', label, defaultValue = '', disabled = false, readonly: readOnly = false, maxLength, rows, min, max, step = 1, controls = true, prefix, suffix, style, inputStyle, isExpressionResultStyle, } = this._props;
3492
+ const activeControl = this.shadowRoot.activeElement?.matches('.card-input')
3493
+ ? this.shadowRoot.activeElement
3494
+ : null;
3495
+ const { placeholder = '', inputType = 'text', inputMode, label, defaultValue = '', disabled = false, readonly: readOnly = false, maxLength, rows, min, max, step = 1, controls = true, prefix, suffix, style, inputStyle, isExpressionResultStyle, } = this._props;
2686
3496
  const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
2687
3497
  const legacyInputStyle = style && typeof style === 'object' && style.resize != null
2688
3498
  ? { resize: style.resize }
@@ -2752,6 +3562,7 @@ class CardInput extends BaseElement {
2752
3562
  id="${inputId}"
2753
3563
  class="card-input ${isMobile ? 'card-mobile' : 'card-desktop'}"
2754
3564
  placeholder="${this.escapeAttr(String(placeholder))}"
3565
+ ${inputMode ? `inputmode="${this.escapeAttr(String(inputMode))}"` : ''}
2755
3566
  ${describedBy}
2756
3567
  ${disabled ? 'disabled' : ''}
2757
3568
  ${readOnly ? 'readonly' : ''}
@@ -2763,6 +3574,7 @@ class CardInput extends BaseElement {
2763
3574
  id="${inputId}"
2764
3575
  class="card-input ${isMobile ? 'card-mobile' : 'card-desktop'}"
2765
3576
  type="${this.escapeAttr(String(inputType))}"
3577
+ ${inputMode ? `inputmode="${this.escapeAttr(String(inputMode))}"` : ''}
2766
3578
  placeholder="${this.escapeAttr(String(placeholder))}"
2767
3579
  value="${this.escapeAttr(String(defaultValue))}"
2768
3580
  ${describedBy}
@@ -2772,7 +3584,7 @@ class CardInput extends BaseElement {
2772
3584
  ${numberAttributes}
2773
3585
  style="${nativeInlineStyle}"
2774
3586
  />`;
2775
- this.setShadowHTML(`
3587
+ const markup = `
2776
3588
  <style>
2777
3589
  :host {
2778
3590
  display: block;
@@ -2946,7 +3758,11 @@ class CardInput extends BaseElement {
2946
3758
  ${numberStepperHtml}
2947
3759
  </div>
2948
3760
  </div>
2949
- `);
3761
+ `;
3762
+ if (!activeControl
3763
+ || !this.patchShadowHTMLPreservingElement(markup, activeControl, '.card-input')) {
3764
+ this.setShadowHTML(markup);
3765
+ }
2950
3766
  // Wire up native input/change events that bubble out of Shadow DOM.
2951
3767
  // renderCard listens for these standard event names (mapped from onInput / onChange).
2952
3768
  // The `detail.value` carries the current input value so that:
@@ -2954,41 +3770,41 @@ class CardInput extends BaseElement {
2954
3770
  // 2. action handlers can access it if needed
2955
3771
  const inputEl = this.shadowRoot.querySelector('.card-input');
2956
3772
  if (inputEl) {
2957
- inputEl.addEventListener('input', () => {
2958
- this.dispatchEvent(new CustomEvent('input', {
2959
- bubbles: true,
2960
- composed: true,
2961
- detail: { value: inputEl.value },
2962
- }));
2963
- });
2964
- inputEl.addEventListener('change', () => {
2965
- this.dispatchEvent(new CustomEvent('change', {
2966
- bubbles: true,
2967
- composed: true,
2968
- detail: { value: inputEl.value },
2969
- }));
2970
- });
2971
- if (autoFocus && !disabled) {
2972
- queueMicrotask(() => {
2973
- const control = this.getAutoFocusControl();
2974
- if (control && this.isFirstAvailableAutoFocusInput()) {
2975
- control.focus();
2976
- }
3773
+ if (!this.wiredInputs.has(inputEl)) {
3774
+ this.wiredInputs.add(inputEl);
3775
+ inputEl.addEventListener('input', () => {
3776
+ this.dispatchEvent(new CustomEvent('input', {
3777
+ bubbles: true,
3778
+ composed: true,
3779
+ detail: { value: inputEl.value },
3780
+ }));
3781
+ });
3782
+ inputEl.addEventListener('change', () => {
3783
+ this.dispatchEvent(new CustomEvent('change', {
3784
+ bubbles: true,
3785
+ composed: true,
3786
+ detail: { value: inputEl.value },
3787
+ }));
2977
3788
  });
2978
3789
  }
2979
3790
  if (isNumber &&
2980
3791
  showNumberStepper &&
2981
3792
  inputEl instanceof HTMLInputElement) {
2982
- const increaseButton = this.shadowRoot.querySelector('.number-step-button.increase');
2983
- const decreaseButton = this.shadowRoot.querySelector('.number-step-button.decrease');
2984
3793
  const refreshStepperState = () => {
2985
3794
  const state = getNumberStepperDisabledState(inputEl);
3795
+ const increaseButton = this.shadowRoot?.querySelector('.number-step-button.increase');
3796
+ const decreaseButton = this.shadowRoot?.querySelector('.number-step-button.decrease');
2986
3797
  if (increaseButton)
2987
3798
  increaseButton.disabled = state.increase;
2988
3799
  if (decreaseButton)
2989
3800
  decreaseButton.disabled = state.decrease;
2990
3801
  };
2991
- inputEl.addEventListener('input', refreshStepperState);
3802
+ if (!this.wiredNumberInputs.has(inputEl)) {
3803
+ this.wiredNumberInputs.add(inputEl);
3804
+ inputEl.addEventListener('input', refreshStepperState);
3805
+ }
3806
+ const increaseButton = this.shadowRoot.querySelector('.number-step-button.increase');
3807
+ const decreaseButton = this.shadowRoot.querySelector('.number-step-button.decrease');
2992
3808
  increaseButton?.addEventListener('click', () => {
2993
3809
  stepNumberInput(inputEl, 'increase');
2994
3810
  inputEl.focus();
@@ -3004,21 +3820,25 @@ class CardInput extends BaseElement {
3004
3820
  }
3005
3821
  }
3006
3822
  // ─── Helpers ──────────────────────────────────────────────────
3007
- getAutoFocusControl() {
3823
+ /**
3824
+ * Consume an interaction-driven focus request from the card renderer.
3825
+ * Mounts and ordinary updates never call this method.
3826
+ */
3827
+ requestAutoFocus() {
3008
3828
  if (!this.isConnected
3009
3829
  || !this._props.autoFocus
3010
3830
  || this.hasAttribute('data-disabled')) {
3011
- return null;
3831
+ return false;
3012
3832
  }
3013
3833
  const control = this.shadowRoot?.querySelector('.card-input');
3014
- return control && !control.disabled ? control : null;
3015
- }
3016
- isFirstAvailableAutoFocusInput() {
3017
- const root = this.getRootNode();
3018
- if (!('querySelectorAll' in root))
3834
+ if (!control
3835
+ || control.disabled
3836
+ || control.readOnly
3837
+ || control.value !== '') {
3019
3838
  return false;
3020
- const firstAvailable = Array.from(root.querySelectorAll(CardInput.is)).find(input => input.getAutoFocusControl() !== null);
3021
- return firstAvailable === this;
3839
+ }
3840
+ control.focus({ preventScroll: true });
3841
+ return this.shadowRoot?.activeElement === control;
3022
3842
  }
3023
3843
  /** Escape HTML entities for safe insertion. */
3024
3844
  escapeHtml(str) {
@@ -3080,15 +3900,37 @@ class CardImage extends BaseElement {
3080
3900
  if (!this.shadowRoot || !this._node)
3081
3901
  return;
3082
3902
  const { src, alt = '', width, height, objectFit = 'cover', preview = true, style, isExpressionResultStyle, } = this._props;
3083
- const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
3084
3903
  const imgSrc = this.resolveContent(src);
3085
3904
  const imgAlt = this.resolveContent(alt);
3905
+ // Keep the custom-element box independent from the image's intrinsic
3906
+ // dimensions. A failed image has different intrinsic sizing rules, so
3907
+ // sizing only the inner <img> makes flex/grid layouts jump on error.
3908
+ const hostWidth = this.applyHostDimension('width', style?.width, width);
3909
+ const hostHeight = this.applyHostDimension('height', style?.height, height);
3910
+ const ownsWidth = hostWidth != null;
3911
+ const ownsHeight = hostHeight != null;
3912
+ const reservesWidth = ownsWidth && hostWidth !== 'auto';
3913
+ const reservesHeight = ownsHeight && hostHeight !== 'auto';
3914
+ // Width and height belong to the host layout box. Remove them from the
3915
+ // inner style so relative lengths (for example 50%) are not applied twice.
3916
+ const imageStyle = style && typeof style === 'object' ? { ...style } : style;
3917
+ if (imageStyle && typeof imageStyle === 'object') {
3918
+ if (ownsWidth)
3919
+ delete imageStyle.width;
3920
+ if (ownsHeight)
3921
+ delete imageStyle.height;
3922
+ }
3923
+ const inlineStyle = this.buildInlineStyle(imageStyle, isExpressionResultStyle);
3086
3924
  // Build image inline styles
3087
3925
  const imgStyles = [];
3088
- if (width)
3089
- imgStyles.push(`width:${width}`);
3090
- if (height)
3091
- imgStyles.push(`height:${height}`);
3926
+ if (ownsWidth)
3927
+ imgStyles.push(`width:${reservesWidth ? '100%' : 'auto'}`);
3928
+ if (ownsHeight)
3929
+ imgStyles.push(`height:${reservesHeight ? '100%' : 'auto'}`);
3930
+ if (reservesWidth)
3931
+ imgStyles.push('min-width:0');
3932
+ if (reservesHeight)
3933
+ imgStyles.push('min-height:0');
3092
3934
  if (objectFit)
3093
3935
  imgStyles.push(`object-fit:${objectFit}`);
3094
3936
  if (inlineStyle)
@@ -3104,9 +3946,12 @@ class CardImage extends BaseElement {
3104
3946
  position: relative;
3105
3947
  display: inline-block;
3106
3948
  max-width: 100%;
3949
+ ${reservesWidth ? 'width: 100%;' : ''}
3950
+ ${reservesHeight ? 'height: 100%;' : ''}
3107
3951
  }
3108
3952
  .card-image {
3109
3953
  display: block;
3954
+ box-sizing: border-box;
3110
3955
  max-width: 100%;
3111
3956
  height: auto;
3112
3957
  transition: transform 0.2s ease, opacity 0.2s ease;
@@ -3127,8 +3972,8 @@ class CardImage extends BaseElement {
3127
3972
  100% { background-position: -200% 0; }
3128
3973
  }
3129
3974
  .card-image.error {
3130
- min-width: 100px;
3131
- min-height: 60px;
3975
+ min-width: ${reservesWidth ? '0' : '100px'};
3976
+ min-height: ${reservesHeight ? '0' : '60px'};
3132
3977
  background: #f5f5f5;
3133
3978
  display: flex;
3134
3979
  align-items: center;
@@ -3205,6 +4050,26 @@ class CardImage extends BaseElement {
3205
4050
  `);
3206
4051
  this.bindEvents(preview);
3207
4052
  }
4053
+ applyHostDimension(property, styleValue, propValue) {
4054
+ this.style.removeProperty(property);
4055
+ for (const value of [styleValue, propValue]) {
4056
+ const resolved = this.resolveDeclaredDimension(value);
4057
+ if (resolved == null)
4058
+ continue;
4059
+ this.style.setProperty(property, resolved);
4060
+ const applied = this.style.getPropertyValue(property);
4061
+ if (applied)
4062
+ return applied;
4063
+ }
4064
+ return undefined;
4065
+ }
4066
+ resolveDeclaredDimension(value) {
4067
+ if (typeof value === 'number' && Number.isFinite(value))
4068
+ return this.toCSS(value);
4069
+ if (typeof value === 'string' && value.trim() !== '')
4070
+ return this.toCSS(value.trim());
4071
+ return undefined;
4072
+ }
3208
4073
  bindEvents(preview) {
3209
4074
  if (!this.shadowRoot)
3210
4075
  return;
@@ -3319,9 +4184,7 @@ class CardDivider extends BaseElement {
3319
4184
  <div class="divider-line"></div>
3320
4185
  ${displayText ? `<span class="divider-text">${displayText}</span><div class="divider-line"></div>` : ''}
3321
4186
  `);
3322
- if (inlineStyle) {
3323
- this.style.cssText += ';' + inlineStyle;
3324
- }
4187
+ this.style.cssText = inlineStyle;
3325
4188
  }
3326
4189
  }
3327
4190
  CardDivider.is = 'ai-card-divider';
@@ -5250,9 +6113,7 @@ class CardLoading extends BaseElement {
5250
6113
  <div class="spinner"></div>
5251
6114
  ${displayText ? `<span class="loading-text">${displayText}</span>` : ''}
5252
6115
  `);
5253
- if (inlineStyle) {
5254
- this.style.cssText += ';' + inlineStyle;
5255
- }
6116
+ this.style.cssText = inlineStyle;
5256
6117
  }
5257
6118
  }
5258
6119
  CardLoading.is = 'ai-card-loading';
@@ -7013,11 +7874,12 @@ class CardChoiceItem extends BaseElement {
7013
7874
  ...(selected ? selectedIndicatorStyle || {} : {}),
7014
7875
  }, isExpressionResultStyle);
7015
7876
  const indicatorRole = selectionMode === 'single' ? 'radio' : 'checkbox';
7016
- const resolvedCheckedIcon = checkedIcon ?? listCheckedIcon ?? {
7877
+ const configuredCheckedIcon = checkedIcon ?? listCheckedIcon;
7878
+ const resolvedCheckedIcon = configuredCheckedIcon ?? {
7017
7879
  name: 'check_bold',
7018
7880
  size: 12,
7019
7881
  };
7020
- const checkedMark = selectionMode === 'multiple'
7882
+ const checkedMark = selectionMode === 'multiple' || configuredCheckedIcon
7021
7883
  ? `<span class="choice-icon" aria-hidden="true">${renderIconContent(resolvedCheckedIcon)}</span>`
7022
7884
  : '<span class="radio-dot" aria-hidden="true"></span>';
7023
7885
  this.setAttribute('data-selected', String(selected));
@@ -7656,6 +8518,8 @@ function renderDefault(node, props, isMobile, responsive) {
7656
8518
  div.className = `card-element card-${node.type.toLowerCase()} ${isMobile ? 'card-mobile' : 'card-desktop'}`;
7657
8519
  div.setAttribute('data-card-id', node.id);
7658
8520
  div.setAttribute('data-card-type', node.type);
8521
+ // Apply slot base styles FIRST (so props.style can override)
8522
+ applySlotBaseStyle(div, props);
7659
8523
  if (props.style && typeof props.style === 'object') {
7660
8524
  const resolvedStyle = buildStyleString(props.isExpressionResultStyle
7661
8525
  ? props.style
@@ -7718,158 +8582,936 @@ function resolveSizeInStyle(style) {
7718
8582
  return resolved;
7719
8583
  }
7720
8584
 
7721
- /**
7722
- * Render the scoped/materialized branch of a card.
7723
- *
7724
- * This implementation deliberately owns its state independently from the
7725
- * legacy static renderer. Bound writes happen in isolated drafts and publish
7726
- * only after materialization and detached DOM construction both succeed.
7727
- */
7728
- function renderBoundCard(container, schema, options) {
7729
- const variables = cloneJsonData({
7730
- ...schema.variables,
7731
- ...options.variables,
7732
- });
7733
- const schemaActions = schema.actions ?? {};
7734
- const lifecycleManager = createLifecycleManager();
7735
- const abortController = new AbortController();
7736
- const inflightRequests = new Map();
7737
- const activeLifecycleNodes = new Map();
7738
- let currentMaterialized;
7739
- let revision = 0;
7740
- let disposed = false;
7741
- const isMobile = options.isMobile === true;
7742
- let actionQueue = Promise.resolve();
7743
- let lifecycleQueue = Promise.resolve();
7744
- function expressionContextFor(node) {
7745
- return isBoundRenderTreeNode(node)
7746
- ? createExpressionContext(node.scope)
7747
- : variables;
7748
- }
7749
- function resolveNodeValue(value, node, renderVariables) {
7750
- if (node.bindingDialect === 'a2ui') {
7751
- return resolveA2UIDeep(value, renderVariables, node.dataPath);
7752
- }
7753
- if (typeof value === 'string' && hasExpression(value)) {
7754
- return resolveExpression(value, expressionContextFor(node));
7755
- }
7756
- return value;
7757
- }
7758
- function resolveNodeProps(node, renderVariables) {
7759
- const resolvedProps = (node.bindingDialect === 'a2ui'
7760
- ? resolveA2UIDeep(node.props, renderVariables, node.dataPath)
7761
- : resolveDeep(node.props, expressionContextFor(node)));
7762
- if (resolvedProps.content
7763
- && typeof resolvedProps.content === 'object'
7764
- && 'type' in resolvedProps.content) {
7765
- resolvedProps.content = resolveExpressionValue(resolvedProps.content, expressionContextFor(node));
7766
- }
7767
- return resolvedProps;
8585
+ const states = new WeakMap();
8586
+ function dispatchCurrent(element, state, domEvent, event) {
8587
+ const config = state.current;
8588
+ if (!config.isActive())
8589
+ return;
8590
+ if (domEvent === 'input'
8591
+ && config.variableKey
8592
+ && config.writeVariable
8593
+ && event.target === element) {
8594
+ const value = event instanceof CustomEvent
8595
+ && event.detail?.value !== undefined
8596
+ ? event.detail.value
8597
+ : event.target?.value;
8598
+ if (value !== undefined) {
8599
+ config.writeVariable(config.variableKey, value);
8600
+ }
8601
+ }
8602
+ for (const binding of config.events) {
8603
+ if (binding.domEvent !== domEvent)
8604
+ continue;
8605
+ if (binding.ownsValueEvent && event.target !== element)
8606
+ continue;
8607
+ binding.dispatch(event);
7768
8608
  }
7769
- function createPassiveActionContext(renderVariables) {
7770
- return {
7771
- ...createWebActionContext({
7772
- ...options,
7773
- abortSignal: abortController.signal,
7774
- }),
7775
- variables: renderVariables,
7776
- botId: options.botId,
7777
- inflightRequests,
8609
+ }
8610
+ function syncElementBindings(element, config) {
8611
+ let state = states.get(element);
8612
+ if (!state) {
8613
+ state = {
8614
+ current: config,
8615
+ listeners: new Map(),
7778
8616
  };
8617
+ states.set(element, state);
8618
+ }
8619
+ state.current = config;
8620
+ const desiredEvents = new Set(config.events.map(event => event.domEvent));
8621
+ if (config.variableKey)
8622
+ desiredEvents.add('input');
8623
+ for (const [name, listener] of state.listeners) {
8624
+ if (desiredEvents.has(name))
8625
+ continue;
8626
+ element.removeEventListener(name, listener);
8627
+ state.listeners.delete(name);
7779
8628
  }
7780
- function renderNode(node, renderVariables, lifecycleIds) {
7781
- if (node.directives?.visible) {
7782
- const resolved = resolveNodeValue(node.directives.visible, node, renderVariables);
7783
- if (resolved === false
7784
- || resolved === 'false'
7785
- || resolved === ''
7786
- || resolved === 0) {
7787
- const placeholder = document.createElement('div');
7788
- placeholder.style.display = 'none';
7789
- placeholder.setAttribute('data-card-id', node.id);
7790
- return placeholder;
8629
+ for (const name of desiredEvents) {
8630
+ if (state.listeners.has(name))
8631
+ continue;
8632
+ const listener = event => {
8633
+ const latest = states.get(element);
8634
+ if (latest) {
8635
+ dispatchCurrent(element, latest, name, event);
7791
8636
  }
8637
+ };
8638
+ element.addEventListener(name, listener);
8639
+ state.listeners.set(name, listener);
8640
+ }
8641
+ }
8642
+ function clearElementBindings(element) {
8643
+ const state = states.get(element);
8644
+ if (!state)
8645
+ return;
8646
+ let hasError = false;
8647
+ let firstError;
8648
+ for (const [name, listener] of state.listeners) {
8649
+ try {
8650
+ element.removeEventListener(name, listener);
7792
8651
  }
7793
- const resolvedProps = resolveNodeProps(node, renderVariables);
7794
- let isDisabled = false;
7795
- if (node.directives?.disabled) {
7796
- const resolved = resolveNodeValue(node.directives.disabled, node, renderVariables);
7797
- isDisabled = (resolved === true
7798
- || resolved === 'true'
7799
- || resolved === 1);
8652
+ catch (error) {
8653
+ if (!hasError)
8654
+ firstError = error;
8655
+ hasError = true;
7800
8656
  }
7801
- const renderer = (componentRenderers[node.type] ?? componentRenderers._default);
7802
- const el = renderer(node, resolvedProps, isMobile, options.responsive);
7803
- if (isDisabled) {
7804
- el.setAttribute('data-disabled', 'true');
7805
- el.style.background = '#F5F5F5';
7806
- el.style.color = '#C0C0C0';
7807
- el.style.setProperty('--card-disabled-color', '#C0C0C0');
7808
- el.style.pointerEvents = 'none';
7809
- el.style.cursor = 'default';
8657
+ }
8658
+ state.listeners.clear();
8659
+ states.delete(element);
8660
+ if (hasError)
8661
+ throw firstError;
8662
+ }
8663
+ function clearElementBindingsIn(root) {
8664
+ let hasError = false;
8665
+ let firstError;
8666
+ const clear = (element) => {
8667
+ try {
8668
+ clearElementBindings(element);
7810
8669
  }
7811
- const variableKey = resolvedProps.variableKey;
7812
- if (variableKey) {
7813
- el.addEventListener('input', ((event) => {
7814
- if (event.target !== el || disposed)
7815
- return;
7816
- const value = event.detail?.value
7817
- ?? event.target?.value;
7818
- if (value !== undefined) {
7819
- // Keep the existing focus-preserving, no-rerender behavior. Bumping
7820
- // the revision prevents an older async action from overwriting it.
7821
- variables[variableKey] = value;
7822
- revision += 1;
7823
- }
7824
- }));
8670
+ catch (error) {
8671
+ if (!hasError)
8672
+ firstError = error;
8673
+ hasError = true;
7825
8674
  }
7826
- if (node.events && !isDisabled) {
7827
- for (const [eventName, eventValue] of Object.entries(node.events)) {
7828
- if (!eventValue || !resolveActionRef(eventValue, schemaActions)) {
7829
- continue;
8675
+ };
8676
+ clear(root);
8677
+ root.querySelectorAll('*').forEach(clear);
8678
+ if (hasError)
8679
+ throw firstError;
8680
+ }
8681
+
8682
+ let unserializableFingerprint = 0;
8683
+ function nextUnserializableFingerprint() {
8684
+ unserializableFingerprint += 1;
8685
+ return `__unserializable__${unserializableFingerprint}`;
8686
+ }
8687
+ function serializeJsonLike(value, ancestors) {
8688
+ if (value === null)
8689
+ return 'null';
8690
+ switch (typeof value) {
8691
+ case 'string':
8692
+ return JSON.stringify(value);
8693
+ case 'boolean':
8694
+ return value ? 'true' : 'false';
8695
+ case 'number':
8696
+ if (!Number.isFinite(value))
8697
+ throw new TypeError('Non-finite number');
8698
+ return JSON.stringify(value);
8699
+ case 'object':
8700
+ break;
8701
+ default:
8702
+ throw new TypeError('Unsupported JSON value');
8703
+ }
8704
+ if (ancestors.has(value))
8705
+ throw new TypeError('Circular JSON value');
8706
+ ancestors.add(value);
8707
+ try {
8708
+ if (Array.isArray(value)) {
8709
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
8710
+ throw new TypeError('Unsupported array prototype');
8711
+ }
8712
+ const ownKeys = Reflect.ownKeys(value);
8713
+ const expectedKeys = new Set(['length']);
8714
+ for (let index = 0; index < value.length; index += 1) {
8715
+ expectedKeys.add(String(index));
8716
+ }
8717
+ if (ownKeys.some(key => typeof key !== 'string' || !expectedKeys.has(key))
8718
+ || ownKeys.length !== expectedKeys.size) {
8719
+ throw new TypeError('Unsupported array shape');
8720
+ }
8721
+ const values = [];
8722
+ for (let index = 0; index < value.length; index += 1) {
8723
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
8724
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
8725
+ throw new TypeError('Unsupported array item');
7830
8726
  }
7831
- const domEvent = EVENT_MAP[eventName] ?? eventName;
7832
- el.addEventListener(domEvent, ((event) => {
7833
- const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
7834
- && VALUE_CONTROL_TYPES$1.has(node.type));
7835
- if (ownsValueEvent && event.target !== el)
7836
- return;
7837
- const eventDetail = (event instanceof CustomEvent && event.detail != null)
7838
- ? event.detail
7839
- : undefined;
7840
- enqueueBoundEvent(node.id, eventName, eventDetail);
7841
- }));
8727
+ values.push(serializeJsonLike(descriptor.value, ancestors));
7842
8728
  }
8729
+ return `[${values.join(',')}]`;
7843
8730
  }
7844
- if (node.lifecycle) {
7845
- lifecycleIds.add(node.id);
8731
+ const prototype = Object.getPrototypeOf(value);
8732
+ if (prototype !== Object.prototype && prototype !== null) {
8733
+ throw new TypeError('Unsupported object prototype');
7846
8734
  }
7847
- const renderChild = (child) => renderNode(child, renderVariables, lifecycleIds);
7848
- const childrenMap = {};
7849
- for (const child of node.children) {
7850
- childrenMap[child.id] = child;
8735
+ const ownKeys = Reflect.ownKeys(value);
8736
+ if (ownKeys.some(key => typeof key !== 'string')) {
8737
+ throw new TypeError('Unsupported symbol key');
7851
8738
  }
7852
- const layoutApplied = renderSlotLayout(el, node.children, resolvedProps, child => renderChild(child), childrenMap, createPassiveActionContext(renderVariables));
7853
- if (!layoutApplied) {
7854
- for (const child of node.children) {
7855
- el.appendChild(renderChild(child));
8739
+ const entries = ownKeys.sort().map(key => {
8740
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
8741
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
8742
+ throw new TypeError('Unsupported object property');
7856
8743
  }
8744
+ return `${JSON.stringify(key)}:${serializeJsonLike(descriptor.value, ancestors)}`;
8745
+ });
8746
+ return `{${entries.join(',')}}`;
8747
+ }
8748
+ finally {
8749
+ ancestors.delete(value);
8750
+ }
8751
+ }
8752
+ function stableFingerprint(value) {
8753
+ try {
8754
+ return serializeJsonLike(value, new Set());
8755
+ }
8756
+ catch {
8757
+ return nextUnserializableFingerprint();
8758
+ }
8759
+ }
8760
+ function disposeWithoutThrow(mounted, hooks) {
8761
+ try {
8762
+ hooks.dispose(mounted);
8763
+ }
8764
+ catch {
8765
+ // DOM ownership has already been decided; cleanup errors cannot change it.
8766
+ }
8767
+ }
8768
+ function reportUnreconciled(hooks, error) {
8769
+ try {
8770
+ hooks.onUnreconciled?.(error);
8771
+ }
8772
+ catch {
8773
+ // Reporting cannot change which DOM tree is live.
8774
+ }
8775
+ }
8776
+ function replaceIncrementalNode(current, next, hooks) {
8777
+ const parent = current.element.parentNode;
8778
+ if (!parent) {
8779
+ reportUnreconciled(hooks);
8780
+ return current;
8781
+ }
8782
+ let replacement;
8783
+ try {
8784
+ replacement = hooks.mountReplacement(next);
8785
+ }
8786
+ catch (error) {
8787
+ reportUnreconciled(hooks, error);
8788
+ return current;
8789
+ }
8790
+ if (replacement.element === current.element
8791
+ || replacement.element.parentNode !== null) {
8792
+ reportUnreconciled(hooks);
8793
+ return current;
8794
+ }
8795
+ let adopted = false;
8796
+ let adoptionError;
8797
+ try {
8798
+ parent.replaceChild(replacement.element, current.element);
8799
+ adopted = replacement.element.parentNode === parent
8800
+ && current.element.parentNode !== parent;
8801
+ }
8802
+ catch (error) {
8803
+ adoptionError = error;
8804
+ adopted = replacement.element.parentNode === parent
8805
+ && current.element.parentNode !== parent;
8806
+ }
8807
+ if (!adopted) {
8808
+ if (replacement.element.parentNode === null) {
8809
+ disposeWithoutThrow(replacement, hooks);
8810
+ }
8811
+ reportUnreconciled(hooks, adoptionError);
8812
+ return current;
8813
+ }
8814
+ disposeWithoutThrow(current, hooks);
8815
+ return replacement;
8816
+ }
8817
+ function reconcileIncrementalNode(current, next, hooks) {
8818
+ const parent = current.element.parentNode;
8819
+ const sameChildren = current.children.length === next.children.length
8820
+ && current.children.every((child, index) => (child.occurrenceKey === next.children[index].occurrenceKey));
8821
+ let decision = 'replace';
8822
+ if (sameChildren) {
8823
+ try {
8824
+ decision = hooks.decide(current, next);
8825
+ }
8826
+ catch {
8827
+ return replaceIncrementalNode(current, next, hooks);
8828
+ }
8829
+ if (current.element.parentNode !== parent) {
8830
+ reportUnreconciled(hooks);
8831
+ return current;
8832
+ }
8833
+ }
8834
+ if (decision === 'replace') {
8835
+ return replaceIncrementalNode(current, next, hooks);
8836
+ }
8837
+ if (decision === 'update') {
8838
+ try {
8839
+ hooks.update(current, next);
8840
+ }
8841
+ catch {
8842
+ return replaceIncrementalNode(current, next, hooks);
8843
+ }
8844
+ if (current.element.parentNode !== parent) {
8845
+ reportUnreconciled(hooks);
8846
+ return current;
8847
+ }
8848
+ }
8849
+ try {
8850
+ hooks.syncBindings(current, next);
8851
+ }
8852
+ catch {
8853
+ return replaceIncrementalNode(current, next, hooks);
8854
+ }
8855
+ if (current.element.parentNode !== parent) {
8856
+ reportUnreconciled(hooks);
8857
+ return current;
8858
+ }
8859
+ const children = current.children.map((child, index) => (reconcileIncrementalNode(child, next.children[index], hooks)));
8860
+ return { ...next, element: current.element, children };
8861
+ }
8862
+
8863
+ const CARD_NODE_SELECTOR = '[data-card-id][data-card-type]';
8864
+ const FOCUS_TARGET_SELECTOR = [
8865
+ 'input:not([disabled])',
8866
+ 'textarea:not([disabled])',
8867
+ 'select:not([disabled])',
8868
+ 'button:not([disabled])',
8869
+ '[contenteditable="true"]',
8870
+ '[tabindex]:not([tabindex="-1"])',
8871
+ ].join(',');
8872
+ /**
8873
+ * Add logical node access to one rendered card without depending on whether
8874
+ * its renderer retains or replaces DOM elements between updates.
8875
+ */
8876
+ function createCardNodeAccess(container) {
8877
+ const handles = new Map();
8878
+ const listeners = new Set();
8879
+ let focusedId = null;
8880
+ let focusSyncScheduled = false;
8881
+ let observing = false;
8882
+ let observationEpoch = 0;
8883
+ let observer = null;
8884
+ let disposed = false;
8885
+ const resolveElement = (id) => {
8886
+ if (disposed)
8887
+ return null;
8888
+ return Array.from(container.querySelectorAll(CARD_NODE_SELECTOR)).find(element => element.getAttribute('data-card-id') === id) ?? null;
8889
+ };
8890
+ const getNode = (id) => {
8891
+ const existing = handles.get(id);
8892
+ if (existing)
8893
+ return existing;
8894
+ const handle = {
8895
+ id,
8896
+ get type() {
8897
+ return resolveElement(id)?.getAttribute('data-card-type') ?? null;
8898
+ },
8899
+ get current() {
8900
+ return resolveElement(id);
8901
+ },
8902
+ getRect() {
8903
+ return resolveElement(id)?.getBoundingClientRect() ?? null;
8904
+ },
8905
+ focus(options) {
8906
+ const element = resolveElement(id);
8907
+ if (!element || element.hasAttribute('data-disabled'))
8908
+ return false;
8909
+ const target = element.shadowRoot
8910
+ ?.querySelector(FOCUS_TARGET_SELECTOR) ?? element;
8911
+ target.focus(options);
8912
+ return document.activeElement === element
8913
+ || element.shadowRoot?.activeElement != null;
8914
+ },
8915
+ blur() {
8916
+ const element = resolveElement(id);
8917
+ if (!element)
8918
+ return false;
8919
+ const active = element.shadowRoot?.activeElement;
8920
+ if (active instanceof HTMLElement) {
8921
+ active.blur();
8922
+ }
8923
+ else {
8924
+ element.blur();
8925
+ }
8926
+ return true;
8927
+ },
8928
+ scrollIntoView(options) {
8929
+ const element = resolveElement(id);
8930
+ if (!element || typeof element.scrollIntoView !== 'function') {
8931
+ return false;
8932
+ }
8933
+ element.scrollIntoView(options);
8934
+ return true;
8935
+ },
8936
+ };
8937
+ handles.set(id, handle);
8938
+ return handle;
8939
+ };
8940
+ const resolveFocusedId = () => {
8941
+ let active = document.activeElement;
8942
+ if (!active || !container.contains(active))
8943
+ return null;
8944
+ while (active) {
8945
+ if (active instanceof HTMLElement
8946
+ && active.matches(CARD_NODE_SELECTOR)) {
8947
+ return active.getAttribute('data-card-id');
8948
+ }
8949
+ active = active.shadowRoot?.activeElement ?? null;
8950
+ }
8951
+ return null;
8952
+ };
8953
+ const syncFocus = () => {
8954
+ if (disposed || !observing)
8955
+ return;
8956
+ const nextId = resolveFocusedId();
8957
+ if (nextId === focusedId)
8958
+ return;
8959
+ const previousNode = focusedId ? getNode(focusedId) : null;
8960
+ const node = nextId ? getNode(nextId) : null;
8961
+ focusedId = nextId;
8962
+ for (const listener of [...listeners]) {
8963
+ try {
8964
+ listener({ node, previousNode });
8965
+ }
8966
+ catch (error) {
8967
+ console.error('[CardInstance] focus-change listener failed', error);
8968
+ }
8969
+ }
8970
+ };
8971
+ const scheduleFocusSync = () => {
8972
+ if (disposed || !observing || focusSyncScheduled)
8973
+ return;
8974
+ focusSyncScheduled = true;
8975
+ const scheduledEpoch = observationEpoch;
8976
+ queueMicrotask(() => {
8977
+ if (scheduledEpoch !== observationEpoch)
8978
+ return;
8979
+ focusSyncScheduled = false;
8980
+ syncFocus();
8981
+ });
8982
+ };
8983
+ const startObserving = () => {
8984
+ if (disposed || observing)
8985
+ return;
8986
+ observing = true;
8987
+ observationEpoch += 1;
8988
+ focusedId = resolveFocusedId();
8989
+ container.addEventListener('focusin', scheduleFocusSync, true);
8990
+ container.addEventListener('focusout', scheduleFocusSync, true);
8991
+ if (typeof MutationObserver !== 'undefined') {
8992
+ observer ?? (observer = new MutationObserver(scheduleFocusSync));
8993
+ observer.observe(container, { childList: true, subtree: true });
8994
+ }
8995
+ };
8996
+ const stopObserving = () => {
8997
+ if (!observing)
8998
+ return;
8999
+ observing = false;
9000
+ observationEpoch += 1;
9001
+ focusSyncScheduled = false;
9002
+ const cleanupSteps = [
9003
+ () => container.removeEventListener('focusin', scheduleFocusSync, true),
9004
+ () => container.removeEventListener('focusout', scheduleFocusSync, true),
9005
+ () => observer?.disconnect(),
9006
+ ];
9007
+ for (const cleanup of cleanupSteps) {
9008
+ try {
9009
+ cleanup();
9010
+ }
9011
+ catch (error) {
9012
+ console.error('[CardInstance] Focus observation cleanup failed', error);
9013
+ }
9014
+ }
9015
+ focusedId = null;
9016
+ };
9017
+ return {
9018
+ getNode,
9019
+ onFocusChange(listener) {
9020
+ if (disposed)
9021
+ return () => { };
9022
+ listeners.add(listener);
9023
+ if (listeners.size === 1)
9024
+ startObserving();
9025
+ let subscribed = true;
9026
+ return () => {
9027
+ if (!subscribed)
9028
+ return;
9029
+ subscribed = false;
9030
+ listeners.delete(listener);
9031
+ if (listeners.size === 0)
9032
+ stopObserving();
9033
+ };
9034
+ },
9035
+ dispose() {
9036
+ if (disposed)
9037
+ return;
9038
+ stopObserving();
9039
+ disposed = true;
9040
+ listeners.clear();
9041
+ handles.clear();
9042
+ },
9043
+ };
9044
+ }
9045
+
9046
+ const FORCE_REPLACE = Symbol('bound-force-replace');
9047
+ /**
9048
+ * Render the scoped/materialized branch of a card.
9049
+ *
9050
+ * This implementation deliberately owns its state independently from the
9051
+ * legacy static renderer. Bound writes happen in isolated drafts and publish
9052
+ * only after materialization and detached DOM construction both succeed.
9053
+ */
9054
+ function renderBoundCard(container, schema, options) {
9055
+ const nodeAccess = createCardNodeAccess(container);
9056
+ const variables = cloneJsonData({
9057
+ ...schema.variables,
9058
+ ...options.variables,
9059
+ });
9060
+ const schemaActions = schema.actions ?? {};
9061
+ const lifecycleManager = createLifecycleManager();
9062
+ const abortController = new AbortController();
9063
+ const inflightRequests = new Map();
9064
+ const activeLifecycleNodes = new Map();
9065
+ let currentMaterialized;
9066
+ let currentMountedTree = null;
9067
+ let lastPublishedVariables = cloneJsonData(variables);
9068
+ let revision = 0;
9069
+ let disposed = false;
9070
+ let disposeRequested = false;
9071
+ let publishing = false;
9072
+ const isMobile = options.isMobile === true;
9073
+ let actionQueue = Promise.resolve();
9074
+ let queuedActionCount = 0;
9075
+ let lifecycleQueue = Promise.resolve();
9076
+ const repeatIdentityCountCache = new WeakMap();
9077
+ function expressionContextFor(node) {
9078
+ return isBoundRenderTreeNode(node)
9079
+ ? createExpressionContext(node.scope)
9080
+ : variables;
9081
+ }
9082
+ function resolveNodeValue(value, node, renderVariables) {
9083
+ if (node.bindingDialect === 'a2ui') {
9084
+ return resolveA2UIDeep(value, renderVariables, node.dataPath);
9085
+ }
9086
+ if (typeof value === 'string' && hasExpression(value)) {
9087
+ return resolveExpression(value, expressionContextFor(node));
9088
+ }
9089
+ return value;
9090
+ }
9091
+ function resolveNodeProps(node, renderVariables) {
9092
+ const resolvedProps = (node.bindingDialect === 'a2ui'
9093
+ ? resolveA2UIDeep(node.props, renderVariables, node.dataPath)
9094
+ : resolveDeep(node.props, expressionContextFor(node)));
9095
+ if (resolvedProps.content
9096
+ && typeof resolvedProps.content === 'object'
9097
+ && 'type' in resolvedProps.content) {
9098
+ resolvedProps.content = resolveExpressionValue(resolvedProps.content, expressionContextFor(node));
9099
+ }
9100
+ return resolvedProps;
9101
+ }
9102
+ function createPassiveActionContext(renderVariables) {
9103
+ return {
9104
+ ...createWebActionContext({
9105
+ ...options,
9106
+ abortSignal: abortController.signal,
9107
+ }),
9108
+ variables: renderVariables,
9109
+ botId: options.botId,
9110
+ inflightRequests,
9111
+ };
9112
+ }
9113
+ function childOccurrenceKey(parentKey, id, sameIdOrdinal) {
9114
+ return `${parentKey}/${encodeURIComponent(id)}#${sameIdOrdinal}`;
9115
+ }
9116
+ function isNodeDisabled(node, renderVariables) {
9117
+ if (!node.directives?.disabled)
9118
+ return false;
9119
+ const resolved = resolveNodeValue(node.directives.disabled, node, renderVariables);
9120
+ return resolved === true || resolved === 'true' || resolved === 1;
9121
+ }
9122
+ function nodeBindingFingerprint(node, resolvedProps, disabled) {
9123
+ const binding = { disabled };
9124
+ if (resolvedProps?.variableKey !== undefined) {
9125
+ binding.variableKey = resolvedProps.variableKey;
9126
+ }
9127
+ if (!disabled && node.events !== undefined)
9128
+ binding.events = node.events;
9129
+ return stableFingerprint(binding);
9130
+ }
9131
+ function resolveBoundMetadata(node, occurrenceKey, renderVariables) {
9132
+ const visible = isVisible(node, renderVariables);
9133
+ const rendererToken = componentRenderers[node.type]
9134
+ ?? componentRenderers._default;
9135
+ if (!visible) {
9136
+ return {
9137
+ occurrenceKey,
9138
+ node,
9139
+ nodeId: node.id,
9140
+ nodeType: node.type,
9141
+ rendererToken,
9142
+ visible: false,
9143
+ disabled: false,
9144
+ resolvedProps: null,
9145
+ propsFingerprint: stableFingerprint(null),
9146
+ layoutFingerprint: stableFingerprint(null),
9147
+ bindingFingerprint: stableFingerprint(null),
9148
+ };
9149
+ }
9150
+ const resolvedProps = resolveNodeProps(node, renderVariables);
9151
+ const disabled = isNodeDisabled(node, renderVariables);
9152
+ return {
9153
+ occurrenceKey,
9154
+ node,
9155
+ nodeId: node.id,
9156
+ nodeType: node.type,
9157
+ rendererToken,
9158
+ visible: true,
9159
+ disabled,
9160
+ resolvedProps,
9161
+ propsFingerprint: stableFingerprint(resolvedProps),
9162
+ layoutFingerprint: stableFingerprint(getSlotLayoutFingerprintInput(resolvedProps, node.children, child => resolveNodeProps(child, renderVariables))),
9163
+ bindingFingerprint: nodeBindingFingerprint(node, resolvedProps, disabled),
9164
+ };
9165
+ }
9166
+ function applyDisabledState(element, disabled) {
9167
+ if (!disabled)
9168
+ return;
9169
+ element.setAttribute('data-disabled', 'true');
9170
+ element.style.background = '#F5F5F5';
9171
+ element.style.color = '#C0C0C0';
9172
+ element.style.setProperty('--card-disabled-color', '#C0C0C0');
9173
+ element.style.pointerEvents = 'none';
9174
+ element.style.cursor = 'default';
9175
+ }
9176
+ function applyDisabledDescendants(element, disabled) {
9177
+ if (!disabled)
9178
+ return;
9179
+ element.querySelectorAll('*').forEach((child) => {
9180
+ const htmlChild = child;
9181
+ htmlChild.setAttribute('data-disabled', 'true');
9182
+ htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
9183
+ });
9184
+ }
9185
+ function disposeBoundElement(element) {
9186
+ const cleanupSteps = [
9187
+ () => clearSlotOwnerBehaviorsIn(element),
9188
+ () => clearSlotItemPresentationsIn(element),
9189
+ () => clearElementBindingsIn(element),
9190
+ () => disposeChartsIn(element),
9191
+ ];
9192
+ for (const cleanup of cleanupSteps) {
9193
+ try {
9194
+ cleanup();
9195
+ }
9196
+ catch (error) {
9197
+ console.error('[renderCard] Bound element cleanup failed', error);
9198
+ }
9199
+ }
9200
+ }
9201
+ function disposeUncoveredBoundBranches(current, coverageRoot) {
9202
+ for (const child of current.children) {
9203
+ if (coverageRoot.contains(child.element)) {
9204
+ disposeUncoveredBoundBranches(child, coverageRoot);
9205
+ }
9206
+ else {
9207
+ disposeBoundSubtree(child);
9208
+ }
9209
+ }
9210
+ }
9211
+ function disposeBoundSubtree(current) {
9212
+ disposeBoundElement(current.element);
9213
+ disposeUncoveredBoundBranches(current, current.element);
9214
+ }
9215
+ function disposeBoundBranchesOutsideContainer(current, disposedCoverage = null) {
9216
+ if (container.contains(current.element)) {
9217
+ for (const child of current.children) {
9218
+ disposeBoundBranchesOutsideContainer(child);
9219
+ }
9220
+ return;
9221
+ }
9222
+ let nextCoverage = disposedCoverage;
9223
+ if (!disposedCoverage?.contains(current.element)) {
9224
+ try {
9225
+ disposeBoundElement(current.element);
9226
+ nextCoverage = current.element;
9227
+ }
9228
+ catch {
9229
+ nextCoverage = null;
9230
+ }
9231
+ }
9232
+ for (const child of current.children) {
9233
+ disposeBoundBranchesOutsideContainer(child, nextCoverage);
9234
+ }
9235
+ }
9236
+ function disposeBoundSubtreeWithoutThrow(current) {
9237
+ try {
9238
+ disposeBoundSubtree(current);
9239
+ }
9240
+ catch {
9241
+ // Cleanup cannot change which candidate owns the live DOM.
9242
+ }
9243
+ }
9244
+ function mountBoundSubtree(node, occurrenceKey, renderVariables) {
9245
+ const metadata = resolveBoundMetadata(node, occurrenceKey, renderVariables);
9246
+ if (!metadata.visible) {
9247
+ const placeholder = document.createElement('div');
9248
+ placeholder.style.display = 'none';
9249
+ placeholder.setAttribute('data-card-id', node.id);
9250
+ return { ...metadata, element: placeholder, children: [] };
9251
+ }
9252
+ const renderer = metadata.rendererToken;
9253
+ const element = renderer(node, metadata.resolvedProps, isMobile, options.responsive);
9254
+ const children = [];
9255
+ try {
9256
+ applyDisabledState(element, metadata.disabled);
9257
+ const sameIdOrdinals = new Map();
9258
+ const renderChild = (child) => {
9259
+ const sameIdOrdinal = sameIdOrdinals.get(child.id) ?? 0;
9260
+ sameIdOrdinals.set(child.id, sameIdOrdinal + 1);
9261
+ const mounted = mountBoundSubtree(child, childOccurrenceKey(occurrenceKey, child.id, sameIdOrdinal), renderVariables);
9262
+ children.push(mounted);
9263
+ return mounted.element;
9264
+ };
9265
+ const childrenMap = {};
9266
+ for (const child of node.children)
9267
+ childrenMap[child.id] = child;
9268
+ const layoutApplied = renderSlotLayout(element, node.children, metadata.resolvedProps, child => renderChild(child), childrenMap, createPassiveActionContext(renderVariables), child => resolveNodeProps(child, renderVariables));
9269
+ if (!layoutApplied) {
9270
+ for (const child of node.children) {
9271
+ element.appendChild(renderChild(child));
9272
+ }
9273
+ }
9274
+ applyDisabledDescendants(element, metadata.disabled);
9275
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
9276
+ return { ...metadata, element, children };
9277
+ }
9278
+ catch (error) {
9279
+ disposeBoundSubtreeWithoutThrow({ ...metadata, element, children });
9280
+ throw error;
9281
+ }
9282
+ }
9283
+ function syncBoundBindings(element, node) {
9284
+ const events = [];
9285
+ if (node.visible && !node.disabled && node.node.events) {
9286
+ const repeatTargetFingerprint = getRepeatTargetFingerprint(node.node);
9287
+ for (const [schemaEvent, eventValue] of Object.entries(node.node.events)) {
9288
+ if (!eventValue || !resolveActionRef(eventValue, schemaActions)) {
9289
+ continue;
9290
+ }
9291
+ const domEvent = EVENT_MAP[schemaEvent] ?? schemaEvent;
9292
+ events.push({
9293
+ schemaEvent,
9294
+ domEvent,
9295
+ ownsValueEvent: ((domEvent === 'input' || domEvent === 'change')
9296
+ && VALUE_CONTROL_TYPES$1.has(node.nodeType)),
9297
+ dispatch: (event) => {
9298
+ const detail = event instanceof CustomEvent && event.detail != null
9299
+ ? event.detail
9300
+ : undefined;
9301
+ enqueueBoundEvent(node.nodeId, schemaEvent, detail, repeatTargetFingerprint);
9302
+ },
9303
+ });
9304
+ }
9305
+ }
9306
+ const variableKey = node.resolvedProps?.variableKey;
9307
+ syncElementBindings(element, {
9308
+ variableKey: typeof variableKey === 'string' ? variableKey : undefined,
9309
+ writeVariable: (key, value) => {
9310
+ if (disposed || publishing || !container.contains(element))
9311
+ return;
9312
+ variables[key] = value;
9313
+ revision += 1;
9314
+ },
9315
+ events,
9316
+ isActive: () => (!disposed
9317
+ && !publishing
9318
+ && container.contains(element)),
9319
+ });
9320
+ }
9321
+ function syncMountedBindings(current) {
9322
+ syncBoundBindings(current.element, current);
9323
+ for (const child of current.children)
9324
+ syncMountedBindings(child);
9325
+ }
9326
+ function collectMountedOccurrences(current, occurrences) {
9327
+ occurrences.set(current.occurrenceKey, current);
9328
+ for (const child of current.children) {
9329
+ collectMountedOccurrences(child, occurrences);
9330
+ }
9331
+ }
9332
+ function prepareBoundNode(node, occurrenceKey, renderVariables, forcedOwnerIds, current) {
9333
+ const metadata = resolveBoundMetadata(node, occurrenceKey, renderVariables);
9334
+ const forceReplace = forcedOwnerIds.has(node.id);
9335
+ if (!metadata.visible || forceReplace) {
9336
+ return {
9337
+ ...metadata,
9338
+ ...(forceReplace ? { [FORCE_REPLACE]: true } : {}),
9339
+ children: [],
9340
+ };
9341
+ }
9342
+ if (!current)
9343
+ return { ...metadata, children: [] };
9344
+ const nextChildrenById = new Map();
9345
+ for (const child of node.children) {
9346
+ const matching = nextChildrenById.get(child.id) ?? [];
9347
+ matching.push(child);
9348
+ nextChildrenById.set(child.id, matching);
9349
+ }
9350
+ const nextChildOrdinals = new Map();
9351
+ const children = [];
9352
+ for (const currentChild of current.children) {
9353
+ const matching = nextChildrenById.get(currentChild.nodeId);
9354
+ if (!matching?.length) {
9355
+ return { ...metadata, [FORCE_REPLACE]: true, children: [] };
9356
+ }
9357
+ const ordinal = nextChildOrdinals.get(currentChild.nodeId) ?? 0;
9358
+ nextChildOrdinals.set(currentChild.nodeId, ordinal + 1);
9359
+ const child = matching[Math.min(ordinal, matching.length - 1)];
9360
+ children.push(prepareBoundNode(child, currentChild.occurrenceKey, renderVariables, forcedOwnerIds, currentChild));
9361
+ }
9362
+ return {
9363
+ ...metadata,
9364
+ children,
9365
+ };
9366
+ }
9367
+ function decideBoundUpdate(current, next) {
9368
+ if (next[FORCE_REPLACE]
9369
+ || current.occurrenceKey !== next.occurrenceKey
9370
+ || current.nodeId !== next.nodeId
9371
+ || current.nodeType !== next.nodeType
9372
+ || current.rendererToken !== next.rendererToken
9373
+ || current.visible !== next.visible
9374
+ || current.disabled !== next.disabled
9375
+ || current.layoutFingerprint !== next.layoutFingerprint) {
9376
+ return 'replace';
9377
+ }
9378
+ if (current.propsFingerprint === next.propsFingerprint)
9379
+ return 'retain';
9380
+ return (current.element instanceof BaseElement
9381
+ || current.rendererToken === componentRenderers._default) ? 'update' : 'replace';
9382
+ }
9383
+ function updateBoundNode(current, next) {
9384
+ const element = current.element;
9385
+ if (element instanceof BaseElement) {
9386
+ updateSlotItemShellPresentation(element, () => {
9387
+ element.updateProps(next.resolvedProps, isMobile, options.responsive);
9388
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
9389
+ }, getSlotHostPresentationOwnership(next.resolvedProps));
9390
+ return;
9391
+ }
9392
+ if (current.rendererToken !== componentRenderers._default) {
9393
+ throw new Error('[renderCard] Bound node cannot update in place');
9394
+ }
9395
+ const previous = componentRenderers._default(current.node, current.resolvedProps, isMobile, options.responsive);
9396
+ const desired = componentRenderers._default(next.node, next.resolvedProps, isMobile, options.responsive);
9397
+ applyDisabledState(previous, current.disabled);
9398
+ applyDisabledState(desired, next.disabled);
9399
+ const hostOwnership = getSlotHostPresentationOwnership(next.resolvedProps);
9400
+ updateSlotItemShellPresentation(element, () => {
9401
+ patchElementShellPresentation(element, previous, desired, hostOwnership, getSlotItemPresentationOwnership(element));
9402
+ }, hostOwnership);
9403
+ }
9404
+ function collectChangedPointers(before, after, path = '') {
9405
+ if (Object.is(before, after))
9406
+ return [];
9407
+ if (before === null
9408
+ || after === null
9409
+ || typeof before !== 'object'
9410
+ || typeof after !== 'object') {
9411
+ return [path];
9412
+ }
9413
+ if (Array.isArray(before) || Array.isArray(after)) {
9414
+ if (!Array.isArray(before) || !Array.isArray(after))
9415
+ return [path];
9416
+ if (before.length !== after.length)
9417
+ return [path];
9418
+ }
9419
+ const left = before;
9420
+ const right = after;
9421
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
9422
+ const changed = [];
9423
+ for (const key of keys) {
9424
+ const token = key.replace(/~/g, '~0').replace(/\//g, '~1');
9425
+ changed.push(...collectChangedPointers(left[key], right[key], `${path}/${token}`));
9426
+ }
9427
+ return changed;
9428
+ }
9429
+ function selectForcedRepeatOwnerIds(before, draft) {
9430
+ if (currentMaterialized.unresolvedRepeatOwners.size > 0) {
9431
+ return new Set([currentMaterialized.root.id]);
9432
+ }
9433
+ const selectedKeys = new Set();
9434
+ for (const pointer of collectChangedPointers(before, draft)) {
9435
+ for (const owner of findAffectedRepeatOwners(currentMaterialized, pointer)) {
9436
+ selectedKeys.add(owner.key);
9437
+ }
9438
+ }
9439
+ for (const key of [...selectedKeys]) {
9440
+ let parentKey = currentMaterialized.repeatOwners.get(key)?.parentKey;
9441
+ while (parentKey) {
9442
+ if (selectedKeys.has(parentKey)) {
9443
+ selectedKeys.delete(key);
9444
+ break;
9445
+ }
9446
+ parentKey = currentMaterialized.repeatOwners.get(parentKey)?.parentKey;
9447
+ }
9448
+ }
9449
+ return new Set([...selectedKeys].flatMap((key) => {
9450
+ const id = currentMaterialized.repeatOwners.get(key)?.runtimeOwnerId;
9451
+ return id ? [id] : [];
9452
+ }));
9453
+ }
9454
+ function collectMountedLifecycleIds(root) {
9455
+ const ids = new Set();
9456
+ const visit = (node) => {
9457
+ if (!node.visible)
9458
+ return;
9459
+ if (node.node.lifecycle)
9460
+ ids.add(node.nodeId);
9461
+ for (const child of node.children)
9462
+ visit(child);
9463
+ };
9464
+ if (root)
9465
+ visit(root);
9466
+ return ids;
9467
+ }
9468
+ function prepareReplacementRoots(current, next, draft, replacements) {
9469
+ const sameChildren = current.children.length === next.children.length
9470
+ && current.children.every((child, index) => (child.occurrenceKey === next.children[index].occurrenceKey));
9471
+ const decision = sameChildren
9472
+ ? decideBoundUpdate(current, next)
9473
+ : 'replace';
9474
+ if (decision === 'replace'
9475
+ || (decision === 'update' && next.children.length === 0)) {
9476
+ replacements.set(next.occurrenceKey, mountBoundSubtree(next.node, next.occurrenceKey, draft));
9477
+ }
9478
+ if (decision === 'replace')
9479
+ return;
9480
+ for (let index = 0; index < next.children.length; index += 1) {
9481
+ prepareReplacementRoots(current.children[index], next.children[index], draft, replacements);
7857
9482
  }
7858
- if (isDisabled) {
7859
- el.querySelectorAll('*').forEach((child) => {
7860
- const htmlChild = child;
7861
- htmlChild.setAttribute('data-disabled', 'true');
7862
- htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
7863
- });
7864
- }
7865
- applyResponsiveStyles(el, createResponsiveContext(isMobile, options.responsive));
7866
- return el;
7867
9483
  }
7868
- function prepareCandidate(draft) {
9484
+ function prepareIncrementalCandidate(before, draft) {
7869
9485
  const materialized = materializeCard(schema, draft);
7870
- const lifecycleIds = new Set();
7871
- const dom = renderNode(materialized.root, draft, lifecycleIds);
7872
- return { materialized, dom, lifecycleIds };
9486
+ const forcedOwnerIds = currentMountedTree
9487
+ ? materialized.unresolvedRepeatOwners.size > 0
9488
+ ? new Set([materialized.root.id])
9489
+ : selectForcedRepeatOwnerIds(before, draft)
9490
+ : new Set();
9491
+ const preparedRoot = prepareBoundNode(materialized.root, `${materialized.root.id}#0`, draft, forcedOwnerIds, currentMountedTree);
9492
+ const replacementRoots = new Map();
9493
+ try {
9494
+ if (currentMountedTree) {
9495
+ prepareReplacementRoots(currentMountedTree, preparedRoot, draft, replacementRoots);
9496
+ }
9497
+ else {
9498
+ replacementRoots.set(preparedRoot.occurrenceKey, mountBoundSubtree(materialized.root, preparedRoot.occurrenceKey, draft));
9499
+ }
9500
+ return {
9501
+ materialized,
9502
+ preparedRoot,
9503
+ replacementRoots,
9504
+ autoFocusIds: currentMountedTree
9505
+ ? collectAutoFocusRevealIds(currentMaterialized, materialized, draft)
9506
+ : new Set(),
9507
+ };
9508
+ }
9509
+ catch (error) {
9510
+ for (const replacement of replacementRoots.values()) {
9511
+ disposeBoundSubtreeWithoutThrow(replacement);
9512
+ }
9513
+ throw error;
9514
+ }
7873
9515
  }
7874
9516
  function indexNodes(root) {
7875
9517
  const nodes = new Map();
@@ -7880,6 +9522,93 @@ function renderBoundCard(container, schema, options) {
7880
9522
  visit(root);
7881
9523
  return nodes;
7882
9524
  }
9525
+ function getRepeatTargetFingerprint(node) {
9526
+ const identityToken = (key, value) => {
9527
+ if (value === null)
9528
+ return `${key}:null`;
9529
+ if (typeof value === 'string')
9530
+ return `${key}:string:${value}`;
9531
+ if (typeof value === 'boolean')
9532
+ return `${key}:boolean:${value}`;
9533
+ return `${key}:number:${Object.is(value, -0) ? '-0' : String(value)}`;
9534
+ };
9535
+ const identityCountsFor = (root) => {
9536
+ const cached = repeatIdentityCountCache.get(root);
9537
+ if (cached)
9538
+ return cached;
9539
+ const counts = new Map();
9540
+ const visited = new Set();
9541
+ const visit = (candidate) => {
9542
+ if (candidate === null || typeof candidate !== 'object')
9543
+ return;
9544
+ if (visited.has(candidate))
9545
+ return;
9546
+ visited.add(candidate);
9547
+ if (!Array.isArray(candidate)) {
9548
+ for (const key of ['id', 'key']) {
9549
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
9550
+ if (!descriptor || !('value' in descriptor))
9551
+ continue;
9552
+ const identity = descriptor.value;
9553
+ if (identity === null
9554
+ || (typeof identity !== 'string'
9555
+ && typeof identity !== 'number'
9556
+ && typeof identity !== 'boolean')) {
9557
+ continue;
9558
+ }
9559
+ const token = identityToken(key, identity);
9560
+ counts.set(token, (counts.get(token) ?? 0) + 1);
9561
+ }
9562
+ }
9563
+ for (const nested of Object.values(candidate))
9564
+ visit(nested);
9565
+ };
9566
+ visit(root);
9567
+ repeatIdentityCountCache.set(root, counts);
9568
+ return counts;
9569
+ };
9570
+ const identityFor = (value, root) => {
9571
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
9572
+ return value;
9573
+ }
9574
+ const record = value;
9575
+ for (const key of ['id', 'key']) {
9576
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
9577
+ if (!descriptor || !('value' in descriptor))
9578
+ continue;
9579
+ const identity = descriptor.value;
9580
+ if (identity === null
9581
+ || (typeof identity !== 'string'
9582
+ && typeof identity !== 'number'
9583
+ && typeof identity !== 'boolean')) {
9584
+ continue;
9585
+ }
9586
+ const matches = identityCountsFor(root).get(identityToken(key, identity)) ?? 0;
9587
+ if (matches === 1)
9588
+ return { [key]: identity };
9589
+ }
9590
+ return value;
9591
+ };
9592
+ const targets = [];
9593
+ let scope = node.scope;
9594
+ while (scope) {
9595
+ const scopeRoot = scope.root;
9596
+ const localEntries = Object.entries(scope.locals);
9597
+ if (localEntries.length > 0) {
9598
+ targets.push(Object.fromEntries(localEntries.map(([key, value]) => [
9599
+ key,
9600
+ identityFor(value, scopeRoot),
9601
+ ])));
9602
+ }
9603
+ else if (scope.bindingDialect === 'a2ui' && scope.dataPath !== '') {
9604
+ targets.push(identityFor(resolveA2UIDeep({ path: '' }, scopeRoot, scope.dataPath), scopeRoot));
9605
+ }
9606
+ scope = scope.parent;
9607
+ }
9608
+ if (targets.length === 0)
9609
+ return undefined;
9610
+ return stableFingerprint({ sourceId: node.sourceId, targets });
9611
+ }
7883
9612
  function selectLifecycleNodes(materialized, ids) {
7884
9613
  const indexed = indexNodes(materialized.root);
7885
9614
  const selected = new Map();
@@ -7890,26 +9619,80 @@ function renderBoundCard(container, schema, options) {
7890
9619
  }
7891
9620
  return selected;
7892
9621
  }
7893
- function createLifecycleActionContext(node) {
9622
+ function isVisible(node, renderVariables) {
9623
+ if (!node.directives?.visible)
9624
+ return true;
9625
+ const resolved = resolveNodeValue(node.directives.visible, node, renderVariables);
9626
+ return !(resolved === false
9627
+ || resolved === 'false'
9628
+ || resolved === ''
9629
+ || resolved === 0);
9630
+ }
9631
+ function collectAutoFocusRevealIds(previous, next, draft) {
9632
+ const previousNodes = indexNodes(previous.root);
9633
+ const requested = new Set();
9634
+ const visit = (nextNode, previousParentVisible, nextParentVisible) => {
9635
+ const previousNode = previousNodes.get(nextNode.id);
9636
+ const wasVisible = Boolean(previousNode
9637
+ && previousParentVisible
9638
+ && isVisible(previousNode, variables));
9639
+ const nowVisible = nextParentVisible && isVisible(nextNode, draft);
9640
+ if (nextNode.type === 'Input'
9641
+ && !wasVisible
9642
+ && nowVisible
9643
+ && resolveNodeProps(nextNode, draft).autoFocus === true) {
9644
+ requested.add(nextNode.id);
9645
+ }
9646
+ nextNode.children.forEach(child => visit(child, wasVisible, nowVisible));
9647
+ };
9648
+ visit(next.root, true, true);
9649
+ return requested;
9650
+ }
9651
+ function applyAutoFocus(candidate) {
9652
+ if (candidate.autoFocusIds.size === 0)
9653
+ return;
9654
+ const inputs = container.querySelectorAll(CardInput.is);
9655
+ for (const input of inputs) {
9656
+ const id = input.getAttribute('data-card-id');
9657
+ if (id && candidate.autoFocusIds.has(id) && input.requestAutoFocus()) {
9658
+ break;
9659
+ }
9660
+ }
9661
+ }
9662
+ function createLifecycleActionSession(node) {
9663
+ const lifecycleVariables = cloneJsonData(variables);
9664
+ const rebaseScope = (scope) => ({
9665
+ ...scope,
9666
+ root: lifecycleVariables,
9667
+ parent: scope.parent ? rebaseScope(scope.parent) : undefined,
9668
+ });
9669
+ const lifecycleScope = rebaseScope(node.scope);
9670
+ let authorityRevision = revision;
7894
9671
  const writeLiveVariable = (key, value, silent = false) => {
7895
- if (disposed)
9672
+ const nextValue = cloneJsonData(value);
9673
+ writeDraftVariable(lifecycleVariables, key, nextValue);
9674
+ repeatIdentityCountCache.delete(lifecycleVariables);
9675
+ if (disposed || authorityRevision !== revision)
7896
9676
  return;
7897
9677
  if (silent) {
7898
- writeDraftVariable(variables, key, cloneJsonData(value));
9678
+ writeDraftVariable(variables, key, nextValue);
7899
9679
  return;
7900
9680
  }
7901
- updateVariables({ [key]: value });
9681
+ // Publish the complete lifecycle draft so direct additions, changes,
9682
+ // and deletions join this explicit write in one authoritative render.
9683
+ commitDraft(cloneJsonData(lastPublishedVariables), lifecycleVariables, authorityRevision);
9684
+ authorityRevision = revision;
7902
9685
  };
7903
- return {
9686
+ const context = {
7904
9687
  ...createWebActionContext({
7905
9688
  ...options,
7906
9689
  setVariable: writeLiveVariable,
7907
9690
  abortSignal: abortController.signal,
7908
9691
  }),
7909
- variables,
7910
- expressionContext: createExpressionContext(node.scope),
9692
+ variables: lifecycleVariables,
9693
+ expressionContext: createExpressionContext(lifecycleScope),
7911
9694
  parameterResolver: node.bindingDialect === 'a2ui'
7912
- ? createA2UIParameterResolver(variables, node.dataPath)
9695
+ ? createA2UIParameterResolver(lifecycleVariables, node.dataPath)
7913
9696
  : undefined,
7914
9697
  variableWriter: (key, value, options) => {
7915
9698
  writeLiveVariable(key, value, options.silent);
@@ -7917,6 +9700,20 @@ function renderBoundCard(container, schema, options) {
7917
9700
  botId: options.botId,
7918
9701
  inflightRequests,
7919
9702
  };
9703
+ return {
9704
+ context,
9705
+ publishDirectMutations() {
9706
+ if (disposed
9707
+ || authorityRevision !== revision
9708
+ || !hasVariableChanges(variables, lifecycleVariables)) {
9709
+ return;
9710
+ }
9711
+ // Direct custom-handler mutations historically updated the live
9712
+ // variables without rendering. Preserve that behavior while keeping
9713
+ // a stale async lifecycle detached after a newer revision wins.
9714
+ replaceRootContents(variables, lifecycleVariables);
9715
+ },
9716
+ };
7920
9717
  }
7921
9718
  function reconcileLifecycles(nextNodes) {
7922
9719
  const removed = [...activeLifecycleNodes.entries()]
@@ -7929,45 +9726,263 @@ function renderBoundCard(container, schema, options) {
7929
9726
  }
7930
9727
  lifecycleQueue = lifecycleQueue.then(async () => {
7931
9728
  for (const [id, node] of removed) {
7932
- await lifecycleManager.destroy(id, createLifecycleActionContext(node));
7933
- lifecycleManager.unregister(id);
9729
+ if (disposed)
9730
+ return;
9731
+ const session = createLifecycleActionSession(node);
9732
+ try {
9733
+ await lifecycleManager.destroy(id, session.context);
9734
+ session.publishDirectMutations();
9735
+ }
9736
+ catch (error) {
9737
+ console.error('[renderCard] Bound lifecycle action failed', error);
9738
+ }
9739
+ finally {
9740
+ lifecycleManager.unregister(id);
9741
+ }
7934
9742
  }
7935
- if (disposed)
7936
- return;
7937
9743
  for (const [id, node] of added) {
9744
+ if (disposed)
9745
+ return;
7938
9746
  lifecycleManager.register(id, node.lifecycle);
7939
- await lifecycleManager.mount(id, createLifecycleActionContext(node));
9747
+ const session = createLifecycleActionSession(node);
9748
+ try {
9749
+ await lifecycleManager.mount(id, session.context);
9750
+ session.publishDirectMutations();
9751
+ }
9752
+ catch (error) {
9753
+ lifecycleManager.unregister(id);
9754
+ console.error('[renderCard] Bound lifecycle action failed', error);
9755
+ }
7940
9756
  }
7941
9757
  }).catch((error) => {
7942
9758
  console.error('[renderCard] Bound lifecycle action failed', error);
7943
9759
  });
7944
9760
  }
7945
- function publishDOM(candidate, incrementRevision) {
7946
- const scrollPositions = captureScrollPositions$1(container);
7947
- const mediaStates = captureMediaStates$1(container);
7948
- disposeChartsIn(container);
7949
- container.replaceChildren(candidate.dom);
7950
- currentMaterialized = candidate.materialized;
7951
- if (incrementRevision)
7952
- revision += 1;
7953
- reconcileLifecycles(selectLifecycleNodes(currentMaterialized, candidate.lifecycleIds));
7954
- restoreScrollPositions$1(container, scrollPositions);
7955
- restoreMediaStates$1(container, mediaStates);
9761
+ function disposeCandidate(candidate) {
9762
+ for (const replacement of candidate.replacementRoots.values()) {
9763
+ disposeBoundSubtreeWithoutThrow(replacement);
9764
+ }
9765
+ candidate.replacementRoots.clear();
9766
+ }
9767
+ function restoreChangedSubtreeState(previous, next, scrollPositions, mediaStates) {
9768
+ if (previous.element !== next.element) {
9769
+ restoreScrollPositions$1(next.element, scrollPositions);
9770
+ restoreMediaStates$1(next.element, mediaStates);
9771
+ return;
9772
+ }
9773
+ if (previous.propsFingerprint !== next.propsFingerprint
9774
+ && (next.nodeType === 'Audio' || next.nodeType === 'Video')) {
9775
+ restoreMediaStates$1(next.element, mediaStates);
9776
+ }
9777
+ for (let index = 0; index < next.children.length; index += 1) {
9778
+ const previousChild = previous.children[index];
9779
+ const nextChild = next.children[index];
9780
+ if (previousChild
9781
+ && previousChild.occurrenceKey === nextChild.occurrenceKey) {
9782
+ restoreChangedSubtreeState(previousChild, nextChild, scrollPositions, mediaStates);
9783
+ }
9784
+ }
9785
+ }
9786
+ function restoreDisabledDescendantPresentation(current) {
9787
+ if (current.disabled) {
9788
+ applyDisabledState(current.element, true);
9789
+ applyDisabledDescendants(current.element, true);
9790
+ }
9791
+ for (const child of current.children) {
9792
+ restoreDisabledDescendantPresentation(child);
9793
+ }
9794
+ }
9795
+ function replaceContainerRoot(element, failureMessage) {
9796
+ let adoptionThrew = false;
9797
+ let adoptionError;
9798
+ try {
9799
+ container.replaceChildren(element);
9800
+ }
9801
+ catch (error) {
9802
+ adoptionThrew = true;
9803
+ adoptionError = error;
9804
+ }
9805
+ const adopted = (element.parentNode === container
9806
+ && container.firstChild === element
9807
+ && container.childNodes.length === 1);
9808
+ if (adopted)
9809
+ return;
9810
+ if (adoptionThrew)
9811
+ throw adoptionError;
9812
+ throw new Error(failureMessage);
9813
+ }
9814
+ function commitIncrementalCandidate(candidate, renderVariables) {
9815
+ const replacements = candidate.replacementRoots;
9816
+ try {
9817
+ if (!currentMountedTree) {
9818
+ const initial = replacements.get(candidate.preparedRoot.occurrenceKey);
9819
+ if (!initial) {
9820
+ throw new Error('[renderCard] Missing bound root candidate');
9821
+ }
9822
+ syncMountedBindings(initial);
9823
+ disposeChartsIn(container);
9824
+ replaceContainerRoot(initial.element, '[renderCard] Bound root candidate was not adopted');
9825
+ replacements.delete(candidate.preparedRoot.occurrenceKey);
9826
+ restoreDisabledDescendantPresentation(initial);
9827
+ return initial;
9828
+ }
9829
+ const previous = currentMountedTree;
9830
+ const replacementSources = new Map();
9831
+ collectMountedOccurrences(previous, replacementSources);
9832
+ const scrollPositions = captureScrollPositions$1(container);
9833
+ const mediaStates = captureMediaStates$1(container);
9834
+ let unreconciled = false;
9835
+ const next = reconcileIncrementalNode(previous, candidate.preparedRoot, {
9836
+ decide: (current, prepared) => decideBoundUpdate(current, prepared),
9837
+ update: (current, prepared) => updateBoundNode(current, prepared),
9838
+ mountReplacement: (prepared) => {
9839
+ const candidateReplacement = replacements.get(prepared.occurrenceKey);
9840
+ const replacement = candidateReplacement ?? mountBoundSubtree(prepared.node, prepared.occurrenceKey, renderVariables);
9841
+ const current = replacementSources.get(prepared.occurrenceKey);
9842
+ let staged = false;
9843
+ try {
9844
+ if (replacement.element.parentNode !== null) {
9845
+ throw new Error(`[renderCard] Bound replacement "${prepared.occurrenceKey}" is not detached`);
9846
+ }
9847
+ syncMountedBindings(replacement);
9848
+ if (current && replacement.element !== current.element) {
9849
+ transferSlotItemPresentation(current.element, replacement.element);
9850
+ }
9851
+ staged = true;
9852
+ }
9853
+ finally {
9854
+ if (!staged && !candidateReplacement) {
9855
+ disposeBoundSubtreeWithoutThrow(replacement);
9856
+ }
9857
+ }
9858
+ if (candidateReplacement) {
9859
+ replacements.delete(prepared.occurrenceKey);
9860
+ }
9861
+ return replacement;
9862
+ },
9863
+ dispose: disposeBoundSubtree,
9864
+ syncBindings: () => { },
9865
+ onUnreconciled: () => {
9866
+ unreconciled = true;
9867
+ },
9868
+ });
9869
+ if (!unreconciled
9870
+ && (next.element.parentNode !== container
9871
+ || container.firstElementChild !== next.element)) {
9872
+ unreconciled = true;
9873
+ }
9874
+ if (!unreconciled) {
9875
+ try {
9876
+ syncMountedBindings(next);
9877
+ }
9878
+ catch {
9879
+ unreconciled = true;
9880
+ }
9881
+ }
9882
+ if (unreconciled) {
9883
+ let emergency;
9884
+ try {
9885
+ emergency = mountBoundSubtree(candidate.materialized.root, candidate.preparedRoot.occurrenceKey, renderVariables);
9886
+ }
9887
+ catch (error) {
9888
+ disposeBoundBranchesOutsideContainer(next);
9889
+ throw error;
9890
+ }
9891
+ let adopted = false;
9892
+ try {
9893
+ syncMountedBindings(emergency);
9894
+ replaceContainerRoot(emergency.element, '[renderCard] Bound emergency root was not adopted');
9895
+ adopted = true;
9896
+ }
9897
+ finally {
9898
+ if (!adopted) {
9899
+ disposeBoundSubtreeWithoutThrow(emergency);
9900
+ disposeBoundBranchesOutsideContainer(next);
9901
+ }
9902
+ }
9903
+ disposeBoundSubtreeWithoutThrow(next);
9904
+ try {
9905
+ restoreChangedSubtreeState(previous, emergency, scrollPositions, mediaStates);
9906
+ }
9907
+ catch {
9908
+ // Presentation restoration cannot roll back an adopted next root.
9909
+ }
9910
+ restoreDisabledDescendantPresentation(emergency);
9911
+ return emergency;
9912
+ }
9913
+ try {
9914
+ restoreChangedSubtreeState(previous, next, scrollPositions, mediaStates);
9915
+ }
9916
+ catch {
9917
+ // Presentation restoration does not affect published data ownership.
9918
+ }
9919
+ restoreDisabledDescendantPresentation(next);
9920
+ return next;
9921
+ }
9922
+ finally {
9923
+ disposeCandidate(candidate);
9924
+ }
7956
9925
  }
7957
9926
  function assertCurrentRevision(baseRevision) {
7958
- if (disposed || revision !== baseRevision) {
9927
+ if (disposed || publishing || revision !== baseRevision) {
7959
9928
  const error = new Error('[renderCard] BOUND_TRANSACTION_CONFLICT');
7960
9929
  error.code = 'BOUND_TRANSACTION_CONFLICT';
7961
9930
  throw error;
7962
9931
  }
7963
9932
  }
7964
- function commitDraft(draft, candidate, baseRevision) {
9933
+ function commitDraft(before, draft, baseRevision) {
7965
9934
  assertCurrentRevision(baseRevision);
7966
- replaceRootContents(variables, draft);
7967
- // Re-materialize against the stable public variables root. The detached
7968
- // DOM already represents the exact same validated JSON data.
7969
- candidate.materialized = materializeCard(schema, variables);
7970
- publishDOM(candidate, true);
9935
+ const validationRoot = cloneJsonData(variables);
9936
+ replaceRootContents(validationRoot, draft);
9937
+ const candidate = prepareIncrementalCandidate(before, draft);
9938
+ try {
9939
+ assertCurrentRevision(baseRevision);
9940
+ }
9941
+ catch (error) {
9942
+ disposeCandidate(candidate);
9943
+ throw error;
9944
+ }
9945
+ publishing = true;
9946
+ let publishingFailed = false;
9947
+ let publishingError;
9948
+ let deferredDisposeFailed = false;
9949
+ let deferredDisposeError;
9950
+ try {
9951
+ const nextMounted = commitIncrementalCandidate(candidate, draft);
9952
+ replaceRootContents(variables, draft);
9953
+ currentMaterialized = materializeCard(schema, variables);
9954
+ currentMountedTree = nextMounted;
9955
+ lastPublishedVariables = cloneJsonData(variables);
9956
+ revision += 1;
9957
+ }
9958
+ catch (error) {
9959
+ publishingFailed = true;
9960
+ publishingError = error;
9961
+ }
9962
+ finally {
9963
+ publishing = false;
9964
+ if (disposeRequested) {
9965
+ try {
9966
+ disposeNow();
9967
+ }
9968
+ catch (error) {
9969
+ deferredDisposeFailed = true;
9970
+ deferredDisposeError = error;
9971
+ }
9972
+ }
9973
+ }
9974
+ if (publishingFailed) {
9975
+ if (deferredDisposeFailed) {
9976
+ console.error('[renderCard] Bound deferred dispose failed', deferredDisposeError);
9977
+ }
9978
+ throw publishingError;
9979
+ }
9980
+ if (deferredDisposeFailed)
9981
+ throw deferredDisposeError;
9982
+ if (disposed)
9983
+ return;
9984
+ reconcileLifecycles(selectLifecycleNodes(currentMaterialized, collectMountedLifecycleIds(currentMountedTree)));
9985
+ applyAutoFocus(candidate);
7971
9986
  }
7972
9987
  function writeDraftVariable(draft, key, value) {
7973
9988
  Object.defineProperty(draft, String(key), {
@@ -7977,6 +9992,33 @@ function renderBoundCard(container, schema, options) {
7977
9992
  writable: true,
7978
9993
  });
7979
9994
  }
9995
+ function jsonDataEqual(before, after) {
9996
+ if (Object.is(before, after))
9997
+ return true;
9998
+ if (Array.isArray(before) || Array.isArray(after)) {
9999
+ if (!Array.isArray(before) || !Array.isArray(after))
10000
+ return false;
10001
+ return before.length === after.length
10002
+ && before.every((value, index) => jsonDataEqual(value, after[index]));
10003
+ }
10004
+ if (before === null
10005
+ || after === null
10006
+ || typeof before !== 'object'
10007
+ || typeof after !== 'object') {
10008
+ return false;
10009
+ }
10010
+ const beforeRecord = before;
10011
+ const afterRecord = after;
10012
+ const beforeKeys = Object.keys(beforeRecord);
10013
+ const afterKeys = Object.keys(afterRecord);
10014
+ return beforeKeys.length === afterKeys.length
10015
+ && beforeKeys.every(key => Object.prototype.hasOwnProperty.call(afterRecord, key)
10016
+ && jsonDataEqual(beforeRecord[key], afterRecord[key]));
10017
+ }
10018
+ function hasVariableChanges(before, after) {
10019
+ const validatedAfter = cloneJsonData(after);
10020
+ return !jsonDataEqual(before, validatedAfter);
10021
+ }
7980
10022
  function createDraftActionContext(node, draft) {
7981
10023
  const write = (key, value) => {
7982
10024
  writeDraftVariable(draft, key, value);
@@ -7997,19 +10039,27 @@ function renderBoundCard(container, schema, options) {
7997
10039
  inflightRequests,
7998
10040
  };
7999
10041
  }
8000
- async function runBoundEvent(runtimeId, eventName, eventDetail) {
10042
+ async function runBoundEvent(runtimeId, eventName, eventDetail, baseRevision, repeatTargetFingerprint) {
8001
10043
  if (disposed)
8002
10044
  return;
8003
- const baseRevision = revision;
8004
- const draft = cloneJsonData(variables);
10045
+ assertCurrentRevision(baseRevision);
10046
+ const transactionBefore = cloneJsonData(variables);
10047
+ const draft = cloneJsonData(transactionBefore);
8005
10048
  if (eventDetail !== undefined) {
8006
10049
  writeDraftVariable(draft, '_event', cloneJsonData(eventDetail));
8007
10050
  }
10051
+ const actionBaseline = cloneJsonData(draft);
8008
10052
  const freshMaterialized = materializeCard(schema, draft);
8009
10053
  const freshNode = indexNodes(freshMaterialized.root).get(runtimeId);
8010
10054
  if (!freshNode) {
10055
+ if (repeatTargetFingerprint !== undefined)
10056
+ return;
8011
10057
  throw new Error(`[renderCard] Bound runtime node "${runtimeId}" no longer exists`);
8012
10058
  }
10059
+ if (repeatTargetFingerprint !== undefined
10060
+ && getRepeatTargetFingerprint(freshNode) !== repeatTargetFingerprint) {
10061
+ return;
10062
+ }
8013
10063
  const eventValue = freshNode.events?.[eventName];
8014
10064
  const steps = eventValue
8015
10065
  ? resolveActionRef(eventValue, schemaActions)
@@ -8018,14 +10068,21 @@ function renderBoundCard(container, schema, options) {
8018
10068
  return;
8019
10069
  await runActionSteps(steps, createDraftActionContext(freshNode, draft));
8020
10070
  assertCurrentRevision(baseRevision);
8021
- const candidate = prepareCandidate(draft);
8022
- commitDraft(draft, candidate, baseRevision);
10071
+ if (!hasVariableChanges(actionBaseline, draft))
10072
+ return;
10073
+ // The target check above may have cached identity counts before action
10074
+ // steps changed the draft. Recount once when binding the published tree.
10075
+ repeatIdentityCountCache.delete(draft);
10076
+ commitDraft(cloneJsonData(lastPublishedVariables), draft, baseRevision);
8023
10077
  }
8024
- function enqueueBoundEvent(runtimeId, eventName, eventDetail) {
10078
+ function enqueueBoundEvent(runtimeId, eventName, eventDetail, repeatTargetFingerprint) {
8025
10079
  if (disposed)
8026
10080
  return;
10081
+ const enqueueRevision = revision;
10082
+ const queuedBehindAnotherAction = queuedActionCount > 0;
10083
+ queuedActionCount += 1;
8027
10084
  actionQueue = actionQueue
8028
- .then(() => runBoundEvent(runtimeId, eventName, eventDetail))
10085
+ .then(() => runBoundEvent(runtimeId, eventName, eventDetail, queuedBehindAnotherAction ? revision : enqueueRevision, repeatTargetFingerprint))
8029
10086
  .catch((error) => {
8030
10087
  if (error
8031
10088
  && typeof error === 'object'
@@ -8034,44 +10091,87 @@ function renderBoundCard(container, schema, options) {
8034
10091
  return;
8035
10092
  }
8036
10093
  console.error('[renderCard] Bound action failed', error);
10094
+ })
10095
+ .finally(() => {
10096
+ queuedActionCount -= 1;
8037
10097
  });
8038
10098
  }
8039
10099
  function updateVariables(newVariables) {
8040
10100
  if (disposed)
8041
10101
  return;
8042
10102
  const baseRevision = revision;
10103
+ const before = cloneJsonData(lastPublishedVariables);
8043
10104
  const draft = cloneJsonData(variables);
8044
10105
  const patch = cloneJsonData(newVariables);
8045
10106
  for (const key of Object.keys(patch)) {
8046
10107
  writeDraftVariable(draft, key, patch[key]);
8047
10108
  }
8048
- const candidate = prepareCandidate(draft);
8049
- commitDraft(draft, candidate, baseRevision);
10109
+ commitDraft(before, draft, baseRevision);
8050
10110
  }
8051
- const initialCandidate = prepareCandidate(variables);
8052
- currentMaterialized = initialCandidate.materialized;
8053
- publishDOM(initialCandidate, false);
8054
- return {
8055
- dispose() {
8056
- if (disposed)
8057
- return;
8058
- disposed = true;
8059
- abortController.abort();
10111
+ function disposeNow() {
10112
+ if (disposed)
10113
+ return;
10114
+ disposed = true;
10115
+ disposeRequested = false;
10116
+ nodeAccess.dispose();
10117
+ abortController.abort();
10118
+ if (currentMountedTree) {
10119
+ disposeBoundSubtree(currentMountedTree);
10120
+ currentMountedTree = null;
10121
+ }
10122
+ else {
8060
10123
  disposeChartsIn(container);
8061
- container.replaceChildren();
8062
- const lifecycleNodes = [...activeLifecycleNodes.entries()];
8063
- activeLifecycleNodes.clear();
8064
- lifecycleQueue = lifecycleQueue
8065
- .then(async () => {
8066
- for (const [id, node] of lifecycleNodes) {
8067
- await lifecycleManager.destroy(id, createLifecycleActionContext(node));
10124
+ }
10125
+ container.replaceChildren();
10126
+ const lifecycleNodes = [...activeLifecycleNodes.entries()];
10127
+ activeLifecycleNodes.clear();
10128
+ lifecycleQueue = lifecycleQueue
10129
+ .then(async () => {
10130
+ for (const [id, node] of lifecycleNodes) {
10131
+ const session = createLifecycleActionSession(node);
10132
+ try {
10133
+ await lifecycleManager.destroy(id, session.context);
10134
+ session.publishDirectMutations();
10135
+ }
10136
+ catch (error) {
10137
+ console.error('[renderCard] Bound lifecycle dispose failed', error);
10138
+ }
10139
+ finally {
8068
10140
  lifecycleManager.unregister(id);
8069
10141
  }
10142
+ }
10143
+ try {
8070
10144
  await lifecycleManager.dispose(createPassiveActionContext(variables));
8071
- })
8072
- .catch((error) => {
10145
+ }
10146
+ catch (error) {
8073
10147
  console.error('[renderCard] Bound lifecycle dispose failed', error);
8074
- });
10148
+ }
10149
+ })
10150
+ .catch((error) => {
10151
+ console.error('[renderCard] Bound lifecycle dispose failed', error);
10152
+ });
10153
+ }
10154
+ const initialCandidate = prepareIncrementalCandidate(variables, variables);
10155
+ publishing = true;
10156
+ try {
10157
+ currentMountedTree = commitIncrementalCandidate(initialCandidate, variables);
10158
+ currentMaterialized = materializeCard(schema, variables);
10159
+ }
10160
+ finally {
10161
+ publishing = false;
10162
+ }
10163
+ reconcileLifecycles(selectLifecycleNodes(currentMaterialized, collectMountedLifecycleIds(currentMountedTree)));
10164
+ return {
10165
+ getNode: nodeAccess.getNode,
10166
+ onFocusChange: nodeAccess.onFocusChange,
10167
+ dispose() {
10168
+ if (disposed || disposeRequested)
10169
+ return;
10170
+ if (publishing) {
10171
+ disposeRequested = true;
10172
+ return;
10173
+ }
10174
+ disposeNow();
8075
10175
  },
8076
10176
  updateVariables,
8077
10177
  };
@@ -8089,7 +10189,11 @@ function captureScrollPositions$1(root) {
8089
10189
  function restoreScrollPositions$1(root, positions) {
8090
10190
  if (positions.size === 0)
8091
10191
  return;
8092
- root.querySelectorAll('[data-scroll-id]').forEach((element) => {
10192
+ const scrollers = [];
10193
+ if (root.matches('[data-scroll-id]'))
10194
+ scrollers.push(root);
10195
+ scrollers.push(...root.querySelectorAll('[data-scroll-id]'));
10196
+ scrollers.forEach((element) => {
8093
10197
  const id = element.getAttribute('data-scroll-id');
8094
10198
  const saved = id ? positions.get(id) : undefined;
8095
10199
  if (saved == null)
@@ -8118,7 +10222,11 @@ function captureMediaStates$1(root) {
8118
10222
  function restoreMediaStates$1(root, states) {
8119
10223
  if (states.size === 0)
8120
10224
  return;
8121
- root.querySelectorAll('ai-card-audio, ai-card-video').forEach((element) => {
10225
+ const mediaHosts = [];
10226
+ if (root.matches('ai-card-audio, ai-card-video'))
10227
+ mediaHosts.push(root);
10228
+ mediaHosts.push(...root.querySelectorAll('ai-card-audio, ai-card-video'));
10229
+ mediaHosts.forEach((element) => {
8122
10230
  const id = element.getAttribute('data-card-id');
8123
10231
  const snapshot = id ? states.get(id) : undefined;
8124
10232
  if (!snapshot)
@@ -8180,64 +10288,759 @@ function renderCard(container, schemaInput, options = {}) {
8180
10288
  return renderBoundCard(container, schema, options);
8181
10289
  }
8182
10290
  function renderStaticCard(container, schema, options) {
10291
+ const nodeAccess = createCardNodeAccess(container);
8183
10292
  // 2. Parse into render tree
8184
10293
  const tree = parseSchema(schema);
8185
10294
  // 3. Reactive variables store (mutable copy, merged with external variables)
8186
10295
  let variables = { ...schema.variables, ...options.variables };
8187
- // 4. Create action context with setVariable wired into re-render
8188
- const lifecycleManager = createLifecycleManager();
10296
+ let disposed = false;
10297
+ let publishing = false;
10298
+ let disposeRequested = false;
10299
+ let hostAuthorityEpoch = 0;
10300
+ let nextStaticEventSequence = 0;
10301
+ const latestStaticEventWriterByKey = new Map();
10302
+ const lifecycleRecords = new Map();
8189
10303
  const abortController = new AbortController();
8190
10304
  // Per-instance request dedup map (isolated from other cards on the page).
8191
10305
  const inflightRequests = new Map();
8192
- function buildActionContext() {
8193
- return {
10306
+ function isVisible(node, renderVariables, parentVisible) {
10307
+ if (!parentVisible)
10308
+ return false;
10309
+ if (!node.directives?.visible)
10310
+ return true;
10311
+ const visible = node.directives.visible;
10312
+ const resolved = hasExpression(visible)
10313
+ ? resolveExpression(visible, renderVariables)
10314
+ : visible;
10315
+ return !(resolved === false
10316
+ || resolved === 'false'
10317
+ || resolved === ''
10318
+ || resolved === 0);
10319
+ }
10320
+ function collectAutoFocusRevealIds(previousVariables, nextVariables) {
10321
+ const requested = new Set();
10322
+ const visit = (node, previousParentVisible, nextParentVisible) => {
10323
+ const wasVisible = isVisible(node, previousVariables, previousParentVisible);
10324
+ const nowVisible = isVisible(node, nextVariables, nextParentVisible);
10325
+ if (node.type === 'Input'
10326
+ && !wasVisible
10327
+ && nowVisible
10328
+ && resolveDeep(node.props, nextVariables).autoFocus === true) {
10329
+ requested.add(node.id);
10330
+ }
10331
+ node.children.forEach(child => visit(child, wasVisible, nowVisible));
10332
+ };
10333
+ visit(tree, true, true);
10334
+ return requested;
10335
+ }
10336
+ function applyAutoFocus(requested) {
10337
+ if (requested.size === 0)
10338
+ return;
10339
+ for (const input of container.querySelectorAll(CardInput.is)) {
10340
+ const id = input.getAttribute('data-card-id');
10341
+ if (id && requested.has(id) && input.requestAutoFocus())
10342
+ break;
10343
+ }
10344
+ }
10345
+ function buildActionContext(pendingAutoFocusIds, initialVariables = variables, eventSequence) {
10346
+ const baseAuthorityEpoch = hostAuthorityEpoch;
10347
+ let context;
10348
+ const writeVariable = (key, value, { silent }) => {
10349
+ let actionVariables = context.variables;
10350
+ const latestWriter = latestStaticEventWriterByKey.get(key);
10351
+ if (disposed
10352
+ || publishing
10353
+ || baseAuthorityEpoch !== hostAuthorityEpoch
10354
+ || (eventSequence !== undefined
10355
+ && latestWriter !== undefined
10356
+ && latestWriter > eventSequence)) {
10357
+ if (actionVariables === variables) {
10358
+ actionVariables = { ...actionVariables };
10359
+ context.variables = actionVariables;
10360
+ }
10361
+ actionVariables[key] = value;
10362
+ return;
10363
+ }
10364
+ const previousVariables = !silent && pendingAutoFocusIds
10365
+ ? { ...actionVariables }
10366
+ : null;
10367
+ const liveVariables = variables;
10368
+ const hadLiveValue = Object.prototype.hasOwnProperty.call(liveVariables, key);
10369
+ const previousLiveValue = liveVariables[key];
10370
+ actionVariables[key] = value;
10371
+ if (silent)
10372
+ return;
10373
+ // Event actions use a private snapshot so a later event cannot replace
10374
+ // their `_event` detail while they await. Publish only the key written by
10375
+ // this step, preserving concurrent actions that update different keys.
10376
+ if (actionVariables !== variables) {
10377
+ variables[key] = value;
10378
+ }
10379
+ if (previousVariables && pendingAutoFocusIds) {
10380
+ for (const id of collectAutoFocusRevealIds(previousVariables, actionVariables)) {
10381
+ pendingAutoFocusIds.add(id);
10382
+ }
10383
+ }
10384
+ try {
10385
+ rerender();
10386
+ }
10387
+ catch (error) {
10388
+ if (variables === liveVariables) {
10389
+ if (hadLiveValue)
10390
+ liveVariables[key] = previousLiveValue;
10391
+ else
10392
+ Reflect.deleteProperty(liveVariables, key);
10393
+ if (eventSequence !== undefined
10394
+ && latestStaticEventWriterByKey.get(key) === eventSequence) {
10395
+ if (latestWriter === undefined) {
10396
+ latestStaticEventWriterByKey.delete(key);
10397
+ }
10398
+ else {
10399
+ latestStaticEventWriterByKey.set(key, latestWriter);
10400
+ }
10401
+ }
10402
+ pendingAutoFocusIds?.clear();
10403
+ if (!disposed)
10404
+ actionContext = buildActionContext();
10405
+ }
10406
+ throw error;
10407
+ }
10408
+ };
10409
+ context = {
8194
10410
  ...createWebActionContext({
8195
10411
  ...options,
8196
10412
  setVariable: (key, value) => {
8197
- variables[key] = value;
8198
- // Trigger re-render after variable change
8199
- rerender();
10413
+ writeVariable(key, value, { silent: false});
8200
10414
  },
8201
10415
  abortSignal: abortController.signal,
8202
10416
  }),
8203
- variables,
10417
+ variables: initialVariables,
10418
+ variableWriter: writeVariable,
8204
10419
  botId: options.botId,
8205
10420
  inflightRequests,
8206
10421
  };
10422
+ return context;
8207
10423
  }
8208
10424
  let actionContext = buildActionContext();
8209
10425
  // 5. Responsive
8210
10426
  const isMobile = options.isMobile === true;
8211
10427
  // 6. Schema-level action definitions (for string references in events)
8212
10428
  const schemaActions = schema.actions ?? {};
8213
- // 7. Render function
10429
+ function destroyLifecycle(record) {
10430
+ if (record.state !== 'mounted')
10431
+ return;
10432
+ record.state = 'destroying';
10433
+ void runActionSteps(record.lifecycle.onDestroy ?? [], buildActionContext())
10434
+ .catch((error) => {
10435
+ console.error('[renderCard] Static lifecycle destroy failed', error);
10436
+ })
10437
+ .finally(() => {
10438
+ record.state = 'destroyed';
10439
+ });
10440
+ }
10441
+ function startLifecycleMount(id, lifecycle) {
10442
+ if (disposed || lifecycleRecords.has(id))
10443
+ return;
10444
+ const record = { lifecycle, state: 'mounting' };
10445
+ lifecycleRecords.set(id, record);
10446
+ void runActionSteps(lifecycle.onMount ?? [], buildActionContext())
10447
+ .then(() => {
10448
+ record.state = 'mounted';
10449
+ if (disposed)
10450
+ destroyLifecycle(record);
10451
+ })
10452
+ .catch((error) => {
10453
+ console.error('[renderCard] Static lifecycle mount failed', error);
10454
+ if (disposed) {
10455
+ record.state = 'destroyed';
10456
+ }
10457
+ else if (lifecycleRecords.get(id) === record) {
10458
+ lifecycleRecords.delete(id);
10459
+ }
10460
+ });
10461
+ }
10462
+ let mountedTree = null;
10463
+ let renderGeneration = 0;
10464
+ function childOccurrenceKey(parentKey, id, sameIdOrdinal) {
10465
+ return `${parentKey}/${encodeURIComponent(id)}#${sameIdOrdinal}`;
10466
+ }
10467
+ function resolveNodeProps(node) {
10468
+ const resolvedProps = resolveDeep(node.props, variables);
10469
+ if (resolvedProps.content
10470
+ && typeof resolvedProps.content === 'object'
10471
+ && 'type' in resolvedProps.content) {
10472
+ resolvedProps.content = resolveExpressionValue(resolvedProps.content, variables);
10473
+ }
10474
+ return resolvedProps;
10475
+ }
10476
+ function isNodeDisabled(node) {
10477
+ if (!node.directives?.disabled)
10478
+ return false;
10479
+ const disabled = node.directives.disabled;
10480
+ const resolved = hasExpression(disabled)
10481
+ ? resolveExpression(disabled, variables)
10482
+ : disabled;
10483
+ return resolved === true || resolved === 'true' || resolved === 1;
10484
+ }
10485
+ function nodeBindingFingerprint(node, resolvedProps, disabled) {
10486
+ const binding = { disabled };
10487
+ if (resolvedProps && resolvedProps.variableKey !== undefined) {
10488
+ binding.variableKey = resolvedProps.variableKey;
10489
+ }
10490
+ if (!disabled && node.events !== undefined) {
10491
+ binding.events = node.events;
10492
+ }
10493
+ return stableFingerprint(binding);
10494
+ }
10495
+ function resolveStaticMetadata(node, occurrenceKey) {
10496
+ const visible = isVisible(node, variables, true);
10497
+ const rendererToken = componentRenderers[node.type]
10498
+ ?? componentRenderers._default;
10499
+ if (!visible) {
10500
+ return {
10501
+ occurrenceKey,
10502
+ node,
10503
+ nodeId: node.id,
10504
+ nodeType: node.type,
10505
+ rendererToken,
10506
+ visible: false,
10507
+ disabled: false,
10508
+ resolvedProps: null,
10509
+ propsFingerprint: stableFingerprint(null),
10510
+ layoutFingerprint: stableFingerprint(null),
10511
+ bindingFingerprint: stableFingerprint(null),
10512
+ };
10513
+ }
10514
+ const resolvedProps = resolveNodeProps(node);
10515
+ const disabled = isNodeDisabled(node);
10516
+ return {
10517
+ occurrenceKey,
10518
+ node,
10519
+ nodeId: node.id,
10520
+ nodeType: node.type,
10521
+ rendererToken,
10522
+ visible: true,
10523
+ disabled,
10524
+ resolvedProps,
10525
+ propsFingerprint: stableFingerprint(resolvedProps),
10526
+ layoutFingerprint: stableFingerprint(getSlotLayoutFingerprintInput(resolvedProps, node.children, resolveNodeProps)),
10527
+ bindingFingerprint: nodeBindingFingerprint(node, resolvedProps, disabled),
10528
+ };
10529
+ }
10530
+ function applyDisabledState(element, disabled) {
10531
+ if (!disabled)
10532
+ return;
10533
+ element.setAttribute('data-disabled', 'true');
10534
+ element.style.background = '#F5F5F5';
10535
+ element.style.color = '#C0C0C0';
10536
+ element.style.setProperty('--card-disabled-color', '#C0C0C0');
10537
+ element.style.pointerEvents = 'none';
10538
+ element.style.cursor = 'default';
10539
+ }
10540
+ function applyDisabledDescendants(element, disabled) {
10541
+ if (!disabled)
10542
+ return;
10543
+ element.querySelectorAll('*').forEach((child) => {
10544
+ const htmlChild = child;
10545
+ htmlChild.setAttribute('data-disabled', 'true');
10546
+ htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
10547
+ });
10548
+ }
10549
+ function runStaticEventActions(steps, detail) {
10550
+ if (disposed)
10551
+ return;
10552
+ if (detail != null)
10553
+ variables._event = detail;
10554
+ const sourceVariables = variables;
10555
+ const actionAuthorityEpoch = hostAuthorityEpoch;
10556
+ const eventSequence = ++nextStaticEventSequence;
10557
+ const localVariables = Object.create(null);
10558
+ if (Object.prototype.hasOwnProperty.call(sourceVariables, '_event')) {
10559
+ localVariables._event = sourceVariables._event;
10560
+ }
10561
+ const deletedProperties = new Set();
10562
+ let frozenVariables = null;
10563
+ const eventVariables = new Proxy(localVariables, {
10564
+ get(target, property) {
10565
+ if (frozenVariables)
10566
+ return Reflect.get(frozenVariables, property);
10567
+ if (deletedProperties.has(property))
10568
+ return undefined;
10569
+ return Object.prototype.hasOwnProperty.call(target, property)
10570
+ ? Reflect.get(target, property)
10571
+ : Reflect.get(sourceVariables, property);
10572
+ },
10573
+ set(target, property, value) {
10574
+ if (frozenVariables) {
10575
+ Reflect.set(frozenVariables, property, value);
10576
+ return true;
10577
+ }
10578
+ deletedProperties.delete(property);
10579
+ Reflect.set(target, property, value);
10580
+ if (property !== '_event'
10581
+ && !disposed
10582
+ && !publishing
10583
+ && variables === sourceVariables
10584
+ && actionAuthorityEpoch === hostAuthorityEpoch
10585
+ && (latestStaticEventWriterByKey.get(property) === undefined
10586
+ || latestStaticEventWriterByKey.get(property) <= eventSequence)) {
10587
+ latestStaticEventWriterByKey.set(property, eventSequence);
10588
+ Reflect.set(sourceVariables, property, value);
10589
+ }
10590
+ return true;
10591
+ },
10592
+ deleteProperty(target, property) {
10593
+ if (frozenVariables) {
10594
+ return Reflect.deleteProperty(frozenVariables, property);
10595
+ }
10596
+ deletedProperties.add(property);
10597
+ Reflect.deleteProperty(target, property);
10598
+ if (property !== '_event'
10599
+ && !disposed
10600
+ && !publishing
10601
+ && variables === sourceVariables
10602
+ && actionAuthorityEpoch === hostAuthorityEpoch
10603
+ && (latestStaticEventWriterByKey.get(property) === undefined
10604
+ || latestStaticEventWriterByKey.get(property) <= eventSequence)) {
10605
+ latestStaticEventWriterByKey.set(property, eventSequence);
10606
+ Reflect.deleteProperty(sourceVariables, property);
10607
+ }
10608
+ return true;
10609
+ },
10610
+ has(target, property) {
10611
+ if (!frozenVariables && deletedProperties.has(property))
10612
+ return false;
10613
+ return frozenVariables
10614
+ ? property in frozenVariables
10615
+ : property in target || property in sourceVariables;
10616
+ },
10617
+ ownKeys(target) {
10618
+ return Reflect.ownKeys(frozenVariables ?? { ...sourceVariables, ...target }).filter(property => !deletedProperties.has(property));
10619
+ },
10620
+ getOwnPropertyDescriptor(target, property) {
10621
+ if (!frozenVariables && deletedProperties.has(property)) {
10622
+ return undefined;
10623
+ }
10624
+ const owner = frozenVariables
10625
+ ?? (Object.prototype.hasOwnProperty.call(target, property)
10626
+ ? target
10627
+ : sourceVariables);
10628
+ const descriptor = Object.getOwnPropertyDescriptor(owner, property);
10629
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
10630
+ },
10631
+ });
10632
+ const pendingAutoFocusIds = new Set();
10633
+ void runActionSteps(steps, buildActionContext(pendingAutoFocusIds, eventVariables, eventSequence))
10634
+ .catch((error) => {
10635
+ console.error('[renderCard] Static action failed', error);
10636
+ })
10637
+ .finally(() => {
10638
+ const snapshot = {
10639
+ ...sourceVariables,
10640
+ ...localVariables,
10641
+ };
10642
+ for (const property of deletedProperties) {
10643
+ Reflect.deleteProperty(snapshot, property);
10644
+ }
10645
+ frozenVariables = snapshot;
10646
+ if (disposed || actionAuthorityEpoch !== hostAuthorityEpoch)
10647
+ return;
10648
+ applyAutoFocus(pendingAutoFocusIds);
10649
+ });
10650
+ }
10651
+ function syncStaticBindings(element, node) {
10652
+ const resolvedEventBindings = [];
10653
+ if (node.visible && !node.disabled && node.node.events) {
10654
+ for (const [schemaEvent, eventValue] of Object.entries(node.node.events)) {
10655
+ if (!eventValue)
10656
+ continue;
10657
+ const steps = resolveActionRef(eventValue, schemaActions);
10658
+ if (!steps)
10659
+ continue;
10660
+ const domEvent = eventMap[schemaEvent] ?? schemaEvent;
10661
+ resolvedEventBindings.push({
10662
+ schemaEvent,
10663
+ domEvent,
10664
+ ownsValueEvent: ((domEvent === 'input' || domEvent === 'change')
10665
+ && VALUE_CONTROL_TYPES.has(node.nodeType)),
10666
+ dispatch: (event) => {
10667
+ const detail = event instanceof CustomEvent
10668
+ ? event.detail
10669
+ : undefined;
10670
+ runStaticEventActions(steps, detail);
10671
+ },
10672
+ });
10673
+ }
10674
+ }
10675
+ const variableKey = node.resolvedProps?.variableKey;
10676
+ syncElementBindings(element, {
10677
+ variableKey: typeof variableKey === 'string' ? variableKey : undefined,
10678
+ writeVariable: (key, value) => {
10679
+ if (disposed
10680
+ || publishing
10681
+ || !container.contains(element)) {
10682
+ return;
10683
+ }
10684
+ variables[key] = value;
10685
+ hostAuthorityEpoch += 1;
10686
+ },
10687
+ events: resolvedEventBindings,
10688
+ isActive: () => (!disposed
10689
+ && !publishing
10690
+ && container.contains(element)),
10691
+ });
10692
+ }
10693
+ function mountStaticSubtree(node, occurrenceKey) {
10694
+ const metadata = resolveStaticMetadata(node, occurrenceKey);
10695
+ if (!metadata.visible) {
10696
+ const placeholder = document.createElement('div');
10697
+ placeholder.style.display = 'none';
10698
+ placeholder.setAttribute('data-card-id', node.id);
10699
+ syncStaticBindings(placeholder, { ...metadata});
10700
+ return { ...metadata, element: placeholder, children: [] };
10701
+ }
10702
+ const renderer = metadata.rendererToken;
10703
+ const element = renderer(node, metadata.resolvedProps, isMobile, options.responsive);
10704
+ try {
10705
+ applyDisabledState(element, metadata.disabled);
10706
+ syncStaticBindings(element, { ...metadata, children: [] });
10707
+ const children = [];
10708
+ const sameIdOrdinals = new Map();
10709
+ const renderChild = (child) => {
10710
+ const sameIdOrdinal = sameIdOrdinals.get(child.id) ?? 0;
10711
+ sameIdOrdinals.set(child.id, sameIdOrdinal + 1);
10712
+ const mounted = mountStaticSubtree(child, childOccurrenceKey(occurrenceKey, child.id, sameIdOrdinal));
10713
+ children.push(mounted);
10714
+ return mounted.element;
10715
+ };
10716
+ const childrenMap = {};
10717
+ for (const child of node.children)
10718
+ childrenMap[child.id] = child;
10719
+ const layoutApplied = renderSlotLayout(element, node.children, metadata.resolvedProps, renderChild, childrenMap, actionContext, resolveNodeProps);
10720
+ if (!layoutApplied) {
10721
+ for (const child of node.children) {
10722
+ element.appendChild(renderChild(child));
10723
+ }
10724
+ }
10725
+ applyDisabledDescendants(element, metadata.disabled);
10726
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
10727
+ return { ...metadata, element, children };
10728
+ }
10729
+ catch (error) {
10730
+ disposeStaticElement(element);
10731
+ throw error;
10732
+ }
10733
+ }
10734
+ function prepareStaticNode(current) {
10735
+ const metadata = resolveStaticMetadata(current.node, current.occurrenceKey);
10736
+ if (current.visible !== metadata.visible
10737
+ || current.layoutFingerprint !== metadata.layoutFingerprint) {
10738
+ return { ...metadata, children: [] };
10739
+ }
10740
+ return {
10741
+ ...metadata,
10742
+ children: current.children.map(prepareStaticNode),
10743
+ };
10744
+ }
10745
+ function mountStaticReplacement(current, next) {
10746
+ const replacement = mountStaticSubtree(next.node, next.occurrenceKey);
10747
+ try {
10748
+ if (replacement.element !== current.element
10749
+ && replacement.element.parentNode === null) {
10750
+ transferSlotItemPresentation(current.element, replacement.element);
10751
+ }
10752
+ return replacement;
10753
+ }
10754
+ catch (error) {
10755
+ disposeStaticSubtree(replacement);
10756
+ throw error;
10757
+ }
10758
+ }
10759
+ /**
10760
+ * Build every renderer-owned replacement before touching the mounted DOM.
10761
+ * This preserves the old committed card when a later business renderer
10762
+ * rejects the same host update. Retained BaseElements are intentionally not
10763
+ * instantiated here: their native controls must stay untouched unless the
10764
+ * live update actually needs its existing replacement fallback.
10765
+ */
10766
+ function stageStaticReplacements(current, next, staged) {
10767
+ const sameChildren = current.children.length === next.children.length
10768
+ && current.children.every((child, index) => (child.occurrenceKey === next.children[index].occurrenceKey));
10769
+ const decision = sameChildren
10770
+ ? decideStaticUpdate(current, next)
10771
+ : 'replace';
10772
+ if (decision === 'replace') {
10773
+ staged.set(next.occurrenceKey, mountStaticReplacement(current, next));
10774
+ }
10775
+ if (decision === 'replace')
10776
+ return;
10777
+ current.children.forEach((child, index) => {
10778
+ stageStaticReplacements(child, next.children[index], staged);
10779
+ });
10780
+ }
10781
+ function decideStaticUpdate(current, next) {
10782
+ if (current.occurrenceKey !== next.occurrenceKey
10783
+ || current.nodeId !== next.nodeId
10784
+ || current.nodeType !== next.nodeType
10785
+ || current.rendererToken !== next.rendererToken
10786
+ || current.visible !== next.visible
10787
+ || current.disabled !== next.disabled
10788
+ || current.layoutFingerprint !== next.layoutFingerprint) {
10789
+ return 'replace';
10790
+ }
10791
+ if (current.propsFingerprint === next.propsFingerprint
10792
+ && current.bindingFingerprint === next.bindingFingerprint) {
10793
+ return 'retain';
10794
+ }
10795
+ if (current.element instanceof BaseElement
10796
+ || current.rendererToken === componentRenderers._default) {
10797
+ return 'update';
10798
+ }
10799
+ return 'replace';
10800
+ }
10801
+ function updateStaticNode(current, next) {
10802
+ const element = current.element;
10803
+ if (element instanceof BaseElement) {
10804
+ updateSlotItemShellPresentation(element, () => {
10805
+ element.updateProps(next.resolvedProps, isMobile, options.responsive);
10806
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
10807
+ }, getSlotHostPresentationOwnership(next.resolvedProps));
10808
+ return;
10809
+ }
10810
+ const previous = componentRenderers._default(current.node, current.resolvedProps, isMobile, options.responsive);
10811
+ const desired = componentRenderers._default(next.node, next.resolvedProps, isMobile, options.responsive);
10812
+ applyDisabledState(previous, current.disabled);
10813
+ applyDisabledState(desired, next.disabled);
10814
+ const hostOwnership = getSlotHostPresentationOwnership(next.resolvedProps);
10815
+ updateSlotItemShellPresentation(current.element, () => {
10816
+ patchElementShellPresentation(current.element, previous, desired, hostOwnership, getSlotItemPresentationOwnership(current.element));
10817
+ }, hostOwnership);
10818
+ }
10819
+ function disposeStaticElement(element) {
10820
+ const cleanupSteps = [
10821
+ () => clearSlotOwnerBehaviorsIn(element),
10822
+ () => clearSlotItemPresentationsIn(element),
10823
+ () => clearElementBindingsIn(element),
10824
+ () => disposeChartsIn(element),
10825
+ ];
10826
+ for (const cleanup of cleanupSteps) {
10827
+ try {
10828
+ cleanup();
10829
+ }
10830
+ catch (error) {
10831
+ console.error('[renderCard] Static element cleanup failed', error);
10832
+ }
10833
+ }
10834
+ }
10835
+ function disposeStaticSubtree(current) {
10836
+ disposeStaticElement(current.element);
10837
+ }
10838
+ function collectMountedOccurrences(current, occurrences) {
10839
+ occurrences.set(current.occurrenceKey, current);
10840
+ for (const child of current.children) {
10841
+ collectMountedOccurrences(child, occurrences);
10842
+ }
10843
+ }
10844
+ function collectMountedLifecycles(current, collection) {
10845
+ if (!current.visible
10846
+ || !container.contains(current.element)) {
10847
+ return;
10848
+ }
10849
+ if (current.node.lifecycle) {
10850
+ collection.push({
10851
+ id: current.nodeId,
10852
+ lifecycle: current.node.lifecycle,
10853
+ });
10854
+ }
10855
+ for (const child of current.children) {
10856
+ collectMountedLifecycles(child, collection);
10857
+ }
10858
+ }
10859
+ function restoreDisabledDescendantPresentation(current) {
10860
+ if (current.disabled) {
10861
+ applyDisabledState(current.element, true);
10862
+ applyDisabledDescendants(current.element, true);
10863
+ }
10864
+ for (const child of current.children) {
10865
+ restoreDisabledDescendantPresentation(child);
10866
+ }
10867
+ }
10868
+ function restoreChangedSubtreeState(previous, next, scrollPositions, mediaStates) {
10869
+ if (previous.element !== next.element) {
10870
+ restoreScrollPositions(next.element, scrollPositions);
10871
+ restoreMediaStates(next.element, mediaStates);
10872
+ return;
10873
+ }
10874
+ if (previous.propsFingerprint !== next.propsFingerprint
10875
+ && (next.nodeType === 'Audio' || next.nodeType === 'Video')) {
10876
+ restoreMediaStates(next.element, mediaStates);
10877
+ }
10878
+ for (let index = 0; index < next.children.length; index += 1) {
10879
+ const previousChild = previous.children[index];
10880
+ const nextChild = next.children[index];
10881
+ if (previousChild
10882
+ && previousChild.occurrenceKey === nextChild.occurrenceKey) {
10883
+ restoreChangedSubtreeState(previousChild, nextChild, scrollPositions, mediaStates);
10884
+ }
10885
+ }
10886
+ }
8214
10887
  function render() {
10888
+ if (disposed)
10889
+ return;
10890
+ const generation = ++renderGeneration;
10891
+ const collectedLifecycles = [];
8215
10892
  const scrollPositions = captureScrollPositions(container);
8216
10893
  const mediaStates = captureMediaStates(container);
8217
- disposeChartsIn(container); // tear down old chart instances before clearing
8218
- container.innerHTML = '';
8219
- const dom = renderNode(tree, variables, actionContext, isMobile, options.responsive, lifecycleManager, schemaActions);
8220
- container.appendChild(dom);
8221
- restoreScrollPositions(container, scrollPositions);
8222
- restoreMediaStates(container, mediaStates);
10894
+ let previousTree = null;
10895
+ if (!mountedTree) {
10896
+ const initialTree = mountStaticSubtree(tree, `${tree.id}#0`);
10897
+ if (disposed || generation !== renderGeneration) {
10898
+ disposeStaticSubtree(initialTree);
10899
+ return;
10900
+ }
10901
+ disposeChartsIn(container);
10902
+ container.replaceChildren(initialTree.element);
10903
+ mountedTree = initialTree;
10904
+ }
10905
+ else {
10906
+ previousTree = mountedTree;
10907
+ let unreconciled = false;
10908
+ let unreconciledError;
10909
+ const replacementSources = new Map();
10910
+ collectMountedOccurrences(mountedTree, replacementSources);
10911
+ const preparedTree = prepareStaticNode(mountedTree);
10912
+ if (disposed || generation !== renderGeneration)
10913
+ return;
10914
+ const stagedReplacements = new Map();
10915
+ publishing = true;
10916
+ try {
10917
+ stageStaticReplacements(mountedTree, preparedTree, stagedReplacements);
10918
+ if (disposeRequested)
10919
+ return;
10920
+ mountedTree = reconcileIncrementalNode(mountedTree, preparedTree, {
10921
+ decide: decideStaticUpdate,
10922
+ update: updateStaticNode,
10923
+ mountReplacement: next => {
10924
+ const staged = stagedReplacements.get(next.occurrenceKey);
10925
+ if (staged) {
10926
+ stagedReplacements.delete(next.occurrenceKey);
10927
+ return staged;
10928
+ }
10929
+ const replacement = mountStaticSubtree(next.node, next.occurrenceKey);
10930
+ const current = replacementSources.get(next.occurrenceKey);
10931
+ if (current
10932
+ && replacement.element !== current.element
10933
+ && replacement.element.parentNode === null) {
10934
+ transferSlotItemPresentation(current.element, replacement.element);
10935
+ }
10936
+ return replacement;
10937
+ },
10938
+ dispose: disposeStaticSubtree,
10939
+ syncBindings: (current, next) => {
10940
+ syncStaticBindings(current.element, next);
10941
+ },
10942
+ onUnreconciled: (error) => {
10943
+ if (unreconciled)
10944
+ return;
10945
+ unreconciled = true;
10946
+ unreconciledError = error ?? new Error('[renderCard] Static incremental update could not be reconciled');
10947
+ },
10948
+ });
10949
+ }
10950
+ finally {
10951
+ for (const replacement of stagedReplacements.values()) {
10952
+ disposeStaticSubtree(replacement);
10953
+ }
10954
+ stagedReplacements.clear();
10955
+ publishing = false;
10956
+ if (disposeRequested)
10957
+ disposeNow();
10958
+ }
10959
+ if (unreconciled)
10960
+ throw unreconciledError;
10961
+ if (disposed)
10962
+ return;
10963
+ }
10964
+ restoreDisabledDescendantPresentation(mountedTree);
10965
+ collectMountedLifecycles(mountedTree, collectedLifecycles);
10966
+ if (previousTree) {
10967
+ restoreChangedSubtreeState(previousTree, mountedTree, scrollPositions, mediaStates);
10968
+ }
10969
+ else {
10970
+ restoreScrollPositions(container, scrollPositions);
10971
+ restoreMediaStates(container, mediaStates);
10972
+ }
10973
+ if (disposed || generation !== renderGeneration)
10974
+ return;
10975
+ for (const { id, lifecycle } of collectedLifecycles) {
10976
+ if (disposed || generation !== renderGeneration)
10977
+ break;
10978
+ startLifecycleMount(id, lifecycle);
10979
+ }
8223
10980
  }
8224
10981
  function rerender() {
10982
+ if (disposed)
10983
+ return;
8225
10984
  actionContext = buildActionContext();
8226
10985
  render();
8227
10986
  }
10987
+ function disposeNow() {
10988
+ if (disposed)
10989
+ return;
10990
+ disposed = true;
10991
+ disposeRequested = false;
10992
+ hostAuthorityEpoch += 1;
10993
+ nodeAccess.dispose();
10994
+ abortController.abort();
10995
+ if (mountedTree) {
10996
+ disposeStaticSubtree(mountedTree);
10997
+ mountedTree = null;
10998
+ }
10999
+ else {
11000
+ disposeChartsIn(container);
11001
+ }
11002
+ container.replaceChildren();
11003
+ for (const record of lifecycleRecords.values())
11004
+ destroyLifecycle(record);
11005
+ }
8228
11006
  // 7. Initial render
8229
11007
  render();
8230
11008
  // 8. Return instance handle
8231
11009
  return {
11010
+ getNode: nodeAccess.getNode,
11011
+ onFocusChange: nodeAccess.onFocusChange,
8232
11012
  dispose() {
8233
- abortController.abort();
8234
- lifecycleManager.dispose(actionContext);
8235
- disposeChartsIn(container);
8236
- container.innerHTML = '';
11013
+ if (disposed || disposeRequested)
11014
+ return;
11015
+ if (publishing) {
11016
+ disposeRequested = true;
11017
+ return;
11018
+ }
11019
+ disposeNow();
8237
11020
  },
8238
11021
  updateVariables(newVars) {
11022
+ if (disposed)
11023
+ return;
11024
+ if (publishing) {
11025
+ const error = new Error('[renderCard] STATIC_TRANSACTION_CONFLICT');
11026
+ error.code = 'STATIC_TRANSACTION_CONFLICT';
11027
+ throw error;
11028
+ }
11029
+ const previousVariables = variables;
11030
+ const previousAuthorityEpoch = hostAuthorityEpoch;
11031
+ hostAuthorityEpoch += 1;
8239
11032
  variables = { ...variables, ...newVars };
8240
- rerender();
11033
+ try {
11034
+ rerender();
11035
+ }
11036
+ catch (error) {
11037
+ if (!disposed) {
11038
+ variables = previousVariables;
11039
+ hostAuthorityEpoch = previousAuthorityEpoch;
11040
+ actionContext = buildActionContext();
11041
+ }
11042
+ throw error;
11043
+ }
8241
11044
  },
8242
11045
  };
8243
11046
  }
@@ -8263,7 +11066,11 @@ function captureScrollPositions(root) {
8263
11066
  function restoreScrollPositions(root, positions) {
8264
11067
  if (positions.size === 0)
8265
11068
  return;
8266
- root.querySelectorAll('[data-scroll-id]').forEach((el) => {
11069
+ const scrollers = [];
11070
+ if (root.matches('[data-scroll-id]'))
11071
+ scrollers.push(root);
11072
+ scrollers.push(...root.querySelectorAll('[data-scroll-id]'));
11073
+ scrollers.forEach((el) => {
8267
11074
  const id = el.getAttribute('data-scroll-id');
8268
11075
  const saved = id ? positions.get(id) : undefined;
8269
11076
  if (saved == null)
@@ -8303,7 +11110,11 @@ function captureMediaStates(root) {
8303
11110
  function restoreMediaStates(root, states) {
8304
11111
  if (states.size === 0)
8305
11112
  return;
8306
- root.querySelectorAll('ai-card-audio, ai-card-video').forEach((el) => {
11113
+ const mediaHosts = [];
11114
+ if (root.matches('ai-card-audio, ai-card-video'))
11115
+ mediaHosts.push(root);
11116
+ mediaHosts.push(...root.querySelectorAll('ai-card-audio, ai-card-video'));
11117
+ mediaHosts.forEach((el) => {
8307
11118
  const id = el.getAttribute('data-card-id');
8308
11119
  const snap = id ? states.get(id) : undefined;
8309
11120
  if (!snap)
@@ -8312,125 +11123,6 @@ function restoreMediaStates(root, states) {
8312
11123
  restoreMedia(media, snap);
8313
11124
  });
8314
11125
  }
8315
- // ─── Recursive Node Renderer ─────────────────────────────────────
8316
- function renderNode(node, variables, actionContext, isMobile, responsive, lifecycleManager, schemaActions) {
8317
- // Check directives.visible
8318
- if (node.directives?.visible) {
8319
- const visibleExpr = node.directives.visible;
8320
- const resolved = hasExpression(visibleExpr)
8321
- ? resolveExpression(visibleExpr, variables)
8322
- : visibleExpr;
8323
- if (resolved === false || resolved === 'false' || resolved === '' || resolved === 0) {
8324
- // Hidden element — return empty placeholder
8325
- const placeholder = document.createElement('div');
8326
- placeholder.style.display = 'none';
8327
- placeholder.setAttribute('data-card-id', node.id);
8328
- return placeholder;
8329
- }
8330
- }
8331
- // Resolve props with variable expressions
8332
- const resolvedProps = resolveDeep(node.props, variables);
8333
- // Resolve content if it's an ExpressionValue
8334
- if (resolvedProps.content && typeof resolvedProps.content === 'object' && 'type' in resolvedProps.content) {
8335
- resolvedProps.content = resolveExpressionValue(resolvedProps.content, variables);
8336
- }
8337
- // Check directives.disabled
8338
- let isDisabled = false;
8339
- if (node.directives?.disabled) {
8340
- const disabledExpr = node.directives.disabled;
8341
- const resolved = hasExpression(disabledExpr)
8342
- ? resolveExpression(disabledExpr, variables)
8343
- : disabledExpr;
8344
- isDisabled = resolved === true || resolved === 'true' || resolved === 1;
8345
- }
8346
- // Lookup component renderer
8347
- const renderer = componentRenderers[node.type] ?? componentRenderers['_default'];
8348
- const el = renderer(node, resolvedProps, isMobile, responsive);
8349
- // Apply disabled styling & attribute
8350
- if (isDisabled) {
8351
- el.setAttribute('data-disabled', 'true');
8352
- el.style.background = '#F5F5F5';
8353
- el.style.color = '#C0C0C0';
8354
- el.style.setProperty('--card-disabled-color', '#C0C0C0');
8355
- el.style.pointerEvents = 'none';
8356
- el.style.cursor = 'default';
8357
- }
8358
- // ── variableKey auto-sync for Input components ──────────────
8359
- // When an Input element declares `variableKey`, its value is
8360
- // automatically written into the reactive variables store on
8361
- // every `input` event (without triggering a full re-render).
8362
- // The value is then available as `${variableKey}` in action params.
8363
- const variableKey = resolvedProps.variableKey;
8364
- if (variableKey) {
8365
- el.addEventListener('input', ((e) => {
8366
- // Nested value controls own their variableKey. Only a value event
8367
- // dispatched by this component may update this component's variable.
8368
- if (e.target !== el)
8369
- return;
8370
- const value = e.detail?.value
8371
- ?? e.target?.value;
8372
- if (value !== undefined) {
8373
- // Write silently — do NOT trigger rerender (avoids losing focus)
8374
- variables[variableKey] = value;
8375
- }
8376
- }));
8377
- }
8378
- // ── Bind events (skip if disabled) ──
8379
- if (node.events && !isDisabled) {
8380
- for (const [event, eventValue] of Object.entries(node.events)) {
8381
- if (!eventValue)
8382
- continue;
8383
- const resolvedSteps = resolveActionRef(eventValue, schemaActions);
8384
- if (!resolvedSteps)
8385
- continue;
8386
- const domEvent = eventMap[event] ?? event;
8387
- el.addEventListener(domEvent, ((e) => {
8388
- // Value controls own their input/change handlers; a nested control's
8389
- // same-named event must not trigger the parent's schema action.
8390
- // Other component/event pairs keep the renderer's existing bubbling
8391
- // behavior (for example a Container-level click handler).
8392
- const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
8393
- && VALUE_CONTROL_TYPES.has(node.type));
8394
- if (ownsValueEvent && e.target !== el)
8395
- return;
8396
- // Write event.detail to _event variable so action params can use ${_event.xxx}
8397
- if (e instanceof CustomEvent && e.detail != null) {
8398
- variables._event = e.detail;
8399
- }
8400
- runActionSteps(resolvedSteps, actionContext);
8401
- }));
8402
- }
8403
- }
8404
- // Register lifecycle
8405
- if (node.lifecycle) {
8406
- lifecycleManager.register(node.id, node.lifecycle);
8407
- lifecycleManager.mount(node.id, actionContext);
8408
- }
8409
- // Render children — use slot layout if applicable, otherwise flat append
8410
- const renderChild = (child) => renderNode(child, variables, actionContext, isMobile, responsive, lifecycleManager, schemaActions);
8411
- // Build children-by-id map for layouts that reference IDs (columns groups, float overlays)
8412
- const childrenMap = {};
8413
- for (const child of node.children) {
8414
- childrenMap[child.id] = child;
8415
- }
8416
- const layoutApplied = renderSlotLayout(el, node.children, resolvedProps, renderChild, childrenMap, actionContext);
8417
- if (!layoutApplied) {
8418
- for (const child of node.children) {
8419
- el.appendChild(renderChild(child));
8420
- }
8421
- }
8422
- // Apply disabled grey text to all descendants (after children are appended)
8423
- // Propagate disabled state to child elements (including Shadow DOM custom elements)
8424
- if (isDisabled) {
8425
- el.querySelectorAll('*').forEach((child) => {
8426
- const htmlChild = child;
8427
- htmlChild.setAttribute('data-disabled', 'true');
8428
- htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
8429
- });
8430
- }
8431
- applyResponsiveStyles(el, createResponsiveContext(isMobile, responsive));
8432
- return el;
8433
- }
8434
11126
  /** Map schema event names → DOM event names */
8435
11127
  const eventMap = {
8436
11128
  onClick: 'click',
@@ -8484,6 +11176,7 @@ const VALUE_CONTROL_TYPES = new Set([
8484
11176
  * ```
8485
11177
  */
8486
11178
  function renderStreamingCard(container, options = {}) {
11179
+ const nodeAccess = createCardNodeAccess(container);
8487
11180
  // ─── State ──────────────────────────────────────────────────────
8488
11181
  const parser = new StreamingParser(options.parserOptions);
8489
11182
  const elementMap = new Map(); // elementId → DOM element
@@ -8511,19 +11204,32 @@ function renderStreamingCard(container, options = {}) {
8511
11204
  let boundActionQueue = Promise.resolve();
8512
11205
  let boundLifecycleEpoch = 0;
8513
11206
  let disposed = false;
11207
+ let userActionDepth = 0;
8514
11208
  const sourceOccurrences = new Map();
8515
11209
  const activeBoundLifecycles = new Map();
8516
11210
  const mountedBoundLifecycles = new Map();
8517
11211
  const boundLifecycleGenerations = new Map();
11212
+ function applyAutoFocusIds(ids) {
11213
+ if (ids.size === 0)
11214
+ return;
11215
+ for (const id of ids) {
11216
+ const input = elementMap.get(id);
11217
+ if (input instanceof CardInput && input.requestAutoFocus())
11218
+ break;
11219
+ }
11220
+ }
8518
11221
  // ─── Action Context ─────────────────────────────────────────────
8519
11222
  function buildActionContext() {
8520
11223
  return {
8521
11224
  ...createWebActionContext({
8522
11225
  ...options,
8523
11226
  setVariable: (key, value) => {
11227
+ const previousVariables = userActionDepth > 0
11228
+ ? { ...variables }
11229
+ : undefined;
8524
11230
  variables[key] = value;
8525
11231
  // On variable change, patch only affected elements (keyed diff)
8526
- diffAllElements();
11232
+ diffAllElements(previousVariables);
8527
11233
  },
8528
11234
  abortSignal: abortController.signal,
8529
11235
  }),
@@ -8606,6 +11312,27 @@ function renderStreamingCard(container, options = {}) {
8606
11312
  || resolved === ''
8607
11313
  || resolved === 0);
8608
11314
  }
11315
+ function collectBoundAutoFocusRevealIds(previous, next, previousVariables, nextVariables) {
11316
+ const previousNodes = indexBoundNodes(previous.root);
11317
+ const requested = new Set();
11318
+ const visit = (nextNode, previousParentVisible, nextParentVisible) => {
11319
+ const previousNode = previousNodes.get(nextNode.id);
11320
+ const wasVisible = Boolean(previousNode
11321
+ && previousParentVisible
11322
+ && computeBoundVisible(previousNode, previousVariables));
11323
+ const nowVisible = nextParentVisible
11324
+ && computeBoundVisible(nextNode, nextVariables);
11325
+ if (nextNode.type === 'Input'
11326
+ && !wasVisible
11327
+ && nowVisible
11328
+ && resolveBoundNodeProps(nextNode, nextVariables).autoFocus === true) {
11329
+ requested.add(nextNode.id);
11330
+ }
11331
+ nextNode.children.forEach(child => visit(child, wasVisible, nowVisible));
11332
+ };
11333
+ visit(next.root, true, true);
11334
+ return requested;
11335
+ }
8609
11336
  function computeBoundDisabled(node, renderVariables) {
8610
11337
  const disabled = node.directives?.disabled;
8611
11338
  if (!disabled)
@@ -8781,7 +11508,10 @@ function renderStreamingCard(container, options = {}) {
8781
11508
  if (e instanceof CustomEvent && e.detail != null) {
8782
11509
  variables._event = e.detail;
8783
11510
  }
8784
- runSteps(resolvedSteps, node.id);
11511
+ userActionDepth += 1;
11512
+ void runSteps(resolvedSteps, node.id).finally(() => {
11513
+ userActionDepth -= 1;
11514
+ });
8785
11515
  }));
8786
11516
  }
8787
11517
  }
@@ -8836,6 +11566,43 @@ function renderStreamingCard(container, options = {}) {
8836
11566
  : visibleExpr;
8837
11567
  return !(resolved === false || resolved === 'false' || resolved === '' || resolved === 0);
8838
11568
  }
11569
+ function collectLegacyAutoFocusRevealIds(schema, previousVariables) {
11570
+ let tree;
11571
+ try {
11572
+ tree = parseSchema(schema);
11573
+ }
11574
+ catch {
11575
+ return new Set();
11576
+ }
11577
+ const requested = new Set();
11578
+ const ownVisibility = (node, renderVariables) => {
11579
+ const expression = node.directives?.visible;
11580
+ if (!expression)
11581
+ return true;
11582
+ const resolved = hasExpression(expression)
11583
+ ? resolveExpression(expression, renderVariables)
11584
+ : expression;
11585
+ return !(resolved === false
11586
+ || resolved === 'false'
11587
+ || resolved === ''
11588
+ || resolved === 0);
11589
+ };
11590
+ const visit = (node, previousParentVisible, nextParentVisible) => {
11591
+ const wasVisible = previousParentVisible
11592
+ && ownVisibility(node, previousVariables);
11593
+ const nowVisible = nextParentVisible && ownVisibility(node, variables);
11594
+ if (node.type === 'Input'
11595
+ && !wasVisible
11596
+ && nowVisible
11597
+ && resolveDeep(node.props, variables)
11598
+ .autoFocus === true) {
11599
+ requested.add(node.id);
11600
+ }
11601
+ node.children.forEach(child => visit(child, wasVisible, nowVisible));
11602
+ };
11603
+ visit(tree, true, true);
11604
+ return requested;
11605
+ }
8839
11606
  /** Entrance transition for blocks streamed in incrementally. */
8840
11607
  function animateEnter(el) {
8841
11608
  if (options.appearTransition === false)
@@ -8909,11 +11676,14 @@ function renderStreamingCard(container, options = {}) {
8909
11676
  * (visibility flips / container shape changes) re-render only their subtree.
8910
11677
  * Falls back to a full render solely when a subtree can't be rebuilt.
8911
11678
  */
8912
- function diffAllElements() {
11679
+ function diffAllElements(previousVariables) {
8913
11680
  actionContext = buildActionContext();
8914
11681
  const schema = (currentSurfaceId ? engine.getSchema(currentSurfaceId) : undefined) ?? currentSchema;
8915
11682
  if (!schema)
8916
11683
  return;
11684
+ const autoFocusIds = previousVariables
11685
+ ? collectLegacyAutoFocusRevealIds(schema, previousVariables)
11686
+ : new Set();
8917
11687
  for (const [id, element] of Object.entries(schema.elements)) {
8918
11688
  const el = elementMap.get(id);
8919
11689
  if (!el)
@@ -8924,6 +11694,7 @@ function renderStreamingCard(container, options = {}) {
8924
11694
  // Visibility flipped (either direction) → rebuild this subtree in place
8925
11695
  if (!replaceSubtree(schema, id, el)) {
8926
11696
  safeRenderFull();
11697
+ applyAutoFocusIds(autoFocusIds);
8927
11698
  return;
8928
11699
  }
8929
11700
  continue;
@@ -8940,9 +11711,11 @@ function renderStreamingCard(container, options = {}) {
8940
11711
  }
8941
11712
  else if (!replaceSubtree(schema, id, el)) {
8942
11713
  safeRenderFull();
11714
+ applyAutoFocusIds(autoFocusIds);
8943
11715
  return;
8944
11716
  }
8945
11717
  }
11718
+ applyAutoFocusIds(autoFocusIds);
8946
11719
  }
8947
11720
  function captureBoundScrollPositions(root) {
8948
11721
  const positions = new Map();
@@ -9665,7 +12438,10 @@ function renderStreamingCard(container, options = {}) {
9665
12438
  if (!steps)
9666
12439
  return;
9667
12440
  await runBoundSteps(steps, sourceNode, createBoundActionContext(sourceNode, draft));
12441
+ const nextMaterialized = materializeStreamingCard(currentSchema, draft);
12442
+ const autoFocusIds = collectBoundAutoFocusRevealIds(currentMaterialized, nextMaterialized, before, draft);
9668
12443
  commitBoundDraftTransaction(before, draft, baseRevision);
12444
+ applyAutoFocusIds(autoFocusIds);
9669
12445
  }
9670
12446
  function enqueueBoundEvent(runtimeId, eventName, eventDetail) {
9671
12447
  boundActionQueue = boundActionQueue
@@ -10247,6 +13023,8 @@ function renderStreamingCard(container, options = {}) {
10247
13023
  });
10248
13024
  // ─── Public API ─────────────────────────────────────────────────
10249
13025
  return {
13026
+ getNode: nodeAccess.getNode,
13027
+ onFocusChange: nodeAccess.onFocusChange,
10250
13028
  applyCommand(command) {
10251
13029
  if (!lockMode('commands'))
10252
13030
  return;
@@ -10323,6 +13101,7 @@ function renderStreamingCard(container, options = {}) {
10323
13101
  if (disposed)
10324
13102
  return;
10325
13103
  disposed = true;
13104
+ nodeAccess.dispose();
10326
13105
  boundRevision += 1;
10327
13106
  abortController.abort();
10328
13107
  teardownBoundLifecycles();