@adia-ai/web-components 0.6.7 → 0.6.9

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.
@@ -90,7 +90,7 @@
90
90
  }
91
91
 
92
92
  /* Header — chrome band above the code block */
93
- > header {
93
+ & > header {
94
94
  display: flex;
95
95
  flex-direction: row;
96
96
  align-items: center;
@@ -103,14 +103,14 @@
103
103
  color: var(--code-header-fg);
104
104
  }
105
105
 
106
- > header [slot="label"] {
106
+ & > header [slot="label"] {
107
107
  font-weight: 500;
108
108
  text-transform: uppercase;
109
109
  letter-spacing: 0.05em;
110
110
  }
111
111
 
112
112
  /* Copy button — ghost style */
113
- > header [slot="copy"] {
113
+ & > header [slot="copy"] {
114
114
  all: unset;
115
115
  cursor: pointer;
116
116
  font-size: var(--code-header-font);
@@ -121,7 +121,7 @@
121
121
  background var(--code-duration) var(--code-easing),
122
122
  color var(--code-duration) var(--code-easing);
123
123
  }
124
- > header [slot="copy"]:hover {
124
+ & > header [slot="copy"]:hover {
125
125
  background: var(--code-copy-hover-bg);
126
126
  color: var(--code-copy-hover-fg);
127
127
  }
@@ -129,7 +129,7 @@
129
129
  /* Pre > Code — reset any ambient page styling (margins, borders,
130
130
  radii, backgrounds) so the <code-ui> chrome is the single source
131
131
  of visual framing. */
132
- > pre {
132
+ & > pre {
133
133
  margin: 0;
134
134
  border: none;
135
135
  border-radius: 0;
@@ -141,11 +141,11 @@
141
141
  line-height: 1.5;
142
142
  scrollbar-width: none;
143
143
  }
144
- > pre::-webkit-scrollbar {
144
+ & > pre::-webkit-scrollbar {
145
145
  display: none;
146
146
  }
147
147
 
148
- > pre > code {
148
+ & > pre > code {
149
149
  margin: 0;
150
150
  padding: 0;
151
151
  border: none;
@@ -162,10 +162,10 @@
162
162
  a `[data-line-state]` row whose bg picks up the state token. The
163
163
  CodeMirror path doesn't use these markers; line decorations there go
164
164
  through CM extensions instead. */
165
- > pre > code[data-line-state-mode] {
165
+ & > pre > code[data-line-state-mode] {
166
166
  display: block;
167
167
  }
168
- > pre > code[data-line-state-mode] > [data-line-state] {
168
+ & > pre > code[data-line-state-mode] > [data-line-state] {
169
169
  display: grid;
170
170
  grid-template-columns: 1fr;
171
171
  /* Bleed the row tint to the pre's padding edges so it reads as a
@@ -173,33 +173,33 @@
173
173
  margin-inline: calc(-1 * var(--code-px));
174
174
  padding-inline: var(--code-px);
175
175
  }
176
- > pre > code[data-line-numbers] > [data-line-state] {
176
+ & > pre > code[data-line-numbers] > [data-line-state] {
177
177
  grid-template-columns: auto 1fr;
178
178
  column-gap: var(--a-space-3);
179
179
  }
180
- > pre > code[data-line-state-mode] [data-line-num] {
180
+ & > pre > code[data-line-state-mode] [data-line-num] {
181
181
  color: var(--a-fg-subtle);
182
182
  text-align: end;
183
183
  user-select: none;
184
184
  min-width: 1.5ch;
185
185
  }
186
- > pre > code[data-line-state-mode] [data-line-body] {
186
+ & > pre > code[data-line-state-mode] [data-line-body] {
187
187
  white-space: pre;
188
188
  }
189
189
  /* Empty lines still need height so the diff column counts line up. */
190
- > pre > code[data-line-state-mode] [data-line-body]:empty::before {
190
+ & > pre > code[data-line-state-mode] [data-line-body]:empty::before {
191
191
  content: " ";
192
192
  }
193
- > pre > code [data-line-state="added"] {
193
+ & > pre > code [data-line-state="added"] {
194
194
  background: var(--a-success-muted);
195
195
  }
196
- > pre > code [data-line-state="removed"] {
196
+ & > pre > code [data-line-state="removed"] {
197
197
  background: var(--a-danger-muted);
198
198
  }
199
199
 
200
200
  /* Footer — optional chrome band below the code block
201
201
  (line counts, byte size, language family, etc.) */
202
- > footer {
202
+ & > footer {
203
203
  display: flex;
204
204
  flex-direction: row;
205
205
  align-items: center;
@@ -288,7 +288,7 @@
288
288
  CM's CSS-in-JS stylesheets load first + at lower specificity, so
289
289
  these rules override without !important. */
290
290
 
291
- > [data-cm-mount] {
291
+ & > [data-cm-mount] {
292
292
  display: block;
293
293
  overflow: hidden;
294
294
  }
@@ -87,7 +87,7 @@
87
87
  }
88
88
 
89
89
  /* ── Search header ── */
90
- > header {
90
+ & > header {
91
91
  display: flex;
92
92
  align-items: center;
93
93
  gap: var(--command-gap);
@@ -96,13 +96,13 @@
96
96
  flex-shrink: 0;
97
97
  }
98
98
 
99
- > header icon-ui {
99
+ & > header icon-ui {
100
100
  flex-shrink: 0;
101
101
  color: var(--command-fg-muted);
102
102
  --a-icon-size: 1rem;
103
103
  }
104
104
 
105
- > header input {
105
+ & > header input {
106
106
  flex: 1;
107
107
  min-width: 0;
108
108
  border: none;
@@ -115,16 +115,16 @@
115
115
  padding: 0;
116
116
  }
117
117
 
118
- > header input::placeholder {
118
+ & > header input::placeholder {
119
119
  color: var(--command-fg-muted);
120
120
  }
121
121
 
122
122
  /* Suppress focus ring on the input — the palette itself is the focused surface */
123
- > header input:focus-visible {
123
+ & > header input:focus-visible {
124
124
  outline: none;
125
125
  box-shadow: none;
126
126
  }
127
- > header:focus-within {
127
+ & > header:focus-within {
128
128
  outline: none;
129
129
  box-shadow: none;
130
130
  }
@@ -217,7 +217,7 @@
217
217
  }
218
218
 
219
219
  /* ── Footer ── */
220
- > footer {
220
+ & > footer {
221
221
  display: flex;
222
222
  align-items: center;
223
223
  gap: var(--command-px);
@@ -33,10 +33,10 @@
33
33
  :scope[columns="auto-fit"] { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }
34
34
 
35
35
  /* Column span — children can span multiple columns */
36
- > [span="2"] { grid-column: span 2; }
37
- > [span="3"] { grid-column: span 3; }
38
- > [span="4"] { grid-column: span 4; }
39
- > [span="5"] { grid-column: span 5; }
40
- > [span="6"] { grid-column: span 6; }
41
- > [span="full"] { grid-column: 1 / -1; }
36
+ & > [span="2"] { grid-column: span 2; }
37
+ & > [span="3"] { grid-column: span 3; }
38
+ & > [span="4"] { grid-column: span 4; }
39
+ & > [span="5"] { grid-column: span 5; }
40
+ & > [span="6"] { grid-column: span 6; }
41
+ & > [span="full"] { grid-column: 1 / -1; }
42
42
  }
@@ -81,7 +81,7 @@
81
81
  }
82
82
 
83
83
  /* ── Pane header ── */
84
- > header {
84
+ & > header {
85
85
  box-sizing: border-box;
86
86
  display: flex;
87
87
  align-items: center;
@@ -98,17 +98,17 @@
98
98
  transition: background var(--pane-duration) var(--pane-easing);
99
99
  }
100
100
 
101
- > header:hover {
101
+ & > header:hover {
102
102
  background: var(--pane-header-bg-hover);
103
103
  }
104
104
 
105
- > header:focus-visible {
105
+ & > header:focus-visible {
106
106
  outline: none;
107
107
  box-shadow: var(--a-focus-ring) inset;
108
108
  }
109
109
 
110
110
  /* Collapse indicator — stamped by JS as icon-ui */
111
- > header > [slot="chevron"] {
111
+ & > header > [slot="chevron"] {
112
112
  --a-icon-size: var(--a-caret-size);
113
113
  flex-shrink: 0;
114
114
  margin-inline-start: auto;
@@ -122,7 +122,7 @@
122
122
  }
123
123
 
124
124
  /* ── Sections ── */
125
- > section {
125
+ & > section {
126
126
  flex: 1;
127
127
  min-height: 0;
128
128
  overflow-y: auto;
@@ -130,13 +130,13 @@
130
130
  padding: var(--pane-section-py) var(--pane-section-px);
131
131
  }
132
132
 
133
- > section::-webkit-scrollbar {
133
+ & > section::-webkit-scrollbar {
134
134
  display: none;
135
135
  }
136
136
 
137
137
  /* Section header — section's padding is the single source of horizontal inset;
138
138
  content inside the section must not add its own padding. */
139
- > section > header {
139
+ & > section > header {
140
140
  font-size: var(--pane-section-header-size);
141
141
  color: var(--pane-section-header-fg);
142
142
  padding-bottom: var(--pane-col-gap);
@@ -147,12 +147,12 @@
147
147
 
148
148
  /* Defensive: a data-col directly under a section inherits the section's inset;
149
149
  adding its own padding would compound and misalign with the section header. */
150
- > section > [data-col] {
150
+ & > section > [data-col] {
151
151
  padding: 0;
152
152
  }
153
153
 
154
154
  /* Section divider between sections */
155
- > section + section {
155
+ & > section + section {
156
156
  border-top: 1px solid var(--pane-border);
157
157
  }
158
158
 
@@ -165,7 +165,7 @@
165
165
  }
166
166
 
167
167
  /* ── Footer ── */
168
- > footer {
168
+ & > footer {
169
169
  min-height: var(--pane-bar-height);
170
170
  display: flex;
171
171
  align-items: center;
@@ -14,7 +14,7 @@
14
14
  text-align: inherit;
15
15
  }
16
16
 
17
- > * {
17
+ & > * {
18
18
  grid-area: 1 / 1;
19
19
  }
20
20
 
package/core/template.js CHANGED
@@ -91,6 +91,106 @@ function getTemplate(strings) {
91
91
  return tpl;
92
92
  }
93
93
 
94
+ // §-TBD (FB-55, P1): the HTML parser lowercases attribute names inside
95
+ // <template>.innerHTML per HTML5 §13.2.5.32, so `.className=${expr}`
96
+ // arrives at scan() as `.classname`. The `name.slice(1)` strips the dot
97
+ // and the binding writes to a lowercase enumerable expando instead of
98
+ // invoking the camelCase property setter. Net: classes never apply, no
99
+ // warning, no error.
100
+ //
101
+ // The trap affects every camelCase DOM property surface — `.className`,
102
+ // `.innerText`, `.tabIndex`, `.ariaLabel`, `.contentEditable`,
103
+ // `.readOnly`, `.maxLength`, `.minLength`, `.colSpan`, `.rowSpan` — AND
104
+ // every camelCase property declared via `UIElement.static properties`
105
+ // (e.g. `.minL`, `.maxChroma`, `.hueDriftMax`, `.colorScheme`,
106
+ // `.strokeWidth`, `.collapseKeepLeading`). Even the canonical example
107
+ // `<my-panel .minL=${minL}>` from packages/web-components/README.md:212
108
+ // silently failed pre-fix.
109
+ //
110
+ // Two-layer fix:
111
+ //
112
+ // 1. Static PROP_CASE_FIX map covers built-in DOM camelCase property
113
+ // names (zero-cost lookup, no prototype walk per binding for the
114
+ // common case).
115
+ //
116
+ // 2. Prototype-walk fallback finds the canonical camelCase property
117
+ // name on the element's prototype chain via case-insensitive
118
+ // match. Cost is O(prototype-chain-depth × prototype-key-count)
119
+ // per UNIQUE template binding (cached in parts[i].name, not
120
+ // re-walked per update() tick). Custom-element prototype chains
121
+ // are short (subclass → UIElement → HTMLElement → Element → Node
122
+ // → EventTarget); cost is negligible.
123
+ //
124
+ // Backward-compatible: when the prototype walk finds no case-insensitive
125
+ // match (genuine lowercase-expando case — rare but possible), the
126
+ // original lowercase name is preserved. No consumer that was
127
+ // deliberately writing to a lowercase expando regresses.
128
+ //
129
+ // Verification: 8-test matrix in core/template.test.js's FB-55 describe
130
+ // block. RESPONSE-55 documents the trap end-to-end.
131
+ const PROP_CASE_FIX = {
132
+ classname: 'className',
133
+ innertext: 'innerText',
134
+ innerhtml: 'innerHTML',
135
+ outerhtml: 'outerHTML',
136
+ textcontent: 'textContent',
137
+ tabindex: 'tabIndex',
138
+ arialabel: 'ariaLabel',
139
+ ariadescribedby: 'ariaDescribedBy',
140
+ arialabelledby: 'ariaLabelledBy',
141
+ ariarole: 'role',
142
+ contenteditable: 'contentEditable',
143
+ readonly: 'readOnly',
144
+ maxlength: 'maxLength',
145
+ minlength: 'minLength',
146
+ colspan: 'colSpan',
147
+ rowspan: 'rowSpan',
148
+ cellpadding: 'cellPadding',
149
+ cellspacing: 'cellSpacing',
150
+ usemap: 'useMap',
151
+ ismap: 'isMap',
152
+ accesskey: 'accessKey',
153
+ defaultchecked: 'defaultChecked',
154
+ defaultvalue: 'defaultValue',
155
+ defaultselected: 'defaultSelected',
156
+ };
157
+
158
+ // Resolve a lowercase property name to its canonical camelCase form on
159
+ // the given element. Returns the original `lower` name when no
160
+ // case-insensitive match exists (preserves expando semantics). Walks
161
+ // instance own-properties first (UIElement installs camelCase props via
162
+ // `Object.defineProperty(this, ...)` in the constructor, so they live
163
+ // on the instance, not the prototype), then prototype chain. Skips
164
+ // Object.prototype to avoid noisy matches on `toString` etc.
165
+ //
166
+ // IMPORTANT: at scan() time the custom element has typically NOT yet
167
+ // been constructed (template fragment is cloned + scanned before
168
+ // insertion → upgrade), so instance-defined props aren't visible yet.
169
+ // PROP_CASE_FIX covers built-in DOM camelCase props at scan() time;
170
+ // the per-element walk runs ALSO at applyValue() time (lazy resolve,
171
+ // cached on the part after first hit) to catch UIElement instance props
172
+ // once the element has been upgraded.
173
+ function resolvePropName(el, lower) {
174
+ if (PROP_CASE_FIX[lower]) return PROP_CASE_FIX[lower];
175
+ // Instance own-properties (UIElement installProps lives here).
176
+ const ownNames = Object.getOwnPropertyNames(el);
177
+ for (let i = 0; i < ownNames.length; i++) {
178
+ const n = ownNames[i];
179
+ if (n !== lower && n.toLowerCase() === lower) return n;
180
+ }
181
+ // Prototype chain (built-in DOM camelCase + class-defined accessors).
182
+ let proto = Object.getPrototypeOf(el);
183
+ while (proto && proto !== Object.prototype) {
184
+ const names = Object.getOwnPropertyNames(proto);
185
+ for (let i = 0; i < names.length; i++) {
186
+ const n = names[i];
187
+ if (n !== lower && n.toLowerCase() === lower) return n;
188
+ }
189
+ proto = Object.getPrototypeOf(proto);
190
+ }
191
+ return lower;
192
+ }
193
+
94
194
  export function stamp(result, container) {
95
195
  let inst = container._i;
96
196
  if (!inst || inst.s !== result.strings) {
@@ -104,12 +204,118 @@ export function stamp(result, container) {
104
204
  function mount(result, container) {
105
205
  const { strings } = result;
106
206
  const tpl = getTemplate(strings);
107
- const f = tpl.content.cloneNode(true);
207
+ let f = tpl.content.cloneNode(true);
208
+
209
+ // §-TBD (FB-57, P1): when stamping into an SVG or MathML context, the
210
+ // fragment's Elements arrive HTML-namespaced because `tpl.innerHTML =
211
+ // m` is parsed at document level without foreign-content context. SVG
212
+ // and MathML layout are namespace-strict — HTMLUnknownElement nodes
213
+ // inside an SVG render to zero-bound-rect invisible. Re-namespace the
214
+ // fragment before insertion, respecting `<foreignObject>` and
215
+ // `<annotation-xml>` HTML boundaries per the HTML5 foreign-content
216
+ // insertion-mode spec.
217
+ //
218
+ // Discriminator runs once per mount() call (no per-update cost).
219
+ // Re-namespacing walks the fragment recursively (O(node-count)),
220
+ // cheap for typical interpolation arrays (16 lines + 11 stops in
221
+ // the canonical curve-preview case = 27 createElementNS calls).
222
+ //
223
+ // Implementation site choice: mount() rather than getTemplate(),
224
+ // because:
225
+ // - container reference is in hand only at mount()
226
+ // - the same cached template may be stamped into either context;
227
+ // namespacing per-cache-entry would double the WeakMap size
228
+ // and require a cache-key extension
229
+ // - cloning is already happening here; re-namespacing piggybacks
230
+ // on the existing fragment walk
231
+ //
232
+ // RESPONSE-57 documents the trap end-to-end. Regression tests pin
233
+ // the inline-SVG path (must keep working), nested-SVG path (would
234
+ // fail pre-fix), foreignObject boundary (HTML inside SVG stays
235
+ // HTML), and MathML parallel.
236
+ const ns = foreignContentNS(container);
237
+ if (ns) f = renamespaceFragment(f, ns);
238
+
108
239
  const parts = scan(f, result.values.length);
109
240
  container.replaceChildren(f);
110
241
  return { s: strings, p: parts };
111
242
  }
112
243
 
244
+ const SVG_NS = 'http://www.w3.org/2000/svg';
245
+ const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
246
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
247
+
248
+ // Determine whether `container` is inside an SVG or MathML subtree, and
249
+ // return the namespace to re-namespace stamped fragments into. Returns
250
+ // null when the container is in HTML context (the no-op case — vast
251
+ // majority of templates). Respects `<foreignObject>` (SVG) and
252
+ // `<annotation-xml encoding=text/html|application/xhtml+xml>` (MathML)
253
+ // HTML boundaries per HTML5 §12.2.5 foreign-content insertion mode.
254
+ function foreignContentNS(container) {
255
+ if (!container || container.nodeType !== 1) return null;
256
+ // Direct check — container's own namespace is a fast-path.
257
+ if (container.namespaceURI === SVG_NS) return SVG_NS;
258
+ if (container.namespaceURI === MATHML_NS) return MATHML_NS;
259
+ // Walk ancestors to find a foreign-content root. `closest()` matches
260
+ // the element itself + ancestors, so this catches the wrap()-span
261
+ // case (HTML span inside an SVG subtree). The first match wins —
262
+ // foreignObject/annotation-xml inside SVG short-circuits before we
263
+ // see the outer <svg>, correctly returning null (HTML context).
264
+ if (typeof container.closest !== 'function') return null;
265
+ const anchor = container.closest('svg, math, foreignObject, annotation-xml');
266
+ if (!anchor) return null;
267
+ const tag = anchor.localName;
268
+ if (tag === 'foreignobject' || tag === 'foreignObject') return null;
269
+ if (tag === 'annotation-xml') {
270
+ const enc = (anchor.getAttribute('encoding') || '').toLowerCase();
271
+ if (enc === 'text/html' || enc === 'application/xhtml+xml') return null;
272
+ return MATHML_NS;
273
+ }
274
+ if (tag === 'svg') return SVG_NS;
275
+ if (tag === 'math') return MATHML_NS;
276
+ return null;
277
+ }
278
+
279
+ // Recursively transfer fragment children into a new fragment with each
280
+ // Element recreated in the target namespace. Text/comment nodes clone
281
+ // unchanged. `<foreignObject>` and `<annotation-xml encoding=text/html>`
282
+ // stay in the target namespace themselves, but their descendants revert
283
+ // to HTML (matches the browser parser's foreign-content insertion mode).
284
+ function renamespaceFragment(fragment, ns) {
285
+ const out = document.createDocumentFragment();
286
+ transferChildren(fragment, out, ns);
287
+ return out;
288
+ }
289
+
290
+ function transferChildren(src, dstParent, ns) {
291
+ for (const child of [...src.childNodes]) {
292
+ const t = child.nodeType;
293
+ if (t === 1 /* Element */) {
294
+ const dst = document.createElementNS(ns, child.localName);
295
+ for (const a of child.attributes) {
296
+ if (a.namespaceURI) dst.setAttributeNS(a.namespaceURI, a.name, a.value);
297
+ else dst.setAttribute(a.name, a.value);
298
+ }
299
+ dstParent.appendChild(dst);
300
+ // Boundary handling: <foreignObject> (SVG) and
301
+ // <annotation-xml encoding=text/html> (MathML) revert to HTML.
302
+ const local = child.localName;
303
+ let childNs = ns;
304
+ if (ns === SVG_NS && (local === 'foreignObject' || local === 'foreignobject')) {
305
+ childNs = XHTML_NS;
306
+ } else if (ns === MATHML_NS && local === 'annotation-xml') {
307
+ const enc = (child.getAttribute('encoding') || '').toLowerCase();
308
+ if (enc === 'text/html' || enc === 'application/xhtml+xml') childNs = XHTML_NS;
309
+ }
310
+ transferChildren(child, dst, childNs);
311
+ } else {
312
+ // Text, comment, etc. — clone unchanged. Comments are load-bearing
313
+ // (the `<!--p:N-->` placeholders scan() looks for).
314
+ dstParent.appendChild(child.cloneNode(true));
315
+ }
316
+ }
317
+ }
318
+
113
319
  function scan(fragment, count) {
114
320
  const parts = new Array(count);
115
321
  const w = document.createTreeWalker(fragment, 129);
@@ -146,14 +352,21 @@ function scan(fragment, count) {
146
352
  // canonical style-object path; for everything else keep
147
353
  // the generic property-assignment recipe.
148
354
  const a = attr.name;
355
+ // §-TBD (FB-55 #2, P1): updated post-fix. The v0.5.5 §184
356
+ // recommendation had `.className=${expression}` first;
357
+ // that path now works (§-TBD PROP_CASE_FIX above) but
358
+ // `class="${expression}"` is the more discoverable form
359
+ // that doesn't require knowing the camelCase property
360
+ // name. Promoting it first. The `.classList=` line is
361
+ // removed entirely — classList is a read-only getter, no
362
+ // PROP_CASE_FIX entry can make it assignable.
149
363
  const specific =
150
364
  a === 'class'
151
- ? ` .className=\${expression} write to the className property (NOT .class, which is an expando)\n` +
152
- ` class="\${expression}" full replacement (whole class string is the expression)\n` +
153
- ` .classList=\${{foo: true, bar: false}} ← if/when implemented; verify in USAGE.md\n`
365
+ ? ` class="\${expression}" full replacement (whole class string is the expression)\n` +
366
+ ` .className=\${expression} writes to the className property (resolved camelCase since FB-55 fix)\n`
154
367
  : a === 'style'
155
- ? ` .style.cssText=\${expression} write CSS text to .style.cssText\n` +
156
- ` style="\${expression}" full replacement of the style string\n`
368
+ ? ` style="\${expression}" full replacement of the style string\n` +
369
+ ` .style.cssText=\${expression} write CSS text via the style.cssText accessor\n`
157
370
  : ` ${a}="\${expression}" ← full replacement (whole attr is the placeholder)\n` +
158
371
  ` .${a}=\${expression} ← property assignment (preferred for objects/functions)\n`;
159
372
  // eslint-disable-next-line no-console
@@ -173,8 +386,12 @@ function scan(fragment, count) {
173
386
  n.removeAttribute(name);
174
387
  parts[i] = { t: 'e', n, name: name.slice(1), c: undefined, _fx: null };
175
388
  } else if (name[0] === '.') {
389
+ // §-TBD (FB-55, P1): resolve the parser-lowercased property
390
+ // name back to its canonical camelCase via PROP_CASE_FIX +
391
+ // prototype walk. See PROP_CASE_FIX comment above for context.
392
+ // Lazy-resolve in applyValue() too — see _resolved guard there.
176
393
  n.removeAttribute(name);
177
- parts[i] = { t: 'p', n, name: name.slice(1), c: undefined, _fx: null };
394
+ parts[i] = { t: 'p', n, name: resolvePropName(n, name.slice(1)), c: undefined, _fx: null, _resolved: false };
178
395
  } else if (name[0] === '?') {
179
396
  // §250 (v0.5.11, FEEDBACK-27): Lit-style boolean attribute syntax
180
397
  // (`?attr=${bool}`) is NOT supported. Without this branch, the
@@ -256,6 +473,18 @@ function applyValue(p, v) {
256
473
  if (v == null || v === false) p.n.removeAttribute(p.name);
257
474
  else p.n.setAttribute(p.name, v === true ? '' : v);
258
475
  } else if (p.t === 'p') {
476
+ // §-TBD (FB-55, P1): two-phase property-name resolution. PROP_CASE_FIX
477
+ // resolved at scan() time for zero-cost built-in DOM camelCase (the
478
+ // common case). For UIElement custom-element props installed on the
479
+ // INSTANCE via the constructor's installProps(), the property doesn't
480
+ // exist at scan() time (element not yet upgraded), so a lazy lookup
481
+ // here catches them. Resolution is cached back into p.name on first
482
+ // hit — subsequent updates skip the walk.
483
+ if (!p._resolved) {
484
+ const resolved = resolvePropName(p.n, p.name);
485
+ if (resolved !== p.name) p.name = resolved;
486
+ p._resolved = true;
487
+ }
259
488
  p.n[p.name] = v;
260
489
  } else if (p.t === 'e') {
261
490
  if (isHandler(p.c)) p.n.removeEventListener(p.name, p.c);