@symbo.ls/element 3.14.1 → 3.14.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/element",
3
- "version": "3.14.1",
3
+ "version": "3.14.3",
4
4
  "description": "Core element creation, update, and disposal engine for smbls v3.14",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -11,8 +11,8 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@symbo.ls/signal": "^3.14.0",
14
- "@symbo.ls/state": "^3.14.0",
15
- "@symbo.ls/utils": "^3.14.0",
14
+ "@symbo.ls/state": "^3.14.2",
15
+ "@symbo.ls/utils": "^3.14.2",
16
16
  "css-in-props": "^3.14.0",
17
17
  "attrs-in-props": "^3.14.0"
18
18
  },
package/src/create.js CHANGED
@@ -90,6 +90,22 @@ export function create(definition, parent, key, options = {}, attachOptions) {
90
90
  const components = element.context?.components
91
91
  if (components && (components[baseName] || components['smbls.' + baseName])) {
92
92
  definition = { extends: baseName, ...definition }
93
+ } else if (
94
+ // FRAMEWORK-5: dev-mode warning. A key shaped like a multi-instance
95
+ // suffix (e.g. `Link_1`) clearly intends to auto-extend a base
96
+ // component, so silently rendering as a bare div is the kind of
97
+ // failure that takes a click test to discover. Warn loudly in dev
98
+ // so the typo / missing import is visible at mount time.
99
+ components &&
100
+ process.env.NODE_ENV !== 'production' &&
101
+ /^[A-Z]\w*_\d+$/.test(key)
102
+ ) {
103
+ console.warn(
104
+ `[smbls] auto-extend: key "${key}" looks like a multi-instance ` +
105
+ `(${baseName}_N) but neither "${baseName}" nor "smbls.${baseName}" ` +
106
+ `is registered in context.components — element will render as a ` +
107
+ `plain <div>. Register the base component or rename the key.`
108
+ )
93
109
  }
94
110
  }
95
111
  }
@@ -165,15 +181,22 @@ export function create(definition, parent, key, options = {}, attachOptions) {
165
181
  // — the user's reactive function got `undefined` and either threw or
166
182
  // rendered nothing. The frank-rewriter's `__scope` helper already chains
167
183
  // through globalScope; this brings the runtime `el.scope` to parity.
184
+ // Capture scope set by the extends chain (applyExtends ran above) BEFORE
185
+ // we rebuild element.scope. Otherwise scope blocks declared on the base
186
+ // component (e.g. `Navbar.scope = { LANG_META }`) get blown away when
187
+ // a consumer extends without redeclaring scope (`AppNavbar: { extends:
188
+ // 'Navbar' }`). FA-L24.
189
+ const inheritedFromExtends = element.scope && typeof element.scope === 'object' ? element.scope : null
168
190
  const ancestorScope = parent?.scope
169
191
  const globalScope =
170
192
  (element.context && typeof element.context.globalScope === 'object' && element.context.globalScope) || null
171
193
  const baseScope = ancestorScope ||
172
194
  (globalScope ? Object.create(globalScope) : null)
173
- if (definition.scope !== undefined) {
174
- element.scope = baseScope
175
- ? Object.assign(Object.create(baseScope), definition.scope)
176
- : definition.scope
195
+ if (definition.scope !== undefined || inheritedFromExtends) {
196
+ const target = baseScope ? Object.create(baseScope) : {}
197
+ if (inheritedFromExtends) Object.assign(target, inheritedFromExtends)
198
+ if (definition.scope !== undefined) Object.assign(target, definition.scope)
199
+ element.scope = target
177
200
  } else if (baseScope) {
178
201
  element.scope = Object.create(baseScope)
179
202
  }
@@ -304,15 +327,24 @@ export function create(definition, parent, key, options = {}, attachOptions) {
304
327
  triggerLifecycle('RenderRouter', element, options)
305
328
 
306
329
  // Start onFrame loop if defined
330
+ // FT-FRAMEWORK-3: Previously this returned early when `!element.node.isConnected`,
331
+ // killing the loop. During an initial top-down mount, the first rAF often
332
+ // fires before the root has been attached to document.body — so deeply
333
+ // nested children's onFrame loops self-terminated and never restarted.
334
+ // Now we reschedule unconditionally and gate only the handler call on
335
+ // `isConnected`. The loop stops only when the element is disposed
336
+ // (`element.node === null`).
307
337
  if (typeof element.onFrame === 'function') {
308
338
  const frameHandler = element.onFrame
309
339
  const frameDsConfig = element.context?.designSystem
310
340
  const tick = () => {
311
- if (!element.node || !element.node.isConnected) return
312
- if (frameDsConfig) pushConfig(frameDsConfig)
313
- try { frameHandler(element, element.state, element.context) } catch (e) { console.error('[DOMQL] onFrame error:', e) }
314
- finally {
315
- if (frameDsConfig) popConfig()
341
+ if (!element.node) return
342
+ if (element.node.isConnected) {
343
+ if (frameDsConfig) pushConfig(frameDsConfig)
344
+ try { frameHandler(element, element.state, element.context) } catch (e) { console.error('[DOMQL] onFrame error:', e) }
345
+ finally {
346
+ if (frameDsConfig) popConfig()
347
+ }
316
348
  }
317
349
  requestAnimationFrame(tick)
318
350
  }
@@ -473,9 +505,75 @@ function resolveStatePath(path, parent) {
473
505
 
474
506
  // --- Reactive Effects ---
475
507
 
508
+ function wrapCustomPropFunctions(element) {
509
+ const ref = element.__ref
510
+ const tag = element.tag || 'div'
511
+ const ds = element.context?.designSystem
512
+
513
+ // Two-pass: collect first, install second. Lets sibling refs like
514
+ // `a: fn`, `b: (el) => el.a + 1` work regardless of declaration order
515
+ // because every peer is already a value-producing getter once we begin
516
+ // evaluating any of them.
517
+ const targets = []
518
+ const ownProps = Object.keys(element)
519
+ for (let i = 0; i < ownProps.length; i++) {
520
+ const prop = ownProps[i]
521
+ if (typeof element[prop] !== 'function') continue
522
+ if (REGISTRY.has(prop)) continue
523
+ const firstChar = prop.charCodeAt(0)
524
+ if (firstChar >= 65 && firstChar <= 90) continue // child component
525
+ if (isEventHandler(prop)) continue
526
+ if (CSS_PROPS_REGISTRY[prop]) continue // handled by CSS effect loop
527
+ if (DEFAULT_CSS_PROPERTIES_LIST && DEFAULT_CSS_PROPERTIES_LIST.has(prop)) continue
528
+ if (checkAttributeByTagName(tag, prop)) continue // handled by attr effect loop
529
+ targets.push({ prop, fn: element[prop] })
530
+ }
531
+
532
+ for (let i = 0; i < targets.length; i++) {
533
+ const { prop, fn } = targets[i]
534
+ ref.__exec[prop] = fn
535
+ Object.defineProperty(element, prop, {
536
+ get () {
537
+ // Re-run fn on each read. Signal dependency tracking still works:
538
+ // whatever effect is reading el[prop] subscribes to state signals
539
+ // touched inside fn, so it re-fires on state changes. We skip a
540
+ // memoizing layer (createComputed) so fn never sees a peer custom
541
+ // prop in its raw-function form during this pass's eager init.
542
+ if (ds) pushConfig(ds)
543
+ try {
544
+ return fn(element, element.state, element.context)
545
+ } catch (e) {
546
+ return undefined
547
+ } finally {
548
+ if (ds) popConfig()
549
+ }
550
+ },
551
+ set (val) {
552
+ delete ref.__exec[prop]
553
+ Object.defineProperty(element, prop, {
554
+ value: val, writable: true, configurable: true, enumerable: true
555
+ })
556
+ },
557
+ configurable: true,
558
+ enumerable: true
559
+ })
560
+ }
561
+ }
562
+
476
563
  function registerEffects(element, definition) {
477
564
  const ref = element.__ref
478
565
 
566
+ // Custom-prop function reactivity (FRAMEWORK-1).
567
+ // Replace user-defined function-valued props (lowercase, not framework-
568
+ // reserved, not CSS, not HTML attribute, not event/lifecycle) with a getter
569
+ // that calls the function on read. Reads via `el.X` — including
570
+ // `el.parent.X` from descendants — return the evaluated value, and signal
571
+ // dependency tracking propagates through dependent effects (`text:`,
572
+ // `width:`, `href:`, etc.). Without this wrap, dependent effects coerce the
573
+ // un-invoked function via `Function.prototype.toString` and ship the
574
+ // arrow-function source into the DOM (e.g. "(el, s) => 8 / 10").
575
+ wrapCustomPropFunctions(element)
576
+
479
577
  // Text effect
480
578
  if (typeof element.text === 'function') {
481
579
  const fn = element.text
@@ -1018,9 +1116,17 @@ function injectScopedCSS(node, rules) {
1018
1116
  for (const k in val) {
1019
1117
  const v = val[k]
1020
1118
  if (typeof v === 'object') {
1021
- const nestedSelector = k.replace(/&/g, '.' + id)
1022
- const nestedSuffix = ' ' + nestedSelector.replace('.' + id, '')
1023
- const fullSelector = resolvedSelector.split(', ').map(s => s + nestedSuffix).join(', ')
1119
+ // `&` in the nested key references the outer's resolved selector
1120
+ // (standard CSS-nesting semantics). `&.X` chains on the outer,
1121
+ // `& .X` is a descendant of the outer, `&:hover` / `&[disabled]`
1122
+ // chain a pseudo/attr selector — all of these work because the
1123
+ // replace preserves the user's whitespace and punctuation between
1124
+ // `&` and the rest. When the nested key has no `&`, treat it as
1125
+ // an implicit descendant (preserves keys like `'> h1'` rendering
1126
+ // as `outer > h1`).
1127
+ const fullSelector = k.includes('&')
1128
+ ? resolvedSelector.split(', ').map(s => k.replace(/&/g, s)).join(', ')
1129
+ : resolvedSelector.split(', ').map(s => `${s} ${k}`).join(', ')
1024
1130
  let nestedProps = ''
1025
1131
  for (const nk in v) {
1026
1132
  nestedProps += `${nk.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${v[nk]};`
@@ -1190,13 +1296,30 @@ function applyCssInProps(element) {
1190
1296
  cssStyles[prop] = val
1191
1297
  hasStyles = true
1192
1298
  }
1193
- } else if (prop.charCodeAt(0) === 45 && prop.charCodeAt(1) === 45) {
1194
- // CSS custom properties (--var-name): apply as inline style
1299
+ } else if (prop.charCodeAt(0) === 45) {
1300
+ // FT-FRAMEWORK-4: any dash-prefixed CSS key — both custom properties
1301
+ // (`--var-name`) and vendor-prefixed properties
1302
+ // (`-webkit-text-stroke`, `-moz-osx-font-smoothing`, …) — pass
1303
+ // straight through the atomic CSS engine. Previously only `--`
1304
+ // matched; `-webkit-text-stroke: 1px darkgrey` at the top level was
1305
+ // silently dropped, forcing consumers to wrap it in `style: { ... }`.
1195
1306
  const val = element[prop]
1196
1307
  if (val != null && typeof val !== 'object') {
1197
1308
  cssStyles[prop] = val
1198
1309
  hasStyles = true
1199
1310
  }
1311
+ } else if (prop.charAt(0) === '&') {
1312
+ // Top-level scope-anchored selector — e.g. `'& .token': { '&.keyword': {color} }`
1313
+ // — flows straight to the atomic CSS engine, which substitutes `&` with
1314
+ // the generated class. The `:` and `@` branches below already do this for
1315
+ // pseudo-classes and at-rules; without this branch, top-level `&` keys
1316
+ // were silently dropped and projects had to wrap every scope rule in a
1317
+ // `style: {}` block (or inject a `<style>` child as a workaround).
1318
+ const val = element[prop]
1319
+ if (val && typeof val === 'object') {
1320
+ cssStyles[prop] = val
1321
+ hasStyles = true
1322
+ }
1200
1323
  } else if (prop.charAt(0) === '@' || prop.charAt(0) === ':') {
1201
1324
  // Selector-based CSS: @dark, @media, :hover
1202
1325
  const val = element[prop]
package/src/extends.js CHANGED
@@ -5,16 +5,33 @@
5
5
 
6
6
  const extendsCache = new WeakMap()
7
7
 
8
+ // FT-FRAMEWORK-EXTEND-CUSTOM: collect entries from BOTH `extends:`
9
+ // (plural) and `extend:` (singular). Previously the resolver did
10
+ // `definition.extends || definition.extend`, silently dropping one when
11
+ // the other was set. childsafetynet's blog-detail pages used both
12
+ // (`extends: 'Page'` for route-mode + `extend: 'BlogArticle'` for the
13
+ // article shell) and only Page resolved — BlogArticle's child keys
14
+ // never reached the render tree.
15
+ function collectExtendsList (definition) {
16
+ const list = []
17
+ if (definition.extends) {
18
+ if (Array.isArray(definition.extends)) list.push(...definition.extends)
19
+ else list.push(definition.extends)
20
+ }
21
+ if (definition.extend) {
22
+ if (Array.isArray(definition.extend)) list.push(...definition.extend)
23
+ else list.push(definition.extend)
24
+ }
25
+ return list
26
+ }
27
+
8
28
  export function applyExtends(element, definition, options) {
9
- let ext = definition.extends || definition.extend
10
- if (!ext) return
29
+ const ext = collectExtendsList(definition)
30
+ if (!ext.length) return
11
31
 
12
32
  const context = element.context || options?.context || {}
13
33
  const components = context.components || {}
14
34
 
15
- // Normalize to array
16
- if (!Array.isArray(ext)) ext = [ext]
17
-
18
35
  // Resolve extends chain
19
36
  for (let i = 0; i < ext.length; i++) {
20
37
  const base = resolveExtend(ext[i], components)
@@ -48,13 +65,14 @@ function mergeBase(element, base, options, visited = new Set()) {
48
65
  if (visited.has(base)) return
49
66
  visited.add(base)
50
67
 
51
- // Recursively resolve base's own extends first (support both 'extends' and 'extend')
52
- const baseExtends = base.extends || base.extend
53
- if (baseExtends) {
68
+ // Recursively resolve base's own extends first — combining both
69
+ // `extends:` (plural) and `extend:` (singular). Mirrors the entry-point
70
+ // collectExtendsList logic so chains like Page → BlogArticle → Flex
71
+ // resolve correctly when intermediate components mix the two forms.
72
+ const baseExt = collectExtendsList(base)
73
+ if (baseExt.length) {
54
74
  const context = element.context || options?.context || {}
55
75
  const components = context.components || {}
56
- let baseExt = baseExtends
57
- if (!Array.isArray(baseExt)) baseExt = [baseExt]
58
76
  for (let i = 0; i < baseExt.length; i++) {
59
77
  const ancestor = resolveExtend(baseExt[i], components)
60
78
  if (ancestor) mergeBase(element, ancestor, options, visited)
package/src/methods.js CHANGED
@@ -152,10 +152,20 @@ const elementProto = {
152
152
  parse(exclude) {
153
153
  const result = {}
154
154
  const excludeSet = exclude ? new Set(exclude) : null
155
+ const exec = this.__ref?.__exec
155
156
  for (const key in this) {
156
157
  if (key === 'parent' || key === 'node' || key === '__ref') continue
157
- if (typeof this[key] === 'function') continue
158
158
  if (excludeSet?.has(key)) continue
159
+ // Reactive props (text/html/value/style/attr/css/custom-prop fns) are
160
+ // wrapped at create time; the live value is the evaluated result, but
161
+ // ref.__exec[key] holds the original function (or template). Restore it
162
+ // so reset() / parseDeep() reproduce the reactive definition rather
163
+ // than baking in a snapshot value.
164
+ if (exec && exec[key] !== undefined) {
165
+ result[key] = exec[key]
166
+ continue
167
+ }
168
+ if (typeof this[key] === 'function') continue
159
169
  result[key] = this[key]
160
170
  }
161
171
  return result