@adia-ai/web-components 0.5.15 → 0.5.17

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/CHANGELOG.md CHANGED
@@ -11,6 +11,62 @@ runtime ships in the sibling `@adia-ai/a2ui-runtime` package
11
11
 
12
12
  _No pending changes._
13
13
 
14
+ ## [0.5.17] - 2026-05-16
15
+
16
+ ### Fixed
17
+ - §339 (FB-41; P2) — `<swatch-ui>` `[auto-contrast]` overrode `[label-position="overlay"]` position. Two `@scope (swatch-ui)` rules at equal specificity (0,3,1) collided on `> [data-label] { position }`; source order favored auto-contrast's `position: relative` over overlay's `position: absolute` → label dropped below tile (host doubled to 2× block-h). Blocks the canonical `label-position="overlay" + auto-contrast` pattern §253 enabled. Fix: drop `position: relative` from `[auto-contrast]` data-label + data-detail rules — z-index works on positioned elements (overlay supplies position: absolute), and non-overlay defaults are flex flow where z-index is irrelevant. 3 NEW vitest cases lock the CSS source structurally.
18
+ - §340 (FB-42; P2) — `<list-item-ui>` `render()` text-branch asymmetry. Icon + description branches had create-if-missing paths; text branch only updated existing stamped spans. Template engine `.text=${expr}` (canonical authoring pattern per USAGE.md) arrives AFTER `connectedCallback` `#stamp()` — `this.text` was `''` at stamp time so no span created, `render()` fired but couldn't recover → primary text silently invisible. Fix: mirror description branch's create-if-missing + remove-when-cleared pattern. NEW `list.test.js` with 5 vitest cases (create-on-prop-set / update / remove / parity-with-description / consumer-stamp non-interference).
19
+
20
+ ### Tests
21
+ - §338 (FB-40; misdiagnosed) — 4 NEW vitest cases in `core/template.test.js` lock the existing FEEDBACK-06 comment-aware `inTag()` fix (line 55-78). FB-40 reported apostrophes-in-HTML-comments crashes claiming the parser mis-classifies subsequent `${…}` placeholders; the cited mechanism is incorrect — `inTag()` has skipped past `<!--…-->` wholesale since FEEDBACK-06 (pre-v0.5.x). The 4 new tests empirically verify the fix covers apostrophe-comment + double-quote-comment + multi-apostrophe + apostrophe+em-dash cases. RESPONSE-40 requests stack-trace evidence for the actual crash mechanism since the cited apostrophe diagnosis is empirically wrong against current source.
22
+
23
+ ## [0.5.16] - 2026-05-16
24
+
25
+ ### v0.5.16 §331 — `<swatch-ui>` chrome-slot wrapper-span deep-walk (FB-39; P1)
26
+
27
+ Closes FB-39. P1 lifecycle gap that FB-38's "C1.3 unblocked" close-out missed: §326 (v0.5.15) fixed the late-direct-append case but NOT the template-render wrapper-span case. AdiaUI's `html\`\`` template engine wraps each interpolated child fragment in a `<span style="display: contents">` (per `core/template.js:212` `wrap()`). When consumer writes `html\`<swatch-ui>${badgeFrag}</swatch-ui>\``, the `<span slot="chrome">` ends up nested INSIDE the wrapper, not as a direct child. `#stamp()`'s `Array.from(this.children).filter(n => n.getAttribute?.('slot') === 'chrome')` returned `[]`; `innerHTML = ''` wiped the wrapper spans + chrome content; `#absorbChromeSlot()` ran on destroyed DOM.
28
+
29
+ Blocked Tokens Studio's C1.3 dogfood migration (chrome elements silently destroyed) + >95% of AdiaUI consumers using `html\`\`` templates.
30
+
31
+ #### Fixed — `components/swatch/class.js` (dual-site deep-walk)
32
+
33
+ - `#stamp()`'s chrome extraction extends from direct-children-only to also walk `querySelectorAll('[slot="chrome"]')` on non-chrome wrapper-span children. Catches both static-HTML (direct child) AND template-render (nested in wrapper) paths.
34
+ - `#absorbChromeSlot()` gains a parallel deep-walk for wrapper-spanned chrome children that arrive AFTER `#stamp()` (parent template re-render path). Hoists nested chrome out of any non-internal wrapper into the canonical sibling-of-tile slot via `this.insertBefore(child, this.#badgeEl)`.
35
+ - Wrapper spans stay in place post-hoist (`display: contents` makes them invisible to CSS layout; removing them risks interfering with the parent template engine's diff tracking via the `PART` symbol).
36
+
37
+ #### Added — `components/swatch/swatch.test.js` (6 → 9 cases)
38
+
39
+ 3 NEW vitest cases under "FB-39 / §331":
40
+ - `hoists chrome child from inside wrapper span at #stamp() time (template-interpolation path)` — single-wrapper-single-chrome case.
41
+ - `handles MULTIPLE chrome children nested in separate wrapper spans` — multi-`${...}` template interpolation; each fragment lands in its own wrapper.
42
+ - `hoists chrome from late-arriving wrapper span (post-stamp template re-render)` — parent re-renders + appends a new wrapper-span carrying chrome after `#stamp()` already ran.
43
+
44
+ Total `swatch.test.js`: 9/9 passing (4 §325 + 2 §326 + 3 §331).
45
+
46
+ #### Migration
47
+
48
+ Consumers using `<swatch-ui>` with chrome-slot composition via `html\`\`` templates (`<swatch-ui>${badge}${dot}</swatch-ui>`) now get chrome children placed at canonical position post-§331. The consumer-side workaround (skip chrome composition or use bare `<swatch-ui>` without chrome) can be retired. No consumer code changes required — the §331 fix is invisible to consumers writing canonical templates.
49
+
50
+ ### v0.5.x (Unreleased) §330 — `slider-ui` "Next" design + `segmented-ui` initial-render fix
51
+
52
+ **`slider-ui`** — full visual redesign to iOS/macOS-style pill slider. Pill-shaped track (`var(--a-toggle-size)` = 16/20/24px for sm/md/lg), primary-color progressive fill (`--a-primary-bg`), white chrome thumb (`--a-chrome-light`) with 2:1 width:height ratio and 2px internal padding on all sides. Mathematical geometry documented inline in CSS: `fill_width(p) = t + p·(W−t)`, `thumb_center(p) = t/2 + p·(W−t)`; fill starts at thumb-width (p=0) and grows incrementally to full track width (p=1). Two-layer thumb architecture: transparent full-height container provides grab area; `::before` renders the inward-padded white pill. `class.js` `#valueFromX()` maps full track width to [min,max]; `#valueFromX()` simplified back to direct rect ratio. `data-dragging` CSS transition lock kills the `left` transition during pointer moves for instant visual feedback. `slider.examples.html` gains Sizes section (lg/md/sm/14px/12px) + updated CSS token table.
53
+
54
+ **`segmented-ui`** — initial rendering race fix. Added `ResizeObserver` on self + `document.fonts.ready` promise in `connected()`; both call `#updateIndicator()` when layout stabilizes so the animated pill positions correctly after font load or resize. Previously the indicator computed once at mount and stayed misaligned — only corrected on next user interaction.
55
+
56
+ Files: `slider.css`, `slider.class.js`, `slider.examples.html`, `slider.yaml`, `segmented.class.js`.
57
+
58
+ ### Added
59
+ - §333 `<color-input>` gains canonical demo-shell loader pair to restore catalog symmetry with the other 92 primitives. NEW `color-input.html` (52-line shell pattern matching `color-picker.html`: tokens base + composed primitive CSS + composed primitive JS per ADR-0027 + `fetch('./color-input.examples.html')`) + NEW `color-input.examples.html` (7 sections: default · initial values · format hex vs oklch · popover placement · §313 generation constraints `max-chroma`/`max-l`/`min-l`/`hue-drift-max`/`base-hue` · disabled · inside a form). Closes gemini-review refactor-backlog P3-1.
60
+ - §332 `<swiper-ui>` gains `chrome="toolbar"` layout mode with companion `label` + `counter` props. Default chrome (overlay paddles + centered dots below) is unchanged. Toolbar chrome stamps a header row (label on the left, paddles on the right) above the track and a footer row (counter on the left, dots on the right) below — supports gallery-style compositions where consumers want the chrome out of the slide area. `#stampToolbarChrome()` builds the head + foot rows; `#updateCounter()` writes "N / M" (page-based, snap-aware) into `[data-swiper-counter]` on every scroll. New examples at `/site/components/swiper` § chrome (photo gallery / featured products / autoplay testimonials). Counter is page-based so it tracks the dot count correctly for multi-column + center-snap.
61
+ - §329 `<code-ui>` gains a `<template>`-child authoring path — wrap the source in `<template>…</template>` and write literal `<` / `>` without entity-escaping. `#stampBlock` reads `template.innerHTML`, dedents the wrapper column via a new static `#dedent()` helper, and feeds the result to CodeMirror. The HTML parser treats `<template>` content as inert (it lives in a `DocumentFragment`, not rendered), so embedded `<nav-ui>` / `<button-ui>` / etc. don't get upgraded inside the template. Three authoring paths now in priority order: (1) `[text]` attribute, (2) `<template>` child, (3) escaped textContent (back-compat — existing examples unchanged). Demo at `/site/components/code` § "escape-free authoring via &lt;template&gt;"; dogfooded on `patterns/app-nav` structure example. (Carry-forward from worktree §318.)
62
+
63
+ ### Fixed
64
+ - §332 `<swiper-ui>` — comprehensive consumer-feedback hardening pass (9 distinct fixes consolidated). (a) **Click-and-drag preempted by native HTML5 image-drag**: `<img>` inside slide cards fired `dragstart` before our 5px pointermove threshold was reached, suspending pointer events. Fix: `dragstart` listener gated on `this.#drag !== null` (set by `#onPointerDown`) so genuine drag-out of intentionally draggable slide content stays available. (b) **Pagination dots ignored `--swiper-columns`**: stamping one dot per slide regardless of `slides-per-view`. New `#getPageCount()` returns `max(1, slides − cols + 1)` for start/end snap (reads `--swiper-columns` via `getComputedStyle(track)`); `ResizeObserver` on the host re-stamps when container queries shift the cv across breakpoints. `next/prev/play` cap at last page; dot labels read "Page N" (was "Slide N", misleading once dot-index ≠ slide-index). (c) **CSS `container-type` on the wrong element** — `container-type: inline-size` was on `[data-swiper-track]` but `@container` rules targeted `:scope` (the host = track's PARENT, not a descendant). Container queries match descendants of the container, so the rules fell through to whatever further-up ancestor had a container set (the docs shell) and incorrectly stamped `cols=2-3` on every swiper. Moved `container-type: inline-size; container-name: swiper;` to host; `@container swiper (...)` rules now target `:scope > [data-swiper-track]` (a real descendant). JS `#getColumns()` reads from the track via `getComputedStyle` so both `[slides-per-view]` (cascades from host) and `@container` (writes on track) converge. (d) **Snap-aware active tracking** — IntersectionObserver "leftmost intersecting" couldn't distinguish the last two snap positions for center/end snap (both kept the last two slides >50% visible). Replaced with `scroll` listener + `#computeActiveIndex()` that walks every slide, computes its snap target (`left` / `left + width/2 − trackWidth/2` / `left + width − trackWidth` per snap mode), clamps to `[0, maxScroll]`, and picks the closest target to `scrollLeft`. `#pageToSlideIndex` / `#slideToPageIndex` map between page and slide indices (identity for start/center; end-snap shifts by `cols − 1`); snap-aware `goTo()` scrolls to the computed target so dot clicks land without a "scroll to left edge, then scroll-snap re-correct" jank step. `#getPageCount()` returns `slides.length` for `snap="center"` (each slide is its own page; 4-slides-@-cols=2 now stamps 4 dots, not 3). (e) **Loop / autoplay demos didn't visibly demonstrate looping** — at responsive widths (≥960px → cols=3) the 3-slide demos had `pageCount=1`, paddles produced no visible effect. Pinned demos to `slides-per-view="1"`; added a new combined "loop + autoplay" example. (f) **`gap` only accepted token indices despite docs claiming "any CSS length"**: `render()` blindly wrapped `gap` in `var(--a-space-${gap})`. For `gap="1rem"` that became `var(--a-space-1rem)` (undefined token) → property invalidated → CSS gap fell back to 0; three demo variants (`gap=0/1rem/2rem`) all rendered identically. Fix: numeric-only strings resolve to space tokens, anything else used as a raw CSS length. (g) **Dot CSS lost in toolbar chrome**: `:scope > [data-swiper-dots] > button` (direct-child combinator) stopped matching when dots moved inside `[data-swiper-foot]`; buttons fell back to browser defaults (wide gray bars). Relaxed all five dot rules to `:scope [data-swiper-dots]` (descendant combinator). (h) **Active dot rendered much larger than inactive** — `background: <color>` shorthand in `:hover` and `[aria-current="true"]` rules silently RESET `background-clip` back to its initial `border-box`, so the colored area filled the full 1rem outer hitbox and `border-radius: 100rem` rounded that into a ~16px circle (vs the 6px content-box-clipped circle for inactive). Fix: longhand `background-color: <color>` so `background-clip: content-box` from the base rule is preserved. Active dot now has a deliberate +4px (`--a-space-1`) size delta via NEW `--swiper-dot-size-active` token; padding back-computed so the 1rem hitbox stays invariant. Focus ring moved from outer `var(--a-focus-ring)` to inset 2px shadow so focus doesn't change footprint either. **Pitfall worth knowing for future CSS work: `background: <color>` shorthand resets `background-clip`/`background-origin`/`background-position`/etc. to initial — use `background-color: <color>` to preserve them.**
65
+ - §327 P2 correctness — `<code-ui language="…">` (every `/site/patterns/*` page) rendered as flow-wrapped plain text with chrome header below the content + no syntax highlighting. Root cause: the `textContent` key in `static properties` of `code/class.js`, `button/class.js`, `tag/class.js`, `badge/class.js` made `UIElement.installProps()` shadow the native `HTMLElement.prototype.textContent` setter. `this.textContent = ''` in `code-ui`'s `#stampBlock` became a signal-write no-op — original authored HTML-escaped source stayed in the element above the stamped chrome, and CodeMirror mounted with an empty doc (1 cm-line, 19.5px tall editor below leftover authored text). Same hazard on `<tag-ui>` produced visible empty pills across every `/site/traits/*` page's API table (the `chip()` helper in `traits/_api-table.js` and 3 other call sites all do `t.textContent = text` after `createElement('tag-ui')`). Fix: removed the `textContent` entry from each of the 4 class.js files. Yaml + `.d.ts` entries kept (renderer routes via `setAttribute('text', …)` for non-text-bearing tags; doesn't depend on JS prop declarations). New drift class #7 (native-DOM-accessor collision). **Carry-forward note:** initially staged as §316 on a worktree branch (2026-05-15) before v0.5.14 cut; §316-§318 were claimed by parallel peer work that shipped via v0.5.15. Renumbered to §327 on re-apply to main against v0.5.15 HEAD (2026-05-16).
66
+
67
+ ### Docs
68
+ - §328 source-side polish: re-flowed 6 one-line `<code-ui>` blocks across `stat`/`pagination`/`rating`/`count-up`/`theme-panel`/`theming` USAGE sections — multi-attribute single tags now author each attribute on its own line for readability + consistency with how the rest of the corpus is authored. (Carry-forward from worktree §317.)
69
+
14
70
  ## [0.5.15] - 2026-05-16
15
71
 
16
72
  > **Scope note:** §325 + §326 were originally planned as a separate v0.5.16 cycle (see retired `docs/plans/0.5.16-release-plan.md`); absorbed into v0.5.15 since both landed within the same release window (same-day commits prior to bump). The peer's v0.5.16 plan-doc explicitly anticipated this absorption option ("Hermes's call per `/lockstep-release`"). v0.5.16 plan-doc marked SHIPPED-IN-V0.5.15.
@@ -40,7 +40,11 @@ const STATUS_MAP = {
40
40
  export class UIBadge extends UIElement {
41
41
  static properties = {
42
42
  text: { type: String, default: '', reflect: true },
43
- textContent: { type: String, default: '' },
43
+ // NOTE: `textContent` is intentionally NOT declared as a reactive
44
+ // prop — `installProps()` would override the native DOM setter, so
45
+ // `el.textContent = '…'` would silently no-op. Yaml keeps the prop
46
+ // entry for renderer metadata, but the JS class must not declare
47
+ // it. v0.5.x §327.
44
48
  variant: { type: String, default: 'default', reflect: true },
45
49
  size: { type: String, default: 'md', reflect: true },
46
50
  icon: { type: String, default: '', reflect: true },
@@ -19,7 +19,11 @@ import { getIcon } from '../../core/icons.js';
19
19
  export class UIButton extends UIElement {
20
20
  static properties = {
21
21
  text: { type: String, default: '', reflect: true },
22
- textContent: { type: String, default: '' },
22
+ // NOTE: `textContent` is intentionally NOT declared as a reactive
23
+ // prop — `installProps()` would override the native DOM setter, so
24
+ // `el.textContent = '…'` would silently no-op instead of updating
25
+ // child text. Yaml keeps the prop entry for renderer metadata, but
26
+ // the JS class must not declare it. v0.5.x §327.
23
27
  variant: { type: String, default: 'solid', reflect: true },
24
28
  color: { type: String, default: '', reflect: true },
25
29
  size: { type: String, default: 'md', reflect: true },
@@ -62,7 +62,13 @@ export class UICode extends UIElement {
62
62
  language: { type: String, default: '', reflect: true },
63
63
  inline: { type: Boolean, default: false, reflect: true },
64
64
  text: { type: String, default: '', reflect: true },
65
- textContent: { type: String, default: '' },
65
+ // NOTE: `textContent` is intentionally NOT declared as a reactive prop.
66
+ // `installProps()` would override the native DOM setter with a signal
67
+ // setter, breaking `this.textContent = ''` in #stampBlock (the wipe
68
+ // becomes a no-op, leaving the authored HTML-escaped source visible
69
+ // above the stamped chrome and feeding an empty doc to CodeMirror).
70
+ // Initial content is read via the native getter in #stampBlock; live
71
+ // updates flow through the reactive `text` property. v0.5.x §327.
66
72
  lineNumbers: { type: Boolean, default: false, reflect: true, attribute: 'line-numbers' },
67
73
  editable: { type: Boolean, default: false, reflect: true },
68
74
  bare: { type: Boolean, default: false, reflect: true },
@@ -132,10 +138,25 @@ export class UICode extends UIElement {
132
138
  }
133
139
 
134
140
  #stampBlock() {
135
- // Prefer the `text` attribute when present; otherwise read the authored
136
- // textContent. Either way, the block is rebuilt from semantic elements
137
- // so whitespace in source HTML doesn't leak into the rendered <code>.
138
- const raw = (this.text || this.textContent || '').trim();
141
+ // Content-source priority:
142
+ // 1. `text` attribute — reactive, programmatic.
143
+ // 2. `<template>` child escape-free authoring path. The HTML parser
144
+ // treats `<template>` content as inert (DocumentFragment, not
145
+ // rendered); we read `template.innerHTML` to recover the literal
146
+ // source. Author can write `<code-ui language="html"><template>
147
+ // <nav-ui>…</nav-ui></template></code-ui>` without `&lt;`/`&gt;`
148
+ // escaping. Indent is normalized via #dedent() so the inner block
149
+ // doesn't carry the wrapper's indentation. (v0.5.x §329)
150
+ // 3. Authored textContent — original path, requires HTML entities
151
+ // for `<` and `>` (same constraint as `<pre><code>`).
152
+ // The block is rebuilt from semantic elements so whitespace in source
153
+ // HTML doesn't leak into the rendered <code>.
154
+ const tmpl = this.querySelector(':scope > template');
155
+ const raw = this.text
156
+ ? this.text.trim()
157
+ : tmpl
158
+ ? UICode.#dedent(tmpl.innerHTML)
159
+ : (this.textContent || '').trim();
139
160
  this.textContent = '';
140
161
 
141
162
  // Header — omitted in [bare] mode. Consumers that need zero chrome
@@ -206,6 +227,22 @@ export class UICode extends UIElement {
206
227
  this.appendChild(pre);
207
228
  }
208
229
 
230
+ /** Normalize indentation pulled from a `<template>` child. Strips
231
+ * the common leading whitespace from every non-empty line so the
232
+ * inner block reads as if it were authored at column 0. Equivalent
233
+ * in spirit to Python's `textwrap.dedent` or a tagged-template
234
+ * `dedent` helper. (v0.5.x §329) */
235
+ static #dedent(s) {
236
+ const trimmed = s.replace(/^\n+|\n+$/g, '');
237
+ if (!trimmed) return '';
238
+ const lines = trimmed.split('\n');
239
+ const indents = lines
240
+ .filter((l) => l.trim().length > 0)
241
+ .map((l) => l.match(/^[ \t]*/)[0].length);
242
+ const min = indents.length ? Math.min(...indents) : 0;
243
+ return min > 0 ? lines.map((l) => l.slice(min)).join('\n') : trimmed;
244
+ }
245
+
209
246
  async #mountEditor() {
210
247
  if (this.#pendingMount || this.#cmView) return;
211
248
  const gen = this.#mountGen;
@@ -156,7 +156,7 @@
156
156
  ],
157
157
  "slots": {
158
158
  "default": {
159
- "description": "Raw text fallback when the text property is not set"
159
+ "description": "Raw text fallback when the text property is not set. Two authoring shapes:\n(a) HTML-entity-escaped text (`&lt;nav-ui&gt;`) — same constraint as `<pre><code>`,\nsince the HTML parser treats unescaped tags as child elements; or\n(b) a `<template>` child whose `innerHTML` carries the literal source —\nescape-free authoring path. Indentation is dedented from the wrapper\ncolumn so the template's inner block reads at column 0. (v0.5.x §329)\n"
160
160
  }
161
161
  },
162
162
  "states": [
@@ -40,6 +40,33 @@ export interface CodeLanguageLoadErrorEventDetail {
40
40
  }
41
41
  export type CodeLanguageLoadErrorEvent = CustomEvent<CodeLanguageLoadErrorEventDetail>;
42
42
 
43
+ /**
44
+ * `<code-ui>` — syntax-highlighted code block (CodeMirror 6 under the hood).
45
+ *
46
+ * Three content-source paths in priority order:
47
+ *
48
+ * 1. `[text]` attribute — reactive, programmatic; updates re-render the block.
49
+ *
50
+ * 2. `<template>` child — escape-free authoring path (v0.5.x §329). Author
51
+ * the source with literal `<` / `>` (the HTML parser treats `<template>`
52
+ * content as inert; it lives in a `DocumentFragment` instead of being
53
+ * upgraded into DOM elements). The wrapper indent is dedented so the
54
+ * inner block reads at column 0.
55
+ *
56
+ * ```html
57
+ * <code-ui language="html">
58
+ * <template>
59
+ * <nav-ui>
60
+ * <nav-item-ui text="Home"></nav-item-ui>
61
+ * </nav-ui>
62
+ * </template>
63
+ * </code-ui>
64
+ * ```
65
+ *
66
+ * 3. Authored textContent — original path. HTML samples require entity
67
+ * escaping (`&lt;`/`&gt;`/`&amp;`), same constraint as `<pre><code>`,
68
+ * since the parser treats unescaped tags as child elements.
69
+ */
43
70
  export class UICode extends UIFormElement {
44
71
  /** CodeMirror language slug — `javascript` / `typescript` / `html` / `css` / `json` / `markdown` / `python` etc. */
45
72
  language: string;
@@ -93,7 +93,13 @@ events:
93
93
  description: The underlying load failure.
94
94
  slots:
95
95
  default:
96
- description: Raw text fallback when the text property is not set
96
+ description: |
97
+ Raw text fallback when the text property is not set. Two authoring shapes:
98
+ (a) HTML-entity-escaped text (`&lt;nav-ui&gt;`) — same constraint as `<pre><code>`,
99
+ since the HTML parser treats unescaped tags as child elements; or
100
+ (b) a `<template>` child whose `innerHTML` carries the literal source —
101
+ escape-free authoring path. Indentation is dedented from the wrapper
102
+ column so the template's inner block reads at column 0. (v0.5.x §329)
97
103
  states:
98
104
  - name: idle
99
105
  description: Default, ready for interaction.
@@ -227,9 +227,24 @@ export class UIListItem extends UIElement {
227
227
  iconEl.remove();
228
228
  }
229
229
 
230
- // Sync text — only touch elements we stamped.
230
+ // Sync text — mirror the icon + description branches: create-if-missing
231
+ // path so post-connect property bindings (template engine `.text=${expr}`)
232
+ // can stamp the span when `#stamp()` ran with `this.text === ''`. §340
233
+ // (FB-42) — without this, primary text is silently invisible when set
234
+ // via property binding (the canonical authoring pattern per USAGE.md).
231
235
  const textEl = this.#ownChild('[slot="text"]');
232
- if (this.#wasStamped(textEl)) textEl.textContent = this.text;
236
+ if (this.text) {
237
+ if (textEl) {
238
+ if (this.#wasStamped(textEl)) textEl.textContent = this.text;
239
+ } else {
240
+ const el = this.#stampMark(document.createElement('span'));
241
+ el.setAttribute('slot', 'text');
242
+ el.textContent = this.text;
243
+ this.appendChild(el);
244
+ }
245
+ } else if (this.#wasStamped(textEl)) {
246
+ textEl.remove();
247
+ }
233
248
 
234
249
  // Sync description — only touch elements we stamped.
235
250
  const descEl = this.#ownChild('[slot="description"]');
@@ -0,0 +1,106 @@
1
+ /**
2
+ * <list-item-ui> behavioral tests.
3
+ *
4
+ * Initial scope: FB-42 / §340 regression coverage — `render()`'s text
5
+ * branch can now create the slot element when `this.text` is set
6
+ * post-connect (e.g. via template-engine property binding), matching
7
+ * the icon + description branches' create-if-missing pattern.
8
+ *
9
+ * Test setup note: happy-dom connects custom elements synchronously
10
+ * during innerHTML parsing. To exercise the post-connect property-set
11
+ * timing (template-engine path), tests use `document.createElement` +
12
+ * `host.appendChild()` to connect with empty `text`, then set the
13
+ * property AFTER connect to trigger render()'s recovery path.
14
+ */
15
+
16
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
17
+
18
+ beforeAll(async () => {
19
+ await import('./list.js');
20
+ });
21
+
22
+ describe('<list-item-ui> render() text branch — post-connect property binding (FB-42 / §340)', () => {
23
+ let host;
24
+
25
+ beforeEach(() => {
26
+ host = document.createElement('div');
27
+ document.body.appendChild(host);
28
+ });
29
+
30
+ it('creates [slot="text"] span when .text is set AFTER connectedCallback (template-engine timing)', async () => {
31
+ const li = document.createElement('list-item-ui');
32
+ host.appendChild(li);
33
+ // connectedCallback fires here with this.text === '' — span NOT stamped
34
+ await new Promise((r) => setTimeout(r, 30));
35
+ expect(li.querySelector('[slot="text"]')).toBeNull();
36
+
37
+ // Template engine sets the property post-connect
38
+ li.text = 'Primary text via property binding';
39
+ await new Promise((r) => setTimeout(r, 30));
40
+
41
+ const textSpan = li.querySelector('[slot="text"]');
42
+ expect(textSpan).not.toBeNull();
43
+ expect(textSpan.textContent).toBe('Primary text via property binding');
44
+ });
45
+
46
+ it('updates existing [slot="text"] span when .text changes', async () => {
47
+ const li = document.createElement('list-item-ui');
48
+ li.setAttribute('text', 'initial');
49
+ host.appendChild(li);
50
+ await new Promise((r) => setTimeout(r, 30));
51
+
52
+ const textSpan = li.querySelector('[slot="text"]');
53
+ expect(textSpan).not.toBeNull();
54
+ expect(textSpan.textContent).toBe('initial');
55
+
56
+ li.text = 'updated';
57
+ await new Promise((r) => setTimeout(r, 30));
58
+ expect(li.querySelector('[slot="text"]').textContent).toBe('updated');
59
+ });
60
+
61
+ it('removes stamped [slot="text"] span when .text is cleared', async () => {
62
+ const li = document.createElement('list-item-ui');
63
+ li.setAttribute('text', 'will be removed');
64
+ host.appendChild(li);
65
+ await new Promise((r) => setTimeout(r, 30));
66
+ expect(li.querySelector('[slot="text"]')).not.toBeNull();
67
+
68
+ li.text = '';
69
+ await new Promise((r) => setTimeout(r, 30));
70
+ expect(li.querySelector('[slot="text"]')).toBeNull();
71
+ });
72
+
73
+ it('text branch parity with description branch — both create-if-missing post-connect', async () => {
74
+ const li = document.createElement('list-item-ui');
75
+ host.appendChild(li);
76
+ await new Promise((r) => setTimeout(r, 30));
77
+ expect(li.querySelector('[slot="text"]')).toBeNull();
78
+ expect(li.querySelector('[slot="description"]')).toBeNull();
79
+
80
+ li.text = 'primary';
81
+ li.description = 'secondary';
82
+ await new Promise((r) => setTimeout(r, 30));
83
+
84
+ expect(li.querySelector('[slot="text"]')?.textContent).toBe('primary');
85
+ expect(li.querySelector('[slot="description"]')?.textContent).toBe('secondary');
86
+ });
87
+
88
+ it('does not stamp [slot="text"] for unauthored consumer-provided text content', async () => {
89
+ // If the consumer provides their own [slot="text"] child (e.g. for
90
+ // richer markup), the render() branch must NOT touch it.
91
+ const li = document.createElement('list-item-ui');
92
+ const userSpan = document.createElement('span');
93
+ userSpan.setAttribute('slot', 'text');
94
+ userSpan.innerHTML = '<strong>bold</strong> user';
95
+ li.appendChild(userSpan);
96
+ host.appendChild(li);
97
+ await new Promise((r) => setTimeout(r, 30));
98
+
99
+ li.text = 'attempt to set';
100
+ await new Promise((r) => setTimeout(r, 30));
101
+
102
+ const span = li.querySelector('[slot="text"]');
103
+ expect(span).toBe(userSpan); // identity preserved
104
+ expect(span.innerHTML).toBe('<strong>bold</strong> user'); // markup preserved
105
+ });
106
+ });
@@ -36,6 +36,8 @@ export class UISegmented extends UIFormElement {
36
36
  #indicator = null;
37
37
  #bound = false;
38
38
  #transitionRaf = null;
39
+ #resizeObs = null;
40
+ #fontWait = null;
39
41
 
40
42
  connected() {
41
43
  super.connected();
@@ -52,6 +54,20 @@ export class UISegmented extends UIFormElement {
52
54
  const first = this.querySelector('segment-ui:not([disabled])');
53
55
  if (first) this.value = first.value || first.getAttribute('value') || '';
54
56
  }
57
+
58
+ // Recalculate indicator when layout shifts (font load, resize, content change)
59
+ if (typeof ResizeObserver !== 'undefined') {
60
+ this.#resizeObs = new ResizeObserver(() => this.#updateIndicator(this.#segments));
61
+ this.#resizeObs.observe(this);
62
+ }
63
+
64
+ // Also wait for fonts to settle — a common source of initial-layout drift
65
+ if (document.fonts?.ready) {
66
+ this.#fontWait = document.fonts.ready.then(() => {
67
+ this.#fontWait = null;
68
+ this.#updateIndicator(this.#segments);
69
+ });
70
+ }
55
71
  }
56
72
 
57
73
  disconnected() {
@@ -60,6 +76,12 @@ export class UISegmented extends UIFormElement {
60
76
  cancelAnimationFrame(this.#transitionRaf);
61
77
  this.#transitionRaf = null;
62
78
  }
79
+ this.#resizeObs?.disconnect();
80
+ this.#resizeObs = null;
81
+ if (this.#fontWait) {
82
+ // Promise can't be cancelled; the then-handler checks this.isConnected
83
+ this.#fontWait = null;
84
+ }
63
85
  this.removeEventListener('click', this.#handleClick);
64
86
  this.removeEventListener('keydown', this.#handleKeydown);
65
87
  this.#indicator = null;
@@ -63,6 +63,7 @@ export class UISlider extends UIFormElement {
63
63
  #trackEl = null;
64
64
  #thumbEl = null;
65
65
  #dragging = false;
66
+ #dragOffset = 0;
66
67
 
67
68
  get #pct() {
68
69
  const range = this.max - this.min;
@@ -140,9 +141,10 @@ export class UISlider extends UIFormElement {
140
141
  }
141
142
 
142
143
  const pct = this.#pct;
143
- const fill = this.querySelector('[slot="fill"]');
144
- if (fill) fill.style.width = `${pct}%`;
145
- if (this.#thumbEl) this.#thumbEl.style.left = `${pct}%`;
144
+ // Write progress to CSS custom property for pure-CSS positioning.
145
+ // --slider-pct is a fraction (0.0–1.0) used in calc() alongside
146
+ // --slider-travel so thumb + fill stay inside the track.
147
+ this.style.setProperty('--slider-pct', String(pct / 100));
146
148
 
147
149
  const valueEl = this.querySelector('[slot="value"]');
148
150
  if (valueEl) valueEl.textContent = this.#format(this.value);
@@ -152,9 +154,34 @@ export class UISlider extends UIFormElement {
152
154
  this.syncValue(String(this.value));
153
155
  }
154
156
 
157
+ /**
158
+ * Inverse geometry: given a *desired* thumb-center viewport-x, compute
159
+ * the slider value such that the thumb center lands exactly at that
160
+ * coordinate (clamped at the min/max extremes).
161
+ *
162
+ * Forward geometry (in slider.css):
163
+ * thumb_center(p) = t/2 + p · (W − t)
164
+ *
165
+ * Inverse (solve for p, clamped):
166
+ * p = clamp01((target − t/2) / (W − t))
167
+ *
168
+ * Two call paths share this:
169
+ * • #onTrackClick — clientX is the click position; thumb center lands
170
+ * under the cursor (or snaps to the t/2 end-zone when clicked beyond).
171
+ * • #onPointerMove — (clientX − dragOffset) is the *intended* thumb
172
+ * center (offset preserves where the user originally grabbed the
173
+ * thumb, so dragging feels relative rather than snap-to-cursor).
174
+ */
155
175
  #valueFromX(clientX) {
156
- const rect = this.#trackEl.getBoundingClientRect();
157
- const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
176
+ if (!this.#trackEl || !this.#thumbEl) return this.min;
177
+ const trackRect = this.#trackEl.getBoundingClientRect();
178
+ const thumbRect = this.#thumbEl.getBoundingClientRect();
179
+ const W = trackRect.width;
180
+ const t = thumbRect.width;
181
+ const travel = W - t;
182
+ if (travel <= 0) return this.min;
183
+ const target = clientX - trackRect.left; // desired thumb-center, track-relative
184
+ const ratio = Math.max(0, Math.min(1, (target - t / 2) / travel));
158
185
  const raw = this.min + ratio * (this.max - this.min);
159
186
  return this.#snap(raw);
160
187
  }
@@ -178,6 +205,28 @@ export class UISlider extends UIFormElement {
178
205
  if (this.disabled) return;
179
206
  e.preventDefault();
180
207
  this.#dragging = true;
208
+ this.setAttribute('data-dragging', '');
209
+ // Capture the offset between the click point and the thumb's
210
+ // *logical* center (derived from `this.value` via the same forward
211
+ // equation the CSS uses). We deliberately do NOT read the thumb's
212
+ // bounding rect here — the CSS `left` transition can leave the
213
+ // physical rect mid-animation between values, producing a stale
214
+ // offset that would translate into a snap on the first move.
215
+ // Logical center is transition-immune and matches what #valueFromX
216
+ // inverts.
217
+ if (this.#trackEl && this.#thumbEl) {
218
+ const trackRect = this.#trackEl.getBoundingClientRect();
219
+ const thumbRect = this.#thumbEl.getBoundingClientRect();
220
+ const W = trackRect.width;
221
+ const t = thumbRect.width;
222
+ const travel = W - t;
223
+ const range = this.max - this.min;
224
+ const p = range > 0 ? (this.value - this.min) / range : 0;
225
+ const logicalCenter = trackRect.left + t / 2 + p * travel;
226
+ this.#dragOffset = e.clientX - logicalCenter;
227
+ } else {
228
+ this.#dragOffset = 0;
229
+ }
181
230
  this.#thumbEl.setPointerCapture(e.pointerId);
182
231
  this.#thumbEl.addEventListener('pointermove', this.#onPointerMove);
183
232
  this.#thumbEl.addEventListener('pointerup', this.#onPointerUp);
@@ -185,11 +234,16 @@ export class UISlider extends UIFormElement {
185
234
 
186
235
  #onPointerMove = (e) => {
187
236
  if (!this.#dragging) return;
188
- this.#setValue(this.#valueFromX(e.clientX));
237
+ // Subtract the captured offset so the thumb center tracks the
238
+ // cursor relative to where the user originally pressed, avoiding
239
+ // the initial snap.
240
+ this.#setValue(this.#valueFromX(e.clientX - this.#dragOffset));
189
241
  };
190
242
 
191
243
  #onPointerUp = (e) => {
192
244
  this.#dragging = false;
245
+ this.#dragOffset = 0;
246
+ this.removeAttribute('data-dragging');
193
247
  this.#thumbEl.releasePointerCapture(e.pointerId);
194
248
  this.#thumbEl.removeEventListener('pointermove', this.#onPointerMove);
195
249
  this.#thumbEl.removeEventListener('pointerup', this.#onPointerUp);
@@ -153,13 +153,19 @@
153
153
  "tag": "slider-ui",
154
154
  "tokens": {
155
155
  "--slider-fill": {
156
- "description": "Filled track / thumb color"
156
+ "description": "Filled portion / progress color (was mixed accent, now primary)"
157
157
  },
158
- "--slider-thumb-size": {
159
- "description": "Thumb diameter"
158
+ "--slider-thumb-height": {
159
+ "description": "Thumb pill height (track-height − 2× inset)"
160
+ },
161
+ "--slider-thumb-width": {
162
+ "description": "Thumb pill width (2× thumb-height, driven by track-height)"
160
163
  },
161
164
  "--slider-track": {
162
- "description": "Track background color"
165
+ "description": "Unfilled track background color"
166
+ },
167
+ "--slider-track-height": {
168
+ "description": "Full track height (scales via universal [size] attribute)"
163
169
  }
164
170
  },
165
171
  "traits": [],