@antglobal/copilot-cards-web 1.0.6 → 1.0.8

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.
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,7 @@ function buildHeatmapOption(config) {
706
724
  */
707
725
  // ─── Slot Layout Constants ──────────────────────────────────────
708
726
  const SLOT_LAYOUT = {
727
+ DEFAULT: 'default',
709
728
  FLEX: 'flex',
710
729
  COLUMNS: 'columns',
711
730
  GRID: 'grid',
@@ -717,6 +736,377 @@ const SLOT_LAYOUT = {
717
736
  TABLE: 'table',
718
737
  CHART: 'chart',
719
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
+ }
720
1110
  // ─── Dispatcher ─────────────────────────────────────────────────
721
1111
  /**
722
1112
  * Apply host-level layout styles based on the slot key.
@@ -746,11 +1136,11 @@ function resolveFlexGap(value) {
746
1136
  * Returns true if a special layout was applied (children already appended).
747
1137
  * Returns false for `default` — caller uses original append loop.
748
1138
  */
749
- function renderSlotLayout(container, children, props, renderChild, childrenMap, actionContext) {
1139
+ function renderSlotLayout(container, children, props, renderChild, childrenMap, actionContext, resolveChildProps) {
750
1140
  const slots = props.slots;
751
1141
  if (!slots)
752
1142
  return false;
753
- const slotKey = Object.keys(slots).find((k) => k !== 'default');
1143
+ const slotKey = findSpecialSlotKey(props);
754
1144
  if (!slotKey)
755
1145
  return false;
756
1146
  const slotContent = slots[slotKey];
@@ -759,7 +1149,7 @@ function renderSlotLayout(container, children, props, renderChild, childrenMap,
759
1149
  renderColumnsSlot(container, slotContent, childrenMap, renderChild);
760
1150
  return true;
761
1151
  case SLOT_LAYOUT.GRID:
762
- renderGridSlot(container, children, slotContent, renderChild);
1152
+ renderGridSlot(container, children, slotContent, renderChild, resolveChildProps);
763
1153
  return true;
764
1154
  case SLOT_LAYOUT.HORIZONTAL_SCROLL:
765
1155
  renderHorizontalScrollSlot(container, children, slotContent, renderChild);
@@ -833,7 +1223,7 @@ function renderColumnsSlot(container, slotContent, childrenMap, renderChild) {
833
1223
  *
834
1224
  * Schema: `slots: { grid: { children: [...], config: { columns: 2, gap: '8px', rows?: 3, rowHeight?: '48px' } } }`
835
1225
  */
836
- function renderGridSlot(container, children, slotContent, renderChild) {
1226
+ function renderGridSlot(container, children, slotContent, renderChild, resolveChildProps) {
837
1227
  const columns = slotContent?.config?.columns ?? 2;
838
1228
  const gap = slotContent?.config?.gap ?? '8px';
839
1229
  const rows = slotContent?.config?.rows;
@@ -851,7 +1241,7 @@ function renderGridSlot(container, children, slotContent, renderChild) {
851
1241
  // regardless of whether the child component applies props.style to its host
852
1242
  // or to an inner shadow-DOM node. No-op for children without placement, so
853
1243
  // auto-flow cards are unaffected.
854
- applyGridPlacement(el, child);
1244
+ bindSlotItemPresentation(el, gridItemDescriptor(resolveChildProps?.(child) ?? child.props));
855
1245
  grid.appendChild(el);
856
1246
  }
857
1247
  container.appendChild(grid);
@@ -864,16 +1254,31 @@ function renderGridSlot(container, children, slotContent, renderChild) {
864
1254
  * unresolved expression object) is ignored. Children that declare no placement
865
1255
  * are left untouched — preserving auto-flow behaviour for legacy cards.
866
1256
  */
867
- function applyGridPlacement(el, child) {
868
- const style = child?.props?.style;
869
- if (!style || typeof style !== 'object')
870
- return;
871
- if (isCssPlacement(style.gridColumn))
872
- el.style.gridColumn = String(style.gridColumn);
873
- if (isCssPlacement(style.gridRow))
874
- el.style.gridRow = String(style.gridRow);
875
- if (isCssPlacement(style.gridArea))
876
- 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
+ };
877
1282
  }
878
1283
  function isCssPlacement(value) {
879
1284
  return typeof value === 'string' || typeof value === 'number';
@@ -897,7 +1302,14 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
897
1302
  const autoScrollLoop = config.autoScrollLoop !== false;
898
1303
  const scrollbar = resolveScrollbarMode(config.scrollbar);
899
1304
  const hidesScrollbar = scrollbar == null || scrollbar === 'hidden';
1305
+ const itemDescriptor = horizontalScrollItemDescriptor(itemWidth, snap, itemHoverStyle);
900
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;
901
1313
  track.style.cssText = [
902
1314
  'display:flex',
903
1315
  'overflow-x:auto',
@@ -917,20 +1329,17 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
917
1329
  track.dataset.scrollbar = scrollbar;
918
1330
  for (const child of children) {
919
1331
  const item = renderChild(child);
920
- item.style.flexShrink = '0';
921
- if (itemWidth)
922
- item.style.width = itemWidth;
923
- if (snap)
924
- item.style.scrollSnapAlign = 'start';
925
- if (itemHoverStyle)
926
- applyHoverStyle(item, itemHoverStyle);
1332
+ bindSlotItemPresentation(item, itemDescriptor);
927
1333
  track.appendChild(item);
928
1334
  }
929
1335
  // Mouse drag (desktop)
930
1336
  let isDragging = false;
931
1337
  let startX = 0;
932
1338
  let scrollLeft = 0;
933
- track.addEventListener('mousedown', (e) => {
1339
+ const handleMouseDown = (event) => {
1340
+ if (ownerDisposed)
1341
+ return;
1342
+ const e = event;
934
1343
  if (e.button !== 0)
935
1344
  return; // left button only — keep middle/right clicks out of drag state
936
1345
  // Nested scrollers: consume the gesture so an enclosing horizontalScroll doesn't
@@ -944,8 +1353,11 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
944
1353
  track.style.userSelect = 'none';
945
1354
  if (snap)
946
1355
  track.style.scrollSnapType = 'none';
947
- });
948
- track.addEventListener('mousemove', (e) => {
1356
+ };
1357
+ const handleMouseMove = (event) => {
1358
+ if (ownerDisposed)
1359
+ return;
1360
+ const e = event;
949
1361
  if (!isDragging)
950
1362
  return;
951
1363
  // Only swallow the move while THIS track owns the drag — otherwise an in-progress
@@ -954,7 +1366,7 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
954
1366
  e.stopPropagation();
955
1367
  e.preventDefault();
956
1368
  track.scrollLeft = scrollLeft - (e.pageX - startX);
957
- });
1369
+ };
958
1370
  const stopDrag = () => {
959
1371
  if (!isDragging)
960
1372
  return;
@@ -964,15 +1376,19 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
964
1376
  if (snap)
965
1377
  track.style.scrollSnapType = 'x mandatory';
966
1378
  };
967
- track.addEventListener('mouseup', stopDrag);
968
- 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);
969
1383
  // Wheel / trackpad → horizontal scroll.
970
1384
  // Mandatory snap would swallow small wheel deltas (each increment snaps right
971
1385
  // back to the same item, so the wheel appears dead) — suspend snap while
972
1386
  // wheeling and restore it shortly after the last tick, which also re-aligns
973
1387
  // the track to the nearest snap point.
974
- let wheelTimer = 0;
975
- track.addEventListener('wheel', (e) => {
1388
+ const handleWheel = (event) => {
1389
+ if (ownerDisposed)
1390
+ return;
1391
+ const e = event;
976
1392
  e.preventDefault();
977
1393
  // Consume the wheel so a nested horizontalScroll scrolls only itself instead of
978
1394
  // also driving an enclosing scroller (simplest nested model — no edge hand-off
@@ -985,12 +1401,16 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
985
1401
  const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
986
1402
  track.scrollLeft += delta;
987
1403
  if (snap) {
988
- clearTimeout(wheelTimer);
1404
+ window.clearTimeout(wheelTimer);
989
1405
  wheelTimer = window.setTimeout(() => {
1406
+ wheelTimer = 0;
1407
+ if (ownerDisposed)
1408
+ return;
990
1409
  track.style.scrollSnapType = 'x mandatory';
991
1410
  }, 150);
992
1411
  }
993
- }, { passive: false });
1412
+ };
1413
+ addSlotOwnerListener(ownerCleanup, track, 'wheel', handleWheel, { passive: false });
994
1414
  // Hover autoplay (desktop): while the pointer is over the track, glide to the next
995
1415
  // item every `autoScrollInterval` ms, looping back to the first at the end when
996
1416
  // `autoScrollLoop`. Opt-in via `autoScroll`. Manual drag pauses it (resumes on the
@@ -999,9 +1419,10 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
999
1419
  // chrome. Targets are item starts (= snap points), so mandatory snap won't fight the
1000
1420
  // glide — same reason the arrow buttons can scrollBy smoothly without suspending snap.
1001
1421
  if (autoScroll) {
1002
- let autoTimer = 0;
1003
1422
  const glideTo = (left) => track.scrollTo({ left, behavior: 'smooth' });
1004
1423
  const advance = () => {
1424
+ if (ownerDisposed)
1425
+ return stopAuto();
1005
1426
  // A variable-driven re-render rebuilds the track; bail and clear so the interval
1006
1427
  // doesn't keep driving a detached node (mirrors the ResizeObserver self-teardown).
1007
1428
  if (!track.isConnected)
@@ -1022,6 +1443,8 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1022
1443
  glideTo(track.scrollLeft + (next.getBoundingClientRect().left - trackLeft));
1023
1444
  };
1024
1445
  const startAuto = () => {
1446
+ if (ownerDisposed)
1447
+ return;
1025
1448
  if (autoTimer)
1026
1449
  return;
1027
1450
  if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
@@ -1031,13 +1454,33 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1031
1454
  function stopAuto() {
1032
1455
  if (!autoTimer)
1033
1456
  return;
1034
- clearInterval(autoTimer);
1457
+ window.clearInterval(autoTimer);
1035
1458
  autoTimer = 0;
1036
1459
  }
1037
- track.addEventListener('mouseenter', startAuto);
1038
- track.addEventListener('mouseleave', stopAuto);
1039
- track.addEventListener('mousedown', stopAuto);
1460
+ addSlotOwnerListener(ownerCleanup, track, 'mouseenter', startAuto);
1461
+ addSlotOwnerListener(ownerCleanup, track, 'mouseleave', stopAuto);
1462
+ addSlotOwnerListener(ownerCleanup, track, 'mousedown', stopAuto);
1040
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
+ });
1041
1484
  if (hidesScrollbar) {
1042
1485
  const trackClass = `card-scroll-${cardId}`;
1043
1486
  track.classList.add(trackClass);
@@ -1082,13 +1525,25 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1082
1525
  : typeof config.arrowOffset === 'number' ? `${config.arrowOffset}px` : String(config.arrowOffset);
1083
1526
  leftArrow = createArrowButton('left', arrowStyle, arrowOffset, config.arrowIconLeft ?? config.arrowIcon, config.arrowIconLeft == null);
1084
1527
  rightArrow = createArrowButton('right', arrowStyle, arrowOffset, config.arrowIconRight ?? config.arrowIcon, false);
1085
- leftArrow.addEventListener('click', () => track.scrollBy({ left: -step(), behavior: 'smooth' }));
1086
- 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);
1087
1540
  wrapper.appendChild(leftArrow);
1088
1541
  wrapper.appendChild(rightArrow);
1089
1542
  }
1090
1543
  // Show each arrow/mask only while the track can scroll in that direction.
1091
1544
  const updateOverlays = () => {
1545
+ if (ownerDisposed)
1546
+ return;
1092
1547
  const canLeft = track.scrollLeft > 1;
1093
1548
  const canRight = track.scrollLeft + track.clientWidth < track.scrollWidth - 1;
1094
1549
  if (leftArrow)
@@ -1100,27 +1555,54 @@ function renderHorizontalScrollSlot(container, children, slotContent, renderChil
1100
1555
  if (rightMask)
1101
1556
  rightMask.style.opacity = canRight ? maskOpacity : '0';
1102
1557
  };
1103
- track.addEventListener('scroll', updateOverlays, { passive: true });
1558
+ addSlotOwnerListener(ownerCleanup, track, 'scroll', updateOverlays, { passive: true });
1104
1559
  // ResizeObserver fires once on observe (covers initial layout) and again on
1105
1560
  // any size change; it self-disconnects once the track leaves the DOM.
1106
1561
  if (typeof ResizeObserver !== 'undefined') {
1107
- const ro = new ResizeObserver(() => {
1562
+ resizeObserver = new ResizeObserver(() => {
1563
+ if (ownerDisposed)
1564
+ return;
1108
1565
  if (!track.isConnected) {
1109
- ro.disconnect();
1566
+ resizeObserver?.disconnect();
1567
+ resizeObserver = null;
1110
1568
  return;
1111
1569
  }
1112
1570
  updateOverlays();
1113
1571
  });
1114
- ro.observe(track);
1572
+ resizeObserver.observe(track);
1115
1573
  }
1116
1574
  else {
1117
- requestAnimationFrame(updateOverlays);
1575
+ overlayFrame = requestAnimationFrame(() => {
1576
+ overlayFrame = 0;
1577
+ updateOverlays();
1578
+ });
1118
1579
  }
1119
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
+ }
1120
1598
  /** Apply `hoverStyle` on mouseenter and restore the previous inline values on leave. */
1121
1599
  function applyHoverStyle(item, hoverStyle) {
1122
1600
  const previous = {};
1123
- item.addEventListener('mouseenter', () => {
1601
+ let hovered = false;
1602
+ const enter = () => {
1603
+ if (hovered)
1604
+ return;
1605
+ hovered = true;
1124
1606
  for (const [key, value] of Object.entries(hoverStyle)) {
1125
1607
  if (key.startsWith('--')) {
1126
1608
  const previousValue = item.style.getPropertyValue(key);
@@ -1143,8 +1625,11 @@ function applyHoverStyle(item, hoverStyle) {
1143
1625
  item.style[key] = value;
1144
1626
  }
1145
1627
  }
1146
- });
1147
- item.addEventListener('mouseleave', () => {
1628
+ };
1629
+ const leave = () => {
1630
+ if (!hovered)
1631
+ return;
1632
+ hovered = false;
1148
1633
  for (const [key, state] of Object.entries(previous)) {
1149
1634
  if (state.isCustomProperty) {
1150
1635
  if (state.wasPresent) {
@@ -1157,8 +1642,38 @@ function applyHoverStyle(item, hoverStyle) {
1157
1642
  else {
1158
1643
  item.style[key] = state.value;
1159
1644
  }
1645
+ delete previous[key];
1160
1646
  }
1161
- });
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
+ };
1162
1677
  }
1163
1678
  /** Translucent edge-fade mask hinting that more content is available in that direction. */
1164
1679
  function createEdgeMask(side, width, color) {
@@ -1238,6 +1753,66 @@ function createArrowButton(side, arrowStyle, offset, icon, mirror = false) {
1238
1753
  return btn;
1239
1754
  }
1240
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
+ }
1241
1816
  /**
1242
1817
  * Center-focused carousel with scale/opacity/overlay effects and snap.
1243
1818
  *
@@ -1248,30 +1823,40 @@ function createArrowButton(side, arrowStyle, offset, icon, mirror = false) {
1248
1823
  * to the first after the last (default false)
1249
1824
  * - `autoplayInterval` — ms between auto-advances (default 3000)
1250
1825
  */
1251
- function renderCarouselSlot(container, children, slotContent, props, renderChild) {
1252
- const config = slotContent?.config ?? {};
1253
- const inactiveScale = config.scale ?? props.carouselScale ?? 0.85;
1254
- const inactiveOpacity = config.opacity ?? props.carouselOpacity ?? 0.4;
1255
- const overlayColor = config.overlayColor ?? props.carouselOverlayColor ?? 'rgba(255,255,255,0.5)';
1256
- const gap = config.gap ?? props.carouselGap ?? 16;
1257
- const itemWidth = config.itemWidthPercent ?? props.carouselItemWidth ?? 70;
1258
- const itemWidthPx = config.itemWidthPx;
1259
- const initialIndex = Math.max(0, Math.min(children.length - 1, config.initialIndex ?? props.carouselInitialIndex ?? 0));
1260
- const autoplay = config.autoplay ?? props.carouselAutoplay ?? false;
1261
- const autoplayInterval = config.autoplayInterval ?? props.carouselAutoplayInterval ?? 3000;
1262
- Object.assign(container.style, {
1263
- display: 'flex',
1264
- flexDirection: 'row',
1265
- overflowX: 'auto',
1266
- scrollSnapType: 'x mandatory',
1267
- WebkitOverflowScrolling: 'touch',
1268
- gap: `${gap}px`,
1269
- paddingTop: '16px',
1270
- paddingBottom: '16px',
1271
- scrollbarWidth: 'none',
1272
- cursor: 'grab',
1273
- position: 'relative',
1274
- });
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));
1275
1860
  // Hide scrollbar
1276
1861
  const styleEl = document.createElement('style');
1277
1862
  const id = container.getAttribute('data-card-id') ?? '';
@@ -1279,34 +1864,16 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1279
1864
  container.appendChild(styleEl);
1280
1865
  // The carousel element scrolls itself — tag it so renderCard can snapshot/restore
1281
1866
  // its scrollLeft across variable-driven re-renders (see captureScrollPositions/restoreScrollPositions).
1282
- container.setAttribute('data-scroll-id', id || 'x');
1867
+ container.setAttribute(CAROUSEL_SCROLL_ID_ATTRIBUTE, id || 'x');
1283
1868
  // Render children
1284
- const items = [];
1285
1869
  for (let i = 0; i < children.length; i++) {
1286
1870
  const child = children[i];
1287
1871
  const item = renderChild(child);
1288
- item.style.flex = itemWidthPx ? `0 0 ${itemWidthPx}px` : `0 0 ${itemWidth}%`;
1289
- item.style.scrollSnapAlign = 'center';
1290
- item.style.transformOrigin = 'center center';
1291
- item.style.position = 'relative';
1292
- item.style.overflow = 'hidden';
1293
- const overlay = document.createElement('div');
1294
- overlay.className = 'carousel-overlay';
1295
- Object.assign(overlay.style, {
1296
- position: 'absolute',
1297
- inset: '0',
1298
- background: overlayColor,
1299
- opacity: '0',
1300
- pointerEvents: 'none',
1301
- borderRadius: 'inherit',
1302
- zIndex: '1',
1303
- });
1304
- item.appendChild(overlay);
1872
+ bindSlotItemPresentation(item, carouselItemDescriptor(i, children.length, itemWidthPx ? `${itemWidthPx}px` : `${itemWidth}%`, overlayColor));
1305
1873
  container.appendChild(item);
1306
- items.push(item);
1307
1874
  }
1308
1875
  // Index of the item whose center is closest to the viewport center
1309
- const nearestIndex = () => {
1876
+ const nearestIndex = (items) => {
1310
1877
  const centerX = container.scrollLeft + container.offsetWidth / 2;
1311
1878
  let idx = 0;
1312
1879
  let minDist = Infinity;
@@ -1320,9 +1887,29 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1320
1887
  });
1321
1888
  return idx;
1322
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
+ };
1323
1907
  // Center a given item. 'smooth' animates and restores CSS snap afterwards;
1324
1908
  // 'auto' jumps instantly (used for initial positioning).
1325
1909
  const centerItem = (item, behavior = 'smooth') => {
1910
+ if (ownerDisposed)
1911
+ return;
1912
+ clearPendingSnap();
1326
1913
  const target = item.offsetLeft - (container.offsetWidth - item.offsetWidth) / 2;
1327
1914
  if (behavior === 'auto') {
1328
1915
  // Disable mandatory snap for the instant jump — a mandatory-snap container
@@ -1330,7 +1917,7 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1330
1917
  // Restore snap next frame once the scroll offset is committed.
1331
1918
  container.style.scrollSnapType = 'none';
1332
1919
  container.scrollLeft = target;
1333
- requestAnimationFrame(() => {
1920
+ requestOwnerFrame(() => {
1334
1921
  container.style.scrollSnapType = 'x mandatory';
1335
1922
  });
1336
1923
  return;
@@ -1338,12 +1925,12 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1338
1925
  // Disable CSS snap during the smooth animation, restore after for pixel-perfect alignment
1339
1926
  container.style.scrollSnapType = 'none';
1340
1927
  container.scrollTo({ left: target, behavior: 'smooth' });
1341
- container.addEventListener('scrollend', () => {
1342
- container.style.scrollSnapType = 'x mandatory';
1343
- }, { once: true });
1928
+ pendingSnapScrollEnd = restoreSnap;
1929
+ container.addEventListener('scrollend', pendingSnapScrollEnd, { once: true });
1344
1930
  // Fallback for browsers without scrollend event
1345
- setTimeout(() => {
1346
- container.style.scrollSnapType = 'x mandatory';
1931
+ pendingSnapTimer = window.setTimeout(() => {
1932
+ pendingSnapTimer = 0;
1933
+ restoreSnap();
1347
1934
  }, 400);
1348
1935
  };
1349
1936
  // Snap helper
@@ -1352,7 +1939,10 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1352
1939
  let isDragging = false;
1353
1940
  let startX = 0;
1354
1941
  let scrollStart = 0;
1355
- container.addEventListener('mousedown', (e) => {
1942
+ const handleMouseDown = (event) => {
1943
+ if (ownerDisposed)
1944
+ return;
1945
+ const e = event;
1356
1946
  if (e.button !== 0)
1357
1947
  return; // left button only — keep middle/right clicks out of drag state
1358
1948
  isDragging = true;
@@ -1361,35 +1951,52 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1361
1951
  container.style.cursor = 'grabbing';
1362
1952
  container.style.scrollSnapType = 'none';
1363
1953
  e.preventDefault();
1364
- });
1365
- document.addEventListener('mousemove', (e) => {
1366
- if (!isDragging)
1954
+ };
1955
+ const handleMouseMove = (event) => {
1956
+ if (ownerDisposed || !isDragging)
1367
1957
  return;
1958
+ const e = event;
1368
1959
  container.scrollLeft = scrollStart - (e.pageX - startX);
1369
- });
1370
- document.addEventListener('mouseup', () => {
1371
- if (!isDragging)
1960
+ };
1961
+ const handleMouseUp = () => {
1962
+ if (ownerDisposed || !isDragging)
1372
1963
  return;
1373
1964
  isDragging = false;
1374
1965
  container.style.cursor = 'grab';
1375
1966
  snapToNearest();
1376
- });
1377
- container.addEventListener('touchend', () => {
1378
- snapToNearest();
1379
- }, { passive: true });
1380
- // Wheel support
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 });
1976
+ // Wheel support
1381
1977
  let wheelTimer = 0;
1382
- container.addEventListener('wheel', (e) => {
1978
+ const handleWheel = (event) => {
1979
+ if (ownerDisposed)
1980
+ return;
1981
+ const e = event;
1383
1982
  if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) {
1384
1983
  e.preventDefault();
1385
1984
  container.style.scrollSnapType = 'none';
1386
1985
  container.scrollLeft += e.deltaY;
1387
- clearTimeout(wheelTimer);
1388
- wheelTimer = window.setTimeout(() => snapToNearest(), 150);
1986
+ window.clearTimeout(wheelTimer);
1987
+ wheelTimer = window.setTimeout(() => {
1988
+ wheelTimer = 0;
1989
+ if (!ownerDisposed)
1990
+ snapToNearest();
1991
+ }, 150);
1389
1992
  }
1390
- }, { passive: false });
1993
+ };
1994
+ addSlotOwnerListener(ownerCleanup, container, 'wheel', handleWheel, { passive: false });
1391
1995
  // Scale/opacity effects
1392
1996
  const updateEffects = () => {
1997
+ if (ownerDisposed)
1998
+ return;
1999
+ const items = carouselItems(container);
1393
2000
  const containerWidth = container.offsetWidth;
1394
2001
  const scrollCenter = container.scrollLeft + containerWidth / 2;
1395
2002
  items.forEach((item) => {
@@ -1402,20 +2009,27 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1402
2009
  const opacity = 1 - ratio * (1 - inactiveOpacity);
1403
2010
  item.style.transform = `scale3d(${scale},${scale},1)`;
1404
2011
  item.style.opacity = `${opacity}`;
1405
- const overlayEl = item.querySelector('.carousel-overlay');
2012
+ const overlayEl = carouselOverlay(item);
1406
2013
  if (overlayEl)
1407
2014
  overlayEl.style.opacity = `${ratio}`;
1408
2015
  });
1409
2016
  };
1410
2017
  let rafId = 0;
1411
- container.addEventListener('scroll', () => {
1412
- cancelAnimationFrame(rafId);
1413
- rafId = requestAnimationFrame(updateEffects);
1414
- }, { 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 });
1415
2028
  // Smooth snap to nearest item center + restore scrollSnapType for pixel-perfect final position
1416
2029
  snapToNearest = () => {
2030
+ const items = carouselItems(container);
1417
2031
  if (items.length > 0)
1418
- centerItem(items[nearestIndex()]);
2032
+ centerItem(items[nearestIndex(items)]);
1419
2033
  };
1420
2034
  // Autoplay — advance to the next item on an interval, looping back to the
1421
2035
  // first after the last. Pauses while the user hovers or interacts.
@@ -1423,33 +2037,64 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1423
2037
  let autoplayTimer = 0;
1424
2038
  const stopAutoplay = () => {
1425
2039
  if (autoplayTimer) {
1426
- clearInterval(autoplayTimer);
2040
+ window.clearInterval(autoplayTimer);
1427
2041
  autoplayTimer = 0;
1428
2042
  }
1429
2043
  };
1430
2044
  const startAutoplay = () => {
1431
- if (!autoplay || items.length < 2)
2045
+ const items = carouselItems(container);
2046
+ if (ownerDisposed
2047
+ || !container.isConnected
2048
+ || !autoplay
2049
+ || items.length < 2)
1432
2050
  return;
1433
2051
  stopAutoplay();
1434
2052
  autoplayTimer = window.setInterval(() => {
1435
- // Self-clean once the carousel leaves the DOM (no teardown hook is available here)
1436
- if (!container.isConnected) {
2053
+ if (ownerDisposed || !container.isConnected) {
1437
2054
  stopAutoplay();
1438
2055
  return;
1439
2056
  }
1440
2057
  if (paused || isDragging)
1441
2058
  return;
1442
- 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]);
1443
2063
  }, autoplayInterval);
1444
2064
  };
1445
2065
  if (autoplay) {
1446
- container.addEventListener('mouseenter', () => { paused = true; });
1447
- container.addEventListener('mouseleave', () => { paused = false; });
1448
- container.addEventListener('touchstart', () => { paused = true; }, { passive: true });
1449
- container.addEventListener('touchend', () => { paused = false; }, { passive: true });
1450
- }
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
+ });
1451
2095
  // Initial position & effects — deferred until element is in DOM and laid out
1452
- requestAnimationFrame(() => {
2096
+ requestOwnerFrame(() => {
2097
+ const items = carouselItems(container);
1453
2098
  if (items.length > 0) {
1454
2099
  // Use ACTUAL rendered width (includes padding in content-box mode) for perfect centering
1455
2100
  const containerWidth = container.offsetWidth;
@@ -1464,13 +2109,13 @@ function renderCarouselSlot(container, children, slotContent, props, renderChild
1464
2109
  delete container.dataset.restoreScrollLeft;
1465
2110
  container.style.scrollSnapType = 'none';
1466
2111
  container.scrollLeft = parseFloat(saved);
1467
- requestAnimationFrame(() => {
2112
+ requestOwnerFrame(() => {
1468
2113
  container.style.scrollSnapType = 'x mandatory';
1469
2114
  });
1470
2115
  }
1471
2116
  else {
1472
2117
  // Jump to the configured initial item (defaults to the first) — CSS snap maintains alignment
1473
- centerItem(items[initialIndex], 'auto');
2118
+ centerItem(items[Math.min(initialIndex, items.length - 1)], 'auto');
1474
2119
  }
1475
2120
  }
1476
2121
  updateEffects();
@@ -1757,6 +2402,99 @@ class BaseElement extends HTMLElement {
1757
2402
  this.shadowRoot.innerHTML = html;
1758
2403
  applyResponsiveStyles(this.shadowRoot, this._responsive);
1759
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
+ if (current.getAttribute(attribute.name) !== attribute.value) {
2468
+ current.setAttribute(attribute.name, attribute.value);
2469
+ }
2470
+ }
2471
+ if (current === preserved)
2472
+ return;
2473
+ if (current.contains(preserved)) {
2474
+ this.reconcilePreservedTree(current, desired, preserved);
2475
+ return;
2476
+ }
2477
+ current.innerHTML = desired.innerHTML;
2478
+ }
2479
+ reconcileKey(element) {
2480
+ if (element.classList.contains('card-input'))
2481
+ return 'card-input';
2482
+ if (element.classList.contains('card-input-wrapper')) {
2483
+ return 'card-input-wrapper';
2484
+ }
2485
+ if (element.classList.contains('card-input-label')) {
2486
+ return 'card-input-label';
2487
+ }
2488
+ if (element.classList.contains('input-control'))
2489
+ return 'input-control';
2490
+ if (element.classList.contains('input-prefix'))
2491
+ return 'input-prefix';
2492
+ if (element.classList.contains('input-suffix'))
2493
+ return 'input-suffix';
2494
+ if (element.classList.contains('number-stepper'))
2495
+ return 'number-stepper';
2496
+ return element.tagName;
2497
+ }
1760
2498
  /**
1761
2499
  * Escape a value before interpolating it into a double-quoted HTML
1762
2500
  * attribute. Browsers decode the entities before parsing inline CSS, so
@@ -2745,10 +3483,18 @@ function normalizeNumberAttribute(value, options = {}) {
2745
3483
  return numeric;
2746
3484
  }
2747
3485
  class CardInput extends BaseElement {
3486
+ constructor() {
3487
+ super(...arguments);
3488
+ this.wiredInputs = new WeakSet();
3489
+ this.wiredNumberInputs = new WeakSet();
3490
+ }
2748
3491
  render() {
2749
3492
  if (!this.shadowRoot || !this._node)
2750
3493
  return;
2751
- 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;
3494
+ const activeControl = this.shadowRoot.activeElement?.matches('.card-input')
3495
+ ? this.shadowRoot.activeElement
3496
+ : null;
3497
+ 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;
2752
3498
  const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
2753
3499
  const legacyInputStyle = style && typeof style === 'object' && style.resize != null
2754
3500
  ? { resize: style.resize }
@@ -2818,6 +3564,7 @@ class CardInput extends BaseElement {
2818
3564
  id="${inputId}"
2819
3565
  class="card-input ${isMobile ? 'card-mobile' : 'card-desktop'}"
2820
3566
  placeholder="${this.escapeAttr(String(placeholder))}"
3567
+ ${inputMode ? `inputmode="${this.escapeAttr(String(inputMode))}"` : ''}
2821
3568
  ${describedBy}
2822
3569
  ${disabled ? 'disabled' : ''}
2823
3570
  ${readOnly ? 'readonly' : ''}
@@ -2829,6 +3576,7 @@ class CardInput extends BaseElement {
2829
3576
  id="${inputId}"
2830
3577
  class="card-input ${isMobile ? 'card-mobile' : 'card-desktop'}"
2831
3578
  type="${this.escapeAttr(String(inputType))}"
3579
+ ${inputMode ? `inputmode="${this.escapeAttr(String(inputMode))}"` : ''}
2832
3580
  placeholder="${this.escapeAttr(String(placeholder))}"
2833
3581
  value="${this.escapeAttr(String(defaultValue))}"
2834
3582
  ${describedBy}
@@ -2838,7 +3586,7 @@ class CardInput extends BaseElement {
2838
3586
  ${numberAttributes}
2839
3587
  style="${nativeInlineStyle}"
2840
3588
  />`;
2841
- this.setShadowHTML(`
3589
+ const markup = `
2842
3590
  <style>
2843
3591
  :host {
2844
3592
  display: block;
@@ -3012,7 +3760,11 @@ class CardInput extends BaseElement {
3012
3760
  ${numberStepperHtml}
3013
3761
  </div>
3014
3762
  </div>
3015
- `);
3763
+ `;
3764
+ if (!activeControl
3765
+ || !this.patchShadowHTMLPreservingElement(markup, activeControl, '.card-input')) {
3766
+ this.setShadowHTML(markup);
3767
+ }
3016
3768
  // Wire up native input/change events that bubble out of Shadow DOM.
3017
3769
  // renderCard listens for these standard event names (mapped from onInput / onChange).
3018
3770
  // The `detail.value` carries the current input value so that:
@@ -3020,41 +3772,41 @@ class CardInput extends BaseElement {
3020
3772
  // 2. action handlers can access it if needed
3021
3773
  const inputEl = this.shadowRoot.querySelector('.card-input');
3022
3774
  if (inputEl) {
3023
- inputEl.addEventListener('input', () => {
3024
- this.dispatchEvent(new CustomEvent('input', {
3025
- bubbles: true,
3026
- composed: true,
3027
- detail: { value: inputEl.value },
3028
- }));
3029
- });
3030
- inputEl.addEventListener('change', () => {
3031
- this.dispatchEvent(new CustomEvent('change', {
3032
- bubbles: true,
3033
- composed: true,
3034
- detail: { value: inputEl.value },
3035
- }));
3036
- });
3037
- if (autoFocus && !disabled) {
3038
- queueMicrotask(() => {
3039
- const control = this.getAutoFocusControl();
3040
- if (control && this.isFirstAvailableAutoFocusInput()) {
3041
- control.focus();
3042
- }
3775
+ if (!this.wiredInputs.has(inputEl)) {
3776
+ this.wiredInputs.add(inputEl);
3777
+ inputEl.addEventListener('input', () => {
3778
+ this.dispatchEvent(new CustomEvent('input', {
3779
+ bubbles: true,
3780
+ composed: true,
3781
+ detail: { value: inputEl.value },
3782
+ }));
3783
+ });
3784
+ inputEl.addEventListener('change', () => {
3785
+ this.dispatchEvent(new CustomEvent('change', {
3786
+ bubbles: true,
3787
+ composed: true,
3788
+ detail: { value: inputEl.value },
3789
+ }));
3043
3790
  });
3044
3791
  }
3045
3792
  if (isNumber &&
3046
3793
  showNumberStepper &&
3047
3794
  inputEl instanceof HTMLInputElement) {
3048
- const increaseButton = this.shadowRoot.querySelector('.number-step-button.increase');
3049
- const decreaseButton = this.shadowRoot.querySelector('.number-step-button.decrease');
3050
3795
  const refreshStepperState = () => {
3051
3796
  const state = getNumberStepperDisabledState(inputEl);
3797
+ const increaseButton = this.shadowRoot?.querySelector('.number-step-button.increase');
3798
+ const decreaseButton = this.shadowRoot?.querySelector('.number-step-button.decrease');
3052
3799
  if (increaseButton)
3053
3800
  increaseButton.disabled = state.increase;
3054
3801
  if (decreaseButton)
3055
3802
  decreaseButton.disabled = state.decrease;
3056
3803
  };
3057
- inputEl.addEventListener('input', refreshStepperState);
3804
+ if (!this.wiredNumberInputs.has(inputEl)) {
3805
+ this.wiredNumberInputs.add(inputEl);
3806
+ inputEl.addEventListener('input', refreshStepperState);
3807
+ }
3808
+ const increaseButton = this.shadowRoot.querySelector('.number-step-button.increase');
3809
+ const decreaseButton = this.shadowRoot.querySelector('.number-step-button.decrease');
3058
3810
  increaseButton?.addEventListener('click', () => {
3059
3811
  stepNumberInput(inputEl, 'increase');
3060
3812
  inputEl.focus();
@@ -3070,21 +3822,25 @@ class CardInput extends BaseElement {
3070
3822
  }
3071
3823
  }
3072
3824
  // ─── Helpers ──────────────────────────────────────────────────
3073
- getAutoFocusControl() {
3825
+ /**
3826
+ * Consume an interaction-driven focus request from the card renderer.
3827
+ * Mounts and ordinary updates never call this method.
3828
+ */
3829
+ requestAutoFocus() {
3074
3830
  if (!this.isConnected
3075
3831
  || !this._props.autoFocus
3076
3832
  || this.hasAttribute('data-disabled')) {
3077
- return null;
3833
+ return false;
3078
3834
  }
3079
3835
  const control = this.shadowRoot?.querySelector('.card-input');
3080
- return control && !control.disabled ? control : null;
3081
- }
3082
- isFirstAvailableAutoFocusInput() {
3083
- const root = this.getRootNode();
3084
- if (!('querySelectorAll' in root))
3836
+ if (!control
3837
+ || control.disabled
3838
+ || control.readOnly
3839
+ || control.value !== '') {
3085
3840
  return false;
3086
- const firstAvailable = Array.from(root.querySelectorAll(CardInput.is)).find(input => input.getAutoFocusControl() !== null);
3087
- return firstAvailable === this;
3841
+ }
3842
+ control.focus({ preventScroll: true });
3843
+ return this.shadowRoot?.activeElement === control;
3088
3844
  }
3089
3845
  /** Escape HTML entities for safe insertion. */
3090
3846
  escapeHtml(str) {
@@ -3141,25 +3897,55 @@ class CardImage extends BaseElement {
3141
3897
  constructor() {
3142
3898
  super(...arguments);
3143
3899
  this._lightbox = null;
3900
+ this._wiredImages = new WeakSet();
3901
+ this._wiredLightboxes = new WeakSet();
3902
+ this._wiredCloseButtons = new WeakSet();
3903
+ this._handleEscape = (event) => {
3904
+ if (event.key === 'Escape' && this._lightbox) {
3905
+ this.closeLightbox(this._lightbox);
3906
+ }
3907
+ };
3144
3908
  }
3145
3909
  render() {
3146
3910
  if (!this.shadowRoot || !this._node)
3147
3911
  return;
3148
3912
  const { src, alt = '', width, height, objectFit = 'cover', preview = true, style, isExpressionResultStyle, } = this._props;
3149
- const inlineStyle = this.buildInlineStyle(style, isExpressionResultStyle);
3150
3913
  const imgSrc = this.resolveContent(src);
3151
3914
  const imgAlt = this.resolveContent(alt);
3915
+ // Keep the custom-element box independent from the image's intrinsic
3916
+ // dimensions. A failed image has different intrinsic sizing rules, so
3917
+ // sizing only the inner <img> makes flex/grid layouts jump on error.
3918
+ const hostWidth = this.applyHostDimension('width', style?.width, width);
3919
+ const hostHeight = this.applyHostDimension('height', style?.height, height);
3920
+ const ownsWidth = hostWidth != null;
3921
+ const ownsHeight = hostHeight != null;
3922
+ const reservesWidth = ownsWidth && hostWidth !== 'auto';
3923
+ const reservesHeight = ownsHeight && hostHeight !== 'auto';
3924
+ // Width and height belong to the host layout box. Remove them from the
3925
+ // inner style so relative lengths (for example 50%) are not applied twice.
3926
+ const imageStyle = style && typeof style === 'object' ? { ...style } : style;
3927
+ if (imageStyle && typeof imageStyle === 'object') {
3928
+ if (ownsWidth)
3929
+ delete imageStyle.width;
3930
+ if (ownsHeight)
3931
+ delete imageStyle.height;
3932
+ }
3933
+ const inlineStyle = this.buildInlineStyle(imageStyle, isExpressionResultStyle);
3152
3934
  // Build image inline styles
3153
3935
  const imgStyles = [];
3154
- if (width)
3155
- imgStyles.push(`width:${width}`);
3156
- if (height)
3157
- imgStyles.push(`height:${height}`);
3936
+ if (ownsWidth)
3937
+ imgStyles.push(`width:${reservesWidth ? '100%' : 'auto'}`);
3938
+ if (ownsHeight)
3939
+ imgStyles.push(`height:${reservesHeight ? '100%' : 'auto'}`);
3940
+ if (reservesWidth)
3941
+ imgStyles.push('min-width:0');
3942
+ if (reservesHeight)
3943
+ imgStyles.push('min-height:0');
3158
3944
  if (objectFit)
3159
3945
  imgStyles.push(`object-fit:${objectFit}`);
3160
3946
  if (inlineStyle)
3161
3947
  imgStyles.push(inlineStyle);
3162
- this.setShadowHTML(`
3948
+ const markup = `
3163
3949
  <style>
3164
3950
  :host {
3165
3951
  display: inline-block;
@@ -3170,9 +3956,12 @@ class CardImage extends BaseElement {
3170
3956
  position: relative;
3171
3957
  display: inline-block;
3172
3958
  max-width: 100%;
3959
+ ${reservesWidth ? 'width: 100%;' : ''}
3960
+ ${reservesHeight ? 'height: 100%;' : ''}
3173
3961
  }
3174
3962
  .card-image {
3175
3963
  display: block;
3964
+ box-sizing: border-box;
3176
3965
  max-width: 100%;
3177
3966
  height: auto;
3178
3967
  transition: transform 0.2s ease, opacity 0.2s ease;
@@ -3193,8 +3982,8 @@ class CardImage extends BaseElement {
3193
3982
  100% { background-position: -200% 0; }
3194
3983
  }
3195
3984
  .card-image.error {
3196
- min-width: 100px;
3197
- min-height: 60px;
3985
+ min-width: ${reservesWidth ? '0' : '100px'};
3986
+ min-height: ${reservesHeight ? '0' : '60px'};
3198
3987
  background: #f5f5f5;
3199
3988
  display: flex;
3200
3989
  align-items: center;
@@ -3264,66 +4053,113 @@ class CardImage extends BaseElement {
3264
4053
  loading="lazy"
3265
4054
  />
3266
4055
  </div>
3267
- <div class="lightbox">
3268
- <button class="lightbox-close" aria-label="Close">&times;</button>
3269
- <img class="lightbox-image" src="${imgSrc}" alt="${imgAlt}" />
3270
- </div>
3271
- `);
4056
+ ${preview ? `
4057
+ <div class="lightbox">
4058
+ <button class="lightbox-close" aria-label="Close">&times;</button>
4059
+ <img class="lightbox-image" src="${imgSrc}" alt="${imgAlt}" />
4060
+ </div>
4061
+ ` : ''}
4062
+ `;
4063
+ const currentImage = this.shadowRoot.querySelector('.card-image');
4064
+ const keepError = currentImage?.classList.contains('error') === true
4065
+ && currentImage.getAttribute('src') === imgSrc;
4066
+ if (this._lightbox?.classList.contains('open')) {
4067
+ this.closeLightbox(this._lightbox);
4068
+ }
4069
+ if (!currentImage
4070
+ || !this.patchShadowHTMLPreservingElement(markup, currentImage, '.card-image')) {
4071
+ this.setShadowHTML(markup);
4072
+ }
4073
+ if (keepError)
4074
+ currentImage.classList.add('error');
3272
4075
  this.bindEvents(preview);
3273
4076
  }
4077
+ applyHostDimension(property, styleValue, propValue) {
4078
+ this.style.removeProperty(property);
4079
+ for (const value of [styleValue, propValue]) {
4080
+ const resolved = this.resolveDeclaredDimension(value);
4081
+ if (resolved == null)
4082
+ continue;
4083
+ this.style.setProperty(property, resolved);
4084
+ const applied = this.style.getPropertyValue(property);
4085
+ if (applied)
4086
+ return applied;
4087
+ }
4088
+ return undefined;
4089
+ }
4090
+ resolveDeclaredDimension(value) {
4091
+ if (typeof value === 'number' && Number.isFinite(value))
4092
+ return this.toCSS(value);
4093
+ if (typeof value === 'string' && value.trim() !== '')
4094
+ return this.toCSS(value.trim());
4095
+ return undefined;
4096
+ }
3274
4097
  bindEvents(preview) {
3275
4098
  if (!this.shadowRoot)
3276
4099
  return;
3277
4100
  const img = this.shadowRoot.querySelector('.card-image');
3278
4101
  const lightbox = this.shadowRoot.querySelector('.lightbox');
3279
4102
  const closeBtn = this.shadowRoot.querySelector('.lightbox-close');
3280
- if (!img || !lightbox)
4103
+ if (!img)
3281
4104
  return;
3282
4105
  // Handle image load/error states
3283
- img.addEventListener('load', () => {
3284
- img.classList.remove('loading');
3285
- });
3286
- img.addEventListener('error', () => {
3287
- img.classList.remove('loading');
3288
- img.classList.add('error');
3289
- });
3290
- if (!preview)
4106
+ if (!this._wiredImages.has(img)) {
4107
+ this._wiredImages.add(img);
4108
+ img.addEventListener('load', () => {
4109
+ img.classList.remove('loading');
4110
+ img.classList.remove('error');
4111
+ });
4112
+ img.addEventListener('error', () => {
4113
+ img.classList.remove('loading');
4114
+ img.classList.add('error');
4115
+ });
4116
+ img.addEventListener('click', (event) => {
4117
+ if (this._props.preview === false)
4118
+ return;
4119
+ const activeLightbox = this.shadowRoot?.querySelector('.lightbox');
4120
+ if (!activeLightbox)
4121
+ return;
4122
+ event.stopPropagation();
4123
+ this.openLightbox(activeLightbox);
4124
+ });
4125
+ }
4126
+ if (!preview || !lightbox || !closeBtn) {
4127
+ this._lightbox = null;
3291
4128
  return;
3292
- // Open lightbox on image click
3293
- img.addEventListener('click', (e) => {
3294
- e.stopPropagation();
3295
- this.openLightbox(lightbox);
3296
- });
4129
+ }
3297
4130
  // Close lightbox on overlay/close button click
3298
- lightbox.addEventListener('click', () => {
3299
- this.closeLightbox(lightbox);
3300
- });
3301
- closeBtn.addEventListener('click', (e) => {
3302
- e.stopPropagation();
3303
- this.closeLightbox(lightbox);
3304
- });
4131
+ if (!this._wiredLightboxes.has(lightbox)) {
4132
+ this._wiredLightboxes.add(lightbox);
4133
+ lightbox.addEventListener('click', () => {
4134
+ this.closeLightbox(lightbox);
4135
+ });
4136
+ }
4137
+ if (!this._wiredCloseButtons.has(closeBtn)) {
4138
+ this._wiredCloseButtons.add(closeBtn);
4139
+ closeBtn.addEventListener('click', (event) => {
4140
+ event.stopPropagation();
4141
+ this.closeLightbox(lightbox);
4142
+ });
4143
+ }
3305
4144
  // Close on Escape key
3306
4145
  this._lightbox = lightbox;
3307
4146
  }
3308
4147
  openLightbox(lightbox) {
4148
+ this._lightbox = lightbox;
3309
4149
  lightbox.classList.add('open');
3310
4150
  document.body.style.overflow = 'hidden';
3311
- // Add escape key listener
3312
- const handleEscape = (e) => {
3313
- if (e.key === 'Escape') {
3314
- this.closeLightbox(lightbox);
3315
- document.removeEventListener('keydown', handleEscape);
3316
- }
3317
- };
3318
- document.addEventListener('keydown', handleEscape);
4151
+ document.addEventListener('keydown', this._handleEscape);
3319
4152
  }
3320
4153
  closeLightbox(lightbox) {
3321
4154
  lightbox.classList.remove('open');
3322
4155
  document.body.style.overflow = '';
4156
+ document.removeEventListener('keydown', this._handleEscape);
3323
4157
  }
3324
4158
  disconnectedCallback() {
3325
4159
  // Ensure body overflow is restored when element is removed
3326
4160
  document.body.style.overflow = '';
4161
+ document.removeEventListener('keydown', this._handleEscape);
4162
+ this._lightbox = null;
3327
4163
  }
3328
4164
  }
3329
4165
  CardImage.is = 'ai-card-image';
@@ -3385,9 +4221,7 @@ class CardDivider extends BaseElement {
3385
4221
  <div class="divider-line"></div>
3386
4222
  ${displayText ? `<span class="divider-text">${displayText}</span><div class="divider-line"></div>` : ''}
3387
4223
  `);
3388
- if (inlineStyle) {
3389
- this.style.cssText += ';' + inlineStyle;
3390
- }
4224
+ this.style.cssText = inlineStyle;
3391
4225
  }
3392
4226
  }
3393
4227
  CardDivider.is = 'ai-card-divider';
@@ -5316,9 +6150,7 @@ class CardLoading extends BaseElement {
5316
6150
  <div class="spinner"></div>
5317
6151
  ${displayText ? `<span class="loading-text">${displayText}</span>` : ''}
5318
6152
  `);
5319
- if (inlineStyle) {
5320
- this.style.cssText += ';' + inlineStyle;
5321
- }
6153
+ this.style.cssText = inlineStyle;
5322
6154
  }
5323
6155
  }
5324
6156
  CardLoading.is = 'ai-card-loading';
@@ -7787,168 +8619,1033 @@ function resolveSizeInStyle(style) {
7787
8619
  return resolved;
7788
8620
  }
7789
8621
 
7790
- /**
7791
- * Render the scoped/materialized branch of a card.
7792
- *
7793
- * This implementation deliberately owns its state independently from the
7794
- * legacy static renderer. Bound writes happen in isolated drafts and publish
7795
- * only after materialization and detached DOM construction both succeed.
7796
- */
7797
- function renderBoundCard(container, schema, options) {
7798
- const variables = cloneJsonData({
7799
- ...schema.variables,
7800
- ...options.variables,
7801
- });
7802
- const schemaActions = schema.actions ?? {};
7803
- const lifecycleManager = createLifecycleManager();
7804
- const abortController = new AbortController();
7805
- const inflightRequests = new Map();
7806
- const activeLifecycleNodes = new Map();
7807
- let currentMaterialized;
7808
- let revision = 0;
7809
- let disposed = false;
7810
- const isMobile = options.isMobile === true;
7811
- let actionQueue = Promise.resolve();
7812
- let lifecycleQueue = Promise.resolve();
7813
- function expressionContextFor(node) {
7814
- return isBoundRenderTreeNode(node)
7815
- ? createExpressionContext(node.scope)
7816
- : variables;
7817
- }
7818
- function resolveNodeValue(value, node, renderVariables) {
7819
- if (node.bindingDialect === 'a2ui') {
7820
- return resolveA2UIDeep(value, renderVariables, node.dataPath);
7821
- }
7822
- if (typeof value === 'string' && hasExpression(value)) {
7823
- return resolveExpression(value, expressionContextFor(node));
7824
- }
7825
- return value;
7826
- }
7827
- function resolveNodeProps(node, renderVariables) {
7828
- const resolvedProps = (node.bindingDialect === 'a2ui'
7829
- ? resolveA2UIDeep(node.props, renderVariables, node.dataPath)
7830
- : resolveDeep(node.props, expressionContextFor(node)));
7831
- if (resolvedProps.content
7832
- && typeof resolvedProps.content === 'object'
7833
- && 'type' in resolvedProps.content) {
7834
- resolvedProps.content = resolveExpressionValue(resolvedProps.content, expressionContextFor(node));
7835
- }
7836
- return resolvedProps;
8622
+ const states = new WeakMap();
8623
+ function dispatchCurrent(element, state, domEvent, event) {
8624
+ const config = state.current;
8625
+ if (!config.isActive())
8626
+ return;
8627
+ if (domEvent === 'input'
8628
+ && config.variableKey
8629
+ && config.writeVariable
8630
+ && event.target === element) {
8631
+ const value = event instanceof CustomEvent
8632
+ && event.detail?.value !== undefined
8633
+ ? event.detail.value
8634
+ : event.target?.value;
8635
+ if (value !== undefined) {
8636
+ config.writeVariable(config.variableKey, value);
8637
+ }
8638
+ }
8639
+ for (const binding of config.events) {
8640
+ if (binding.domEvent !== domEvent)
8641
+ continue;
8642
+ if (binding.ownsValueEvent && event.target !== element)
8643
+ continue;
8644
+ binding.dispatch(event);
7837
8645
  }
7838
- function createPassiveActionContext(renderVariables) {
7839
- return {
7840
- ...createWebActionContext({
7841
- ...options,
7842
- abortSignal: abortController.signal,
7843
- }),
7844
- variables: renderVariables,
7845
- botId: options.botId,
7846
- inflightRequests,
8646
+ }
8647
+ function syncElementBindings(element, config) {
8648
+ let state = states.get(element);
8649
+ if (!state) {
8650
+ state = {
8651
+ current: config,
8652
+ listeners: new Map(),
7847
8653
  };
8654
+ states.set(element, state);
8655
+ }
8656
+ state.current = config;
8657
+ const desiredEvents = new Set(config.events.map(event => event.domEvent));
8658
+ if (config.variableKey)
8659
+ desiredEvents.add('input');
8660
+ for (const [name, listener] of state.listeners) {
8661
+ if (desiredEvents.has(name))
8662
+ continue;
8663
+ element.removeEventListener(name, listener);
8664
+ state.listeners.delete(name);
7848
8665
  }
7849
- function renderNode(node, renderVariables, lifecycleIds) {
7850
- if (node.directives?.visible) {
7851
- const resolved = resolveNodeValue(node.directives.visible, node, renderVariables);
7852
- if (resolved === false
7853
- || resolved === 'false'
7854
- || resolved === ''
7855
- || resolved === 0) {
7856
- const placeholder = document.createElement('div');
7857
- placeholder.style.display = 'none';
7858
- placeholder.setAttribute('data-card-id', node.id);
7859
- return placeholder;
8666
+ for (const name of desiredEvents) {
8667
+ if (state.listeners.has(name))
8668
+ continue;
8669
+ const listener = event => {
8670
+ const latest = states.get(element);
8671
+ if (latest) {
8672
+ dispatchCurrent(element, latest, name, event);
7860
8673
  }
8674
+ };
8675
+ element.addEventListener(name, listener);
8676
+ state.listeners.set(name, listener);
8677
+ }
8678
+ }
8679
+ function clearElementBindings(element) {
8680
+ const state = states.get(element);
8681
+ if (!state)
8682
+ return;
8683
+ let hasError = false;
8684
+ let firstError;
8685
+ for (const [name, listener] of state.listeners) {
8686
+ try {
8687
+ element.removeEventListener(name, listener);
7861
8688
  }
7862
- const resolvedProps = resolveNodeProps(node, renderVariables);
7863
- let isDisabled = false;
7864
- if (node.directives?.disabled) {
7865
- const resolved = resolveNodeValue(node.directives.disabled, node, renderVariables);
7866
- isDisabled = (resolved === true
7867
- || resolved === 'true'
7868
- || resolved === 1);
8689
+ catch (error) {
8690
+ if (!hasError)
8691
+ firstError = error;
8692
+ hasError = true;
7869
8693
  }
7870
- const renderer = (componentRenderers[node.type] ?? componentRenderers._default);
7871
- const el = renderer(node, resolvedProps, isMobile, options.responsive);
7872
- if (isDisabled) {
7873
- el.setAttribute('data-disabled', 'true');
7874
- el.style.background = '#F5F5F5';
7875
- el.style.color = '#C0C0C0';
7876
- el.style.setProperty('--card-disabled-color', '#C0C0C0');
7877
- el.style.pointerEvents = 'none';
7878
- el.style.cursor = 'default';
8694
+ }
8695
+ state.listeners.clear();
8696
+ states.delete(element);
8697
+ if (hasError)
8698
+ throw firstError;
8699
+ }
8700
+ function clearElementBindingsIn(root) {
8701
+ let hasError = false;
8702
+ let firstError;
8703
+ const clear = (element) => {
8704
+ try {
8705
+ clearElementBindings(element);
7879
8706
  }
7880
- const variableKey = resolvedProps.variableKey;
7881
- if (variableKey) {
7882
- el.addEventListener('input', ((event) => {
7883
- if (event.target !== el || disposed)
7884
- return;
7885
- const value = event.detail?.value
7886
- ?? event.target?.value;
7887
- if (value !== undefined) {
7888
- // Keep the existing focus-preserving, no-rerender behavior. Bumping
7889
- // the revision prevents an older async action from overwriting it.
7890
- variables[variableKey] = value;
7891
- revision += 1;
7892
- }
7893
- }));
8707
+ catch (error) {
8708
+ if (!hasError)
8709
+ firstError = error;
8710
+ hasError = true;
7894
8711
  }
7895
- if (node.events && !isDisabled) {
7896
- for (const [eventName, eventValue] of Object.entries(node.events)) {
7897
- if (!eventValue || !resolveActionRef(eventValue, schemaActions)) {
7898
- continue;
8712
+ };
8713
+ clear(root);
8714
+ root.querySelectorAll('*').forEach(clear);
8715
+ if (hasError)
8716
+ throw firstError;
8717
+ }
8718
+
8719
+ let unserializableFingerprint = 0;
8720
+ function nextUnserializableFingerprint() {
8721
+ unserializableFingerprint += 1;
8722
+ return `__unserializable__${unserializableFingerprint}`;
8723
+ }
8724
+ function serializeJsonLike(value, ancestors) {
8725
+ if (value === null)
8726
+ return 'null';
8727
+ switch (typeof value) {
8728
+ case 'string':
8729
+ return JSON.stringify(value);
8730
+ case 'boolean':
8731
+ return value ? 'true' : 'false';
8732
+ case 'number':
8733
+ if (!Number.isFinite(value))
8734
+ throw new TypeError('Non-finite number');
8735
+ return JSON.stringify(value);
8736
+ case 'object':
8737
+ break;
8738
+ default:
8739
+ throw new TypeError('Unsupported JSON value');
8740
+ }
8741
+ if (ancestors.has(value))
8742
+ throw new TypeError('Circular JSON value');
8743
+ ancestors.add(value);
8744
+ try {
8745
+ if (Array.isArray(value)) {
8746
+ if (Object.getPrototypeOf(value) !== Array.prototype) {
8747
+ throw new TypeError('Unsupported array prototype');
8748
+ }
8749
+ const ownKeys = Reflect.ownKeys(value);
8750
+ const expectedKeys = new Set(['length']);
8751
+ for (let index = 0; index < value.length; index += 1) {
8752
+ expectedKeys.add(String(index));
8753
+ }
8754
+ if (ownKeys.some(key => typeof key !== 'string' || !expectedKeys.has(key))
8755
+ || ownKeys.length !== expectedKeys.size) {
8756
+ throw new TypeError('Unsupported array shape');
8757
+ }
8758
+ const values = [];
8759
+ for (let index = 0; index < value.length; index += 1) {
8760
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
8761
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
8762
+ throw new TypeError('Unsupported array item');
7899
8763
  }
7900
- const domEvent = EVENT_MAP[eventName] ?? eventName;
7901
- el.addEventListener(domEvent, ((event) => {
7902
- const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
7903
- && VALUE_CONTROL_TYPES$1.has(node.type));
7904
- if (ownsValueEvent && event.target !== el)
7905
- return;
7906
- const eventDetail = (event instanceof CustomEvent && event.detail != null)
7907
- ? event.detail
7908
- : undefined;
7909
- enqueueBoundEvent(node.id, eventName, eventDetail);
7910
- }));
8764
+ values.push(serializeJsonLike(descriptor.value, ancestors));
7911
8765
  }
8766
+ return `[${values.join(',')}]`;
7912
8767
  }
7913
- if (node.lifecycle) {
7914
- lifecycleIds.add(node.id);
8768
+ const prototype = Object.getPrototypeOf(value);
8769
+ if (prototype !== Object.prototype && prototype !== null) {
8770
+ throw new TypeError('Unsupported object prototype');
7915
8771
  }
7916
- const renderChild = (child) => renderNode(child, renderVariables, lifecycleIds);
7917
- const childrenMap = {};
7918
- for (const child of node.children) {
7919
- childrenMap[child.id] = child;
8772
+ const ownKeys = Reflect.ownKeys(value);
8773
+ if (ownKeys.some(key => typeof key !== 'string')) {
8774
+ throw new TypeError('Unsupported symbol key');
7920
8775
  }
7921
- const layoutApplied = renderSlotLayout(el, node.children, resolvedProps, child => renderChild(child), childrenMap, createPassiveActionContext(renderVariables));
7922
- if (!layoutApplied) {
7923
- for (const child of node.children) {
7924
- el.appendChild(renderChild(child));
8776
+ const entries = ownKeys.sort().map(key => {
8777
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
8778
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
8779
+ throw new TypeError('Unsupported object property');
7925
8780
  }
7926
- }
7927
- if (isDisabled) {
7928
- el.querySelectorAll('*').forEach((child) => {
7929
- const htmlChild = child;
7930
- htmlChild.setAttribute('data-disabled', 'true');
7931
- htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
7932
- });
7933
- }
7934
- applyResponsiveStyles(el, createResponsiveContext(isMobile, options.responsive));
7935
- return el;
8781
+ return `${JSON.stringify(key)}:${serializeJsonLike(descriptor.value, ancestors)}`;
8782
+ });
8783
+ return `{${entries.join(',')}}`;
7936
8784
  }
7937
- function prepareCandidate(draft) {
7938
- const materialized = materializeCard(schema, draft);
7939
- const lifecycleIds = new Set();
7940
- const dom = renderNode(materialized.root, draft, lifecycleIds);
7941
- return { materialized, dom, lifecycleIds };
8785
+ finally {
8786
+ ancestors.delete(value);
7942
8787
  }
7943
- function indexNodes(root) {
7944
- const nodes = new Map();
7945
- const visit = (node) => {
7946
- nodes.set(node.id, node);
8788
+ }
8789
+ function stableFingerprint(value) {
8790
+ try {
8791
+ return serializeJsonLike(value, new Set());
8792
+ }
8793
+ catch {
8794
+ return nextUnserializableFingerprint();
8795
+ }
8796
+ }
8797
+ function disposeWithoutThrow(mounted, hooks) {
8798
+ try {
8799
+ hooks.dispose(mounted);
8800
+ }
8801
+ catch {
8802
+ // DOM ownership has already been decided; cleanup errors cannot change it.
8803
+ }
8804
+ }
8805
+ function reportUnreconciled(hooks, error) {
8806
+ try {
8807
+ hooks.onUnreconciled?.(error);
8808
+ }
8809
+ catch {
8810
+ // Reporting cannot change which DOM tree is live.
8811
+ }
8812
+ }
8813
+ function replaceIncrementalNode(current, next, hooks) {
8814
+ const parent = current.element.parentNode;
8815
+ if (!parent) {
8816
+ reportUnreconciled(hooks);
8817
+ return current;
8818
+ }
8819
+ let replacement;
8820
+ try {
8821
+ replacement = hooks.mountReplacement(next);
8822
+ }
8823
+ catch (error) {
8824
+ reportUnreconciled(hooks, error);
8825
+ return current;
8826
+ }
8827
+ if (replacement.element === current.element
8828
+ || replacement.element.parentNode !== null) {
8829
+ reportUnreconciled(hooks);
8830
+ return current;
8831
+ }
8832
+ let adopted = false;
8833
+ let adoptionError;
8834
+ try {
8835
+ parent.replaceChild(replacement.element, current.element);
8836
+ adopted = replacement.element.parentNode === parent
8837
+ && current.element.parentNode !== parent;
8838
+ }
8839
+ catch (error) {
8840
+ adoptionError = error;
8841
+ adopted = replacement.element.parentNode === parent
8842
+ && current.element.parentNode !== parent;
8843
+ }
8844
+ if (!adopted) {
8845
+ if (replacement.element.parentNode === null) {
8846
+ disposeWithoutThrow(replacement, hooks);
8847
+ }
8848
+ reportUnreconciled(hooks, adoptionError);
8849
+ return current;
8850
+ }
8851
+ disposeWithoutThrow(current, hooks);
8852
+ return replacement;
8853
+ }
8854
+ function reconcileIncrementalNode(current, next, hooks) {
8855
+ const parent = current.element.parentNode;
8856
+ const sameChildren = current.children.length === next.children.length
8857
+ && current.children.every((child, index) => (child.occurrenceKey === next.children[index].occurrenceKey));
8858
+ let decision = 'replace';
8859
+ if (sameChildren) {
8860
+ try {
8861
+ decision = hooks.decide(current, next);
8862
+ }
8863
+ catch {
8864
+ return replaceIncrementalNode(current, next, hooks);
8865
+ }
8866
+ if (current.element.parentNode !== parent) {
8867
+ reportUnreconciled(hooks);
8868
+ return current;
8869
+ }
8870
+ }
8871
+ if (decision === 'replace') {
8872
+ return replaceIncrementalNode(current, next, hooks);
8873
+ }
8874
+ if (decision === 'update') {
8875
+ try {
8876
+ hooks.update(current, next);
8877
+ }
8878
+ catch {
8879
+ return replaceIncrementalNode(current, next, hooks);
8880
+ }
8881
+ if (current.element.parentNode !== parent) {
8882
+ reportUnreconciled(hooks);
8883
+ return current;
8884
+ }
8885
+ }
8886
+ try {
8887
+ hooks.syncBindings(current, next);
8888
+ }
8889
+ catch {
8890
+ return replaceIncrementalNode(current, next, hooks);
8891
+ }
8892
+ if (current.element.parentNode !== parent) {
8893
+ reportUnreconciled(hooks);
8894
+ return current;
8895
+ }
8896
+ const children = current.children.map((child, index) => (reconcileIncrementalNode(child, next.children[index], hooks)));
8897
+ return { ...next, element: current.element, children };
8898
+ }
8899
+
8900
+ const CARD_NODE_SELECTOR = '[data-card-id][data-card-type]';
8901
+ const FOCUS_TARGET_SELECTOR = [
8902
+ 'input:not([disabled])',
8903
+ 'textarea:not([disabled])',
8904
+ 'select:not([disabled])',
8905
+ 'button:not([disabled])',
8906
+ '[contenteditable="true"]',
8907
+ '[tabindex]:not([tabindex="-1"])',
8908
+ ].join(',');
8909
+ /**
8910
+ * Add logical node access to one rendered card without depending on whether
8911
+ * its renderer retains or replaces DOM elements between updates.
8912
+ */
8913
+ function createCardNodeAccess(container) {
8914
+ const handles = new Map();
8915
+ const listeners = new Set();
8916
+ let focusedId = null;
8917
+ let focusSyncScheduled = false;
8918
+ let observing = false;
8919
+ let observationEpoch = 0;
8920
+ let observer = null;
8921
+ let disposed = false;
8922
+ const resolveElement = (id) => {
8923
+ if (disposed)
8924
+ return null;
8925
+ return Array.from(container.querySelectorAll(CARD_NODE_SELECTOR)).find(element => element.getAttribute('data-card-id') === id) ?? null;
8926
+ };
8927
+ const getNode = (id) => {
8928
+ const existing = handles.get(id);
8929
+ if (existing)
8930
+ return existing;
8931
+ const handle = {
8932
+ id,
8933
+ get type() {
8934
+ return resolveElement(id)?.getAttribute('data-card-type') ?? null;
8935
+ },
8936
+ get current() {
8937
+ return resolveElement(id);
8938
+ },
8939
+ getRect() {
8940
+ return resolveElement(id)?.getBoundingClientRect() ?? null;
8941
+ },
8942
+ focus(options) {
8943
+ const element = resolveElement(id);
8944
+ if (!element || element.hasAttribute('data-disabled'))
8945
+ return false;
8946
+ const target = element.shadowRoot
8947
+ ?.querySelector(FOCUS_TARGET_SELECTOR) ?? element;
8948
+ target.focus(options);
8949
+ return document.activeElement === element
8950
+ || element.shadowRoot?.activeElement != null;
8951
+ },
8952
+ blur() {
8953
+ const element = resolveElement(id);
8954
+ if (!element)
8955
+ return false;
8956
+ const active = element.shadowRoot?.activeElement;
8957
+ if (active instanceof HTMLElement) {
8958
+ active.blur();
8959
+ }
8960
+ else {
8961
+ element.blur();
8962
+ }
8963
+ return true;
8964
+ },
8965
+ scrollIntoView(options) {
8966
+ const element = resolveElement(id);
8967
+ if (!element || typeof element.scrollIntoView !== 'function') {
8968
+ return false;
8969
+ }
8970
+ element.scrollIntoView(options);
8971
+ return true;
8972
+ },
8973
+ };
8974
+ handles.set(id, handle);
8975
+ return handle;
8976
+ };
8977
+ const resolveFocusedId = () => {
8978
+ let active = document.activeElement;
8979
+ if (!active || !container.contains(active))
8980
+ return null;
8981
+ while (active) {
8982
+ if (active instanceof HTMLElement
8983
+ && active.matches(CARD_NODE_SELECTOR)) {
8984
+ return active.getAttribute('data-card-id');
8985
+ }
8986
+ active = active.shadowRoot?.activeElement ?? null;
8987
+ }
8988
+ return null;
8989
+ };
8990
+ const syncFocus = () => {
8991
+ if (disposed || !observing)
8992
+ return;
8993
+ const nextId = resolveFocusedId();
8994
+ if (nextId === focusedId)
8995
+ return;
8996
+ const previousNode = focusedId ? getNode(focusedId) : null;
8997
+ const node = nextId ? getNode(nextId) : null;
8998
+ focusedId = nextId;
8999
+ for (const listener of [...listeners]) {
9000
+ try {
9001
+ listener({ node, previousNode });
9002
+ }
9003
+ catch (error) {
9004
+ console.error('[CardInstance] focus-change listener failed', error);
9005
+ }
9006
+ }
9007
+ };
9008
+ const scheduleFocusSync = () => {
9009
+ if (disposed || !observing || focusSyncScheduled)
9010
+ return;
9011
+ focusSyncScheduled = true;
9012
+ const scheduledEpoch = observationEpoch;
9013
+ queueMicrotask(() => {
9014
+ if (scheduledEpoch !== observationEpoch)
9015
+ return;
9016
+ focusSyncScheduled = false;
9017
+ syncFocus();
9018
+ });
9019
+ };
9020
+ const startObserving = () => {
9021
+ if (disposed || observing)
9022
+ return;
9023
+ observing = true;
9024
+ observationEpoch += 1;
9025
+ focusedId = resolveFocusedId();
9026
+ container.addEventListener('focusin', scheduleFocusSync, true);
9027
+ container.addEventListener('focusout', scheduleFocusSync, true);
9028
+ if (typeof MutationObserver !== 'undefined') {
9029
+ observer ?? (observer = new MutationObserver(scheduleFocusSync));
9030
+ observer.observe(container, { childList: true, subtree: true });
9031
+ }
9032
+ };
9033
+ const stopObserving = () => {
9034
+ if (!observing)
9035
+ return;
9036
+ observing = false;
9037
+ observationEpoch += 1;
9038
+ focusSyncScheduled = false;
9039
+ const cleanupSteps = [
9040
+ () => container.removeEventListener('focusin', scheduleFocusSync, true),
9041
+ () => container.removeEventListener('focusout', scheduleFocusSync, true),
9042
+ () => observer?.disconnect(),
9043
+ ];
9044
+ for (const cleanup of cleanupSteps) {
9045
+ try {
9046
+ cleanup();
9047
+ }
9048
+ catch (error) {
9049
+ console.error('[CardInstance] Focus observation cleanup failed', error);
9050
+ }
9051
+ }
9052
+ focusedId = null;
9053
+ };
9054
+ return {
9055
+ getNode,
9056
+ onFocusChange(listener) {
9057
+ if (disposed)
9058
+ return () => { };
9059
+ listeners.add(listener);
9060
+ if (listeners.size === 1)
9061
+ startObserving();
9062
+ let subscribed = true;
9063
+ return () => {
9064
+ if (!subscribed)
9065
+ return;
9066
+ subscribed = false;
9067
+ listeners.delete(listener);
9068
+ if (listeners.size === 0)
9069
+ stopObserving();
9070
+ };
9071
+ },
9072
+ dispose() {
9073
+ if (disposed)
9074
+ return;
9075
+ stopObserving();
9076
+ disposed = true;
9077
+ listeners.clear();
9078
+ handles.clear();
9079
+ },
9080
+ };
9081
+ }
9082
+
9083
+ const FORCE_REPLACE = Symbol('bound-force-replace');
9084
+ /**
9085
+ * Render the scoped/materialized branch of a card.
9086
+ *
9087
+ * This implementation deliberately owns its state independently from the
9088
+ * legacy static renderer. Bound writes happen in isolated drafts and publish
9089
+ * only after materialization and detached DOM construction both succeed.
9090
+ */
9091
+ function renderBoundCard(container, schema, options) {
9092
+ const nodeAccess = createCardNodeAccess(container);
9093
+ const variables = cloneJsonData({
9094
+ ...schema.variables,
9095
+ ...options.variables,
9096
+ });
9097
+ const schemaActions = schema.actions ?? {};
9098
+ const lifecycleManager = createLifecycleManager();
9099
+ const abortController = new AbortController();
9100
+ const inflightRequests = new Map();
9101
+ const activeLifecycleNodes = new Map();
9102
+ let currentMaterialized;
9103
+ let currentMountedTree = null;
9104
+ let lastPublishedVariables = cloneJsonData(variables);
9105
+ let revision = 0;
9106
+ let disposed = false;
9107
+ let disposeRequested = false;
9108
+ let publishing = false;
9109
+ const isMobile = options.isMobile === true;
9110
+ let actionQueue = Promise.resolve();
9111
+ let queuedActionCount = 0;
9112
+ let lifecycleQueue = Promise.resolve();
9113
+ const repeatIdentityCountCache = new WeakMap();
9114
+ function expressionContextFor(node) {
9115
+ return isBoundRenderTreeNode(node)
9116
+ ? createExpressionContext(node.scope)
9117
+ : variables;
9118
+ }
9119
+ function resolveNodeValue(value, node, renderVariables) {
9120
+ if (node.bindingDialect === 'a2ui') {
9121
+ return resolveA2UIDeep(value, renderVariables, node.dataPath);
9122
+ }
9123
+ if (typeof value === 'string' && hasExpression(value)) {
9124
+ return resolveExpression(value, expressionContextFor(node));
9125
+ }
9126
+ return value;
9127
+ }
9128
+ function resolveNodeProps(node, renderVariables) {
9129
+ const resolvedProps = (node.bindingDialect === 'a2ui'
9130
+ ? resolveA2UIDeep(node.props, renderVariables, node.dataPath)
9131
+ : resolveDeep(node.props, expressionContextFor(node)));
9132
+ if (resolvedProps.content
9133
+ && typeof resolvedProps.content === 'object'
9134
+ && 'type' in resolvedProps.content) {
9135
+ resolvedProps.content = resolveExpressionValue(resolvedProps.content, expressionContextFor(node));
9136
+ }
9137
+ return resolvedProps;
9138
+ }
9139
+ function createPassiveActionContext(renderVariables) {
9140
+ return {
9141
+ ...createWebActionContext({
9142
+ ...options,
9143
+ abortSignal: abortController.signal,
9144
+ }),
9145
+ variables: renderVariables,
9146
+ botId: options.botId,
9147
+ inflightRequests,
9148
+ };
9149
+ }
9150
+ function childOccurrenceKey(parentKey, id, sameIdOrdinal) {
9151
+ return `${parentKey}/${encodeURIComponent(id)}#${sameIdOrdinal}`;
9152
+ }
9153
+ function isNodeDisabled(node, renderVariables) {
9154
+ if (!node.directives?.disabled)
9155
+ return false;
9156
+ const resolved = resolveNodeValue(node.directives.disabled, node, renderVariables);
9157
+ return resolved === true || resolved === 'true' || resolved === 1;
9158
+ }
9159
+ function nodeBindingFingerprint(node, resolvedProps, disabled) {
9160
+ const binding = { disabled };
9161
+ if (resolvedProps?.variableKey !== undefined) {
9162
+ binding.variableKey = resolvedProps.variableKey;
9163
+ }
9164
+ if (!disabled && node.events !== undefined)
9165
+ binding.events = node.events;
9166
+ return stableFingerprint(binding);
9167
+ }
9168
+ function resolveBoundMetadata(node, occurrenceKey, renderVariables) {
9169
+ const visible = isVisible(node, renderVariables);
9170
+ const rendererToken = componentRenderers[node.type]
9171
+ ?? componentRenderers._default;
9172
+ if (!visible) {
9173
+ return {
9174
+ occurrenceKey,
9175
+ node,
9176
+ nodeId: node.id,
9177
+ nodeType: node.type,
9178
+ rendererToken,
9179
+ visible: false,
9180
+ disabled: false,
9181
+ resolvedProps: null,
9182
+ propsFingerprint: stableFingerprint(null),
9183
+ layoutFingerprint: stableFingerprint(null),
9184
+ bindingFingerprint: stableFingerprint(null),
9185
+ };
9186
+ }
9187
+ const resolvedProps = resolveNodeProps(node, renderVariables);
9188
+ const disabled = isNodeDisabled(node, renderVariables);
9189
+ return {
9190
+ occurrenceKey,
9191
+ node,
9192
+ nodeId: node.id,
9193
+ nodeType: node.type,
9194
+ rendererToken,
9195
+ visible: true,
9196
+ disabled,
9197
+ resolvedProps,
9198
+ propsFingerprint: stableFingerprint(resolvedProps),
9199
+ layoutFingerprint: stableFingerprint(getSlotLayoutFingerprintInput(resolvedProps, node.children, child => resolveNodeProps(child, renderVariables))),
9200
+ bindingFingerprint: nodeBindingFingerprint(node, resolvedProps, disabled),
9201
+ };
9202
+ }
9203
+ function applyDisabledState(element, disabled) {
9204
+ if (!disabled)
9205
+ return;
9206
+ element.setAttribute('data-disabled', 'true');
9207
+ element.style.background = '#F5F5F5';
9208
+ element.style.color = '#C0C0C0';
9209
+ element.style.setProperty('--card-disabled-color', '#C0C0C0');
9210
+ element.style.pointerEvents = 'none';
9211
+ element.style.cursor = 'default';
9212
+ }
9213
+ function applyDisabledDescendants(element, disabled) {
9214
+ if (!disabled)
9215
+ return;
9216
+ element.querySelectorAll('*').forEach((child) => {
9217
+ const htmlChild = child;
9218
+ htmlChild.setAttribute('data-disabled', 'true');
9219
+ htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
9220
+ });
9221
+ }
9222
+ function disposeBoundElement(element) {
9223
+ const cleanupSteps = [
9224
+ () => clearSlotOwnerBehaviorsIn(element),
9225
+ () => clearSlotItemPresentationsIn(element),
9226
+ () => clearElementBindingsIn(element),
9227
+ () => disposeChartsIn(element),
9228
+ ];
9229
+ for (const cleanup of cleanupSteps) {
9230
+ try {
9231
+ cleanup();
9232
+ }
9233
+ catch (error) {
9234
+ console.error('[renderCard] Bound element cleanup failed', error);
9235
+ }
9236
+ }
9237
+ }
9238
+ function disposeUncoveredBoundBranches(current, coverageRoot) {
9239
+ for (const child of current.children) {
9240
+ if (coverageRoot.contains(child.element)) {
9241
+ disposeUncoveredBoundBranches(child, coverageRoot);
9242
+ }
9243
+ else {
9244
+ disposeBoundSubtree(child);
9245
+ }
9246
+ }
9247
+ }
9248
+ function disposeBoundSubtree(current) {
9249
+ disposeBoundElement(current.element);
9250
+ disposeUncoveredBoundBranches(current, current.element);
9251
+ }
9252
+ function disposeBoundBranchesOutsideContainer(current, disposedCoverage = null) {
9253
+ if (container.contains(current.element)) {
9254
+ for (const child of current.children) {
9255
+ disposeBoundBranchesOutsideContainer(child);
9256
+ }
9257
+ return;
9258
+ }
9259
+ let nextCoverage = disposedCoverage;
9260
+ if (!disposedCoverage?.contains(current.element)) {
9261
+ try {
9262
+ disposeBoundElement(current.element);
9263
+ nextCoverage = current.element;
9264
+ }
9265
+ catch {
9266
+ nextCoverage = null;
9267
+ }
9268
+ }
9269
+ for (const child of current.children) {
9270
+ disposeBoundBranchesOutsideContainer(child, nextCoverage);
9271
+ }
9272
+ }
9273
+ function disposeBoundSubtreeWithoutThrow(current) {
9274
+ try {
9275
+ disposeBoundSubtree(current);
9276
+ }
9277
+ catch {
9278
+ // Cleanup cannot change which candidate owns the live DOM.
9279
+ }
9280
+ }
9281
+ function mountBoundSubtree(node, occurrenceKey, renderVariables) {
9282
+ const metadata = resolveBoundMetadata(node, occurrenceKey, renderVariables);
9283
+ if (!metadata.visible) {
9284
+ const placeholder = document.createElement('div');
9285
+ placeholder.style.display = 'none';
9286
+ placeholder.setAttribute('data-card-id', node.id);
9287
+ return { ...metadata, element: placeholder, children: [] };
9288
+ }
9289
+ const renderer = metadata.rendererToken;
9290
+ const element = renderer(node, metadata.resolvedProps, isMobile, options.responsive);
9291
+ const children = [];
9292
+ try {
9293
+ applyDisabledState(element, metadata.disabled);
9294
+ const sameIdOrdinals = new Map();
9295
+ const renderChild = (child) => {
9296
+ const sameIdOrdinal = sameIdOrdinals.get(child.id) ?? 0;
9297
+ sameIdOrdinals.set(child.id, sameIdOrdinal + 1);
9298
+ const mounted = mountBoundSubtree(child, childOccurrenceKey(occurrenceKey, child.id, sameIdOrdinal), renderVariables);
9299
+ children.push(mounted);
9300
+ return mounted.element;
9301
+ };
9302
+ const childrenMap = {};
9303
+ for (const child of node.children)
9304
+ childrenMap[child.id] = child;
9305
+ const layoutApplied = renderSlotLayout(element, node.children, metadata.resolvedProps, child => renderChild(child), childrenMap, createPassiveActionContext(renderVariables), child => resolveNodeProps(child, renderVariables));
9306
+ if (!layoutApplied) {
9307
+ for (const child of node.children) {
9308
+ element.appendChild(renderChild(child));
9309
+ }
9310
+ }
9311
+ applyDisabledDescendants(element, metadata.disabled);
9312
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
9313
+ return { ...metadata, element, children };
9314
+ }
9315
+ catch (error) {
9316
+ disposeBoundSubtreeWithoutThrow({ ...metadata, element, children });
9317
+ throw error;
9318
+ }
9319
+ }
9320
+ function syncBoundBindings(element, node) {
9321
+ const events = [];
9322
+ if (node.visible && !node.disabled && node.node.events) {
9323
+ const repeatTargetFingerprint = getRepeatTargetFingerprint(node.node);
9324
+ for (const [schemaEvent, eventValue] of Object.entries(node.node.events)) {
9325
+ if (!eventValue || !resolveActionRef(eventValue, schemaActions)) {
9326
+ continue;
9327
+ }
9328
+ const domEvent = EVENT_MAP[schemaEvent] ?? schemaEvent;
9329
+ events.push({
9330
+ schemaEvent,
9331
+ domEvent,
9332
+ ownsValueEvent: ((domEvent === 'input' || domEvent === 'change')
9333
+ && VALUE_CONTROL_TYPES$1.has(node.nodeType)),
9334
+ dispatch: (event) => {
9335
+ const detail = event instanceof CustomEvent && event.detail != null
9336
+ ? event.detail
9337
+ : undefined;
9338
+ enqueueBoundEvent(node.nodeId, schemaEvent, detail, repeatTargetFingerprint);
9339
+ },
9340
+ });
9341
+ }
9342
+ }
9343
+ const variableKey = node.resolvedProps?.variableKey;
9344
+ syncElementBindings(element, {
9345
+ variableKey: typeof variableKey === 'string' ? variableKey : undefined,
9346
+ writeVariable: (key, value) => {
9347
+ if (disposed || publishing || !container.contains(element))
9348
+ return;
9349
+ variables[key] = value;
9350
+ revision += 1;
9351
+ },
9352
+ events,
9353
+ isActive: () => (!disposed
9354
+ && !publishing
9355
+ && container.contains(element)),
9356
+ });
9357
+ }
9358
+ function syncMountedBindings(current) {
9359
+ syncBoundBindings(current.element, current);
9360
+ for (const child of current.children)
9361
+ syncMountedBindings(child);
9362
+ }
9363
+ function collectMountedOccurrences(current, occurrences) {
9364
+ occurrences.set(current.occurrenceKey, current);
9365
+ for (const child of current.children) {
9366
+ collectMountedOccurrences(child, occurrences);
9367
+ }
9368
+ }
9369
+ function prepareBoundNode(node, occurrenceKey, renderVariables, forcedOwnerIds, current) {
9370
+ const metadata = resolveBoundMetadata(node, occurrenceKey, renderVariables);
9371
+ const forceReplace = forcedOwnerIds.has(node.id);
9372
+ if (!metadata.visible || forceReplace) {
9373
+ return {
9374
+ ...metadata,
9375
+ ...(forceReplace ? { [FORCE_REPLACE]: true } : {}),
9376
+ children: [],
9377
+ };
9378
+ }
9379
+ if (!current)
9380
+ return { ...metadata, children: [] };
9381
+ const nextChildrenById = new Map();
9382
+ for (const child of node.children) {
9383
+ const matching = nextChildrenById.get(child.id) ?? [];
9384
+ matching.push(child);
9385
+ nextChildrenById.set(child.id, matching);
9386
+ }
9387
+ const nextChildOrdinals = new Map();
9388
+ const children = [];
9389
+ for (const currentChild of current.children) {
9390
+ const matching = nextChildrenById.get(currentChild.nodeId);
9391
+ if (!matching?.length) {
9392
+ return { ...metadata, [FORCE_REPLACE]: true, children: [] };
9393
+ }
9394
+ const ordinal = nextChildOrdinals.get(currentChild.nodeId) ?? 0;
9395
+ nextChildOrdinals.set(currentChild.nodeId, ordinal + 1);
9396
+ const child = matching[Math.min(ordinal, matching.length - 1)];
9397
+ children.push(prepareBoundNode(child, currentChild.occurrenceKey, renderVariables, forcedOwnerIds, currentChild));
9398
+ }
9399
+ return {
9400
+ ...metadata,
9401
+ children,
9402
+ };
9403
+ }
9404
+ function decideBoundUpdate(current, next) {
9405
+ if (next[FORCE_REPLACE]
9406
+ || current.occurrenceKey !== next.occurrenceKey
9407
+ || current.nodeId !== next.nodeId
9408
+ || current.nodeType !== next.nodeType
9409
+ || current.rendererToken !== next.rendererToken
9410
+ || current.visible !== next.visible
9411
+ || current.disabled !== next.disabled
9412
+ || current.layoutFingerprint !== next.layoutFingerprint) {
9413
+ return 'replace';
9414
+ }
9415
+ if (current.propsFingerprint === next.propsFingerprint)
9416
+ return 'retain';
9417
+ return (current.element instanceof BaseElement
9418
+ || current.rendererToken === componentRenderers._default) ? 'update' : 'replace';
9419
+ }
9420
+ function updateBoundNode(current, next) {
9421
+ const element = current.element;
9422
+ if (element instanceof BaseElement) {
9423
+ updateSlotItemShellPresentation(element, () => {
9424
+ element.updateProps(next.resolvedProps, isMobile, options.responsive);
9425
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
9426
+ }, getSlotHostPresentationOwnership(next.resolvedProps));
9427
+ return;
9428
+ }
9429
+ if (current.rendererToken !== componentRenderers._default) {
9430
+ throw new Error('[renderCard] Bound node cannot update in place');
9431
+ }
9432
+ const previous = componentRenderers._default(current.node, current.resolvedProps, isMobile, options.responsive);
9433
+ const desired = componentRenderers._default(next.node, next.resolvedProps, isMobile, options.responsive);
9434
+ applyDisabledState(previous, current.disabled);
9435
+ applyDisabledState(desired, next.disabled);
9436
+ const hostOwnership = getSlotHostPresentationOwnership(next.resolvedProps);
9437
+ updateSlotItemShellPresentation(element, () => {
9438
+ patchElementShellPresentation(element, previous, desired, hostOwnership, getSlotItemPresentationOwnership(element));
9439
+ }, hostOwnership);
9440
+ }
9441
+ function collectChangedPointers(before, after, path = '') {
9442
+ if (Object.is(before, after))
9443
+ return [];
9444
+ if (before === null
9445
+ || after === null
9446
+ || typeof before !== 'object'
9447
+ || typeof after !== 'object') {
9448
+ return [path];
9449
+ }
9450
+ if (Array.isArray(before) || Array.isArray(after)) {
9451
+ if (!Array.isArray(before) || !Array.isArray(after))
9452
+ return [path];
9453
+ if (before.length !== after.length)
9454
+ return [path];
9455
+ }
9456
+ const left = before;
9457
+ const right = after;
9458
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
9459
+ const changed = [];
9460
+ for (const key of keys) {
9461
+ const token = key.replace(/~/g, '~0').replace(/\//g, '~1');
9462
+ changed.push(...collectChangedPointers(left[key], right[key], `${path}/${token}`));
9463
+ }
9464
+ return changed;
9465
+ }
9466
+ function selectForcedRepeatOwnerIds(before, draft) {
9467
+ if (currentMaterialized.unresolvedRepeatOwners.size > 0) {
9468
+ return new Set([currentMaterialized.root.id]);
9469
+ }
9470
+ const selectedKeys = new Set();
9471
+ for (const pointer of collectChangedPointers(before, draft)) {
9472
+ for (const owner of findAffectedRepeatOwners(currentMaterialized, pointer)) {
9473
+ selectedKeys.add(owner.key);
9474
+ }
9475
+ }
9476
+ for (const key of [...selectedKeys]) {
9477
+ let parentKey = currentMaterialized.repeatOwners.get(key)?.parentKey;
9478
+ while (parentKey) {
9479
+ if (selectedKeys.has(parentKey)) {
9480
+ selectedKeys.delete(key);
9481
+ break;
9482
+ }
9483
+ parentKey = currentMaterialized.repeatOwners.get(parentKey)?.parentKey;
9484
+ }
9485
+ }
9486
+ return new Set([...selectedKeys].flatMap((key) => {
9487
+ const id = currentMaterialized.repeatOwners.get(key)?.runtimeOwnerId;
9488
+ return id ? [id] : [];
9489
+ }));
9490
+ }
9491
+ function collectMountedLifecycleIds(root) {
9492
+ const ids = new Set();
9493
+ const visit = (node) => {
9494
+ if (!node.visible)
9495
+ return;
9496
+ if (node.node.lifecycle)
9497
+ ids.add(node.nodeId);
9498
+ for (const child of node.children)
9499
+ visit(child);
9500
+ };
9501
+ if (root)
9502
+ visit(root);
9503
+ return ids;
9504
+ }
9505
+ function prepareReplacementRoots(current, next, draft, replacements) {
9506
+ const sameChildren = current.children.length === next.children.length
9507
+ && current.children.every((child, index) => (child.occurrenceKey === next.children[index].occurrenceKey));
9508
+ const decision = sameChildren
9509
+ ? decideBoundUpdate(current, next)
9510
+ : 'replace';
9511
+ if (decision === 'replace'
9512
+ || (decision === 'update' && next.children.length === 0)) {
9513
+ replacements.set(next.occurrenceKey, mountBoundSubtree(next.node, next.occurrenceKey, draft));
9514
+ }
9515
+ if (decision === 'replace')
9516
+ return;
9517
+ for (let index = 0; index < next.children.length; index += 1) {
9518
+ prepareReplacementRoots(current.children[index], next.children[index], draft, replacements);
9519
+ }
9520
+ }
9521
+ function prepareIncrementalCandidate(before, draft) {
9522
+ const materialized = materializeCard(schema, draft);
9523
+ const forcedOwnerIds = currentMountedTree
9524
+ ? materialized.unresolvedRepeatOwners.size > 0
9525
+ ? new Set([materialized.root.id])
9526
+ : selectForcedRepeatOwnerIds(before, draft)
9527
+ : new Set();
9528
+ const preparedRoot = prepareBoundNode(materialized.root, `${materialized.root.id}#0`, draft, forcedOwnerIds, currentMountedTree);
9529
+ const replacementRoots = new Map();
9530
+ try {
9531
+ if (currentMountedTree) {
9532
+ prepareReplacementRoots(currentMountedTree, preparedRoot, draft, replacementRoots);
9533
+ }
9534
+ else {
9535
+ replacementRoots.set(preparedRoot.occurrenceKey, mountBoundSubtree(materialized.root, preparedRoot.occurrenceKey, draft));
9536
+ }
9537
+ return {
9538
+ materialized,
9539
+ preparedRoot,
9540
+ replacementRoots,
9541
+ autoFocusIds: currentMountedTree
9542
+ ? collectAutoFocusRevealIds(currentMaterialized, materialized, draft)
9543
+ : new Set(),
9544
+ };
9545
+ }
9546
+ catch (error) {
9547
+ for (const replacement of replacementRoots.values()) {
9548
+ disposeBoundSubtreeWithoutThrow(replacement);
9549
+ }
9550
+ throw error;
9551
+ }
9552
+ }
9553
+ function indexNodes(root) {
9554
+ const nodes = new Map();
9555
+ const visit = (node) => {
9556
+ nodes.set(node.id, node);
7947
9557
  node.children.forEach(visit);
7948
9558
  };
7949
9559
  visit(root);
7950
9560
  return nodes;
7951
9561
  }
9562
+ function getRepeatTargetFingerprint(node) {
9563
+ const identityToken = (key, value) => {
9564
+ if (value === null)
9565
+ return `${key}:null`;
9566
+ if (typeof value === 'string')
9567
+ return `${key}:string:${value}`;
9568
+ if (typeof value === 'boolean')
9569
+ return `${key}:boolean:${value}`;
9570
+ return `${key}:number:${Object.is(value, -0) ? '-0' : String(value)}`;
9571
+ };
9572
+ const identityCountsFor = (root) => {
9573
+ const cached = repeatIdentityCountCache.get(root);
9574
+ if (cached)
9575
+ return cached;
9576
+ const counts = new Map();
9577
+ const visited = new Set();
9578
+ const visit = (candidate) => {
9579
+ if (candidate === null || typeof candidate !== 'object')
9580
+ return;
9581
+ if (visited.has(candidate))
9582
+ return;
9583
+ visited.add(candidate);
9584
+ if (!Array.isArray(candidate)) {
9585
+ for (const key of ['id', 'key']) {
9586
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
9587
+ if (!descriptor || !('value' in descriptor))
9588
+ continue;
9589
+ const identity = descriptor.value;
9590
+ if (identity === null
9591
+ || (typeof identity !== 'string'
9592
+ && typeof identity !== 'number'
9593
+ && typeof identity !== 'boolean')) {
9594
+ continue;
9595
+ }
9596
+ const token = identityToken(key, identity);
9597
+ counts.set(token, (counts.get(token) ?? 0) + 1);
9598
+ }
9599
+ }
9600
+ for (const nested of Object.values(candidate))
9601
+ visit(nested);
9602
+ };
9603
+ visit(root);
9604
+ repeatIdentityCountCache.set(root, counts);
9605
+ return counts;
9606
+ };
9607
+ const identityFor = (value, root) => {
9608
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
9609
+ return value;
9610
+ }
9611
+ const record = value;
9612
+ for (const key of ['id', 'key']) {
9613
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
9614
+ if (!descriptor || !('value' in descriptor))
9615
+ continue;
9616
+ const identity = descriptor.value;
9617
+ if (identity === null
9618
+ || (typeof identity !== 'string'
9619
+ && typeof identity !== 'number'
9620
+ && typeof identity !== 'boolean')) {
9621
+ continue;
9622
+ }
9623
+ const matches = identityCountsFor(root).get(identityToken(key, identity)) ?? 0;
9624
+ if (matches === 1)
9625
+ return { [key]: identity };
9626
+ }
9627
+ return value;
9628
+ };
9629
+ const targets = [];
9630
+ let scope = node.scope;
9631
+ while (scope) {
9632
+ const scopeRoot = scope.root;
9633
+ const localEntries = Object.entries(scope.locals);
9634
+ if (localEntries.length > 0) {
9635
+ targets.push(Object.fromEntries(localEntries.map(([key, value]) => [
9636
+ key,
9637
+ identityFor(value, scopeRoot),
9638
+ ])));
9639
+ }
9640
+ else if (scope.bindingDialect === 'a2ui' && scope.dataPath !== '') {
9641
+ targets.push(identityFor(resolveA2UIDeep({ path: '' }, scopeRoot, scope.dataPath), scopeRoot));
9642
+ }
9643
+ scope = scope.parent;
9644
+ }
9645
+ if (targets.length === 0)
9646
+ return undefined;
9647
+ return stableFingerprint({ sourceId: node.sourceId, targets });
9648
+ }
7952
9649
  function selectLifecycleNodes(materialized, ids) {
7953
9650
  const indexed = indexNodes(materialized.root);
7954
9651
  const selected = new Map();
@@ -7959,26 +9656,80 @@ function renderBoundCard(container, schema, options) {
7959
9656
  }
7960
9657
  return selected;
7961
9658
  }
7962
- function createLifecycleActionContext(node) {
9659
+ function isVisible(node, renderVariables) {
9660
+ if (!node.directives?.visible)
9661
+ return true;
9662
+ const resolved = resolveNodeValue(node.directives.visible, node, renderVariables);
9663
+ return !(resolved === false
9664
+ || resolved === 'false'
9665
+ || resolved === ''
9666
+ || resolved === 0);
9667
+ }
9668
+ function collectAutoFocusRevealIds(previous, next, draft) {
9669
+ const previousNodes = indexNodes(previous.root);
9670
+ const requested = new Set();
9671
+ const visit = (nextNode, previousParentVisible, nextParentVisible) => {
9672
+ const previousNode = previousNodes.get(nextNode.id);
9673
+ const wasVisible = Boolean(previousNode
9674
+ && previousParentVisible
9675
+ && isVisible(previousNode, variables));
9676
+ const nowVisible = nextParentVisible && isVisible(nextNode, draft);
9677
+ if (nextNode.type === 'Input'
9678
+ && !wasVisible
9679
+ && nowVisible
9680
+ && resolveNodeProps(nextNode, draft).autoFocus === true) {
9681
+ requested.add(nextNode.id);
9682
+ }
9683
+ nextNode.children.forEach(child => visit(child, wasVisible, nowVisible));
9684
+ };
9685
+ visit(next.root, true, true);
9686
+ return requested;
9687
+ }
9688
+ function applyAutoFocus(candidate) {
9689
+ if (candidate.autoFocusIds.size === 0)
9690
+ return;
9691
+ const inputs = container.querySelectorAll(CardInput.is);
9692
+ for (const input of inputs) {
9693
+ const id = input.getAttribute('data-card-id');
9694
+ if (id && candidate.autoFocusIds.has(id) && input.requestAutoFocus()) {
9695
+ break;
9696
+ }
9697
+ }
9698
+ }
9699
+ function createLifecycleActionSession(node) {
9700
+ const lifecycleVariables = cloneJsonData(variables);
9701
+ const rebaseScope = (scope) => ({
9702
+ ...scope,
9703
+ root: lifecycleVariables,
9704
+ parent: scope.parent ? rebaseScope(scope.parent) : undefined,
9705
+ });
9706
+ const lifecycleScope = rebaseScope(node.scope);
9707
+ let authorityRevision = revision;
7963
9708
  const writeLiveVariable = (key, value, silent = false) => {
7964
- if (disposed)
9709
+ const nextValue = cloneJsonData(value);
9710
+ writeDraftVariable(lifecycleVariables, key, nextValue);
9711
+ repeatIdentityCountCache.delete(lifecycleVariables);
9712
+ if (disposed || authorityRevision !== revision)
7965
9713
  return;
7966
9714
  if (silent) {
7967
- writeDraftVariable(variables, key, cloneJsonData(value));
9715
+ writeDraftVariable(variables, key, nextValue);
7968
9716
  return;
7969
9717
  }
7970
- updateVariables({ [key]: value });
9718
+ // Publish the complete lifecycle draft so direct additions, changes,
9719
+ // and deletions join this explicit write in one authoritative render.
9720
+ commitDraft(cloneJsonData(lastPublishedVariables), lifecycleVariables, authorityRevision);
9721
+ authorityRevision = revision;
7971
9722
  };
7972
- return {
9723
+ const context = {
7973
9724
  ...createWebActionContext({
7974
9725
  ...options,
7975
9726
  setVariable: writeLiveVariable,
7976
9727
  abortSignal: abortController.signal,
7977
9728
  }),
7978
- variables,
7979
- expressionContext: createExpressionContext(node.scope),
9729
+ variables: lifecycleVariables,
9730
+ expressionContext: createExpressionContext(lifecycleScope),
7980
9731
  parameterResolver: node.bindingDialect === 'a2ui'
7981
- ? createA2UIParameterResolver(variables, node.dataPath)
9732
+ ? createA2UIParameterResolver(lifecycleVariables, node.dataPath)
7982
9733
  : undefined,
7983
9734
  variableWriter: (key, value, options) => {
7984
9735
  writeLiveVariable(key, value, options.silent);
@@ -7986,6 +9737,20 @@ function renderBoundCard(container, schema, options) {
7986
9737
  botId: options.botId,
7987
9738
  inflightRequests,
7988
9739
  };
9740
+ return {
9741
+ context,
9742
+ publishDirectMutations() {
9743
+ if (disposed
9744
+ || authorityRevision !== revision
9745
+ || !hasVariableChanges(variables, lifecycleVariables)) {
9746
+ return;
9747
+ }
9748
+ // Direct custom-handler mutations historically updated the live
9749
+ // variables without rendering. Preserve that behavior while keeping
9750
+ // a stale async lifecycle detached after a newer revision wins.
9751
+ replaceRootContents(variables, lifecycleVariables);
9752
+ },
9753
+ };
7989
9754
  }
7990
9755
  function reconcileLifecycles(nextNodes) {
7991
9756
  const removed = [...activeLifecycleNodes.entries()]
@@ -7998,45 +9763,263 @@ function renderBoundCard(container, schema, options) {
7998
9763
  }
7999
9764
  lifecycleQueue = lifecycleQueue.then(async () => {
8000
9765
  for (const [id, node] of removed) {
8001
- await lifecycleManager.destroy(id, createLifecycleActionContext(node));
8002
- lifecycleManager.unregister(id);
9766
+ if (disposed)
9767
+ return;
9768
+ const session = createLifecycleActionSession(node);
9769
+ try {
9770
+ await lifecycleManager.destroy(id, session.context);
9771
+ session.publishDirectMutations();
9772
+ }
9773
+ catch (error) {
9774
+ console.error('[renderCard] Bound lifecycle action failed', error);
9775
+ }
9776
+ finally {
9777
+ lifecycleManager.unregister(id);
9778
+ }
8003
9779
  }
8004
- if (disposed)
8005
- return;
8006
9780
  for (const [id, node] of added) {
9781
+ if (disposed)
9782
+ return;
8007
9783
  lifecycleManager.register(id, node.lifecycle);
8008
- await lifecycleManager.mount(id, createLifecycleActionContext(node));
9784
+ const session = createLifecycleActionSession(node);
9785
+ try {
9786
+ await lifecycleManager.mount(id, session.context);
9787
+ session.publishDirectMutations();
9788
+ }
9789
+ catch (error) {
9790
+ lifecycleManager.unregister(id);
9791
+ console.error('[renderCard] Bound lifecycle action failed', error);
9792
+ }
8009
9793
  }
8010
9794
  }).catch((error) => {
8011
9795
  console.error('[renderCard] Bound lifecycle action failed', error);
8012
9796
  });
8013
9797
  }
8014
- function publishDOM(candidate, incrementRevision) {
8015
- const scrollPositions = captureScrollPositions$1(container);
8016
- const mediaStates = captureMediaStates$1(container);
8017
- disposeChartsIn(container);
8018
- container.replaceChildren(candidate.dom);
8019
- currentMaterialized = candidate.materialized;
8020
- if (incrementRevision)
8021
- revision += 1;
8022
- reconcileLifecycles(selectLifecycleNodes(currentMaterialized, candidate.lifecycleIds));
8023
- restoreScrollPositions$1(container, scrollPositions);
8024
- restoreMediaStates$1(container, mediaStates);
9798
+ function disposeCandidate(candidate) {
9799
+ for (const replacement of candidate.replacementRoots.values()) {
9800
+ disposeBoundSubtreeWithoutThrow(replacement);
9801
+ }
9802
+ candidate.replacementRoots.clear();
9803
+ }
9804
+ function restoreChangedSubtreeState(previous, next, scrollPositions, mediaStates) {
9805
+ if (previous.element !== next.element) {
9806
+ restoreScrollPositions$1(next.element, scrollPositions);
9807
+ restoreMediaStates$1(next.element, mediaStates);
9808
+ return;
9809
+ }
9810
+ if (previous.propsFingerprint !== next.propsFingerprint
9811
+ && (next.nodeType === 'Audio' || next.nodeType === 'Video')) {
9812
+ restoreMediaStates$1(next.element, mediaStates);
9813
+ }
9814
+ for (let index = 0; index < next.children.length; index += 1) {
9815
+ const previousChild = previous.children[index];
9816
+ const nextChild = next.children[index];
9817
+ if (previousChild
9818
+ && previousChild.occurrenceKey === nextChild.occurrenceKey) {
9819
+ restoreChangedSubtreeState(previousChild, nextChild, scrollPositions, mediaStates);
9820
+ }
9821
+ }
9822
+ }
9823
+ function restoreDisabledDescendantPresentation(current) {
9824
+ if (current.disabled) {
9825
+ applyDisabledState(current.element, true);
9826
+ applyDisabledDescendants(current.element, true);
9827
+ }
9828
+ for (const child of current.children) {
9829
+ restoreDisabledDescendantPresentation(child);
9830
+ }
9831
+ }
9832
+ function replaceContainerRoot(element, failureMessage) {
9833
+ let adoptionThrew = false;
9834
+ let adoptionError;
9835
+ try {
9836
+ container.replaceChildren(element);
9837
+ }
9838
+ catch (error) {
9839
+ adoptionThrew = true;
9840
+ adoptionError = error;
9841
+ }
9842
+ const adopted = (element.parentNode === container
9843
+ && container.firstChild === element
9844
+ && container.childNodes.length === 1);
9845
+ if (adopted)
9846
+ return;
9847
+ if (adoptionThrew)
9848
+ throw adoptionError;
9849
+ throw new Error(failureMessage);
9850
+ }
9851
+ function commitIncrementalCandidate(candidate, renderVariables) {
9852
+ const replacements = candidate.replacementRoots;
9853
+ try {
9854
+ if (!currentMountedTree) {
9855
+ const initial = replacements.get(candidate.preparedRoot.occurrenceKey);
9856
+ if (!initial) {
9857
+ throw new Error('[renderCard] Missing bound root candidate');
9858
+ }
9859
+ syncMountedBindings(initial);
9860
+ disposeChartsIn(container);
9861
+ replaceContainerRoot(initial.element, '[renderCard] Bound root candidate was not adopted');
9862
+ replacements.delete(candidate.preparedRoot.occurrenceKey);
9863
+ restoreDisabledDescendantPresentation(initial);
9864
+ return initial;
9865
+ }
9866
+ const previous = currentMountedTree;
9867
+ const replacementSources = new Map();
9868
+ collectMountedOccurrences(previous, replacementSources);
9869
+ const scrollPositions = captureScrollPositions$1(container);
9870
+ const mediaStates = captureMediaStates$1(container);
9871
+ let unreconciled = false;
9872
+ const next = reconcileIncrementalNode(previous, candidate.preparedRoot, {
9873
+ decide: (current, prepared) => decideBoundUpdate(current, prepared),
9874
+ update: (current, prepared) => updateBoundNode(current, prepared),
9875
+ mountReplacement: (prepared) => {
9876
+ const candidateReplacement = replacements.get(prepared.occurrenceKey);
9877
+ const replacement = candidateReplacement ?? mountBoundSubtree(prepared.node, prepared.occurrenceKey, renderVariables);
9878
+ const current = replacementSources.get(prepared.occurrenceKey);
9879
+ let staged = false;
9880
+ try {
9881
+ if (replacement.element.parentNode !== null) {
9882
+ throw new Error(`[renderCard] Bound replacement "${prepared.occurrenceKey}" is not detached`);
9883
+ }
9884
+ syncMountedBindings(replacement);
9885
+ if (current && replacement.element !== current.element) {
9886
+ transferSlotItemPresentation(current.element, replacement.element);
9887
+ }
9888
+ staged = true;
9889
+ }
9890
+ finally {
9891
+ if (!staged && !candidateReplacement) {
9892
+ disposeBoundSubtreeWithoutThrow(replacement);
9893
+ }
9894
+ }
9895
+ if (candidateReplacement) {
9896
+ replacements.delete(prepared.occurrenceKey);
9897
+ }
9898
+ return replacement;
9899
+ },
9900
+ dispose: disposeBoundSubtree,
9901
+ syncBindings: () => { },
9902
+ onUnreconciled: () => {
9903
+ unreconciled = true;
9904
+ },
9905
+ });
9906
+ if (!unreconciled
9907
+ && (next.element.parentNode !== container
9908
+ || container.firstElementChild !== next.element)) {
9909
+ unreconciled = true;
9910
+ }
9911
+ if (!unreconciled) {
9912
+ try {
9913
+ syncMountedBindings(next);
9914
+ }
9915
+ catch {
9916
+ unreconciled = true;
9917
+ }
9918
+ }
9919
+ if (unreconciled) {
9920
+ let emergency;
9921
+ try {
9922
+ emergency = mountBoundSubtree(candidate.materialized.root, candidate.preparedRoot.occurrenceKey, renderVariables);
9923
+ }
9924
+ catch (error) {
9925
+ disposeBoundBranchesOutsideContainer(next);
9926
+ throw error;
9927
+ }
9928
+ let adopted = false;
9929
+ try {
9930
+ syncMountedBindings(emergency);
9931
+ replaceContainerRoot(emergency.element, '[renderCard] Bound emergency root was not adopted');
9932
+ adopted = true;
9933
+ }
9934
+ finally {
9935
+ if (!adopted) {
9936
+ disposeBoundSubtreeWithoutThrow(emergency);
9937
+ disposeBoundBranchesOutsideContainer(next);
9938
+ }
9939
+ }
9940
+ disposeBoundSubtreeWithoutThrow(next);
9941
+ try {
9942
+ restoreChangedSubtreeState(previous, emergency, scrollPositions, mediaStates);
9943
+ }
9944
+ catch {
9945
+ // Presentation restoration cannot roll back an adopted next root.
9946
+ }
9947
+ restoreDisabledDescendantPresentation(emergency);
9948
+ return emergency;
9949
+ }
9950
+ try {
9951
+ restoreChangedSubtreeState(previous, next, scrollPositions, mediaStates);
9952
+ }
9953
+ catch {
9954
+ // Presentation restoration does not affect published data ownership.
9955
+ }
9956
+ restoreDisabledDescendantPresentation(next);
9957
+ return next;
9958
+ }
9959
+ finally {
9960
+ disposeCandidate(candidate);
9961
+ }
8025
9962
  }
8026
9963
  function assertCurrentRevision(baseRevision) {
8027
- if (disposed || revision !== baseRevision) {
9964
+ if (disposed || publishing || revision !== baseRevision) {
8028
9965
  const error = new Error('[renderCard] BOUND_TRANSACTION_CONFLICT');
8029
9966
  error.code = 'BOUND_TRANSACTION_CONFLICT';
8030
9967
  throw error;
8031
9968
  }
8032
9969
  }
8033
- function commitDraft(draft, candidate, baseRevision) {
9970
+ function commitDraft(before, draft, baseRevision) {
8034
9971
  assertCurrentRevision(baseRevision);
8035
- replaceRootContents(variables, draft);
8036
- // Re-materialize against the stable public variables root. The detached
8037
- // DOM already represents the exact same validated JSON data.
8038
- candidate.materialized = materializeCard(schema, variables);
8039
- publishDOM(candidate, true);
9972
+ const validationRoot = cloneJsonData(variables);
9973
+ replaceRootContents(validationRoot, draft);
9974
+ const candidate = prepareIncrementalCandidate(before, draft);
9975
+ try {
9976
+ assertCurrentRevision(baseRevision);
9977
+ }
9978
+ catch (error) {
9979
+ disposeCandidate(candidate);
9980
+ throw error;
9981
+ }
9982
+ publishing = true;
9983
+ let publishingFailed = false;
9984
+ let publishingError;
9985
+ let deferredDisposeFailed = false;
9986
+ let deferredDisposeError;
9987
+ try {
9988
+ const nextMounted = commitIncrementalCandidate(candidate, draft);
9989
+ replaceRootContents(variables, draft);
9990
+ currentMaterialized = materializeCard(schema, variables);
9991
+ currentMountedTree = nextMounted;
9992
+ lastPublishedVariables = cloneJsonData(variables);
9993
+ revision += 1;
9994
+ }
9995
+ catch (error) {
9996
+ publishingFailed = true;
9997
+ publishingError = error;
9998
+ }
9999
+ finally {
10000
+ publishing = false;
10001
+ if (disposeRequested) {
10002
+ try {
10003
+ disposeNow();
10004
+ }
10005
+ catch (error) {
10006
+ deferredDisposeFailed = true;
10007
+ deferredDisposeError = error;
10008
+ }
10009
+ }
10010
+ }
10011
+ if (publishingFailed) {
10012
+ if (deferredDisposeFailed) {
10013
+ console.error('[renderCard] Bound deferred dispose failed', deferredDisposeError);
10014
+ }
10015
+ throw publishingError;
10016
+ }
10017
+ if (deferredDisposeFailed)
10018
+ throw deferredDisposeError;
10019
+ if (disposed)
10020
+ return;
10021
+ reconcileLifecycles(selectLifecycleNodes(currentMaterialized, collectMountedLifecycleIds(currentMountedTree)));
10022
+ applyAutoFocus(candidate);
8040
10023
  }
8041
10024
  function writeDraftVariable(draft, key, value) {
8042
10025
  Object.defineProperty(draft, String(key), {
@@ -8093,11 +10076,12 @@ function renderBoundCard(container, schema, options) {
8093
10076
  inflightRequests,
8094
10077
  };
8095
10078
  }
8096
- async function runBoundEvent(runtimeId, eventName, eventDetail) {
10079
+ async function runBoundEvent(runtimeId, eventName, eventDetail, baseRevision, repeatTargetFingerprint) {
8097
10080
  if (disposed)
8098
10081
  return;
8099
- const baseRevision = revision;
8100
- const draft = cloneJsonData(variables);
10082
+ assertCurrentRevision(baseRevision);
10083
+ const transactionBefore = cloneJsonData(variables);
10084
+ const draft = cloneJsonData(transactionBefore);
8101
10085
  if (eventDetail !== undefined) {
8102
10086
  writeDraftVariable(draft, '_event', cloneJsonData(eventDetail));
8103
10087
  }
@@ -8105,8 +10089,14 @@ function renderBoundCard(container, schema, options) {
8105
10089
  const freshMaterialized = materializeCard(schema, draft);
8106
10090
  const freshNode = indexNodes(freshMaterialized.root).get(runtimeId);
8107
10091
  if (!freshNode) {
10092
+ if (repeatTargetFingerprint !== undefined)
10093
+ return;
8108
10094
  throw new Error(`[renderCard] Bound runtime node "${runtimeId}" no longer exists`);
8109
10095
  }
10096
+ if (repeatTargetFingerprint !== undefined
10097
+ && getRepeatTargetFingerprint(freshNode) !== repeatTargetFingerprint) {
10098
+ return;
10099
+ }
8110
10100
  const eventValue = freshNode.events?.[eventName];
8111
10101
  const steps = eventValue
8112
10102
  ? resolveActionRef(eventValue, schemaActions)
@@ -8117,14 +10107,19 @@ function renderBoundCard(container, schema, options) {
8117
10107
  assertCurrentRevision(baseRevision);
8118
10108
  if (!hasVariableChanges(actionBaseline, draft))
8119
10109
  return;
8120
- const candidate = prepareCandidate(draft);
8121
- commitDraft(draft, candidate, baseRevision);
10110
+ // The target check above may have cached identity counts before action
10111
+ // steps changed the draft. Recount once when binding the published tree.
10112
+ repeatIdentityCountCache.delete(draft);
10113
+ commitDraft(cloneJsonData(lastPublishedVariables), draft, baseRevision);
8122
10114
  }
8123
- function enqueueBoundEvent(runtimeId, eventName, eventDetail) {
10115
+ function enqueueBoundEvent(runtimeId, eventName, eventDetail, repeatTargetFingerprint) {
8124
10116
  if (disposed)
8125
10117
  return;
10118
+ const enqueueRevision = revision;
10119
+ const queuedBehindAnotherAction = queuedActionCount > 0;
10120
+ queuedActionCount += 1;
8126
10121
  actionQueue = actionQueue
8127
- .then(() => runBoundEvent(runtimeId, eventName, eventDetail))
10122
+ .then(() => runBoundEvent(runtimeId, eventName, eventDetail, queuedBehindAnotherAction ? revision : enqueueRevision, repeatTargetFingerprint))
8128
10123
  .catch((error) => {
8129
10124
  if (error
8130
10125
  && typeof error === 'object'
@@ -8133,44 +10128,87 @@ function renderBoundCard(container, schema, options) {
8133
10128
  return;
8134
10129
  }
8135
10130
  console.error('[renderCard] Bound action failed', error);
10131
+ })
10132
+ .finally(() => {
10133
+ queuedActionCount -= 1;
8136
10134
  });
8137
10135
  }
8138
10136
  function updateVariables(newVariables) {
8139
10137
  if (disposed)
8140
10138
  return;
8141
10139
  const baseRevision = revision;
10140
+ const before = cloneJsonData(lastPublishedVariables);
8142
10141
  const draft = cloneJsonData(variables);
8143
10142
  const patch = cloneJsonData(newVariables);
8144
10143
  for (const key of Object.keys(patch)) {
8145
10144
  writeDraftVariable(draft, key, patch[key]);
8146
10145
  }
8147
- const candidate = prepareCandidate(draft);
8148
- commitDraft(draft, candidate, baseRevision);
10146
+ commitDraft(before, draft, baseRevision);
8149
10147
  }
8150
- const initialCandidate = prepareCandidate(variables);
8151
- currentMaterialized = initialCandidate.materialized;
8152
- publishDOM(initialCandidate, false);
8153
- return {
8154
- dispose() {
8155
- if (disposed)
8156
- return;
8157
- disposed = true;
8158
- abortController.abort();
10148
+ function disposeNow() {
10149
+ if (disposed)
10150
+ return;
10151
+ disposed = true;
10152
+ disposeRequested = false;
10153
+ nodeAccess.dispose();
10154
+ abortController.abort();
10155
+ if (currentMountedTree) {
10156
+ disposeBoundSubtree(currentMountedTree);
10157
+ currentMountedTree = null;
10158
+ }
10159
+ else {
8159
10160
  disposeChartsIn(container);
8160
- container.replaceChildren();
8161
- const lifecycleNodes = [...activeLifecycleNodes.entries()];
8162
- activeLifecycleNodes.clear();
8163
- lifecycleQueue = lifecycleQueue
8164
- .then(async () => {
8165
- for (const [id, node] of lifecycleNodes) {
8166
- await lifecycleManager.destroy(id, createLifecycleActionContext(node));
10161
+ }
10162
+ container.replaceChildren();
10163
+ const lifecycleNodes = [...activeLifecycleNodes.entries()];
10164
+ activeLifecycleNodes.clear();
10165
+ lifecycleQueue = lifecycleQueue
10166
+ .then(async () => {
10167
+ for (const [id, node] of lifecycleNodes) {
10168
+ const session = createLifecycleActionSession(node);
10169
+ try {
10170
+ await lifecycleManager.destroy(id, session.context);
10171
+ session.publishDirectMutations();
10172
+ }
10173
+ catch (error) {
10174
+ console.error('[renderCard] Bound lifecycle dispose failed', error);
10175
+ }
10176
+ finally {
8167
10177
  lifecycleManager.unregister(id);
8168
10178
  }
10179
+ }
10180
+ try {
8169
10181
  await lifecycleManager.dispose(createPassiveActionContext(variables));
8170
- })
8171
- .catch((error) => {
10182
+ }
10183
+ catch (error) {
8172
10184
  console.error('[renderCard] Bound lifecycle dispose failed', error);
8173
- });
10185
+ }
10186
+ })
10187
+ .catch((error) => {
10188
+ console.error('[renderCard] Bound lifecycle dispose failed', error);
10189
+ });
10190
+ }
10191
+ const initialCandidate = prepareIncrementalCandidate(variables, variables);
10192
+ publishing = true;
10193
+ try {
10194
+ currentMountedTree = commitIncrementalCandidate(initialCandidate, variables);
10195
+ currentMaterialized = materializeCard(schema, variables);
10196
+ }
10197
+ finally {
10198
+ publishing = false;
10199
+ }
10200
+ reconcileLifecycles(selectLifecycleNodes(currentMaterialized, collectMountedLifecycleIds(currentMountedTree)));
10201
+ return {
10202
+ getNode: nodeAccess.getNode,
10203
+ onFocusChange: nodeAccess.onFocusChange,
10204
+ dispose() {
10205
+ if (disposed || disposeRequested)
10206
+ return;
10207
+ if (publishing) {
10208
+ disposeRequested = true;
10209
+ return;
10210
+ }
10211
+ disposeNow();
8174
10212
  },
8175
10213
  updateVariables,
8176
10214
  };
@@ -8188,7 +10226,11 @@ function captureScrollPositions$1(root) {
8188
10226
  function restoreScrollPositions$1(root, positions) {
8189
10227
  if (positions.size === 0)
8190
10228
  return;
8191
- root.querySelectorAll('[data-scroll-id]').forEach((element) => {
10229
+ const scrollers = [];
10230
+ if (root.matches('[data-scroll-id]'))
10231
+ scrollers.push(root);
10232
+ scrollers.push(...root.querySelectorAll('[data-scroll-id]'));
10233
+ scrollers.forEach((element) => {
8192
10234
  const id = element.getAttribute('data-scroll-id');
8193
10235
  const saved = id ? positions.get(id) : undefined;
8194
10236
  if (saved == null)
@@ -8217,7 +10259,11 @@ function captureMediaStates$1(root) {
8217
10259
  function restoreMediaStates$1(root, states) {
8218
10260
  if (states.size === 0)
8219
10261
  return;
8220
- root.querySelectorAll('ai-card-audio, ai-card-video').forEach((element) => {
10262
+ const mediaHosts = [];
10263
+ if (root.matches('ai-card-audio, ai-card-video'))
10264
+ mediaHosts.push(root);
10265
+ mediaHosts.push(...root.querySelectorAll('ai-card-audio, ai-card-video'));
10266
+ mediaHosts.forEach((element) => {
8221
10267
  const id = element.getAttribute('data-card-id');
8222
10268
  const snapshot = id ? states.get(id) : undefined;
8223
10269
  if (!snapshot)
@@ -8279,22 +10325,72 @@ function renderCard(container, schemaInput, options = {}) {
8279
10325
  return renderBoundCard(container, schema, options);
8280
10326
  }
8281
10327
  function renderStaticCard(container, schema, options) {
10328
+ const nodeAccess = createCardNodeAccess(container);
8282
10329
  // 2. Parse into render tree
8283
10330
  const tree = parseSchema(schema);
8284
10331
  // 3. Reactive variables store (mutable copy, merged with external variables)
8285
10332
  let variables = { ...schema.variables, ...options.variables };
8286
10333
  let disposed = false;
10334
+ let publishing = false;
10335
+ let disposeRequested = false;
8287
10336
  let hostAuthorityEpoch = 0;
10337
+ let nextStaticEventSequence = 0;
10338
+ const latestStaticEventWriterByKey = new Map();
8288
10339
  const lifecycleRecords = new Map();
8289
10340
  const abortController = new AbortController();
8290
10341
  // Per-instance request dedup map (isolated from other cards on the page).
8291
10342
  const inflightRequests = new Map();
8292
- function buildActionContext() {
10343
+ function isVisible(node, renderVariables, parentVisible) {
10344
+ if (!parentVisible)
10345
+ return false;
10346
+ if (!node.directives?.visible)
10347
+ return true;
10348
+ const visible = node.directives.visible;
10349
+ const resolved = hasExpression(visible)
10350
+ ? resolveExpression(visible, renderVariables)
10351
+ : visible;
10352
+ return !(resolved === false
10353
+ || resolved === 'false'
10354
+ || resolved === ''
10355
+ || resolved === 0);
10356
+ }
10357
+ function collectAutoFocusRevealIds(previousVariables, nextVariables) {
10358
+ const requested = new Set();
10359
+ const visit = (node, previousParentVisible, nextParentVisible) => {
10360
+ const wasVisible = isVisible(node, previousVariables, previousParentVisible);
10361
+ const nowVisible = isVisible(node, nextVariables, nextParentVisible);
10362
+ if (node.type === 'Input'
10363
+ && !wasVisible
10364
+ && nowVisible
10365
+ && resolveDeep(node.props, nextVariables).autoFocus === true) {
10366
+ requested.add(node.id);
10367
+ }
10368
+ node.children.forEach(child => visit(child, wasVisible, nowVisible));
10369
+ };
10370
+ visit(tree, true, true);
10371
+ return requested;
10372
+ }
10373
+ function applyAutoFocus(requested) {
10374
+ if (requested.size === 0)
10375
+ return;
10376
+ for (const input of container.querySelectorAll(CardInput.is)) {
10377
+ const id = input.getAttribute('data-card-id');
10378
+ if (id && requested.has(id) && input.requestAutoFocus())
10379
+ break;
10380
+ }
10381
+ }
10382
+ function buildActionContext(pendingAutoFocusIds, initialVariables = variables, eventSequence) {
8293
10383
  const baseAuthorityEpoch = hostAuthorityEpoch;
8294
10384
  let context;
8295
10385
  const writeVariable = (key, value, { silent }) => {
8296
10386
  let actionVariables = context.variables;
8297
- if (disposed || baseAuthorityEpoch !== hostAuthorityEpoch) {
10387
+ const latestWriter = latestStaticEventWriterByKey.get(key);
10388
+ if (disposed
10389
+ || publishing
10390
+ || baseAuthorityEpoch !== hostAuthorityEpoch
10391
+ || (eventSequence !== undefined
10392
+ && latestWriter !== undefined
10393
+ && latestWriter > eventSequence)) {
8298
10394
  if (actionVariables === variables) {
8299
10395
  actionVariables = { ...actionVariables };
8300
10396
  context.variables = actionVariables;
@@ -8302,9 +10398,50 @@ function renderStaticCard(container, schema, options) {
8302
10398
  actionVariables[key] = value;
8303
10399
  return;
8304
10400
  }
10401
+ const previousVariables = !silent && pendingAutoFocusIds
10402
+ ? { ...actionVariables }
10403
+ : null;
10404
+ const liveVariables = variables;
10405
+ const hadLiveValue = Object.prototype.hasOwnProperty.call(liveVariables, key);
10406
+ const previousLiveValue = liveVariables[key];
8305
10407
  actionVariables[key] = value;
8306
- if (!silent)
10408
+ if (silent)
10409
+ return;
10410
+ // Event actions use a private snapshot so a later event cannot replace
10411
+ // their `_event` detail while they await. Publish only the key written by
10412
+ // this step, preserving concurrent actions that update different keys.
10413
+ if (actionVariables !== variables) {
10414
+ variables[key] = value;
10415
+ }
10416
+ if (previousVariables && pendingAutoFocusIds) {
10417
+ for (const id of collectAutoFocusRevealIds(previousVariables, actionVariables)) {
10418
+ pendingAutoFocusIds.add(id);
10419
+ }
10420
+ }
10421
+ try {
8307
10422
  rerender();
10423
+ }
10424
+ catch (error) {
10425
+ if (variables === liveVariables) {
10426
+ if (hadLiveValue)
10427
+ liveVariables[key] = previousLiveValue;
10428
+ else
10429
+ Reflect.deleteProperty(liveVariables, key);
10430
+ if (eventSequence !== undefined
10431
+ && latestStaticEventWriterByKey.get(key) === eventSequence) {
10432
+ if (latestWriter === undefined) {
10433
+ latestStaticEventWriterByKey.delete(key);
10434
+ }
10435
+ else {
10436
+ latestStaticEventWriterByKey.set(key, latestWriter);
10437
+ }
10438
+ }
10439
+ pendingAutoFocusIds?.clear();
10440
+ if (!disposed)
10441
+ actionContext = buildActionContext();
10442
+ }
10443
+ throw error;
10444
+ }
8308
10445
  };
8309
10446
  context = {
8310
10447
  ...createWebActionContext({
@@ -8314,7 +10451,7 @@ function renderStaticCard(container, schema, options) {
8314
10451
  },
8315
10452
  abortSignal: abortController.signal,
8316
10453
  }),
8317
- variables,
10454
+ variables: initialVariables,
8318
10455
  variableWriter: writeVariable,
8319
10456
  botId: options.botId,
8320
10457
  inflightRequests,
@@ -8359,8 +10496,431 @@ function renderStaticCard(container, schema, options) {
8359
10496
  }
8360
10497
  });
8361
10498
  }
8362
- // 7. Render function
10499
+ let mountedTree = null;
8363
10500
  let renderGeneration = 0;
10501
+ function childOccurrenceKey(parentKey, id, sameIdOrdinal) {
10502
+ return `${parentKey}/${encodeURIComponent(id)}#${sameIdOrdinal}`;
10503
+ }
10504
+ function resolveNodeProps(node) {
10505
+ const resolvedProps = resolveDeep(node.props, variables);
10506
+ if (resolvedProps.content
10507
+ && typeof resolvedProps.content === 'object'
10508
+ && 'type' in resolvedProps.content) {
10509
+ resolvedProps.content = resolveExpressionValue(resolvedProps.content, variables);
10510
+ }
10511
+ return resolvedProps;
10512
+ }
10513
+ function isNodeDisabled(node) {
10514
+ if (!node.directives?.disabled)
10515
+ return false;
10516
+ const disabled = node.directives.disabled;
10517
+ const resolved = hasExpression(disabled)
10518
+ ? resolveExpression(disabled, variables)
10519
+ : disabled;
10520
+ return resolved === true || resolved === 'true' || resolved === 1;
10521
+ }
10522
+ function nodeBindingFingerprint(node, resolvedProps, disabled) {
10523
+ const binding = { disabled };
10524
+ if (resolvedProps && resolvedProps.variableKey !== undefined) {
10525
+ binding.variableKey = resolvedProps.variableKey;
10526
+ }
10527
+ if (!disabled && node.events !== undefined) {
10528
+ binding.events = node.events;
10529
+ }
10530
+ return stableFingerprint(binding);
10531
+ }
10532
+ function resolveStaticMetadata(node, occurrenceKey) {
10533
+ const visible = isVisible(node, variables, true);
10534
+ const rendererToken = componentRenderers[node.type]
10535
+ ?? componentRenderers._default;
10536
+ if (!visible) {
10537
+ return {
10538
+ occurrenceKey,
10539
+ node,
10540
+ nodeId: node.id,
10541
+ nodeType: node.type,
10542
+ rendererToken,
10543
+ visible: false,
10544
+ disabled: false,
10545
+ resolvedProps: null,
10546
+ propsFingerprint: stableFingerprint(null),
10547
+ layoutFingerprint: stableFingerprint(null),
10548
+ bindingFingerprint: stableFingerprint(null),
10549
+ };
10550
+ }
10551
+ const resolvedProps = resolveNodeProps(node);
10552
+ const disabled = isNodeDisabled(node);
10553
+ return {
10554
+ occurrenceKey,
10555
+ node,
10556
+ nodeId: node.id,
10557
+ nodeType: node.type,
10558
+ rendererToken,
10559
+ visible: true,
10560
+ disabled,
10561
+ resolvedProps,
10562
+ propsFingerprint: stableFingerprint(resolvedProps),
10563
+ layoutFingerprint: stableFingerprint(getSlotLayoutFingerprintInput(resolvedProps, node.children, resolveNodeProps)),
10564
+ bindingFingerprint: nodeBindingFingerprint(node, resolvedProps, disabled),
10565
+ };
10566
+ }
10567
+ function applyDisabledState(element, disabled) {
10568
+ if (!disabled)
10569
+ return;
10570
+ element.setAttribute('data-disabled', 'true');
10571
+ element.style.background = '#F5F5F5';
10572
+ element.style.color = '#C0C0C0';
10573
+ element.style.setProperty('--card-disabled-color', '#C0C0C0');
10574
+ element.style.pointerEvents = 'none';
10575
+ element.style.cursor = 'default';
10576
+ }
10577
+ function applyDisabledDescendants(element, disabled) {
10578
+ if (!disabled)
10579
+ return;
10580
+ element.querySelectorAll('*').forEach((child) => {
10581
+ const htmlChild = child;
10582
+ htmlChild.setAttribute('data-disabled', 'true');
10583
+ htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
10584
+ });
10585
+ }
10586
+ function runStaticEventActions(steps, detail) {
10587
+ if (disposed)
10588
+ return;
10589
+ if (detail != null)
10590
+ variables._event = detail;
10591
+ const sourceVariables = variables;
10592
+ const actionAuthorityEpoch = hostAuthorityEpoch;
10593
+ const eventSequence = ++nextStaticEventSequence;
10594
+ const localVariables = Object.create(null);
10595
+ if (Object.prototype.hasOwnProperty.call(sourceVariables, '_event')) {
10596
+ localVariables._event = sourceVariables._event;
10597
+ }
10598
+ const deletedProperties = new Set();
10599
+ let frozenVariables = null;
10600
+ const eventVariables = new Proxy(localVariables, {
10601
+ get(target, property) {
10602
+ if (frozenVariables)
10603
+ return Reflect.get(frozenVariables, property);
10604
+ if (deletedProperties.has(property))
10605
+ return undefined;
10606
+ return Object.prototype.hasOwnProperty.call(target, property)
10607
+ ? Reflect.get(target, property)
10608
+ : Reflect.get(sourceVariables, property);
10609
+ },
10610
+ set(target, property, value) {
10611
+ if (frozenVariables) {
10612
+ Reflect.set(frozenVariables, property, value);
10613
+ return true;
10614
+ }
10615
+ deletedProperties.delete(property);
10616
+ Reflect.set(target, property, value);
10617
+ if (property !== '_event'
10618
+ && !disposed
10619
+ && !publishing
10620
+ && variables === sourceVariables
10621
+ && actionAuthorityEpoch === hostAuthorityEpoch
10622
+ && (latestStaticEventWriterByKey.get(property) === undefined
10623
+ || latestStaticEventWriterByKey.get(property) <= eventSequence)) {
10624
+ latestStaticEventWriterByKey.set(property, eventSequence);
10625
+ Reflect.set(sourceVariables, property, value);
10626
+ }
10627
+ return true;
10628
+ },
10629
+ deleteProperty(target, property) {
10630
+ if (frozenVariables) {
10631
+ return Reflect.deleteProperty(frozenVariables, property);
10632
+ }
10633
+ deletedProperties.add(property);
10634
+ Reflect.deleteProperty(target, property);
10635
+ if (property !== '_event'
10636
+ && !disposed
10637
+ && !publishing
10638
+ && variables === sourceVariables
10639
+ && actionAuthorityEpoch === hostAuthorityEpoch
10640
+ && (latestStaticEventWriterByKey.get(property) === undefined
10641
+ || latestStaticEventWriterByKey.get(property) <= eventSequence)) {
10642
+ latestStaticEventWriterByKey.set(property, eventSequence);
10643
+ Reflect.deleteProperty(sourceVariables, property);
10644
+ }
10645
+ return true;
10646
+ },
10647
+ has(target, property) {
10648
+ if (!frozenVariables && deletedProperties.has(property))
10649
+ return false;
10650
+ return frozenVariables
10651
+ ? property in frozenVariables
10652
+ : property in target || property in sourceVariables;
10653
+ },
10654
+ ownKeys(target) {
10655
+ return Reflect.ownKeys(frozenVariables ?? { ...sourceVariables, ...target }).filter(property => !deletedProperties.has(property));
10656
+ },
10657
+ getOwnPropertyDescriptor(target, property) {
10658
+ if (!frozenVariables && deletedProperties.has(property)) {
10659
+ return undefined;
10660
+ }
10661
+ const owner = frozenVariables
10662
+ ?? (Object.prototype.hasOwnProperty.call(target, property)
10663
+ ? target
10664
+ : sourceVariables);
10665
+ const descriptor = Object.getOwnPropertyDescriptor(owner, property);
10666
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
10667
+ },
10668
+ });
10669
+ const pendingAutoFocusIds = new Set();
10670
+ void runActionSteps(steps, buildActionContext(pendingAutoFocusIds, eventVariables, eventSequence))
10671
+ .catch((error) => {
10672
+ console.error('[renderCard] Static action failed', error);
10673
+ })
10674
+ .finally(() => {
10675
+ const snapshot = {
10676
+ ...sourceVariables,
10677
+ ...localVariables,
10678
+ };
10679
+ for (const property of deletedProperties) {
10680
+ Reflect.deleteProperty(snapshot, property);
10681
+ }
10682
+ frozenVariables = snapshot;
10683
+ if (disposed || actionAuthorityEpoch !== hostAuthorityEpoch)
10684
+ return;
10685
+ applyAutoFocus(pendingAutoFocusIds);
10686
+ });
10687
+ }
10688
+ function syncStaticBindings(element, node) {
10689
+ const resolvedEventBindings = [];
10690
+ if (node.visible && !node.disabled && node.node.events) {
10691
+ for (const [schemaEvent, eventValue] of Object.entries(node.node.events)) {
10692
+ if (!eventValue)
10693
+ continue;
10694
+ const steps = resolveActionRef(eventValue, schemaActions);
10695
+ if (!steps)
10696
+ continue;
10697
+ const domEvent = eventMap[schemaEvent] ?? schemaEvent;
10698
+ resolvedEventBindings.push({
10699
+ schemaEvent,
10700
+ domEvent,
10701
+ ownsValueEvent: ((domEvent === 'input' || domEvent === 'change')
10702
+ && VALUE_CONTROL_TYPES.has(node.nodeType)),
10703
+ dispatch: (event) => {
10704
+ const detail = event instanceof CustomEvent
10705
+ ? event.detail
10706
+ : undefined;
10707
+ runStaticEventActions(steps, detail);
10708
+ },
10709
+ });
10710
+ }
10711
+ }
10712
+ const variableKey = node.resolvedProps?.variableKey;
10713
+ syncElementBindings(element, {
10714
+ variableKey: typeof variableKey === 'string' ? variableKey : undefined,
10715
+ writeVariable: (key, value) => {
10716
+ if (disposed
10717
+ || publishing
10718
+ || !container.contains(element)) {
10719
+ return;
10720
+ }
10721
+ variables[key] = value;
10722
+ hostAuthorityEpoch += 1;
10723
+ },
10724
+ events: resolvedEventBindings,
10725
+ isActive: () => (!disposed
10726
+ && !publishing
10727
+ && container.contains(element)),
10728
+ });
10729
+ }
10730
+ function mountStaticSubtree(node, occurrenceKey) {
10731
+ const metadata = resolveStaticMetadata(node, occurrenceKey);
10732
+ if (!metadata.visible) {
10733
+ const placeholder = document.createElement('div');
10734
+ placeholder.style.display = 'none';
10735
+ placeholder.setAttribute('data-card-id', node.id);
10736
+ syncStaticBindings(placeholder, { ...metadata});
10737
+ return { ...metadata, element: placeholder, children: [] };
10738
+ }
10739
+ const renderer = metadata.rendererToken;
10740
+ const element = renderer(node, metadata.resolvedProps, isMobile, options.responsive);
10741
+ try {
10742
+ applyDisabledState(element, metadata.disabled);
10743
+ syncStaticBindings(element, { ...metadata, children: [] });
10744
+ const children = [];
10745
+ const sameIdOrdinals = new Map();
10746
+ const renderChild = (child) => {
10747
+ const sameIdOrdinal = sameIdOrdinals.get(child.id) ?? 0;
10748
+ sameIdOrdinals.set(child.id, sameIdOrdinal + 1);
10749
+ const mounted = mountStaticSubtree(child, childOccurrenceKey(occurrenceKey, child.id, sameIdOrdinal));
10750
+ children.push(mounted);
10751
+ return mounted.element;
10752
+ };
10753
+ const childrenMap = {};
10754
+ for (const child of node.children)
10755
+ childrenMap[child.id] = child;
10756
+ const layoutApplied = renderSlotLayout(element, node.children, metadata.resolvedProps, renderChild, childrenMap, actionContext, resolveNodeProps);
10757
+ if (!layoutApplied) {
10758
+ for (const child of node.children) {
10759
+ element.appendChild(renderChild(child));
10760
+ }
10761
+ }
10762
+ applyDisabledDescendants(element, metadata.disabled);
10763
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
10764
+ return { ...metadata, element, children };
10765
+ }
10766
+ catch (error) {
10767
+ disposeStaticElement(element);
10768
+ throw error;
10769
+ }
10770
+ }
10771
+ function prepareStaticNode(current) {
10772
+ const metadata = resolveStaticMetadata(current.node, current.occurrenceKey);
10773
+ if (current.visible !== metadata.visible
10774
+ || current.layoutFingerprint !== metadata.layoutFingerprint) {
10775
+ return { ...metadata, children: [] };
10776
+ }
10777
+ return {
10778
+ ...metadata,
10779
+ children: current.children.map(prepareStaticNode),
10780
+ };
10781
+ }
10782
+ function mountStaticReplacement(current, next) {
10783
+ const replacement = mountStaticSubtree(next.node, next.occurrenceKey);
10784
+ try {
10785
+ if (replacement.element !== current.element
10786
+ && replacement.element.parentNode === null) {
10787
+ transferSlotItemPresentation(current.element, replacement.element);
10788
+ }
10789
+ return replacement;
10790
+ }
10791
+ catch (error) {
10792
+ disposeStaticSubtree(replacement);
10793
+ throw error;
10794
+ }
10795
+ }
10796
+ /**
10797
+ * Build every renderer-owned replacement before touching the mounted DOM.
10798
+ * This preserves the old committed card when a later business renderer
10799
+ * rejects the same host update. Retained BaseElements are intentionally not
10800
+ * instantiated here: their native controls must stay untouched unless the
10801
+ * live update actually needs its existing replacement fallback.
10802
+ */
10803
+ function stageStaticReplacements(current, next, staged) {
10804
+ const sameChildren = current.children.length === next.children.length
10805
+ && current.children.every((child, index) => (child.occurrenceKey === next.children[index].occurrenceKey));
10806
+ const decision = sameChildren
10807
+ ? decideStaticUpdate(current, next)
10808
+ : 'replace';
10809
+ if (decision === 'replace') {
10810
+ staged.set(next.occurrenceKey, mountStaticReplacement(current, next));
10811
+ }
10812
+ if (decision === 'replace')
10813
+ return;
10814
+ current.children.forEach((child, index) => {
10815
+ stageStaticReplacements(child, next.children[index], staged);
10816
+ });
10817
+ }
10818
+ function decideStaticUpdate(current, next) {
10819
+ if (current.occurrenceKey !== next.occurrenceKey
10820
+ || current.nodeId !== next.nodeId
10821
+ || current.nodeType !== next.nodeType
10822
+ || current.rendererToken !== next.rendererToken
10823
+ || current.visible !== next.visible
10824
+ || current.disabled !== next.disabled
10825
+ || current.layoutFingerprint !== next.layoutFingerprint) {
10826
+ return 'replace';
10827
+ }
10828
+ if (current.propsFingerprint === next.propsFingerprint
10829
+ && current.bindingFingerprint === next.bindingFingerprint) {
10830
+ return 'retain';
10831
+ }
10832
+ if (current.element instanceof BaseElement
10833
+ || current.rendererToken === componentRenderers._default) {
10834
+ return 'update';
10835
+ }
10836
+ return 'replace';
10837
+ }
10838
+ function updateStaticNode(current, next) {
10839
+ const element = current.element;
10840
+ if (element instanceof BaseElement) {
10841
+ updateSlotItemShellPresentation(element, () => {
10842
+ element.updateProps(next.resolvedProps, isMobile, options.responsive);
10843
+ applyResponsiveStyles(element, createResponsiveContext(isMobile, options.responsive));
10844
+ }, getSlotHostPresentationOwnership(next.resolvedProps));
10845
+ return;
10846
+ }
10847
+ const previous = componentRenderers._default(current.node, current.resolvedProps, isMobile, options.responsive);
10848
+ const desired = componentRenderers._default(next.node, next.resolvedProps, isMobile, options.responsive);
10849
+ applyDisabledState(previous, current.disabled);
10850
+ applyDisabledState(desired, next.disabled);
10851
+ const hostOwnership = getSlotHostPresentationOwnership(next.resolvedProps);
10852
+ updateSlotItemShellPresentation(current.element, () => {
10853
+ patchElementShellPresentation(current.element, previous, desired, hostOwnership, getSlotItemPresentationOwnership(current.element));
10854
+ }, hostOwnership);
10855
+ }
10856
+ function disposeStaticElement(element) {
10857
+ const cleanupSteps = [
10858
+ () => clearSlotOwnerBehaviorsIn(element),
10859
+ () => clearSlotItemPresentationsIn(element),
10860
+ () => clearElementBindingsIn(element),
10861
+ () => disposeChartsIn(element),
10862
+ ];
10863
+ for (const cleanup of cleanupSteps) {
10864
+ try {
10865
+ cleanup();
10866
+ }
10867
+ catch (error) {
10868
+ console.error('[renderCard] Static element cleanup failed', error);
10869
+ }
10870
+ }
10871
+ }
10872
+ function disposeStaticSubtree(current) {
10873
+ disposeStaticElement(current.element);
10874
+ }
10875
+ function collectMountedOccurrences(current, occurrences) {
10876
+ occurrences.set(current.occurrenceKey, current);
10877
+ for (const child of current.children) {
10878
+ collectMountedOccurrences(child, occurrences);
10879
+ }
10880
+ }
10881
+ function collectMountedLifecycles(current, collection) {
10882
+ if (!current.visible
10883
+ || !container.contains(current.element)) {
10884
+ return;
10885
+ }
10886
+ if (current.node.lifecycle) {
10887
+ collection.push({
10888
+ id: current.nodeId,
10889
+ lifecycle: current.node.lifecycle,
10890
+ });
10891
+ }
10892
+ for (const child of current.children) {
10893
+ collectMountedLifecycles(child, collection);
10894
+ }
10895
+ }
10896
+ function restoreDisabledDescendantPresentation(current) {
10897
+ if (current.disabled) {
10898
+ applyDisabledState(current.element, true);
10899
+ applyDisabledDescendants(current.element, true);
10900
+ }
10901
+ for (const child of current.children) {
10902
+ restoreDisabledDescendantPresentation(child);
10903
+ }
10904
+ }
10905
+ function restoreChangedSubtreeState(previous, next, scrollPositions, mediaStates) {
10906
+ if (previous.element !== next.element) {
10907
+ restoreScrollPositions(next.element, scrollPositions);
10908
+ restoreMediaStates(next.element, mediaStates);
10909
+ return;
10910
+ }
10911
+ if (previous.propsFingerprint !== next.propsFingerprint
10912
+ && (next.nodeType === 'Audio' || next.nodeType === 'Video')) {
10913
+ restoreMediaStates(next.element, mediaStates);
10914
+ }
10915
+ for (let index = 0; index < next.children.length; index += 1) {
10916
+ const previousChild = previous.children[index];
10917
+ const nextChild = next.children[index];
10918
+ if (previousChild
10919
+ && previousChild.occurrenceKey === nextChild.occurrenceKey) {
10920
+ restoreChangedSubtreeState(previousChild, nextChild, scrollPositions, mediaStates);
10921
+ }
10922
+ }
10923
+ }
8364
10924
  function render() {
8365
10925
  if (disposed)
8366
10926
  return;
@@ -8368,28 +10928,85 @@ function renderStaticCard(container, schema, options) {
8368
10928
  const collectedLifecycles = [];
8369
10929
  const scrollPositions = captureScrollPositions(container);
8370
10930
  const mediaStates = captureMediaStates(container);
8371
- disposeChartsIn(container); // tear down old chart instances before clearing
8372
- const dom = renderNode(tree, variables, actionContext, isMobile, options.responsive, schemaActions, (key, value, sourceVariables) => {
8373
- if (disposed || sourceVariables !== variables)
10931
+ let previousTree = null;
10932
+ if (!mountedTree) {
10933
+ const initialTree = mountStaticSubtree(tree, `${tree.id}#0`);
10934
+ if (disposed || generation !== renderGeneration) {
10935
+ disposeStaticSubtree(initialTree);
8374
10936
  return;
8375
- variables[key] = value;
8376
- hostAuthorityEpoch += 1;
8377
- }, (steps, detail) => {
10937
+ }
10938
+ disposeChartsIn(container);
10939
+ container.replaceChildren(initialTree.element);
10940
+ mountedTree = initialTree;
10941
+ }
10942
+ else {
10943
+ previousTree = mountedTree;
10944
+ let unreconciled = false;
10945
+ let unreconciledError;
10946
+ const replacementSources = new Map();
10947
+ collectMountedOccurrences(mountedTree, replacementSources);
10948
+ const preparedTree = prepareStaticNode(mountedTree);
10949
+ if (disposed || generation !== renderGeneration)
10950
+ return;
10951
+ const stagedReplacements = new Map();
10952
+ publishing = true;
10953
+ try {
10954
+ stageStaticReplacements(mountedTree, preparedTree, stagedReplacements);
10955
+ if (disposeRequested)
10956
+ return;
10957
+ mountedTree = reconcileIncrementalNode(mountedTree, preparedTree, {
10958
+ decide: decideStaticUpdate,
10959
+ update: updateStaticNode,
10960
+ mountReplacement: next => {
10961
+ const staged = stagedReplacements.get(next.occurrenceKey);
10962
+ if (staged) {
10963
+ stagedReplacements.delete(next.occurrenceKey);
10964
+ return staged;
10965
+ }
10966
+ const replacement = mountStaticSubtree(next.node, next.occurrenceKey);
10967
+ const current = replacementSources.get(next.occurrenceKey);
10968
+ if (current
10969
+ && replacement.element !== current.element
10970
+ && replacement.element.parentNode === null) {
10971
+ transferSlotItemPresentation(current.element, replacement.element);
10972
+ }
10973
+ return replacement;
10974
+ },
10975
+ dispose: disposeStaticSubtree,
10976
+ syncBindings: (current, next) => {
10977
+ syncStaticBindings(current.element, next);
10978
+ },
10979
+ onUnreconciled: (error) => {
10980
+ if (unreconciled)
10981
+ return;
10982
+ unreconciled = true;
10983
+ unreconciledError = error ?? new Error('[renderCard] Static incremental update could not be reconciled');
10984
+ },
10985
+ });
10986
+ }
10987
+ finally {
10988
+ for (const replacement of stagedReplacements.values()) {
10989
+ disposeStaticSubtree(replacement);
10990
+ }
10991
+ stagedReplacements.clear();
10992
+ publishing = false;
10993
+ if (disposeRequested)
10994
+ disposeNow();
10995
+ }
10996
+ if (unreconciled)
10997
+ throw unreconciledError;
8378
10998
  if (disposed)
8379
10999
  return;
8380
- if (detail != null)
8381
- variables._event = detail;
8382
- void runActionSteps(steps, buildActionContext()).catch((error) => {
8383
- console.error('[renderCard] Static action failed', error);
8384
- });
8385
- }, (id, lifecycle) => {
8386
- collectedLifecycles.push({ id, lifecycle });
8387
- });
8388
- if (disposed || generation !== renderGeneration)
8389
- return;
8390
- container.replaceChildren(dom);
8391
- restoreScrollPositions(container, scrollPositions);
8392
- restoreMediaStates(container, mediaStates);
11000
+ }
11001
+ restoreDisabledDescendantPresentation(mountedTree);
11002
+ collectMountedLifecycles(mountedTree, collectedLifecycles);
11003
+ if (previousTree) {
11004
+ restoreChangedSubtreeState(previousTree, mountedTree, scrollPositions, mediaStates);
11005
+ }
11006
+ else {
11007
+ restoreScrollPositions(container, scrollPositions);
11008
+ restoreMediaStates(container, mediaStates);
11009
+ }
8393
11010
  if (disposed || generation !== renderGeneration)
8394
11011
  return;
8395
11012
  for (const { id, lifecycle } of collectedLifecycles) {
@@ -8404,27 +11021,63 @@ function renderStaticCard(container, schema, options) {
8404
11021
  actionContext = buildActionContext();
8405
11022
  render();
8406
11023
  }
11024
+ function disposeNow() {
11025
+ if (disposed)
11026
+ return;
11027
+ disposed = true;
11028
+ disposeRequested = false;
11029
+ hostAuthorityEpoch += 1;
11030
+ nodeAccess.dispose();
11031
+ abortController.abort();
11032
+ if (mountedTree) {
11033
+ disposeStaticSubtree(mountedTree);
11034
+ mountedTree = null;
11035
+ }
11036
+ else {
11037
+ disposeChartsIn(container);
11038
+ }
11039
+ container.replaceChildren();
11040
+ for (const record of lifecycleRecords.values())
11041
+ destroyLifecycle(record);
11042
+ }
8407
11043
  // 7. Initial render
8408
11044
  render();
8409
11045
  // 8. Return instance handle
8410
11046
  return {
11047
+ getNode: nodeAccess.getNode,
11048
+ onFocusChange: nodeAccess.onFocusChange,
8411
11049
  dispose() {
8412
- if (disposed)
11050
+ if (disposed || disposeRequested)
8413
11051
  return;
8414
- disposed = true;
8415
- hostAuthorityEpoch += 1;
8416
- abortController.abort();
8417
- disposeChartsIn(container);
8418
- container.replaceChildren();
8419
- for (const record of lifecycleRecords.values())
8420
- destroyLifecycle(record);
11052
+ if (publishing) {
11053
+ disposeRequested = true;
11054
+ return;
11055
+ }
11056
+ disposeNow();
8421
11057
  },
8422
11058
  updateVariables(newVars) {
8423
11059
  if (disposed)
8424
11060
  return;
11061
+ if (publishing) {
11062
+ const error = new Error('[renderCard] STATIC_TRANSACTION_CONFLICT');
11063
+ error.code = 'STATIC_TRANSACTION_CONFLICT';
11064
+ throw error;
11065
+ }
11066
+ const previousVariables = variables;
11067
+ const previousAuthorityEpoch = hostAuthorityEpoch;
8425
11068
  hostAuthorityEpoch += 1;
8426
11069
  variables = { ...variables, ...newVars };
8427
- rerender();
11070
+ try {
11071
+ rerender();
11072
+ }
11073
+ catch (error) {
11074
+ if (!disposed) {
11075
+ variables = previousVariables;
11076
+ hostAuthorityEpoch = previousAuthorityEpoch;
11077
+ actionContext = buildActionContext();
11078
+ }
11079
+ throw error;
11080
+ }
8428
11081
  },
8429
11082
  };
8430
11083
  }
@@ -8450,7 +11103,11 @@ function captureScrollPositions(root) {
8450
11103
  function restoreScrollPositions(root, positions) {
8451
11104
  if (positions.size === 0)
8452
11105
  return;
8453
- root.querySelectorAll('[data-scroll-id]').forEach((el) => {
11106
+ const scrollers = [];
11107
+ if (root.matches('[data-scroll-id]'))
11108
+ scrollers.push(root);
11109
+ scrollers.push(...root.querySelectorAll('[data-scroll-id]'));
11110
+ scrollers.forEach((el) => {
8454
11111
  const id = el.getAttribute('data-scroll-id');
8455
11112
  const saved = id ? positions.get(id) : undefined;
8456
11113
  if (saved == null)
@@ -8490,7 +11147,11 @@ function captureMediaStates(root) {
8490
11147
  function restoreMediaStates(root, states) {
8491
11148
  if (states.size === 0)
8492
11149
  return;
8493
- root.querySelectorAll('ai-card-audio, ai-card-video').forEach((el) => {
11150
+ const mediaHosts = [];
11151
+ if (root.matches('ai-card-audio, ai-card-video'))
11152
+ mediaHosts.push(root);
11153
+ mediaHosts.push(...root.querySelectorAll('ai-card-audio, ai-card-video'));
11154
+ mediaHosts.forEach((el) => {
8494
11155
  const id = el.getAttribute('data-card-id');
8495
11156
  const snap = id ? states.get(id) : undefined;
8496
11157
  if (!snap)
@@ -8499,121 +11160,6 @@ function restoreMediaStates(root, states) {
8499
11160
  restoreMedia(media, snap);
8500
11161
  });
8501
11162
  }
8502
- // ─── Recursive Node Renderer ─────────────────────────────────────
8503
- function renderNode(node, variables, actionContext, isMobile, responsive, schemaActions, writeInputVariable, runEventActions, collectLifecycle) {
8504
- // Check directives.visible
8505
- if (node.directives?.visible) {
8506
- const visibleExpr = node.directives.visible;
8507
- const resolved = hasExpression(visibleExpr)
8508
- ? resolveExpression(visibleExpr, variables)
8509
- : visibleExpr;
8510
- if (resolved === false || resolved === 'false' || resolved === '' || resolved === 0) {
8511
- // Hidden element — return empty placeholder
8512
- const placeholder = document.createElement('div');
8513
- placeholder.style.display = 'none';
8514
- placeholder.setAttribute('data-card-id', node.id);
8515
- return placeholder;
8516
- }
8517
- }
8518
- // Resolve props with variable expressions
8519
- const resolvedProps = resolveDeep(node.props, variables);
8520
- // Resolve content if it's an ExpressionValue
8521
- if (resolvedProps.content && typeof resolvedProps.content === 'object' && 'type' in resolvedProps.content) {
8522
- resolvedProps.content = resolveExpressionValue(resolvedProps.content, variables);
8523
- }
8524
- // Check directives.disabled
8525
- let isDisabled = false;
8526
- if (node.directives?.disabled) {
8527
- const disabledExpr = node.directives.disabled;
8528
- const resolved = hasExpression(disabledExpr)
8529
- ? resolveExpression(disabledExpr, variables)
8530
- : disabledExpr;
8531
- isDisabled = resolved === true || resolved === 'true' || resolved === 1;
8532
- }
8533
- // Lookup component renderer
8534
- const renderer = componentRenderers[node.type] ?? componentRenderers['_default'];
8535
- const el = renderer(node, resolvedProps, isMobile, responsive);
8536
- // Apply disabled styling & attribute
8537
- if (isDisabled) {
8538
- el.setAttribute('data-disabled', 'true');
8539
- el.style.background = '#F5F5F5';
8540
- el.style.color = '#C0C0C0';
8541
- el.style.setProperty('--card-disabled-color', '#C0C0C0');
8542
- el.style.pointerEvents = 'none';
8543
- el.style.cursor = 'default';
8544
- }
8545
- // ── variableKey auto-sync for Input components ──────────────
8546
- // When an Input element declares `variableKey`, its value is
8547
- // automatically written into the reactive variables store on
8548
- // every `input` event (without triggering a full re-render).
8549
- // The value is then available as `${variableKey}` in action params.
8550
- const variableKey = resolvedProps.variableKey;
8551
- if (variableKey) {
8552
- el.addEventListener('input', ((e) => {
8553
- // Nested value controls own their variableKey. Only a value event
8554
- // dispatched by this component may update this component's variable.
8555
- if (e.target !== el)
8556
- return;
8557
- const value = e.detail?.value
8558
- ?? e.target?.value;
8559
- if (value !== undefined) {
8560
- // Write silently — do NOT trigger rerender (avoids losing focus)
8561
- writeInputVariable(variableKey, value, variables);
8562
- }
8563
- }));
8564
- }
8565
- // ── Bind events (skip if disabled) ──
8566
- if (node.events && !isDisabled) {
8567
- for (const [event, eventValue] of Object.entries(node.events)) {
8568
- if (!eventValue)
8569
- continue;
8570
- const resolvedSteps = resolveActionRef(eventValue, schemaActions);
8571
- if (!resolvedSteps)
8572
- continue;
8573
- const domEvent = eventMap[event] ?? event;
8574
- el.addEventListener(domEvent, ((e) => {
8575
- // Value controls own their input/change handlers; a nested control's
8576
- // same-named event must not trigger the parent's schema action.
8577
- // Other component/event pairs keep the renderer's existing bubbling
8578
- // behavior (for example a Container-level click handler).
8579
- const ownsValueEvent = ((domEvent === 'input' || domEvent === 'change')
8580
- && VALUE_CONTROL_TYPES.has(node.type));
8581
- if (ownsValueEvent && e.target !== el)
8582
- return;
8583
- const detail = e instanceof CustomEvent ? e.detail : undefined;
8584
- runEventActions(resolvedSteps, detail);
8585
- }));
8586
- }
8587
- }
8588
- // Register lifecycle
8589
- if (node.lifecycle) {
8590
- collectLifecycle(node.id, node.lifecycle);
8591
- }
8592
- // Render children — use slot layout if applicable, otherwise flat append
8593
- const renderChild = (child) => renderNode(child, variables, actionContext, isMobile, responsive, schemaActions, writeInputVariable, runEventActions, collectLifecycle);
8594
- // Build children-by-id map for layouts that reference IDs (columns groups, float overlays)
8595
- const childrenMap = {};
8596
- for (const child of node.children) {
8597
- childrenMap[child.id] = child;
8598
- }
8599
- const layoutApplied = renderSlotLayout(el, node.children, resolvedProps, renderChild, childrenMap, actionContext);
8600
- if (!layoutApplied) {
8601
- for (const child of node.children) {
8602
- el.appendChild(renderChild(child));
8603
- }
8604
- }
8605
- // Apply disabled grey text to all descendants (after children are appended)
8606
- // Propagate disabled state to child elements (including Shadow DOM custom elements)
8607
- if (isDisabled) {
8608
- el.querySelectorAll('*').forEach((child) => {
8609
- const htmlChild = child;
8610
- htmlChild.setAttribute('data-disabled', 'true');
8611
- htmlChild.style.setProperty('--card-disabled-color', '#C0C0C0');
8612
- });
8613
- }
8614
- applyResponsiveStyles(el, createResponsiveContext(isMobile, responsive));
8615
- return el;
8616
- }
8617
11163
  /** Map schema event names → DOM event names */
8618
11164
  const eventMap = {
8619
11165
  onClick: 'click',
@@ -8667,6 +11213,7 @@ const VALUE_CONTROL_TYPES = new Set([
8667
11213
  * ```
8668
11214
  */
8669
11215
  function renderStreamingCard(container, options = {}) {
11216
+ const nodeAccess = createCardNodeAccess(container);
8670
11217
  // ─── State ──────────────────────────────────────────────────────
8671
11218
  const parser = new StreamingParser(options.parserOptions);
8672
11219
  const elementMap = new Map(); // elementId → DOM element
@@ -8694,19 +11241,32 @@ function renderStreamingCard(container, options = {}) {
8694
11241
  let boundActionQueue = Promise.resolve();
8695
11242
  let boundLifecycleEpoch = 0;
8696
11243
  let disposed = false;
11244
+ let userActionDepth = 0;
8697
11245
  const sourceOccurrences = new Map();
8698
11246
  const activeBoundLifecycles = new Map();
8699
11247
  const mountedBoundLifecycles = new Map();
8700
11248
  const boundLifecycleGenerations = new Map();
11249
+ function applyAutoFocusIds(ids) {
11250
+ if (ids.size === 0)
11251
+ return;
11252
+ for (const id of ids) {
11253
+ const input = elementMap.get(id);
11254
+ if (input instanceof CardInput && input.requestAutoFocus())
11255
+ break;
11256
+ }
11257
+ }
8701
11258
  // ─── Action Context ─────────────────────────────────────────────
8702
11259
  function buildActionContext() {
8703
11260
  return {
8704
11261
  ...createWebActionContext({
8705
11262
  ...options,
8706
11263
  setVariable: (key, value) => {
11264
+ const previousVariables = userActionDepth > 0
11265
+ ? { ...variables }
11266
+ : undefined;
8707
11267
  variables[key] = value;
8708
11268
  // On variable change, patch only affected elements (keyed diff)
8709
- diffAllElements();
11269
+ diffAllElements(previousVariables);
8710
11270
  },
8711
11271
  abortSignal: abortController.signal,
8712
11272
  }),
@@ -8789,6 +11349,27 @@ function renderStreamingCard(container, options = {}) {
8789
11349
  || resolved === ''
8790
11350
  || resolved === 0);
8791
11351
  }
11352
+ function collectBoundAutoFocusRevealIds(previous, next, previousVariables, nextVariables) {
11353
+ const previousNodes = indexBoundNodes(previous.root);
11354
+ const requested = new Set();
11355
+ const visit = (nextNode, previousParentVisible, nextParentVisible) => {
11356
+ const previousNode = previousNodes.get(nextNode.id);
11357
+ const wasVisible = Boolean(previousNode
11358
+ && previousParentVisible
11359
+ && computeBoundVisible(previousNode, previousVariables));
11360
+ const nowVisible = nextParentVisible
11361
+ && computeBoundVisible(nextNode, nextVariables);
11362
+ if (nextNode.type === 'Input'
11363
+ && !wasVisible
11364
+ && nowVisible
11365
+ && resolveBoundNodeProps(nextNode, nextVariables).autoFocus === true) {
11366
+ requested.add(nextNode.id);
11367
+ }
11368
+ nextNode.children.forEach(child => visit(child, wasVisible, nowVisible));
11369
+ };
11370
+ visit(next.root, true, true);
11371
+ return requested;
11372
+ }
8792
11373
  function computeBoundDisabled(node, renderVariables) {
8793
11374
  const disabled = node.directives?.disabled;
8794
11375
  if (!disabled)
@@ -8964,7 +11545,10 @@ function renderStreamingCard(container, options = {}) {
8964
11545
  if (e instanceof CustomEvent && e.detail != null) {
8965
11546
  variables._event = e.detail;
8966
11547
  }
8967
- runSteps(resolvedSteps, node.id);
11548
+ userActionDepth += 1;
11549
+ void runSteps(resolvedSteps, node.id).finally(() => {
11550
+ userActionDepth -= 1;
11551
+ });
8968
11552
  }));
8969
11553
  }
8970
11554
  }
@@ -9019,6 +11603,43 @@ function renderStreamingCard(container, options = {}) {
9019
11603
  : visibleExpr;
9020
11604
  return !(resolved === false || resolved === 'false' || resolved === '' || resolved === 0);
9021
11605
  }
11606
+ function collectLegacyAutoFocusRevealIds(schema, previousVariables) {
11607
+ let tree;
11608
+ try {
11609
+ tree = parseSchema(schema);
11610
+ }
11611
+ catch {
11612
+ return new Set();
11613
+ }
11614
+ const requested = new Set();
11615
+ const ownVisibility = (node, renderVariables) => {
11616
+ const expression = node.directives?.visible;
11617
+ if (!expression)
11618
+ return true;
11619
+ const resolved = hasExpression(expression)
11620
+ ? resolveExpression(expression, renderVariables)
11621
+ : expression;
11622
+ return !(resolved === false
11623
+ || resolved === 'false'
11624
+ || resolved === ''
11625
+ || resolved === 0);
11626
+ };
11627
+ const visit = (node, previousParentVisible, nextParentVisible) => {
11628
+ const wasVisible = previousParentVisible
11629
+ && ownVisibility(node, previousVariables);
11630
+ const nowVisible = nextParentVisible && ownVisibility(node, variables);
11631
+ if (node.type === 'Input'
11632
+ && !wasVisible
11633
+ && nowVisible
11634
+ && resolveDeep(node.props, variables)
11635
+ .autoFocus === true) {
11636
+ requested.add(node.id);
11637
+ }
11638
+ node.children.forEach(child => visit(child, wasVisible, nowVisible));
11639
+ };
11640
+ visit(tree, true, true);
11641
+ return requested;
11642
+ }
9022
11643
  /** Entrance transition for blocks streamed in incrementally. */
9023
11644
  function animateEnter(el) {
9024
11645
  if (options.appearTransition === false)
@@ -9092,11 +11713,14 @@ function renderStreamingCard(container, options = {}) {
9092
11713
  * (visibility flips / container shape changes) re-render only their subtree.
9093
11714
  * Falls back to a full render solely when a subtree can't be rebuilt.
9094
11715
  */
9095
- function diffAllElements() {
11716
+ function diffAllElements(previousVariables) {
9096
11717
  actionContext = buildActionContext();
9097
11718
  const schema = (currentSurfaceId ? engine.getSchema(currentSurfaceId) : undefined) ?? currentSchema;
9098
11719
  if (!schema)
9099
11720
  return;
11721
+ const autoFocusIds = previousVariables
11722
+ ? collectLegacyAutoFocusRevealIds(schema, previousVariables)
11723
+ : new Set();
9100
11724
  for (const [id, element] of Object.entries(schema.elements)) {
9101
11725
  const el = elementMap.get(id);
9102
11726
  if (!el)
@@ -9107,6 +11731,7 @@ function renderStreamingCard(container, options = {}) {
9107
11731
  // Visibility flipped (either direction) → rebuild this subtree in place
9108
11732
  if (!replaceSubtree(schema, id, el)) {
9109
11733
  safeRenderFull();
11734
+ applyAutoFocusIds(autoFocusIds);
9110
11735
  return;
9111
11736
  }
9112
11737
  continue;
@@ -9123,9 +11748,11 @@ function renderStreamingCard(container, options = {}) {
9123
11748
  }
9124
11749
  else if (!replaceSubtree(schema, id, el)) {
9125
11750
  safeRenderFull();
11751
+ applyAutoFocusIds(autoFocusIds);
9126
11752
  return;
9127
11753
  }
9128
11754
  }
11755
+ applyAutoFocusIds(autoFocusIds);
9129
11756
  }
9130
11757
  function captureBoundScrollPositions(root) {
9131
11758
  const positions = new Map();
@@ -9848,7 +12475,10 @@ function renderStreamingCard(container, options = {}) {
9848
12475
  if (!steps)
9849
12476
  return;
9850
12477
  await runBoundSteps(steps, sourceNode, createBoundActionContext(sourceNode, draft));
12478
+ const nextMaterialized = materializeStreamingCard(currentSchema, draft);
12479
+ const autoFocusIds = collectBoundAutoFocusRevealIds(currentMaterialized, nextMaterialized, before, draft);
9851
12480
  commitBoundDraftTransaction(before, draft, baseRevision);
12481
+ applyAutoFocusIds(autoFocusIds);
9852
12482
  }
9853
12483
  function enqueueBoundEvent(runtimeId, eventName, eventDetail) {
9854
12484
  boundActionQueue = boundActionQueue
@@ -10430,6 +13060,8 @@ function renderStreamingCard(container, options = {}) {
10430
13060
  });
10431
13061
  // ─── Public API ─────────────────────────────────────────────────
10432
13062
  return {
13063
+ getNode: nodeAccess.getNode,
13064
+ onFocusChange: nodeAccess.onFocusChange,
10433
13065
  applyCommand(command) {
10434
13066
  if (!lockMode('commands'))
10435
13067
  return;
@@ -10506,6 +13138,7 @@ function renderStreamingCard(container, options = {}) {
10506
13138
  if (disposed)
10507
13139
  return;
10508
13140
  disposed = true;
13141
+ nodeAccess.dispose();
10509
13142
  boundRevision += 1;
10510
13143
  abortController.abort();
10511
13144
  teardownBoundLifecycles();