@adia-ai/web-components 0.7.26 → 0.7.28

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
@@ -1,4 +1,36 @@
1
1
  # Changelog — @adia-ai/web-components
2
+ ## [0.7.28] — 2026-07-14
3
+
4
+ ### Added
5
+ - **`table-ui` (`components/table/cell-types.js`) — `markdown` cell type: inline `` `code` ``/**bold**/*italic*/`[link](href)` rendered as real elements, not a plain string.** TKT-0008 (site-a2ui re-verification, direction 1): docs reference tables (props/API tables) mix prose with multiple inline `<code>` spans per cell — content the `{columns, data}` JSON contract previously couldn't carry, forcing a preserved-HTML fallback that dropped the table from the A2UI graph entirely. Renders via `core/markdown.js`'s `inline()` (now exported) — the exact encode/decode pairing the docs-transpiler's own `convertInline()` already produces elsewhere in the pipeline. `[title]` carries the tag-stripped plain text for the truncation tooltip. New unit suite in `table.test.js` (3 cases).
6
+
7
+ ### Changed
8
+ - **`core/markdown.js` — `inline()` is now exported.** Was module-private; reused by `cell-types.js`'s new `markdown` cell type as the client-side half of the docs-transpiler's `convertInline()` contract. No behavior change to existing callers.
9
+
10
+ ### Fixed — TKT-0010, corpus admission-validation content gaps
11
+ - **`kbd.yaml` — declares a `textContent` prop.** Every other text-bearing leaf (`Text`, `Code`) already declares one; `Kbd` was missing it, so the transpiler's unconditional `textContent` extraction for leaf types produced a value the catalog schema rejected as an unevaluated property on every harvested `<kbd-ui>` chunk (12 instances). Purely a schema declaration gap — no behavior change (the transpiler already extracted the value correctly).
12
+ - **`chart.yaml`/`table.yaml` — `data` prop declares `items: {type: object}`.** Opts these two dynamic array props into `@adia-ai/a2ui-corpus`'s new `DynamicObjectList` schema (array-of-object) instead of the default `DynamicStringList` (array-of-string) — the wrong shape for chart/table row records. Paired with a transpiler fix in `@adia-ai/a2ui-compose` (the attribute value was never actually parsed); see that package's CHANGELOG.
13
+
14
+ ### Fixed — GH #224, SVG zero-viewport silent non-render
15
+ - **`styles/resets.css` — `svg` gets its own reset rule, dropping `max-inline-size`/`block-size` instead of sharing `img`/`picture`/`video`/`canvas`'s `max-inline-size: 100%; block-size: auto` rule.** Per the SVG spec, a zero-size viewport — exactly what `max-inline-size: 100%` computes to inside a zero-intrinsic-size ancestor (the common `width:0; height:0; transform:scale(s)` pattern for a CSS-scaled canvas/scene) — makes the element **not render at all**, not "render at 0 visual size": silent, no console warning. `img`/`picture`/`video`/`canvas` don't share this failure mode. Components that embed SVG (`icon-ui`, etc.) already size it explicitly in their own scoped CSS and are unaffected either way (verified: no component CSS relies on the global rule's sizing behavior). Verified via the full web-modules test suite + a manual repro check against the reporting issue's own reproduction case (no dedicated new test — a global CSS reset rule).
16
+
17
+ ### Maintenance
18
+ - **`dist/` CDN bundles rebuilt** (`host.min.css`, `host.sheet.js`, `theme-provider.min.js`, `web-components.min.{css,js}`, `web-components.sheet.js`) to inline the `resets.css` fix above.
19
+
20
+ ## [0.7.27] — 2026-07-12
21
+
22
+ Fixes harvested from the /site → A2UI docs migration's live operator reports (TKT-0002…0006) — each was a real framework defect the migration surfaced, not a docs-only issue — plus the router race that three separate flake classes traced back to.
23
+
24
+ ### Added
25
+ - **`richtext-ui` (`components/richtext/`) — `flush` boolean prop: paragraph-grade embedding mode.** Removes the article chrome (the `[data-richtext-body]` `space-8`/`space-6` padding, the 45rem reading measure, the component's own canvas background, and the trailing paragraph margin) so rendered markdown sits flush in a host column like a plain text block. Use it when richtext carries a single paragraph or fragment inside an already-measured layout (doc pages, cards); leave it off for standalone articles and chat transcripts, which keep the article treatment unchanged. Motivation: the docs transpiler rides richtext for every paragraph with inline code marks — 1,672 instances across 275 pages each carried article insets (TKT-0004 B).
26
+
27
+ ### Fixed
28
+ - **`router-ui` (`core/provider.js`) — `#loadContent` claims a monotonic navigation token BEFORE any await.** A navigation that started earlier but fetched its content slowly could resume after a faster follow-up navigation had fully rendered, and — because nothing sequenced completion against navigation order — legitimately clobber the newer route's content while `data-route-path` still named the new route. Reproduced deterministically (2s-delayed boot fetch: old code showed `path=button` with the Dashboard DOM). The token is re-checked after every await (content fetch, templateResolver, setup import); a superseded call neither writes `innerHTML` nor emits `route-loaded`. This was the root mechanism behind the site-a2ui migration's "no-artifact despite a valid artifact" residue and its batch-sweep flakes.
29
+ - **`nav-ui` (`components/nav/`) — collapsed-mode flyouts no longer re-open after selecting an item (TKT-0005).** The flyout popover is appended inside its `nav-group-ui`, so an option click bubbled to `nav-ui`'s delegated click handler, whose collapsed branch called `group.showPopover()` for any click landing inside the group — instantly re-opening the flyout the option's own handler had just closed. The collapsed branch now ignores clicks originating inside `[popover]`, mirroring the header guard the expanded branch always had.
30
+ - **`core/markdown.js` — inline code spans survive emphasis/link regexes.** `inline()` now extracts backtick code spans into placeholders before applying the bold/italic/link transforms and restores them verbatim after — code spans containing `*`, `_`, `[`, or `]` no longer render mangled. New unit suite (`core/markdown.test.js`, 6 cases).
31
+
32
+ ### Maintenance
33
+ - `dist/` CDN bundles regenerated (`web-components.min.css`, `web-components.min.js`, `theme-provider.min.js`) carrying the richtext `flush` rules + the router/nav/markdown fixes.
2
34
 
3
35
  ## [0.7.26] — 2026-07-04
4
36
 
@@ -411,7 +443,7 @@ Upstream consumer findings from `adia-pay` against installed `0.7.23` (F1–F6).
411
443
 
412
444
  ### Changed — `slot="caret"` disclosure-indicator convention + FEEDBACK-89 (ADR-0036)
413
445
 
414
- - **`components/tree/` (`tree-item-ui`) + `components/pane/` (`pane-ui`)** — renamed the expand/collapse indicator slot `slot="chevron"` → **`slot="caret"`** (and tokens `--*-chevron-*` → `--*-caret-*`, e.g. `--tree-caret-size`, `--pane-caret-fg`), standardizing on the slot name already used by `accordion-item-ui` / `nav-group-ui` / `select-ui` and matching the Phosphor `caret-*` icon vocabulary. **No backward-compat alias** (hard rename; the caret is auto-stamped, so blast radius is small). See [ADR-0036](../../.brain/adrs/0036-caret-slot-disclosure-convention.md).
446
+ - **`components/tree/` (`tree-item-ui`) + `components/pane/` (`pane-ui`)** — renamed the expand/collapse indicator slot `slot="chevron"` → **`slot="caret"`** (and tokens `--*-chevron-*` → `--*-caret-*`, e.g. `--tree-caret-size`, `--pane-caret-fg`), standardizing on the slot name already used by `accordion-item-ui` / `nav-group-ui` / `select-ui` and matching the Phosphor `caret-*` icon vocabulary. **No backward-compat alias** (hard rename; the caret is auto-stamped, so blast radius is small). See `ADR-0036`.
415
447
  - **`tree-item-ui` adopt-or-stamp (FEEDBACK-89)** — `#stamp()` now adopts a consumer's declarative `slot="caret"` and `slot="actions"` children into the auto-stamped row instead of leaving them as siblings below it. A `<button-ui slot="actions">` (the natural way to add a per-row action — add / rename / overflow) now lands inline in the styled, hover-revealed actions area. The host `#onClick` already excludes `[slot="actions"] *` from row selection, so adoption is click-safe. +3 unit tests.
416
448
  - **`dist/` CDN bundles** (`web-components.min.css` + `web-components.min.js`) regenerated to carry the renamed `--*-caret-*` tokens + caret stamp logic.
417
449
 
@@ -3234,7 +3266,7 @@ Companion to the corpus simplification arc in `@adia-ai/a2ui-{runtime,compose,co
3234
3266
 
3235
3267
  Catalog (`packages/a2ui/corpus/catalog-a2ui_0_9.json`) regenerated to pick up every yaml addition. `verify:traits` clean at 56/56; `components --verify` clean at 125 files.
3236
3268
 
3237
- See root [CHANGELOG.md `[Unreleased]`](../../CHANGELOG.md) for the cross-cutting arc narrative + [.claude/docs/journal/2026/05/2026-05-12.md](../../.claude/docs/journal/2026/05/2026-05-12.md) §§ 43 / 44 / 50 / 51 for per-§ details.
3269
+ See root [CHANGELOG.md `[Unreleased]`](../../CHANGELOG.md) for the cross-cutting arc narrative + `.claude/docs/journal/2026/05/2026-05-12.md` §§ 43 / 44 / 50 / 51 for per-§ details.
3238
3270
  ## [0.4.3] - 2026-05-11
3239
3271
 
3240
3272
  ### Added
@@ -3250,7 +3282,7 @@ See root [CHANGELOG.md `[Unreleased]`](../../CHANGELOG.md) for the cross-cutting
3250
3282
 
3251
3283
  ### Lockstep
3252
3284
 
3253
- 9-package coordinated PATCH cut to v0.4.3 (per [`.claude/docs/specs/package-architecture.md` § 15](../../.claude/docs/specs/package-architecture.md#15-versioning-policy)). Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). Ratifies [ADR-0027](../../.brain/adrs/0027-cross-primitive-composition-imports.md) — cross-primitive composition imports are the consumer's responsibility; the primitive's `.js` does NOT side-effect-import composed primitives. Source files touched: `components/input/input.{js,css,yaml,a2ui.json}`. See root [CHANGELOG.md `## [0.4.3]`](../../CHANGELOG.md) for the cut narrative.
3285
+ 9-package coordinated PATCH cut to v0.4.3 (per [`.claude/docs/specs/package-architecture.md` § 15](../../.claude/docs/specs/package-architecture.md#15-versioning-policy)). Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). Ratifies `ADR-0027` — cross-primitive composition imports are the consumer's responsibility; the primitive's `.js` does NOT side-effect-import composed primitives. Source files touched: `components/input/input.{js,css,yaml,a2ui.json}`. See root [CHANGELOG.md `## [0.4.3]`](../../CHANGELOG.md) for the cut narrative.
3254
3286
  ## [0.4.2] - 2026-05-11
3255
3287
 
3256
3288
  ### Changed
@@ -3287,7 +3319,7 @@ See root [CHANGELOG.md `[Unreleased]`](../../CHANGELOG.md) for the cross-cutting
3287
3319
 
3288
3320
  ### Lockstep
3289
3321
 
3290
- 9-package coordinated PATCH cut to v0.4.2 (per [`.claude/docs/specs/package-architecture.md` § 15](../../.claude/docs/specs/package-architecture.md#15-versioning-policy)). Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). ADR-0025 ([`.brain/adrs/0025-no-native-form-controls.md`](../../.brain/adrs/0025-no-native-form-controls.md)) ratified — codifies the "no native or suppressed-native interactive widgets in primitives" principle that the `<input-ui type="number">` rewrite implements; `password` remains the documented exception (physically requires `<input type="password">` for `-webkit-text-security`). See root [CHANGELOG.md `## [0.4.2]`](../../CHANGELOG.md) for the cut narrative.
3322
+ 9-package coordinated PATCH cut to v0.4.2 (per [`.claude/docs/specs/package-architecture.md` § 15](../../.claude/docs/specs/package-architecture.md#15-versioning-policy)). Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). ADR-0025 (`.brain/adrs/0025-no-native-form-controls.md`) ratified — codifies the "no native or suppressed-native interactive widgets in primitives" principle that the `<input-ui type="number">` rewrite implements; `password` remains the documented exception (physically requires `<input type="password">` for `-webkit-text-security`). See root [CHANGELOG.md `## [0.4.2]`](../../CHANGELOG.md) for the cut narrative.
3291
3323
  ## [0.4.1] - 2026-05-10
3292
3324
 
3293
3325
  ### Ride-along (no source changes)
@@ -3738,7 +3770,7 @@ edge instead of leaving the column reading as dead width. Per-row
3738
3770
 
3739
3771
  ### Added — Trait test coverage at 100% + `<traits-host>` wrapper (2026-05-02)
3740
3772
 
3741
- Follow-up landing on top of the v0.2.1 trait-system work — closes the two follow-up items from [ADR-0018](../../.brain/adrs/0018-trait-source-of-truth-and-declarative-attribute.md).
3773
+ Follow-up landing on top of the v0.2.1 trait-system work — closes the two follow-up items from `ADR-0018`.
3742
3774
 
3743
3775
  - **31 new per-trait behavior test files.** Every trait in `traits/` now has a sibling `.test.js`. Total trait-suite footprint: **44 files / 169 tests** (was 12 / 68). Coverage: **42/42 = 100%** (41 trait files + `traits-host`).
3744
3776
  - **`verify:traits` CI gate flipped from soft-warning to hard-fail.** `npm run verify:traits` exits non-zero if any trait file lacks a sibling `.test.js`, with the offending names listed in the failure message. Verified via mutation test: deleting `active-state.test.js` immediately fails the gate; restoring it returns to clean.
@@ -3758,7 +3790,7 @@ Follow-up landing on top of the v0.2.1 trait-system work — closes the two foll
3758
3790
 
3759
3791
  ### Added — Trait system source-of-truth + declarative `traits=` attribute (2026-05-02)
3760
3792
 
3761
- Rolls up under [ADR-0018](../../.brain/adrs/0018-trait-source-of-truth-and-declarative-attribute.md). Consumer-visible additions and one core change to UIElement's lifecycle.
3793
+ Rolls up under `ADR-0018`. Consumer-visible additions and one core change to UIElement's lifecycle.
3762
3794
 
3763
3795
  - **`defineTrait()` schema extended.** Now requires `category` (one of nine canonical values, frozen in `define.js`) and `description` (≥ 11 chars). All 40 trait files updated to declare both inline. Build-time throw on missing fields.
3764
3796
  - **New `getTrait(name)` export from `traits/define.js`.** Returns the trait factory for a kebab-case name, or null if not yet imported. `getTraitSchema(name)` now reads from the same registry (was a separate map).
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "data": {
57
57
  "description": "JS property (set programmatically — `el.data = [...]`). An array of plain objects; each object's keys are named by the `x` and `y` attributes — e.g. `<chart-ui x=\"month\" y=\"revenue\">` consumes `[{month:'Jan', revenue:3200}, {month:'Feb', revenue:4100}]`. The Chart.js `{labels, datasets}` envelope is NOT chart-ui's API — passing it (or any non-array value) renders an empty chart. May also be supplied declaratively as a JSON-array `data=\"[…]\"` attribute, hydrated once at connect. Custom accessor on the element class, not a reflected attribute.",
58
- "$ref": "common_types.json#/$defs/DynamicStringList"
58
+ "$ref": "common_types.json#/$defs/DynamicObjectList"
59
59
  },
60
60
  "format": {
61
61
  "description": "Number-format mode applied to axis labels + value overlays + donut total + gauge value + treemap value + funnel value + internal tooltip. `abbr` is the legacy 1.2K / 3M format; `decimal` fixes 2 decimals; `currency` prefixes via `--chart-currency-prefix` token (default \"$\"); `percent` multiplies × 100 and adds a % suffix.",
@@ -122,6 +122,8 @@ props:
122
122
  hydrated once at connect. Custom accessor on the element class, not a
123
123
  reflected attribute.
124
124
  type: array
125
+ items:
126
+ type: object
125
127
  default: []
126
128
  dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
127
129
  events:
@@ -25,6 +25,10 @@
25
25
  "lg"
26
26
  ],
27
27
  "default": ""
28
+ },
29
+ "textContent": {
30
+ "description": "Key label text, extracted from innerHTML (e.g. \"Ctrl+K\"). The main payload field for Kbd components extracted from HTML — mirrors Text/Code's textContent prop.",
31
+ "$ref": "common_types.json#/$defs/DynamicString"
28
32
  }
29
33
  },
30
34
  "required": [
@@ -15,4 +15,6 @@ import { UIElement } from '../../core/element.js';
15
15
  export class UIKbd extends UIElement {
16
16
  /** Sizing scale: sm, md (default), lg. */
17
17
  size: 'sm' | 'md' | 'lg';
18
+ /** Key label text, extracted from innerHTML (e.g. "Ctrl+K"). The main payload field for Kbd components extracted from HTML — mirrors Text/Code's textContent prop. */
19
+ textContent: string;
18
20
  }
@@ -19,6 +19,11 @@ props:
19
19
  - md
20
20
  - lg
21
21
  reflect: true
22
+ textContent:
23
+ description: Key label text, extracted from innerHTML (e.g. "Ctrl+K"). The main payload field for Kbd components extracted from HTML — mirrors Text/Code's textContent prop.
24
+ type: string
25
+ default: ''
26
+ dynamic: true
22
27
  events: {}
23
28
  slots:
24
29
  default:
@@ -121,6 +121,13 @@ export class UINav extends UIElement {
121
121
  if (group && this.contains(group)) {
122
122
  const isCollapsed = this.collapsed || this.getBoundingClientRect().width <= 96;
123
123
  if (isCollapsed) {
124
+ // The flyout popover is appended INSIDE the group, so an option
125
+ // click bubbles here after the option's own handler has selected
126
+ // and hidePopover()'d — re-showing would instantly reopen the
127
+ // flyout the user just dismissed (TKT-0005, 2026-07-12). The
128
+ // expanded branch below guards on the header; this is the
129
+ // collapsed-branch equivalent.
130
+ if (e.target.closest('[popover]')) return;
124
131
  group.showPopover?.();
125
132
  } else {
126
133
  const header = group.querySelector(':scope > [slot="header"]');
@@ -16,6 +16,11 @@
16
16
  "component": {
17
17
  "const": "RichText"
18
18
  },
19
+ "flush": {
20
+ "description": "Paragraph-grade embedding mode — removes the article chrome (body padding and the 45rem reading measure, plus the trailing paragraph margin) so the rendered markdown sits flush in a host column like a plain text block. Use when richtext carries a single paragraph or fragment inside an already-measured layout (doc pages, cards); leave off for standalone articles and chat transcripts.",
21
+ "type": "boolean",
22
+ "default": false
23
+ },
19
24
  "markdown": {
20
25
  "description": "Component property: markdown.",
21
26
  "type": "string",
@@ -98,6 +98,26 @@
98
98
  padding: var(--richtext-py) var(--richtext-px);
99
99
  }
100
100
 
101
+ /* [flush] — paragraph-grade embedding (yaml `flush` prop): drop the
102
+ article chrome (padding, 45rem measure, own canvas background) so the
103
+ markdown sits in a host column like a plain text block. The trailing
104
+ child keeps no bottom margin — rhythm belongs to the host layout's
105
+ gap, matching text-ui paragraphs in the doc column (TKT-0004: every
106
+ code-bearing doc paragraph rides richtext). */
107
+ :scope[flush] {
108
+ background: transparent;
109
+ overflow-y: visible;
110
+ }
111
+
112
+ :scope[flush] [data-richtext-body] {
113
+ max-width: none;
114
+ padding: 0;
115
+ }
116
+
117
+ :scope[flush] [data-richtext-body] > :last-child {
118
+ margin-bottom: 0;
119
+ }
120
+
101
121
  /* ── Headings ── */
102
122
 
103
123
  [data-richtext-body] h1 {
@@ -21,6 +21,8 @@ editable>.
21
21
  import { UIElement } from '../../core/element.js';
22
22
 
23
23
  export class UIRichText extends UIElement {
24
+ /** Paragraph-grade embedding mode — removes the article chrome (body padding and the 45rem reading measure, plus the trailing paragraph margin) so the rendered markdown sits flush in a host column like a plain text block. Use when richtext carries a single paragraph or fragment inside an already-measured layout (doc pages, cards); leave off for standalone articles and chat transcripts. */
25
+ flush: boolean;
24
26
  /** Component property: markdown. */
25
27
  markdown: string;
26
28
  /** Source URL (image, iframe, etc.). */
@@ -23,6 +23,16 @@ props:
23
23
  description: Source URL (image, iframe, etc.).
24
24
  type: string
25
25
  default: ''
26
+ flush:
27
+ description: >-
28
+ Paragraph-grade embedding mode — removes the article chrome (body
29
+ padding and the 45rem reading measure, plus the trailing paragraph
30
+ margin) so the rendered markdown sits flush in a host column like a
31
+ plain text block. Use when richtext carries a single paragraph or
32
+ fragment inside an already-measured layout (doc pages, cards); leave
33
+ off for standalone articles and chat transcripts.
34
+ type: boolean
35
+ default: false
26
36
  events: {}
27
37
  slots:
28
38
  default:
@@ -1,5 +1,7 @@
1
1
  // cell-types.js - Built-in cell type registry for table-ui
2
2
 
3
+ import { inline } from '../../core/markdown.js';
4
+
3
5
  const cellTypes = {};
4
6
 
5
7
  function registerCellType(name, config) {
@@ -233,7 +235,34 @@ registerCellType('link', {
233
235
  sortFn: 'alphanumeric',
234
236
  });
235
237
 
236
- // 11. progress
238
+ // 11. markdown — TKT-0008: cells with inline `code`/**bold**/*italic*/
239
+ // [link](href) content the docs-transpiler couldn't express as a plain
240
+ // string (table.yaml {columns,data} contract carries plain values; this
241
+ // is the one escape hatch for INLINE-safe rich text, reusing the same
242
+ // inline() converter core/markdown.js already round-trips against
243
+ // scripts/build/lib/docs-transpiler.mjs's convertInline() — the two are
244
+ // paired encode/decode halves of one contract, not independent parsers).
245
+ // Block-level content (nested tables, component tags, colspan/rowspan)
246
+ // stays out of scope; those still fall back to the preserved-HTML Code
247
+ // panel synthesizeTable already emits.
248
+ registerCellType('markdown', {
249
+ render(value, row, cell) {
250
+ let s = cell.firstElementChild;
251
+ if (!s || s.tagName !== 'SPAN') {
252
+ cell.replaceChildren();
253
+ s = cell.appendChild(document.createElement('span'));
254
+ }
255
+ s.innerHTML = inline(String(value ?? ''));
256
+ cell.title = s.textContent;
257
+ },
258
+ format(value) {
259
+ return String(value ?? '');
260
+ },
261
+ align: 'left',
262
+ sortFn: 'alphanumeric',
263
+ });
264
+
265
+ // 12. progress
237
266
  registerCellType('progress', {
238
267
  render(value, row, cell) {
239
268
  let wrapper = cell.querySelector('row-ui');
@@ -262,7 +291,7 @@ registerCellType('progress', {
262
291
  sortFn: 'numeric',
263
292
  });
264
293
 
265
- // 12. actions
294
+ // 13. actions
266
295
  registerCellType('actions', {
267
296
  render(value, row, cell, meta) {
268
297
  const actions = meta?.actions || [];
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "data": {
24
24
  "description": "Row records. Array of plain objects keyed to columns[].key.",
25
- "$ref": "common_types.json#/$defs/DynamicStringList"
25
+ "$ref": "common_types.json#/$defs/DynamicObjectList"
26
26
  },
27
27
  "density": {
28
28
  "description": "Controls cell padding and row height. 'compact' for dense data, 'standard' for default spacing, 'comfortable' for spacious rows.",
@@ -389,3 +389,40 @@ describe('table-ui — F6 per-row expand suppression (rowExpandable)', () => {
389
389
  expect(el.expanded).toContain(1);
390
390
  });
391
391
  });
392
+
393
+ describe('table-ui — "markdown" cell type (TKT-0008)', () => {
394
+ it('renders inline `code`/**bold**/*italic*/[link](href) as real elements, not literal syntax', async () => {
395
+ const el = mount('<table-ui></table-ui>');
396
+ el.columns = [{ key: 'desc', label: 'Description', type: 'markdown' }];
397
+ el.data = [{ desc: 'Semantic status: `new` | `updated` | **beta** or *deprecated*, see [docs](/site/components/badge).' }];
398
+ await raf();
399
+
400
+ const cell = el.querySelector('[role="gridcell"][data-key="desc"]');
401
+ expect([...cell.querySelectorAll('code')].map((c) => c.textContent)).toEqual(['new', 'updated']);
402
+ expect(cell.querySelector('strong').textContent).toBe('beta');
403
+ expect(cell.querySelector('em').textContent).toBe('deprecated');
404
+ expect(cell.querySelector('a').getAttribute('href')).toBe('/site/components/badge');
405
+ expect(cell.textContent).not.toMatch(/`|\*\*|\[docs\]/); // no literal markdown syntax leaked through
406
+ });
407
+
408
+ it('sets [title] to the plain-text (tag-stripped) version, for the truncation tooltip', async () => {
409
+ const el = mount('<table-ui></table-ui>');
410
+ el.columns = [{ key: 'prop', label: 'Prop', type: 'markdown' }];
411
+ el.data = [{ prop: '`variant`' }];
412
+ await raf();
413
+
414
+ const cell = el.querySelector('[role="gridcell"][data-key="prop"]');
415
+ expect(cell.title).toBe('variant');
416
+ });
417
+
418
+ it('a plain value with no markdown syntax renders as ordinary text (no stray elements)', async () => {
419
+ const el = mount('<table-ui></table-ui>');
420
+ el.columns = [{ key: 'desc', label: 'Description', type: 'markdown' }];
421
+ el.data = [{ desc: 'Plain sentence, no special characters.' }];
422
+ await raf();
423
+
424
+ const cell = el.querySelector('[role="gridcell"][data-key="desc"]');
425
+ expect(cell.querySelector('code, strong, em, a')).toBeNull();
426
+ expect(cell.textContent).toBe('Plain sentence, no special characters.');
427
+ });
428
+ });
@@ -27,6 +27,8 @@ props:
27
27
  data:
28
28
  description: Row records. Array of plain objects keyed to columns[].key.
29
29
  type: array
30
+ items:
31
+ type: object
30
32
  default: []
31
33
  dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
32
34
  density:
package/core/markdown.js CHANGED
@@ -86,16 +86,41 @@ export function renderMarkdown(src) {
86
86
  return out.join('\n');
87
87
  }
88
88
 
89
- function inline(text) {
90
- return esc(text)
89
+ // Placeholder marker for protected code-span content — \0 can't occur in
90
+ // authored markdown, so it round-trips unambiguously through the regex pass.
91
+ const CODE_PH = (n) => `\0CODE${n}\0`;
92
+ const CODE_PH_RE = /\0CODE(\d+)\0/g;
93
+
94
+ export function inline(text) {
95
+ const escaped = esc(text);
96
+
97
+ // 1. Extract inline-code spans FIRST and protect their (already-escaped,
98
+ // HTML-safe) raw content behind a placeholder — otherwise a code span
99
+ // containing `*`/`_` (e.g. `--a-chrome-*`, `foo_bar`) gets its content
100
+ // mangled by the emphasis regexes below before the code regex ever
101
+ // sees it (root cause of the inline-flow gap — see
102
+ // site-a2ui/FINDINGS.md "No inline-flow construct").
103
+ const codeSpans = [];
104
+ let out = escaped.replace(/`([^`]+)`/g, (_, code) => {
105
+ const i = codeSpans.length;
106
+ codeSpans.push(code);
107
+ return CODE_PH(i);
108
+ });
109
+
110
+ // 2. Now it's safe to apply emphasis + links to the remaining (non-code)
111
+ // text — code-span content is inert placeholder text at this point.
112
+ out = out
91
113
  // Bold **text** or __text__
92
114
  .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
93
115
  .replace(/__(.+?)__/g, '<strong>$1</strong>')
94
116
  // Italic *text* or _text_
95
117
  .replace(/\*(.+?)\*/g, '<em>$1</em>')
96
118
  .replace(/_(.+?)_/g, '<em>$1</em>')
97
- // Inline code `code`
98
- .replace(/`([^`]+)`/g, '<code>$1</code>')
99
119
  // Links [text](url)
100
120
  .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
121
+
122
+ // 3. Restore the protected code spans verbatim.
123
+ out = out.replace(CODE_PH_RE, (_, i) => `<code>${codeSpans[Number(i)]}</code>`);
124
+
125
+ return out;
101
126
  }
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderMarkdown } from './markdown.js';
3
+
4
+ // ── inline() protect-verbatim-regions regression (site-a2ui/FINDINGS.md
5
+ // "No inline-flow construct" — root cause: emphasis regexes ran BEFORE
6
+ // code-span extraction, so any code span containing `*`/`_` mangled) ──
7
+
8
+ describe('renderMarkdown — inline code spans survive markdown-unsafe content', () => {
9
+ it('code span containing an asterisk is not treated as emphasis', () => {
10
+ const html = renderMarkdown('Use `--a-chrome-*` for chrome tokens.');
11
+ expect(html).toContain('<code>--a-chrome-*</code>');
12
+ expect(html).not.toContain('<em>');
13
+ expect(html).not.toContain('<strong>');
14
+ });
15
+
16
+ it('code span containing underscores is not treated as emphasis', () => {
17
+ const html = renderMarkdown('Call `foo_bar_baz()` to run it.');
18
+ expect(html).toContain('<code>foo_bar_baz()</code>');
19
+ expect(html).not.toContain('<em>');
20
+ });
21
+
22
+ it('code span containing double-asterisks is not treated as bold', () => {
23
+ const html = renderMarkdown('The pattern `**not-bold**` is literal.');
24
+ expect(html).toContain('<code>**not-bold**</code>');
25
+ expect(html).not.toContain('<strong>');
26
+ });
27
+
28
+ it('bold, italic, code, and link combine correctly within one run', () => {
29
+ const html = renderMarkdown('This is **bold**, this is *italic*, this is `code_with_*` and this is [a link](https://example.com).');
30
+ expect(html).toContain('<strong>bold</strong>');
31
+ expect(html).toContain('<em>italic</em>');
32
+ expect(html).toContain('<code>code_with_*</code>');
33
+ expect(html).toContain('<a href="https://example.com">a link</a>');
34
+ });
35
+
36
+ it('adjacent code spans do not bleed into each other', () => {
37
+ const html = renderMarkdown('First `a_b` then `c*d` then plain _italic_ text.');
38
+ expect(html).toContain('<code>a_b</code>');
39
+ expect(html).toContain('<code>c*d</code>');
40
+ expect(html).toContain('<em>italic</em>');
41
+ });
42
+
43
+ it('HTML-unsafe characters inside a code span are still escaped', () => {
44
+ const html = renderMarkdown('Use `<script>` tags carefully.');
45
+ expect(html).toContain('<code>&lt;script&gt;</code>');
46
+ });
47
+ });
package/core/provider.js CHANGED
@@ -165,6 +165,19 @@ export class UIRouter extends UIProvider {
165
165
  #cache = new Map();
166
166
  #ownCtrl = null;
167
167
  #lastPath = null;
168
+ // Monotonic navigation token — claimed at #loadContent ENTRY, before any
169
+ // await, so completion order can never impersonate navigation order. A
170
+ // call that awaits (content fetch, templateResolver) and finds a newer
171
+ // token on resume is superseded: it must neither write innerHTML nor
172
+ // emit route-loaded. Incident (2026-07-12, prod deploy verify run
173
+ // 29199534931): the boot route's SLOW content fetch resumed AFTER a
174
+ // fast follow-up navigation had fully rendered — downstream guards that
175
+ // sequence at templateResolver entry saw the late resumer as "newest"
176
+ // and let it clobber the newer route's content (data-route-path said
177
+ // the new route; the DOM showed the old one). Same class as the
178
+ // "no-artifact despite a valid artifact" and batch-sweep flakes in
179
+ // site-a2ui/FINDINGS.md — the cancellation belongs HERE, at the source.
180
+ #navSeq = 0;
168
181
 
169
182
  get #ctrl() { return this.#ownCtrl || this.controller; }
170
183
 
@@ -214,6 +227,7 @@ export class UIRouter extends UIProvider {
214
227
  }
215
228
 
216
229
  async #loadContent(route, ctrl) {
230
+ const nav = ++this.#navSeq; // claim BEFORE any await — see #navSeq
217
231
  let html;
218
232
  if (this.#cache.has(route.content)) {
219
233
  html = this.#cache.get(route.content);
@@ -228,10 +242,12 @@ export class UIRouter extends UIProvider {
228
242
  return;
229
243
  }
230
244
  }
245
+ if (nav !== this.#navSeq) return; // superseded while fetching content
231
246
 
232
247
  // Template resolver hook — lets the site wrap content in a template before injection
233
248
  if (this.templateResolver) {
234
249
  html = await this.templateResolver(html, route);
250
+ if (nav !== this.#navSeq) return; // superseded while resolving
235
251
  }
236
252
 
237
253
  this.innerHTML = html;
@@ -267,6 +283,7 @@ export class UIRouter extends UIProvider {
267
283
  if (mod.default) mod.default(this);
268
284
  } catch { /* setup module is optional */ }
269
285
  }
286
+ if (nav !== this.#navSeq) return; // superseded during setup import — no phantom route-loaded
270
287
 
271
288
  const state = ctrl.getState();
272
289
  this.emit('route-loaded', {