@stacksjs/stx 0.2.113 → 0.2.115

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/signals.js CHANGED
@@ -264,18 +264,19 @@ else {
264
264
  // expressions like store.someSignal (no parens) and store.x === 'y'
265
265
  // resolve to the value. Action methods on the store pass through as
266
266
  // functions because they don't carry _isSignal/_isDerived markers.
267
- function createAutoUnwrapProxy(scope) {
267
+ function createAutoUnwrapProxy(scope, preserveSignal) {
268
268
  return new Proxy(scope, {
269
269
  get(target, prop) {
270
270
  const val = target[prop];
271
271
  // If it's a signal or derived, call it to get the value
272
272
  if (val && typeof val === 'function' && (val._isSignal || val._isDerived)) {
273
+ if (preserveSignal && preserveSignal(prop, val)) return val;
273
274
  return val();
274
275
  }
275
276
  // If it's an stx store, return a recursively-unwrapping wrapper so
276
277
  // store.signalProp resolves to the value in template expressions
277
278
  if (val && typeof val === 'object' && val._isStxStore) {
278
- return createAutoUnwrapProxy(val);
279
+ return createAutoUnwrapProxy(val, preserveSignal);
279
280
  }
280
281
  return val;
281
282
  },
@@ -700,8 +701,32 @@ finally {
700
701
  const mountCallbacks = [];
701
702
  const destroyCallbacks = [];
702
703
 
703
- function onMount(fn) { mountCallbacks.push(fn); }
704
- function onDestroy(fn) { destroyCallbacks.push(fn); }
704
+ function currentLifecycleScope() {
705
+ var el = window.__STX_CURRENT_ELEMENT__;
706
+ if (!el || !el.getAttribute || !window.stx || !window.stx._scopes) return null;
707
+ var scopeId = el.getAttribute('data-stx-scope');
708
+ return scopeId ? window.stx._scopes[scopeId] || null : null;
709
+ }
710
+
711
+ function onMount(fn) {
712
+ var scope = currentLifecycleScope();
713
+ if (scope) {
714
+ scope.__mountCallbacks = scope.__mountCallbacks || [];
715
+ scope.__mountCallbacks.push(fn);
716
+ return;
717
+ }
718
+ mountCallbacks.push(fn);
719
+ }
720
+
721
+ function onDestroy(fn) {
722
+ var scope = currentLifecycleScope();
723
+ if (scope) {
724
+ scope.__destroyCallbacks = scope.__destroyCallbacks || [];
725
+ scope.__destroyCallbacks.push(fn);
726
+ return;
727
+ }
728
+ destroyCallbacks.push(fn);
729
+ }
705
730
 
706
731
  // Drain any early mount/destroy calls captured by the pre-initialization shim
707
732
  if (window.__stx_early_mounts) { window.__stx_early_mounts.forEach(function(fn) { mountCallbacks.push(fn); }); window.__stx_early_mounts = null; }
@@ -959,17 +984,22 @@ else {
959
984
 
960
985
  // setRouteParams: called by the server-injected script to set initial params
961
986
  function setRouteParams(params) {
962
- _routeParams.set(params || {});
987
+ var nextParams = params || {};
988
+ _routeParams.set(nextParams);
989
+ window.__stx_rp = nextParams;
990
+ if (window.stx) window.stx._rp = nextParams;
963
991
  }
964
992
 
965
993
  // Listen for SPA navigation to update params
966
994
  window.addEventListener('stx:navigate', function(e) {
967
- // Parse params from URL if route patterns are registered
968
- // For now, reset params \u2014 the server will inject them on full loads
969
- // and the page's script can set them via setRouteParams() on SPA nav
970
- if (e.detail && e.detail.params) {
971
- _routeParams.set(e.detail.params);
972
- }
995
+ // The destination fragment's data-stx-route-params script runs after this
996
+ // event and before stx:load. Reset first so a static route can never inherit
997
+ // params from the dynamic route that preceded it.
998
+ var nextParams = e.detail
999
+ && Object.prototype.hasOwnProperty.call(e.detail, 'params')
1000
+ ? e.detail.params
1001
+ : {};
1002
+ setRouteParams(nextParams);
973
1003
  });
974
1004
 
975
1005
  // Apply any server-injected params (set before runtime loaded)
@@ -1252,19 +1282,31 @@ finally {
1252
1282
  }
1253
1283
  // First try component-level scope
1254
1284
  const elementScope = findElementScope(el || currentElement);
1255
- const baseScope = { ...componentScope, ...(elementScope || {}), ...globalHelpers };
1285
+ const baseScope = { ...globalHelpers, ...componentScope, ...(elementScope || {}) };
1256
1286
 
1257
1287
  // Check for pipe syntax (Feature #2)
1258
1288
  const pipeResult = parsePipeExpression(expr, baseScope);
1259
1289
  if (pipeResult) {
1260
1290
  // Use auto-unwrap proxy for pipe expressions
1261
- const unwrapScope = enableAutoUnwrap ? createAutoUnwrapProxy(baseScope) : baseScope;
1291
+ const unwrapScope = enableAutoUnwrap
1292
+ ? createAutoUnwrapProxy(baseScope, function(prop) {
1293
+ if (typeof prop !== 'string') return false;
1294
+ return expressionCallsSignal(expr, prop)
1295
+ || expressionUsesSignalApi(expr, prop);
1296
+ })
1297
+ : baseScope;
1262
1298
  const piped = executePipeExpression(pipeResult.valueExpr, pipeResult.pipes, unwrapScope);
1263
1299
  return (piped && typeof piped === 'function' && (piped._isSignal || piped._isDerived)) ? piped() : piped;
1264
1300
  }
1265
1301
 
1266
1302
  // Use auto-unwrap proxy if enabled (Feature #1)
1267
- const scope = enableAutoUnwrap ? createAutoUnwrapProxy(baseScope) : baseScope;
1303
+ const scope = enableAutoUnwrap
1304
+ ? createAutoUnwrapProxy(baseScope, function(prop) {
1305
+ if (typeof prop !== 'string') return false;
1306
+ return expressionCallsSignal(expr, prop)
1307
+ || expressionUsesSignalApi(expr, prop);
1308
+ })
1309
+ : baseScope;
1268
1310
  const fn = new Function(...Object.keys(baseScope), 'return ' + expr);
1269
1311
  const result = fn(...Object.values(scope));
1270
1312
  // Post-eval unwrap \u2014 see evalAttrExpr note for the reason.
@@ -1364,6 +1406,50 @@ catch (e) {
1364
1406
  return null;
1365
1407
  }
1366
1408
 
1409
+ function expressionUsesSignalApi(expression, name) {
1410
+ var escapedName = name.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');
1411
+ return expressionUsesSignalMethod(expression, name)
1412
+ || expressionUsesSignalValue(expression, name);
1413
+ }
1414
+
1415
+ function expressionUsesSignalMethod(expression, name) {
1416
+ var escapedName = name.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');
1417
+ return new RegExp('(?:^|[^\\\\w$])' + escapedName + '\\\\s*\\\\.\\\\s*(?:set|update|subscribe)\\\\s*\\\\(').test(expression);
1418
+ }
1419
+
1420
+ function expressionUsesSignalValue(expression, name) {
1421
+ var escapedName = name.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');
1422
+ return new RegExp('(?:^|[^\\\\w$])' + escapedName + '\\\\s*\\\\.\\\\s*value\\\\b').test(expression);
1423
+ }
1424
+
1425
+ function expressionCallsSignal(expression, name) {
1426
+ var escapedName = name.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&');
1427
+ return new RegExp('(?:^|[^\\\\w$])' + escapedName + '\\\\s*\\\\(').test(expression);
1428
+ }
1429
+
1430
+ function createExpressionAutoUnwrapProxy(scope, expression) {
1431
+ return createAutoUnwrapProxy(scope, function(prop, signal) {
1432
+ if (typeof prop !== 'string') return false;
1433
+ if (expressionCallsSignal(expression, prop) || expressionUsesSignalMethod(expression, prop))
1434
+ return true;
1435
+ if (!expressionUsesSignalValue(expression, prop))
1436
+ return false;
1437
+
1438
+ // A :for item is itself represented by a signal. If that item's value
1439
+ // is an object with a real value field, item.value means the field,
1440
+ // not the signal wrapper API. Primitive signals still support
1441
+ // count.value, matching the explicit ref syntax.
1442
+ try {
1443
+ var currentValue = signal();
1444
+ return !(currentValue && typeof currentValue === 'object'
1445
+ && Object.prototype.hasOwnProperty.call(currentValue, 'value'));
1446
+ }
1447
+ catch (e) {
1448
+ return true;
1449
+ }
1450
+ });
1451
+ }
1452
+
1367
1453
  function executeHandler(expr, event, el) {
1368
1454
  try {
1369
1455
  // Skip placeholder expressions like __TITLE__ (build-time placeholders)
@@ -1372,7 +1458,7 @@ catch (e) {
1372
1458
  }
1373
1459
  // First try component-level scope
1374
1460
  const elementScope = findElementScope(el || currentElement);
1375
- const scope = { ...componentScope, ...(elementScope || {}), ...globalHelpers };
1461
+ const scope = { ...globalHelpers, ...componentScope, ...(elementScope || {}) };
1376
1462
 
1377
1463
  // Check for shorthand syntax (Feature #8)
1378
1464
  const shorthandFn = parseEventShorthand(expr, scope);
@@ -1534,6 +1620,111 @@ catch (e) {
1534
1620
  processElement(el, scope);
1535
1621
  }
1536
1622
 
1623
+ function isForDirectiveExpression(expr) {
1624
+ return typeof expr === 'string'
1625
+ && /^\\s*\\(?\\s*\\w+(?:\\s*,\\s*\\w+)?\\s*\\)?\\s+(?:in|of)\\s+.+\\s*$/.test(expr);
1626
+ }
1627
+
1628
+ function getModelBinding(el) {
1629
+ if (!el || !el.attributes) return null;
1630
+ var prefixes = ['x-model', ':model', '@model'];
1631
+ var attrs = Array.from(el.attributes);
1632
+ for (var i = 0; i < attrs.length; i++) {
1633
+ var name = attrs[i] && attrs[i].name;
1634
+ if (!name) continue;
1635
+ for (var j = 0; j < prefixes.length; j++) {
1636
+ if (name === prefixes[j] || name.indexOf(prefixes[j] + '.') === 0)
1637
+ return { name: name, expression: attrs[i].value };
1638
+ }
1639
+ }
1640
+ return null;
1641
+ }
1642
+
1643
+ function bindParentComponentProps(el, callerScope) {
1644
+ if (!el || !el.getAttribute || el.__stx_parent_props_bound) return;
1645
+ var names = (el.getAttribute('data-stx-parent-bindings') || '').split(/\\s+/).filter(Boolean);
1646
+ if (!names.length) return;
1647
+ el.__stx_parent_props_bound = true;
1648
+ var scope = { ...globalHelpers, ...callerScope };
1649
+
1650
+ names.forEach(function(name) {
1651
+ var sourceName = ':' + name;
1652
+ if (!el.hasAttribute(sourceName)) return;
1653
+ var expression = el.getAttribute(sourceName);
1654
+ effect(function() {
1655
+ var value;
1656
+ try {
1657
+ // Support both signal styles in forwarded props:
1658
+ // :open="open" and :open="open()".
1659
+ // Auto-unwrap bare signals, but preserve a signal function when this
1660
+ // expression explicitly calls it or accesses its object API.
1661
+ var unwrapScope = createAutoUnwrapProxy(scope, function(prop) {
1662
+ if (typeof prop !== 'string') return false;
1663
+ return expressionCallsSignal(expression, prop)
1664
+ || expressionUsesSignalApi(expression, prop);
1665
+ });
1666
+ var fn = new Function('__scope__', 'with(__scope__) { return (' + expression + ') }');
1667
+ value = fn(unwrapScope);
1668
+ if (value && typeof value === 'function' && (value._isSignal || value._isDerived)) value = value();
1669
+ }
1670
+ catch (e) {
1671
+ if (!(e instanceof ReferenceError) && !(e instanceof TypeError))
1672
+ console.warn('[STX] Parent component prop error:', expression, e);
1673
+ value = undefined;
1674
+ }
1675
+
1676
+ if (value === false || value === null || value === undefined) {
1677
+ el.removeAttribute(name);
1678
+ }
1679
+ else if (value === true) {
1680
+ el.setAttribute(name, '');
1681
+ }
1682
+ else {
1683
+ var serialized = value;
1684
+ if (typeof value === 'object') {
1685
+ try { serialized = JSON.stringify(value); }
1686
+ catch (e) { serialized = String(value); }
1687
+ }
1688
+ el.setAttribute(name, serialized);
1689
+ }
1690
+ });
1691
+ el.removeAttribute(sourceName);
1692
+ });
1693
+ }
1694
+
1695
+ function resolveComponentCallerScope(el, pageScope) {
1696
+ var resolved = { ...pageScope };
1697
+ var ancestors = [];
1698
+ var current = el && (el.parentElement || el.parentNode);
1699
+ while (current && current !== document) {
1700
+ if (current.getAttribute && current.hasAttribute('data-stx-scope'))
1701
+ ancestors.push(current.getAttribute('data-stx-scope'));
1702
+ current = current.parentElement || current.parentNode;
1703
+ }
1704
+ ancestors.reverse().forEach(function(scopeId) {
1705
+ var ancestorScope = window.stx._scopes && window.stx._scopes[scopeId];
1706
+ if (ancestorScope) Object.assign(resolved, ancestorScope);
1707
+ });
1708
+ return resolved;
1709
+ }
1710
+
1711
+ // A false :if detaches its subtree before the DOMContentLoaded scope walk
1712
+ // reaches nested components. Capture and bind their caller-owned props while
1713
+ // the ancestor chain is still connected, otherwise resolveComponentCallerScope
1714
+ // can no longer see the component that declared expressions such as
1715
+ // :options="environmentOptions".
1716
+ function preserveConditionalComponentScopes(root, callerScope) {
1717
+ if (!root || !root.querySelectorAll) return;
1718
+ var scopeEls = [];
1719
+ if (root.matches && root.matches('[data-stx-scope]')) scopeEls.push(root);
1720
+ root.querySelectorAll('[data-stx-scope]').forEach(function(scopeEl) { scopeEls.push(scopeEl); });
1721
+ scopeEls.forEach(function(scopeEl) {
1722
+ if (scopeEl.__stx_parent_props_bound) return;
1723
+ scopeEl.__stx_parent_scope = resolveComponentCallerScope(scopeEl, callerScope);
1724
+ bindParentComponentProps(scopeEl, scopeEl.__stx_parent_scope);
1725
+ });
1726
+ }
1727
+
1537
1728
  function processElement(el, scope = componentScope) {
1538
1729
  // Debug: log every element with x-class or @click
1539
1730
  if (el.nodeType === Node.ELEMENT_NODE && el.hasAttribute) {
@@ -1566,7 +1757,7 @@ catch (e) {
1566
1757
  if (el.hasAttribute('data-stx-memo')) {
1567
1758
  var memoExpr = el.getAttribute('data-stx-memo');
1568
1759
  try {
1569
- var memoScope = { ...scope, ...(findElementScope(el) || {}), ...globalHelpers };
1760
+ var memoScope = { ...globalHelpers, ...scope, ...(findElementScope(el) || {}) };
1570
1761
  var memoFn = new Function(...Object.keys(memoScope), 'return ' + memoExpr);
1571
1762
  var memoVals = JSON.stringify(memoFn(...Object.values(memoScope)));
1572
1763
  if (el.__stx_memo_prev === memoVals) {
@@ -1586,7 +1777,7 @@ catch (e) {
1586
1777
  // Capture scope NOW before effects run asynchronously
1587
1778
  // Use the passed scope parameter (not global componentScope) to preserve context
1588
1779
  // through nested @if/@for processing where componentScope may be restored
1589
- const capturedScope = { ...scope, ...(findElementScope(parentEl) || {}), ...globalHelpers };
1780
+ const capturedScope = { ...globalHelpers, ...scope, ...(findElementScope(parentEl) || {}) };
1590
1781
  parts.forEach(part => {
1591
1782
  const match = part.match(/^\\{\\{\\s*([\\s\\S]+?)\\s*\\}\\}$/);
1592
1783
  if (match) {
@@ -1596,22 +1787,29 @@ catch (e) {
1596
1787
  fragment.appendChild(document.createTextNode(part));
1597
1788
  return;
1598
1789
  }
1599
- const span = document.createElement('span');
1600
- fragment.appendChild(span);
1790
+ // Native text-only elements cannot contain the span wrapper used
1791
+ // for general interpolation. Bind a text node there so options,
1792
+ // titles, and textareas retain valid DOM and accessible labels.
1793
+ const isTextOnlyParent = parentEl
1794
+ && (parentEl.tagName === 'OPTION' || parentEl.tagName === 'TITLE' || parentEl.tagName === 'TEXTAREA');
1795
+ const bindingNode = isTextOnlyParent
1796
+ ? document.createTextNode('')
1797
+ : document.createElement('span');
1798
+ fragment.appendChild(bindingNode);
1601
1799
  // Use captured scope, not dynamic lookup
1602
1800
  effect(() => {
1603
1801
  try {
1604
1802
  // Check for pipe syntax (Feature #2)
1605
1803
  const pipeResult = parsePipeExpression(expr, capturedScope);
1606
1804
  if (pipeResult) {
1607
- const unwrapScope = createAutoUnwrapProxy(capturedScope);
1608
- span.textContent = executePipeExpression(pipeResult.valueExpr, pipeResult.pipes, unwrapScope);
1805
+ const unwrapScope = createExpressionAutoUnwrapProxy(capturedScope, expr);
1806
+ bindingNode.textContent = executePipeExpression(pipeResult.valueExpr, pipeResult.pipes, unwrapScope);
1609
1807
  }
1610
1808
  else {
1611
1809
  // Use auto-unwrap proxy (Feature #1)
1612
- const unwrapScope = createAutoUnwrapProxy(capturedScope);
1810
+ const unwrapScope = createExpressionAutoUnwrapProxy(capturedScope, expr);
1613
1811
  const fn = new Function(...Object.keys(capturedScope), 'return ' + expr);
1614
- span.textContent = fn(...Object.values(unwrapScope));
1812
+ bindingNode.textContent = fn(...Object.values(unwrapScope));
1615
1813
  }
1616
1814
  }
1617
1815
  catch (e) {
@@ -1620,11 +1818,11 @@ catch (e) {
1620
1818
  // Retry without auto-unwrap so signal functions remain callable.
1621
1819
  try {
1622
1820
  const fn = new Function(...Object.keys(capturedScope), 'return ' + expr);
1623
- span.textContent = fn(...Object.values(capturedScope));
1821
+ bindingNode.textContent = fn(...Object.values(capturedScope));
1624
1822
  }
1625
1823
  catch (e2) {
1626
1824
  if (!(e2 instanceof ReferenceError) && !(e2 instanceof TypeError)) console.warn('[STX] Expression error:', expr, e2);
1627
- span.textContent = '';
1825
+ bindingNode.textContent = '';
1628
1826
  }
1629
1827
  }
1630
1828
  });
@@ -1652,8 +1850,9 @@ else if (part) {
1652
1850
  }
1653
1851
 
1654
1852
  // Handle @for / :for / x-for first (reactive list)
1655
- if (el.hasAttribute('@for') || el.hasAttribute(':for') || el.hasAttribute('x-for')) {
1656
- var forAttr = el.hasAttribute(':for') ? ':for' : el.hasAttribute('x-for') ? 'x-for' : '@for';
1853
+ var hasColonForLoop = el.hasAttribute(':for') && isForDirectiveExpression(el.getAttribute(':for'));
1854
+ if (el.hasAttribute('@for') || hasColonForLoop || el.hasAttribute('x-for')) {
1855
+ var forAttr = hasColonForLoop ? ':for' : el.hasAttribute('x-for') ? 'x-for' : '@for';
1657
1856
  bindFor(el, scope, forAttr);
1658
1857
  return;
1659
1858
  }
@@ -1697,14 +1896,14 @@ else if (part) {
1697
1896
  }
1698
1897
 
1699
1898
  // Handle @model / :model / x-model (two-way binding)
1700
- if (el.hasAttribute('@model') || el.hasAttribute(':model') || el.hasAttribute('x-model')) {
1701
- var modelAttr = el.hasAttribute(':model') ? ':model' : el.hasAttribute('x-model') ? 'x-model' : '@model';
1702
- bindModel(el, el.getAttribute(modelAttr), scope, modelAttr);
1899
+ var modelBinding = getModelBinding(el);
1900
+ if (modelBinding) {
1901
+ bindModel(el, modelBinding.expression, scope, modelBinding.name);
1703
1902
  }
1704
1903
 
1705
1904
  // Capture scope once for all attribute bindings on this element
1706
1905
  // Use the passed scope parameter to preserve context through nested processing
1707
- const attrCapturedScope = { ...scope, ...(findElementScope(el) || {}), ...globalHelpers };
1906
+ const attrCapturedScope = { ...globalHelpers, ...scope, ...(findElementScope(el) || {}) };
1708
1907
 
1709
1908
  // Post-eval unwrap: if the expression result is a signal function (e.g.
1710
1909
  // step was resolved via identifier lookup, not via the auto-unwrap
@@ -1717,7 +1916,8 @@ else if (part) {
1717
1916
  if (v && typeof v === 'function' && (v._isSignal || v._isDerived)) return v();
1718
1917
  return v;
1719
1918
  };
1720
- const evalAttrExpr = (rawExpr) => {
1919
+ const evalAttrExpr = (rawExpr, evaluationScope) => {
1920
+ const activeScope = evaluationScope || attrCapturedScope;
1721
1921
  // Decode HTML entities that browsers may encode in attribute values
1722
1922
  // e.g. :text="a > b" may be stored as :text="a &gt; b" in HTML
1723
1923
  var expr = rawExpr.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&#39;/g,"'").replace(/&#x27;/g,"'");
@@ -1725,23 +1925,23 @@ else if (part) {
1725
1925
  if (/^__[A-Z_]+__$/.test(expr.trim())) return expr;
1726
1926
 
1727
1927
  // Check for pipe syntax (Feature #2)
1728
- const pipeResult = parsePipeExpression(expr, attrCapturedScope);
1928
+ const pipeResult = parsePipeExpression(expr, activeScope);
1729
1929
  if (pipeResult) {
1730
- const unwrapScope = createAutoUnwrapProxy(attrCapturedScope);
1930
+ const unwrapScope = createExpressionAutoUnwrapProxy(activeScope, expr);
1731
1931
  return maybeUnwrapSignal(executePipeExpression(pipeResult.valueExpr, pipeResult.pipes, unwrapScope));
1732
1932
  }
1733
1933
 
1734
1934
  // Use auto-unwrap proxy (Feature #1)
1735
- const unwrapScope = createAutoUnwrapProxy(attrCapturedScope);
1736
- const fn = new Function(...Object.keys(attrCapturedScope), 'return ' + expr);
1737
- return maybeUnwrapSignal(fn(...Object.values(unwrapScope)));
1935
+ const unwrapScope = createExpressionAutoUnwrapProxy(activeScope, expr);
1936
+ const fn = new Function('__scope__', 'with(__scope__) { return (' + expr + ') }');
1937
+ return maybeUnwrapSignal(fn(unwrapScope));
1738
1938
  }
1739
1939
  catch (e) {
1740
1940
  // Auto-unwrap can break explicit signal calls like errorData().message
1741
1941
  // Retry without auto-unwrap so signal functions remain callable
1742
1942
  try {
1743
- const fn = new Function(...Object.keys(attrCapturedScope), 'return ' + expr);
1744
- return maybeUnwrapSignal(fn(...Object.values(attrCapturedScope)));
1943
+ const fn = new Function('__scope__', 'with(__scope__) { return (' + expr + ') }');
1944
+ return maybeUnwrapSignal(fn(activeScope));
1745
1945
  }
1746
1946
  catch (e2) {
1747
1947
  if (!(e2 instanceof ReferenceError) && !(e2 instanceof TypeError)) console.warn('[STX] Attribute expression error:', expr, e2);
@@ -1751,7 +1951,7 @@ catch (e2) {
1751
1951
  };
1752
1952
 
1753
1953
  // Known directive names to exclude from generic :attr binding
1754
- var DIRECTIVE_NAMES = {class:1, style:1, text:1, html:1, show:1, model:1, 'if':1, 'for':1, ref:1};
1954
+ var DIRECTIVE_NAMES = {class:1, style:1, text:1, html:1, show:1, model:1, 'if':1, ref:1};
1755
1955
  // SVG attributes are case-sensitive, but the HTML parser lowercases
1756
1956
  // prefixed attribute names \u2014 ':viewBox' arrives as ':viewbox' because
1757
1957
  // the spec's "adjust SVG attributes" step only covers unprefixed names.
@@ -1785,8 +1985,27 @@ catch (e2) {
1785
1985
  || (name.startsWith('x-') && !X_HANDLED[name.split('.')[0]] && !X_HANDLED[name])) {
1786
1986
  let attrName = name.startsWith('@bind:') ? name.slice(6) : name.startsWith('x-bind:') ? name.slice(7) : name.startsWith('x-') ? name.slice(2) : name.slice(1);
1787
1987
  if (el.namespaceURI === SVG_NS && SVG_ATTR_CASE[attrName]) attrName = SVG_ATTR_CASE[attrName];
1988
+ var parentBindingNames = (el.getAttribute && el.getAttribute('data-stx-parent-bindings') || '').split(/\\s+/);
1989
+ var bindingScope = el.__stx_parent_scope && parentBindingNames.includes(attrName)
1990
+ ? el.__stx_parent_scope
1991
+ : attrCapturedScope;
1788
1992
  effect(() => {
1789
- const v = evalAttrExpr(value);
1993
+ const v = evalAttrExpr(value, bindingScope);
1994
+ // Form control values are live DOM properties. Setting only the HTML
1995
+ // attribute does not update a <select>'s chosen option and can leave
1996
+ // inputs displaying stale text after a reactive update.
1997
+ if (attrName === 'value' && 'value' in el) {
1998
+ var controlValue = v;
1999
+ if (typeof v === 'object' && v !== null) {
2000
+ try { controlValue = JSON.stringify(v); }
2001
+ catch (e) { controlValue = String(v); }
2002
+ }
2003
+ controlValue = v === false || v === null || v === undefined ? '' : String(controlValue);
2004
+ el.value = controlValue;
2005
+ if (controlValue === '' && el.tagName !== 'OPTION') el.removeAttribute(attrName);
2006
+ else el.setAttribute(attrName, controlValue);
2007
+ return;
2008
+ }
1790
2009
  if (v === false || v === null || v === undefined) {
1791
2010
  el.removeAttribute(attrName);
1792
2011
  }
@@ -1794,7 +2013,12 @@ else if (v === true) {
1794
2013
  el.setAttribute(attrName, '');
1795
2014
  }
1796
2015
  else {
1797
- el.setAttribute(attrName, v);
2016
+ var attrValue = v;
2017
+ if (typeof v === 'object') {
2018
+ try { attrValue = JSON.stringify(v); }
2019
+ catch (e) { attrValue = String(v); }
2020
+ }
2021
+ el.setAttribute(attrName, attrValue);
1798
2022
  }
1799
2023
  });
1800
2024
  el.removeAttribute(name);
@@ -1827,7 +2051,7 @@ else if (name === 'ref' || name === ':ref' || name === 'x-ref' || name === 'data
1827
2051
  if (scope.$refs) scope.$refs[value] = el;
1828
2052
  if (componentScope.$refs) componentScope.$refs[value] = el;
1829
2053
  }
1830
- else if (name.startsWith('@') || name.startsWith(':')) {
2054
+ else if (name.startsWith('@') || name.startsWith(':')) {
1831
2055
  // Event handlers: @click, :click, @submit.prevent, :keydown.enter, etc.
1832
2056
  const parts = name.slice(1).split('.');
1833
2057
  const eventName = parts[0];
@@ -1844,11 +2068,28 @@ else if (name.startsWith('@') || name.startsWith(':')) {
1844
2068
  el[eventKey] = true;
1845
2069
 
1846
2070
  // Capture scope at setup time so @for loop variables are available when event fires
1847
- const eventCapturedScope = { ...scope, ...(findElementScope(el) || {}), ...globalHelpers };
2071
+ const parentEventNames = (el.getAttribute && el.getAttribute('data-stx-parent-events') || '').split(/\\s+/);
2072
+ const isForwardedComponentEvent = !!(el.__stx_parent_scope && parentEventNames.includes(eventName));
2073
+ const eventCapturedScope = isForwardedComponentEvent
2074
+ ? { ...globalHelpers, ...el.__stx_parent_scope }
2075
+ : { ...globalHelpers, ...scope, ...(findElementScope(el) || {}) };
1848
2076
 
1849
2077
  console.log('[stx] binding event:', eventName, 'on', el.tagName, 'expr:', value.substring(0, 40));
1850
2078
  el.addEventListener(eventName, (event) => {
1851
2079
  console.log('[stx] event fired:', eventName, 'on', el.tagName);
2080
+ // A component listener belongs to the component root. Ignore native
2081
+ // events bubbling out of its internal markup, otherwise a form
2082
+ // component with @submit fires the parent once for the native form
2083
+ // submit and again for defineEmits('submit').
2084
+ if (isForwardedComponentEvent && event.target !== el) return;
2085
+
2086
+ // Vue-style component handlers receive the emitted payload directly.
2087
+ // Native root events still receive the Event object so server-only
2088
+ // components can forward clicks and other browser events.
2089
+ var handlerEvent = isForwardedComponentEvent && event instanceof CustomEvent
2090
+ ? event.detail
2091
+ : event;
2092
+
1852
2093
  // Skip clicks on elements that just became visible in this frame.
1853
2094
  // Prevents modal backdrop from catching the click that opened the modal.
1854
2095
  if (eventName === 'click') {
@@ -1876,7 +2117,7 @@ else if (name.startsWith('@') || name.startsWith(':')) {
1876
2117
  try {
1877
2118
  if (!value || /^__[A-Z_]+__$/.test(value.trim())) return;
1878
2119
  var shorthandFn = parseEventShorthand(value, eventCapturedScope);
1879
- if (shorthandFn) { shorthandFn(event); return; }
2120
+ if (shorthandFn) { shorthandFn(handlerEvent); return; }
1880
2121
  // Check if any scope values are signals (x-data pattern)
1881
2122
  var hasSignals = Object.values(eventCapturedScope).some(function(v) {
1882
2123
  return v && typeof v === 'function' && v._isSignal;
@@ -1898,23 +2139,23 @@ else if (name.startsWith('@') || name.startsWith(':')) {
1898
2139
  }).join(';');
1899
2140
  var body = getVars + ';' + value + ';' + setVars;
1900
2141
  var fn2 = new Function('__s', '$event', body);
1901
- fn2(eventCapturedScope, event);
2142
+ fn2(eventCapturedScope, handlerEvent);
1902
2143
  } else if (hasSignals) {
1903
- // Function call with signals in scope \u2014 keep signals that have .set()
1904
- // called on them as raw signal functions, unwrap the rest for reading
2144
+ // Function calls with signals in scope keep any signal used through
2145
+ // its object API as a raw signal function, and unwrap the rest for
2146
+ // primitive reads.
1905
2147
  var unwrapVars = Object.keys(eventCapturedScope).map(function(k) {
1906
2148
  var v = eventCapturedScope[k];
1907
- // If this signal is used with .set() in the expression, keep it as-is
1908
- if (v && typeof v === 'function' && v._isSignal && value.includes(k + '.set(')) {
2149
+ if (v && typeof v === 'function' && v._isSignal && expressionUsesSignalApi(value, k)) {
1909
2150
  return 'var ' + k + ' = __s["' + k + '"]';
1910
2151
  }
1911
2152
  return 'var ' + k + ' = __s["' + k + '"] && typeof __s["' + k + '"] === "function" && __s["' + k + '"]._isSignal ? __s["' + k + '"]() : __s["' + k + '"]';
1912
2153
  }).join(';');
1913
2154
  var fn3 = new Function('__s', '$event', unwrapVars + ';' + value);
1914
- fn3(eventCapturedScope, event);
2155
+ fn3(eventCapturedScope, handlerEvent);
1915
2156
  } else {
1916
2157
  var fn = new Function(...Object.keys(eventCapturedScope), '$event', value);
1917
- fn(...Object.values(eventCapturedScope), event);
2158
+ fn(...Object.values(eventCapturedScope), handlerEvent);
1918
2159
  }
1919
2160
  }
1920
2161
  catch (e) {
@@ -2015,7 +2256,7 @@ catch (e) {
2015
2256
 
2016
2257
  const currentDisplay = el.style.display;
2017
2258
  const originalDisplay = (currentDisplay && currentDisplay !== 'none') ? currentDisplay : '';
2018
- const capturedScope = { ...passedScope, ...(findElementScope(el) || {}), ...globalHelpers };
2259
+ const capturedScope = { ...globalHelpers, ...passedScope, ...(findElementScope(el) || {}) };
2019
2260
 
2020
2261
  // Check if the expression is a simple signal reference (most common case for :show)
2021
2262
  const directSignal = capturedScope[expr];
@@ -2035,7 +2276,7 @@ catch (e) {
2035
2276
  effect(() => {
2036
2277
  var value;
2037
2278
  try {
2038
- var unwrapScope = createAutoUnwrapProxy(capturedScope);
2279
+ var unwrapScope = createExpressionAutoUnwrapProxy(capturedScope, expr);
2039
2280
  var fn = new Function(...Object.keys(capturedScope), 'return ' + expr);
2040
2281
  value = fn(...Object.values(unwrapScope));
2041
2282
  } catch (e1) {
@@ -2071,20 +2312,40 @@ catch (e) {
2071
2312
  el.__stx_model_bound = true;
2072
2313
  const tag = el.tagName.toLowerCase();
2073
2314
  const type = el.type;
2315
+ const modifiers = attrName.split('.').slice(1);
2316
+ const capturedScope = { ...globalHelpers, ...passedScope, ...(findElementScope(el) || {}) };
2317
+ const coerceValue = (value) => {
2318
+ var next = value;
2319
+ if (modifiers.includes('trim') && typeof next === 'string') next = next.trim();
2320
+ if (modifiers.includes('number') && next !== '') {
2321
+ var numeric = Number(next);
2322
+ if (!Number.isNaN(numeric)) next = numeric;
2323
+ }
2324
+ return next;
2325
+ };
2074
2326
 
2075
- const getValue = () => toValue(expr, el);
2327
+ const getValue = () => {
2328
+ var direct = capturedScope[expr];
2329
+ if (direct && typeof direct === 'function' && (direct._isSignal || direct._isDerived))
2330
+ return direct();
2331
+ try {
2332
+ var unwrapScope = createExpressionAutoUnwrapProxy(capturedScope, expr);
2333
+ var fn = new Function('__scope__', 'with(__scope__) { return (' + expr + ') }');
2334
+ return fn(unwrapScope);
2335
+ }
2336
+ catch (e) {
2337
+ if (!(e instanceof ReferenceError) && !(e instanceof TypeError)) console.warn('[STX] ' + attrName + ' get error:', expr, e);
2338
+ return '';
2339
+ }
2340
+ };
2076
2341
  const setValue = (val) => {
2077
2342
  try {
2078
- // Check component scope first, then element scope - use passed scope
2079
- const elementScope = findElementScope(el);
2080
- const scope = { ...passedScope, ...(elementScope || {}) };
2081
-
2082
- if (scope[expr] && scope[expr]._isSignal) {
2083
- scope[expr].set(val);
2343
+ if (capturedScope[expr] && capturedScope[expr]._isSignal) {
2344
+ capturedScope[expr].set(val);
2084
2345
  }
2085
2346
  else {
2086
- const fn = new Function(...Object.keys(scope), 'v', expr + ' = v');
2087
- fn(...Object.values(scope), val);
2347
+ const fn = new Function(...Object.keys(capturedScope), 'v', expr + ' = v');
2348
+ fn(...Object.values(capturedScope), val);
2088
2349
  }
2089
2350
  }
2090
2351
  catch (e) {
@@ -2096,13 +2357,13 @@ catch (e) {
2096
2357
  effect(() => { el.checked = getValue(); });
2097
2358
  el.addEventListener('change', () => setValue(el.checked));
2098
2359
  }
2099
- else if (tag === 'select') {
2360
+ else if (tag === 'select') {
2100
2361
  effect(() => { el.value = getValue(); });
2101
- el.addEventListener('change', () => setValue(el.value));
2362
+ el.addEventListener('change', () => setValue(coerceValue(el.value)));
2102
2363
  }
2103
2364
  else {
2104
2365
  effect(() => { el.value = getValue() ?? ''; });
2105
- el.addEventListener('input', () => setValue(el.value));
2366
+ el.addEventListener(modifiers.includes('lazy') ? 'change' : 'input', () => setValue(coerceValue(el.value)));
2106
2367
  }
2107
2368
 
2108
2369
  el.removeAttribute(attrName);
@@ -2110,7 +2371,7 @@ else {
2110
2371
 
2111
2372
  function bindClass(el, expr, passedScope = componentScope) {
2112
2373
  const originalClasses = el.className;
2113
- const capturedScope = { ...passedScope, ...(findElementScope(el) || {}), ...globalHelpers };
2374
+ const capturedScope = { ...globalHelpers, ...passedScope, ...(findElementScope(el) || {}) };
2114
2375
  const keys = Object.keys(capturedScope);
2115
2376
 
2116
2377
  // Pre-compile \u2014 filter out keys that aren't valid JS identifiers
@@ -2140,7 +2401,7 @@ else {
2140
2401
  // First pass: auto-unwrap proxy \u2014 handles expressions that read
2141
2402
  // signals as primitives (e.g. { 'active': count > 5 } where count
2142
2403
  // is a signal).
2143
- const unwrapScope = createAutoUnwrapProxy(capturedScope);
2404
+ const unwrapScope = createExpressionAutoUnwrapProxy(capturedScope, expr);
2144
2405
  value = fn.apply(null, safeKeys.map(k => unwrapScope[k]));
2145
2406
  } catch (e1) {
2146
2407
  // Retry with raw scope \u2014 handles call-expressions like activeTab()
@@ -2163,11 +2424,11 @@ else {
2163
2424
  });
2164
2425
  }
2165
2426
  else if (Array.isArray(value)) {
2166
- var _clsArr = originalClasses + ' ' + value.filter(Boolean).join(' ');
2427
+ var _clsArr = [originalClasses, value.filter(Boolean).join(' ')].filter(Boolean).join(' ');
2167
2428
  if (typeof el.className === 'string') el.className = _clsArr; else el.setAttribute('class', _clsArr);
2168
2429
  }
2169
2430
  else {
2170
- var _clsStr = originalClasses + (value ? ' ' + value : '');
2431
+ var _clsStr = [originalClasses, value || ''].filter(Boolean).join(' ');
2171
2432
  if (typeof el.className === 'string') el.className = _clsStr; else el.setAttribute('class', _clsStr);
2172
2433
  }
2173
2434
  });
@@ -2175,7 +2436,7 @@ else {
2175
2436
 
2176
2437
  function bindStyle(el, expr, passedScope = componentScope) {
2177
2438
  // Capture scope at setup time - use passed scope to preserve context
2178
- const capturedScope = { ...passedScope, ...(findElementScope(el) || {}), ...globalHelpers };
2439
+ const capturedScope = { ...globalHelpers, ...passedScope, ...(findElementScope(el) || {}) };
2179
2440
 
2180
2441
  const evalExpr = () => {
2181
2442
  // Mirror bindClass: first try the auto-unwrap proxy (so an expression
@@ -2185,7 +2446,7 @@ else {
2185
2446
  // values, which would make theme() try to call a primitive and
2186
2447
  // throw TypeError.
2187
2448
  try {
2188
- const unwrapScope = createAutoUnwrapProxy(capturedScope);
2449
+ const unwrapScope = createExpressionAutoUnwrapProxy(capturedScope, expr);
2189
2450
  const fn = new Function(...Object.keys(capturedScope), 'return ' + expr);
2190
2451
  return fn(...Object.values(unwrapScope));
2191
2452
  }
@@ -2409,8 +2670,12 @@ else {
2409
2670
  return expression;
2410
2671
  }
2411
2672
  // Use passedScope instead of componentScope to preserve context through nested processing
2412
- const scope = { ...passedScope, ...(capturedScope || {}), ...globalHelpers, ...extraScope };
2413
- const unwrapScope = createAutoUnwrapProxy(scope);
2673
+ const scope = { ...globalHelpers, ...passedScope, ...(capturedScope || {}), ...extraScope };
2674
+ const unwrapScope = createAutoUnwrapProxy(scope, function(prop) {
2675
+ if (typeof prop !== 'string') return false;
2676
+ return expressionCallsSignal(expression, prop)
2677
+ || expressionUsesSignalApi(expression, prop);
2678
+ });
2414
2679
  const fn = new Function(...Object.keys(scope), 'return ' + expression);
2415
2680
  return fn(...Object.values(unwrapScope));
2416
2681
  }
@@ -2426,8 +2691,12 @@ catch (e) {
2426
2691
  const evalLazy = (expression, extraScope = {}) => {
2427
2692
  try {
2428
2693
  if (/^__[A-Z_]+__$/.test(expression.trim())) return expression;
2429
- const scope = { ...passedScope, ...(capturedScope || {}), ...globalHelpers, ...extraScope };
2430
- const unwrapScope = createAutoUnwrapProxy(scope);
2694
+ const scope = { ...globalHelpers, ...passedScope, ...(capturedScope || {}), ...extraScope };
2695
+ const unwrapScope = createAutoUnwrapProxy(scope, function(prop) {
2696
+ if (typeof prop !== 'string') return false;
2697
+ return expressionCallsSignal(expression, prop)
2698
+ || expressionUsesSignalApi(expression, prop);
2699
+ });
2431
2700
  // new Function body is non-strict, so with() works \u2014 only accessed
2432
2701
  // properties trigger the proxy's get trap and register as dependencies
2433
2702
  const fn = new Function('__scope__', 'with(__scope__) { return ' + expression + ' }');
@@ -2487,7 +2756,7 @@ catch (e) {
2487
2756
  const indexSignal = state(index);
2488
2757
  if (key) itemSignalMap.set(key, { item: itemSignal, index: indexSignal });
2489
2758
 
2490
- const itemScope = { ...passedScope, ...(capturedScope || {}), ...globalHelpers };
2759
+ const itemScope = { ...globalHelpers, ...passedScope, ...(capturedScope || {}) };
2491
2760
  itemScope[itemName] = itemSignal;
2492
2761
  if (indexName) itemScope[indexName] = indexSignal;
2493
2762
 
@@ -2496,17 +2765,15 @@ catch (e) {
2496
2765
  Array.from(templateContent.childNodes).forEach(node => {
2497
2766
  const clone = node.cloneNode(true);
2498
2767
  if (clone.nodeType === 1) {
2499
- processElement(clone, itemScope);
2500
- clone.removeAttribute('x-cloak');
2501
- clone.querySelectorAll('[x-cloak]').forEach(c => c.removeAttribute('x-cloak'));
2768
+ // Structural directives need a live parent. Keep the iteration
2769
+ // scope on the clone until the keyed diff inserts it, then bind.
2770
+ clone.__stx_for_scope = itemScope;
2502
2771
  }
2503
2772
  elements.push(clone);
2504
2773
  });
2505
2774
  } else {
2506
2775
  const clone = templateContent.cloneNode(true);
2507
- processElement(clone, itemScope);
2508
- clone.removeAttribute('x-cloak');
2509
- clone.querySelectorAll('[x-cloak]').forEach(c => c.removeAttribute('x-cloak'));
2776
+ clone.__stx_for_scope = itemScope;
2510
2777
  elements.push(clone);
2511
2778
  }
2512
2779
  return elements;
@@ -2606,7 +2873,7 @@ catch (e) {
2606
2873
  // rare, so the extra Object.keys / spread cost is negligible.
2607
2874
  var diagScope;
2608
2875
  try {
2609
- var diagMerged = { ...passedScope, ...(capturedScope || {}), ...globalHelpers };
2876
+ var diagMerged = { ...globalHelpers, ...passedScope, ...(capturedScope || {}) };
2610
2877
  var firstIdent = listExpr.match(/^[A-Za-z_$][\\w$]*/);
2611
2878
  var rootName = firstIdent ? firstIdent[0] : '?';
2612
2879
  var rootInScope = Object.prototype.hasOwnProperty.call(diagMerged, rootName);
@@ -2676,8 +2943,13 @@ catch (e) {
2676
2943
  if (existing && existing.length > 0 && !usedKeys.has(key)) {
2677
2944
  // Reuse this item's nodes \u2014 move the whole group into position, in
2678
2945
  // order, so a multi-node row stays contiguous and correctly ordered.
2946
+ // A structural directive may intentionally have detached one of the
2947
+ // group's nodes. Its own placeholder owns reinsertion, so keyed reuse
2948
+ // must not resurrect that hidden node.
2679
2949
  const group = existing.shift();
2680
- group.forEach(el => parent.insertBefore(el, placeholder));
2950
+ group.forEach(el => {
2951
+ if (el.parentNode === parent) parent.insertBefore(el, placeholder);
2952
+ });
2681
2953
  newElements.push(...group);
2682
2954
  newGroups.push(group);
2683
2955
  usedKeys.add(key);
@@ -2692,7 +2964,17 @@ catch (e) {
2692
2964
  } else {
2693
2965
  // New item \u2014 create DOM elements with a fresh item signal
2694
2966
  const elements = createItemElements(list[i], i, key);
2695
- elements.forEach(el => { parent.insertBefore(el, placeholder); if (tgReady) tgEnter(el, tgName); });
2967
+ elements.forEach(el => {
2968
+ parent.insertBefore(el, placeholder);
2969
+ if (el.nodeType === 1) {
2970
+ var itemScope = el.__stx_for_scope || passedScope;
2971
+ delete el.__stx_for_scope;
2972
+ processElement(el, itemScope);
2973
+ el.removeAttribute('x-cloak');
2974
+ el.querySelectorAll('[x-cloak]').forEach(c => c.removeAttribute('x-cloak'));
2975
+ }
2976
+ if (tgReady && el.isConnected) tgEnter(el, tgName);
2977
+ });
2696
2978
  newElements.push(...elements);
2697
2979
  newGroups.push(elements);
2698
2980
  }
@@ -2785,6 +3067,7 @@ catch (e) {
2785
3067
  // branches instead of bringing their content to life. The active branch
2786
3068
  // is re-inserted below and IS connected, so it processes normally.
2787
3069
  chain.forEach(function(b) {
3070
+ preserveConditionalComponentScopes(b.el, capturedComponentScope);
2788
3071
  b.el.__stx_if_bound = true;
2789
3072
  b.el.__stx_chain_member = true;
2790
3073
  b.capturedElementScope = findElementScope(b.el);
@@ -2823,9 +3106,9 @@ catch (e) {
2823
3106
  function evalBranch(b) {
2824
3107
  var expression = b.expr;
2825
3108
  if (/^__[A-Z_]+__$/.test(expression.trim())) return expression;
2826
- var scope = { ...capturedComponentScope, ...(b.capturedElementScope || {}), ...globalHelpers };
3109
+ var scope = { ...globalHelpers, ...capturedComponentScope, ...(b.capturedElementScope || {}) };
2827
3110
  try {
2828
- var unwrapScope = createAutoUnwrapProxy(scope);
3111
+ var unwrapScope = createExpressionAutoUnwrapProxy(scope, expression);
2829
3112
  // new Function body is non-strict, so with() works.
2830
3113
  var fn = new Function('__scope__', 'with(__scope__) { return ' + expression + ' }');
2831
3114
  return fn(unwrapScope);
@@ -2904,7 +3187,7 @@ catch (e2) {
2904
3187
  // effect's tracked signals.
2905
3188
  (function (branch) {
2906
3189
  setTimeout(function () {
2907
- var childScope = { ...capturedComponentScope, ...(branch.capturedElementScope || {}), ...globalHelpers };
3190
+ var childScope = { ...globalHelpers, ...capturedComponentScope, ...(branch.capturedElementScope || {}) };
2908
3191
  branch.nodes.forEach(function (n) {
2909
3192
  if (n.nodeType !== 1) return; // whitespace/text between template children
2910
3193
  processElement(n, childScope);
@@ -2946,6 +3229,7 @@ catch (e2) {
2946
3229
  const capturedElementScope = findElementScope(el);
2947
3230
  const capturedComponentScope = { ...passedScope };
2948
3231
 
3232
+ preserveConditionalComponentScopes(el, capturedComponentScope);
2949
3233
  parent.insertBefore(placeholder, el);
2950
3234
  el.removeAttribute(attrName);
2951
3235
 
@@ -2975,15 +3259,15 @@ catch (e2) {
2975
3259
  }
2976
3260
  try {
2977
3261
  // Use captured componentScope (with @for vars) merged with element scope
2978
- const scope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers };
2979
- const unwrapScope = createAutoUnwrapProxy(scope);
3262
+ const scope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
3263
+ const unwrapScope = createExpressionAutoUnwrapProxy(scope, expression);
2980
3264
  const fn = new Function(...Object.keys(scope), 'return ' + expression);
2981
3265
  return fn(...Object.values(unwrapScope));
2982
3266
  }
2983
3267
  catch (e1) {
2984
3268
  try {
2985
3269
  // Retry without the unwrap proxy so call-syntax (signal()) works.
2986
- const scope2 = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers };
3270
+ const scope2 = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
2987
3271
  const fn2 = new Function(...Object.keys(scope2), 'return ' + expression);
2988
3272
  return fn2(...Object.values(scope2));
2989
3273
  }
@@ -3001,7 +3285,7 @@ catch (e2) {
3001
3285
  const processChildrenWithScope = () => {
3002
3286
  // Build the combined scope for children - no need to modify global componentScope
3003
3287
  // Just pass the scope explicitly to processElement
3004
- const childScope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers };
3288
+ const childScope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
3005
3289
 
3006
3290
 
3007
3291
  Array.from(el.childNodes).forEach(child => processElement(child, childScope));
@@ -3011,7 +3295,7 @@ catch (e2) {
3011
3295
 
3012
3296
  // Evaluate the :if expression \u2014 use direct signal read for simple refs,
3013
3297
  // falling back to evalExpr for complex expressions
3014
- const fullScope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers };
3298
+ const fullScope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
3015
3299
  const directSignal = fullScope[expr];
3016
3300
 
3017
3301
  if (directSignal) {
@@ -3091,7 +3375,7 @@ else if (!value && isInserted) {
3091
3375
  if (value && isInserted && !childrenProcessed) {
3092
3376
  childrenProcessed = true;
3093
3377
  setTimeout(function() {
3094
- var childScope = { ...capturedComponentScope, ...(capturedElementScope || {}), ...globalHelpers };
3378
+ var childScope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
3095
3379
  processElement(el, childScope);
3096
3380
  // Remove x-cloak from the inserted subtree \u2014 the initial
3097
3381
  // cloak removal (after processElement on the root) already
@@ -3506,16 +3790,35 @@ catch (e) {}
3506
3790
 
3507
3791
  function useDark(options) {
3508
3792
  var cm = useColorMode(options);
3509
- return {
3510
- get isDark() { return cm.isDark; },
3511
- toggle: function() { cm.toggle(); },
3512
- set: function(dark) { cm.set(dark ? 'dark' : 'light'); },
3513
- subscribe: function(fn) {
3514
- return cm.subscribe(function(mode) { fn(mode === 'dark'); });
3515
- }
3516
- };
3793
+ var dark = state(cm.isDark);
3794
+ var setSignal = dark.set;
3795
+ cm.subscribe(function(mode) { setSignal(mode === 'dark'); });
3796
+ dark.toggle = function() { cm.toggle(); };
3797
+ dark.set = function(value) { cm.set(value ? 'dark' : 'light'); };
3798
+ Object.defineProperty(dark, 'isDark', { get: function() { return dark(); } });
3799
+ return dark;
3517
3800
  }
3518
3801
 
3802
+ function useMediaQuery(query) {
3803
+ var mql = window.matchMedia(query);
3804
+ var matches = state(mql.matches);
3805
+ var onChange = function(event) { matches.set(event.matches); };
3806
+ if (mql.addEventListener) mql.addEventListener('change', onChange);
3807
+ else if (mql.addListener) mql.addListener(onChange);
3808
+ onDestroy(function() {
3809
+ if (mql.removeEventListener) mql.removeEventListener('change', onChange);
3810
+ else if (mql.removeListener) mql.removeListener(onChange);
3811
+ });
3812
+ Object.defineProperty(matches, 'matches', { get: function() { return matches(); } });
3813
+ Object.defineProperty(matches, 'value', { get: function() { return matches(); } });
3814
+ return matches;
3815
+ }
3816
+
3817
+ function usePreferredDark() { return useMediaQuery('(prefers-color-scheme: dark)'); }
3818
+ function usePreferredLight() { return useMediaQuery('(prefers-color-scheme: light)'); }
3819
+ function usePreferredReducedMotion() { return useMediaQuery('(prefers-reduced-motion: reduce)'); }
3820
+ function usePreferredContrast() { return useMediaQuery('(prefers-contrast: more)'); }
3821
+
3519
3822
  // Vue-compat aliases
3520
3823
  var ref = state;
3521
3824
  var reactive = state;
@@ -3618,26 +3921,76 @@ catch (e) {}
3618
3921
  function useReactiveProp(name, defaultValue, opts) {
3619
3922
  opts = opts || {};
3620
3923
  var parse = opts.parse || function(v) {
3924
+ // Infer the prop's intended scalar type from its default. A string prop
3925
+ // with an empty value must stay "", not become the boolean true. This
3926
+ // mirrors typed component props and keeps form/error text stable when a
3927
+ // parent forwards an empty signal value through a DOM attribute.
3928
+ if (typeof defaultValue === 'string') return v == null ? '' : String(v);
3929
+ if (typeof defaultValue === 'boolean') {
3930
+ if (v === '' || v === 'true') return true;
3931
+ if (v === 'false' || v === null || v === undefined) return false;
3932
+ return Boolean(v);
3933
+ }
3621
3934
  if (v === '' || v === 'true') return true;
3622
3935
  if (v === 'false') return false;
3936
+ if (typeof v === 'string' && (v.charAt(0) === '[' || v.charAt(0) === '{')) {
3937
+ try { return JSON.parse(v); }
3938
+ catch (e) {}
3939
+ }
3940
+ if (typeof defaultValue === 'number')
3941
+ return v != null && v !== '' && !isNaN(Number(v)) ? Number(v) : defaultValue;
3623
3942
  if (v != null && v !== '' && !isNaN(Number(v))) return Number(v);
3624
3943
  return v;
3625
3944
  };
3626
3945
  var root = window.__STX_CURRENT_ELEMENT__;
3946
+ var kebabName = name.replace(/([A-Z])/g, function(_, c) { return '-' + c.toLowerCase(); });
3947
+ var lowerName = name.toLowerCase();
3948
+ var attributeNames = [name];
3949
+ if (kebabName !== name) attributeNames.push(kebabName);
3950
+ if (lowerName !== name && lowerName !== kebabName) attributeNames.push(lowerName);
3951
+ function readAttribute() {
3952
+ if (!root || !root.hasAttribute) return { has: false, raw: null };
3953
+ for (var i = 0; i < attributeNames.length; i++) {
3954
+ var candidate = attributeNames[i];
3955
+ if (root.hasAttribute(candidate))
3956
+ return { has: true, raw: root.getAttribute(candidate) };
3957
+ }
3958
+ return { has: false, raw: null };
3959
+ }
3960
+ var staticValue = defaultValue;
3961
+ if (root && root.getAttribute) {
3962
+ var serializedProps = root.getAttribute('data-stx-props');
3963
+ if (serializedProps) {
3964
+ try {
3965
+ var parsedProps = JSON.parse(serializedProps);
3966
+ var camelName = name.replace(/-([a-z0-9])/g, function(_, c) { return c.toUpperCase(); });
3967
+ if (Object.prototype.hasOwnProperty.call(parsedProps, name)) staticValue = parsedProps[name];
3968
+ else if (Object.prototype.hasOwnProperty.call(parsedProps, camelName)) staticValue = parsedProps[camelName];
3969
+ }
3970
+ catch (e) {}
3971
+ }
3972
+ }
3627
3973
  var initial;
3628
- if (root && root.hasAttribute && root.hasAttribute(name)) {
3629
- initial = parse(root.getAttribute(name));
3974
+ var lastRaw = null;
3975
+ var initialAttribute = readAttribute();
3976
+ if (initialAttribute.has) {
3977
+ lastRaw = initialAttribute.raw;
3978
+ initial = parse(lastRaw);
3630
3979
  } else {
3631
- initial = defaultValue;
3980
+ initial = staticValue;
3632
3981
  }
3633
3982
  var s = state(initial);
3634
3983
  if (!root) return s;
3635
3984
  var observer = new MutationObserver(function() {
3636
- var hasAttr = root.hasAttribute(name);
3637
- var next = hasAttr ? parse(root.getAttribute(name)) : defaultValue;
3985
+ var attribute = readAttribute();
3986
+ var hasAttr = attribute.has;
3987
+ var raw = attribute.raw;
3988
+ if (raw === lastRaw) return;
3989
+ lastRaw = raw;
3990
+ var next = hasAttr ? parse(raw) : staticValue;
3638
3991
  if (next !== s()) s.set(next);
3639
3992
  });
3640
- observer.observe(root, { attributes: true, attributeFilter: [name] });
3993
+ observer.observe(root, { attributes: true, attributeFilter: attributeNames });
3641
3994
  onDestroy(function() { observer.disconnect(); });
3642
3995
  return s;
3643
3996
  }
@@ -4109,6 +4462,8 @@ catch (e) {}
4109
4462
  var scopeId = el.getAttribute('data-stx-scope');
4110
4463
  var scopeVars = window.stx._scopes && window.stx._scopes[scopeId];
4111
4464
  if (!scopeVars) return;
4465
+ el.__stx_parent_scope = { ...componentScope };
4466
+ bindParentComponentProps(el, el.__stx_parent_scope);
4112
4467
  Object.assign(componentScope, scopeVars);
4113
4468
  el.__stx_disposers = trackEffects(function() { processElement(el, componentScope); });
4114
4469
  if (scopeVars.__mountCallbacks && !scopeVars.__mounted) {
@@ -4143,6 +4498,9 @@ catch (e) {}
4143
4498
  peek,
4144
4499
  onMount,
4145
4500
  onDestroy,
4501
+ onMounted: onMount,
4502
+ onBeforeUnmount: onDestroy,
4503
+ onUnmounted: onDestroy,
4146
4504
  useFetch,
4147
4505
  useRef,
4148
4506
  navigate,
@@ -4180,6 +4538,11 @@ catch (e) {}
4180
4538
  useWebSocket,
4181
4539
  useColorMode,
4182
4540
  useDark,
4541
+ useMediaQuery,
4542
+ usePreferredDark,
4543
+ usePreferredLight,
4544
+ usePreferredReducedMotion,
4545
+ usePreferredContrast,
4183
4546
  useHead,
4184
4547
  useSeoMeta,
4185
4548
  // Client-side no-op for definePageMeta \u2014 the real implementation registers
@@ -4242,7 +4605,7 @@ catch (e) {}
4242
4605
  _tg: { enter: tgEnter, leave: tgLeave, flip: tgFlip, snapshot: tgSnapshot }, // <TransitionGroup> helpers (#1742)
4243
4606
  _scopes: {}, // Component-level scopes
4244
4607
  _scopeCounter: 0, // Counter for generating unique scope IDs during SPA navigation
4245
- _latestSetup: null, // Latest SFC setup function (for SPA re-initialization)
4608
+ _latestSetup: window.__stx_latestSetup || null, // Latest SFC setup function (including scripts parsed before the runtime)
4246
4609
  _stores: new Map(), // Global store registry \u2014 survives SPA navigation
4247
4610
 
4248
4611
  // \u2500\u2500 Store System \u2500\u2500
@@ -4447,6 +4810,16 @@ catch (e) {}
4447
4810
  return result;
4448
4811
  },
4449
4812
 
4813
+ registerStoresClient: function(stores) {
4814
+ window.__STX_STORES__ = window.__STX_STORES__ || {};
4815
+ var names = Object.keys(stores || {});
4816
+ for (var i = 0; i < names.length; i++) {
4817
+ var name = names[i];
4818
+ window.__STX_STORES__[name] = stores[name];
4819
+ }
4820
+ window.dispatchEvent(new CustomEvent('stx:stores-ready', { detail: names }));
4821
+ },
4822
+
4450
4823
  useStore: function(id) {
4451
4824
  var store = window.stx._stores.get(id);
4452
4825
  if (!store && window.__STX_STORES__) {
@@ -4491,7 +4864,7 @@ catch (e) {}
4491
4864
  }
4492
4865
 
4493
4866
  var disposeEffects = trackEffects(function() {
4494
- processElement(root, scope || componentScope);
4867
+ processElement(root, { ...componentScope, ...(scope || {}) });
4495
4868
  });
4496
4869
  root.__stx_disposers = disposeEffects;
4497
4870
 
@@ -4522,9 +4895,26 @@ else {
4522
4895
  var scriptEl = document.currentScript;
4523
4896
 
4524
4897
  function doMount() {
4525
- // Find component root: next sibling element after the script tag
4526
- var root = scriptEl ? scriptEl.nextElementSibling : null;
4527
- if (!root && scriptEl) root = scriptEl.previousElementSibling || scriptEl.parentElement;
4898
+ // Auto-mount scripts are emitted after their component template. Other
4899
+ // generated helper scripts can sit between the rendered root and this
4900
+ // script, so walk past script/style siblings before selecting the root.
4901
+ // Router-injected page scripts are marked data-stx-page and must use the
4902
+ // content-container fallback below instead.
4903
+ var isSpaPageScript = scriptEl && scriptEl.hasAttribute
4904
+ && scriptEl.hasAttribute('data-stx-page')
4905
+ && !scriptEl.hasAttribute('data-stx-positioned');
4906
+ var root = null;
4907
+ if (scriptEl && !isSpaPageScript) {
4908
+ var previous = scriptEl.previousElementSibling;
4909
+ while (previous && (previous.tagName === 'SCRIPT' || previous.tagName === 'STYLE'))
4910
+ previous = previous.previousElementSibling;
4911
+
4912
+ var next = scriptEl.nextElementSibling;
4913
+ while (next && (next.tagName === 'SCRIPT' || next.tagName === 'STYLE'))
4914
+ next = next.nextElementSibling;
4915
+
4916
+ root = previous || next || scriptEl.parentElement;
4917
+ }
4528
4918
 
4529
4919
  // SPA fallback: during router navigation, script is appended to body>
4530
4920
  // (not inside the content container), so nextElementSibling won't find the page content.
@@ -4593,7 +4983,7 @@ else {
4593
4983
 
4594
4984
  // Walk DOM and bind directives, tracking effects for cleanup
4595
4985
  var disposeEffects = trackEffects(function() {
4596
- processElement(root, scope || componentScope);
4986
+ processElement(root, { ...componentScope, ...(scope || {}) });
4597
4987
  });
4598
4988
  root.__stx_disposers = disposeEffects;
4599
4989
 
@@ -4614,7 +5004,9 @@ catch (e) { console.error('[stx] onMount error:', e); }
4614
5004
  root.__stx_destroy = localDestroyHooks;
4615
5005
  }
4616
5006
 
4617
- if (document.readyState === 'loading') {
5007
+ var isRoutedMount = scriptEl && scriptEl.hasAttribute
5008
+ && scriptEl.hasAttribute('data-stx-page');
5009
+ if (document.readyState === 'loading' || isRoutedMount) {
4618
5010
  mountQueue.push(doMount);
4619
5011
  }
4620
5012
  else {
@@ -4819,14 +5211,31 @@ else {
4819
5211
  console.log('[stx] registering DOMContentLoaded handler');
4820
5212
  document.addEventListener('DOMContentLoaded', () => {
4821
5213
  console.log('[stx] DOMContentLoaded fired \u2014 processing scopes');
4822
- // Process mount queue (from stx.mount() calls during loading)
4823
- mountQueue.forEach(function(fn) { fn(); });
4824
- mountQueue = [];
4825
-
4826
5214
  // Track which scoped elements have been processed
4827
5215
  const processedScopes = new Set();
4828
5216
 
4829
- // Initialize components with data-stx attribute
5217
+ // Layout expansion can remove the data-stx marker from a page's first
5218
+ // element while leaving markers owned by the layout itself. The generated
5219
+ // page setup is still queued explicitly, so adopt it when its own marker
5220
+ // did not survive.
5221
+ const latestSetup = window.stx._latestSetup;
5222
+ const latestSetupName = typeof latestSetup === 'function' ? latestSetup.name : '';
5223
+ const hasLatestSetupMarker = latestSetupName && Array.from(document.querySelectorAll('[data-stx]'))
5224
+ .some(el => el.getAttribute('data-stx') === latestSetupName);
5225
+ if (latestSetup && !hasLatestSetupMarker) {
5226
+ try {
5227
+ const result = latestSetup();
5228
+ if (typeof result === 'object' && result !== null)
5229
+ Object.assign(componentScope, result);
5230
+ }
5231
+ catch (e) {
5232
+ console.error('[stx] initial setup error:', e);
5233
+ }
5234
+ }
5235
+
5236
+ // Register page setup before mounting child components. Projected slot
5237
+ // content belongs to the caller, so child mounts need the caller's signals
5238
+ // available when they walk their own rendered roots.
4830
5239
  document.querySelectorAll('[data-stx]').forEach(el => {
4831
5240
  const setupName = el.getAttribute('data-stx');
4832
5241
  console.log('[DOMContentLoaded] data-stx:', setupName, 'el:', el.tagName, 'fn exists:', !!(setupName && window[setupName]));
@@ -4837,6 +5246,16 @@ else {
4837
5246
  Object.assign(componentScope, result);
4838
5247
  }
4839
5248
  }
5249
+ });
5250
+ var pageScopeSnapshot = { ...componentScope };
5251
+
5252
+ // Process mount queue (from stx.mount() calls during loading) only after
5253
+ // caller setup is available for Vue-style projected slot ownership.
5254
+ mountQueue.forEach(function(fn) { fn(); });
5255
+ mountQueue = [];
5256
+
5257
+ // Bind page roots after child components have registered their local scope.
5258
+ document.querySelectorAll('[data-stx]').forEach(el => {
4840
5259
  console.log('[DOMContentLoaded] processElement on:', el.tagName, 'scope keys:', Object.keys(componentScope).slice(0, 10));
4841
5260
  var disposeEffects = trackEffects(function() { processElement(el); });
4842
5261
  el.__stx_disposers = disposeEffects;
@@ -4902,6 +5321,12 @@ else {
4902
5321
 
4903
5322
  if (!scopeVars) return;
4904
5323
 
5324
+ // Forwarded props and events belong to the caller. Capture its scope
5325
+ // before this component's locals are merged, so identical child names
5326
+ // cannot shadow expressions such as :total-items="totalItems".
5327
+ el.__stx_parent_scope = resolveComponentCallerScope(el, pageScopeSnapshot);
5328
+ bindParentComponentProps(el, el.__stx_parent_scope);
5329
+
4905
5330
  // Merge component scope vars into componentScope (don't restore - keep for head elements)
4906
5331
  // This ensures expressions can access component variables even for elements
4907
5332
  // where findElementScope might not work (e.g., cloned elements not yet in DOM)
@@ -5184,10 +5609,6 @@ catch (e) { console.warn('[stx] destroy callback error:', e); }
5184
5609
  });
5185
5610
  destroyCallbacks.length = 0;
5186
5611
 
5187
- // Process mount queue (scripts in swapped content may have called stx.mount())
5188
- mountQueue.forEach(function(fn) { fn(); });
5189
- mountQueue = [];
5190
-
5191
5612
  // Re-process scoped components in new content
5192
5613
  var routerOpts = window.STX_ROUTER_OPTIONS || window.__stxRouterConfig || {};
5193
5614
  var container = document.querySelector('[data-stx-content]')
@@ -5228,6 +5649,13 @@ catch (e) { console.warn('[stx] destroy callback error:', e); }
5228
5649
  }
5229
5650
  });
5230
5651
  }
5652
+ var spaPageScopeSnapshot = { ...componentScope };
5653
+
5654
+ // Routed component mount scripts execute after DOMContentLoaded. Queue them
5655
+ // until the destination page setup is active so projected props and parent
5656
+ // event handlers resolve against the caller, matching initial hydration.
5657
+ mountQueue.forEach(function(fn) { fn(); });
5658
+ mountQueue = [];
5231
5659
 
5232
5660
  // Initialize x-data scopes after SPA fragment swap.
5233
5661
  // On full page load, the reactive bridge <script> calls initScope(). But after SPA
@@ -5277,6 +5705,8 @@ catch (e) { console.warn('[stx] destroy callback error:', e); }
5277
5705
  return;
5278
5706
  }
5279
5707
  if (!scopeVars) return;
5708
+ el.__stx_parent_scope = resolveComponentCallerScope(el, spaPageScopeSnapshot);
5709
+ bindParentComponentProps(el, el.__stx_parent_scope);
5280
5710
  Object.assign(componentScope, scopeVars);
5281
5711
  if (el.__stx_disposers) return;
5282
5712
  var disposeEffects = trackEffects(function() { processElement(el, componentScope); });
@@ -5353,9 +5783,9 @@ catch (e) { console.warn('[stx] mount callback error:', e); }
5353
5783
  var sa = el.hasAttribute(':show') ? ':show' : el.hasAttribute('x-show') ? 'x-show' : '@show';
5354
5784
  bindShow(el, el.getAttribute(sa), componentScope, sa);
5355
5785
  }
5356
- if (el.hasAttribute && (el.hasAttribute('@model') || el.hasAttribute(':model') || el.hasAttribute('x-model'))) {
5357
- var ma = el.hasAttribute(':model') ? ':model' : el.hasAttribute('x-model') ? 'x-model' : '@model';
5358
- bindModel(el, el.getAttribute(ma), componentScope, ma);
5786
+ var mb = getModelBinding(el);
5787
+ if (mb) {
5788
+ bindModel(el, mb.expression, componentScope, mb.name);
5359
5789
  }
5360
5790
  // Process children, skipping scoped containers and script/style elements
5361
5791
  if (el.tagName !== 'SCRIPT' && el.tagName !== 'STYLE') {