@adia-ai/a2ui 0.8.43 → 0.8.45

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,5 +1,22 @@
1
1
  # Changelog — @adia-ai/a2ui
2
2
 
3
+ ## [0.8.45] — 2026-08-20
4
+
5
+ ### Added
6
+ - **ADR-0078 ratified: the in-repo a2ui runtime adopts the vendored genui v1.0 data model internally (Cell/Derived reactive primitives, RFC-6901 pointer semantics), install base and dialect wire surface unchanged.** Phased across future cuts — no runtime behavior changes ship in 0.8.45. Tracked in gh#1784.
7
+ - **ADR-0079 ratified: `for=`-carrying components normalize to an events-only interaction contract (gh#1764/#1780).**
8
+
9
+ ### Changed
10
+ - **The three independently-authored path-walker implementations in `path-pointer.js` unified, deliberately preserving each call site's real divergent edge-case semantics (empty-path, primitive mid-path, doubled-slash) rather than silently collapsing them — staging for ADR-0078's migration.**
11
+
12
+ ### Maintenance
13
+ - **`catalog/` touched in this release window** (3 file(s), e.g. `catalog/catalog-a2ui_0_9.json`) — carried by the entries above.
14
+
15
+ ## [0.8.44] — 2026-08-20
16
+
17
+ ### Maintenance
18
+ - **Catalog rules text re-derived (gh#1669).** `input.yaml`'s `a2ui.rules` entry corrected — it falsely claimed an inline `[hint]` prop on `input-ui` (only `field-ui` has one); `catalog-a2ui_0_9_rules.txt` picks up the fix. No protocol/runtime API change.
19
+
3
20
  ## [0.8.43] — 2026-08-18
4
21
 
5
22
  ### Removed
@@ -5881,6 +5881,16 @@
5881
5881
  "type": "boolean",
5882
5882
  "default": false
5883
5883
  },
5884
+ "ratio": {
5885
+ "description": "Chart 2.0 aspect-ratio bucket (ADR-0074). Unset (default) auto-snaps to the nearest of the three studied ratios by comparing the chart's own live box aspect against two midpoint boundaries (5:4 between 3:2/1:1, 4:5 between 1:1/2:3) — never interpolated; the resolved bucket reflects onto the host as `data-ratio-resolved`. Setting `ratio` explicitly pins that bucket regardless of the box's actual aspect, overriding the auto-snap. Orthogonal to `size` (overall scale) and `type`/`color` (ADR-0064's family axis) — this attribute lands the grammar + snap plumbing only; per-type studied renderings per bucket are later waves (gh#1624 plan steps 2-4).",
5886
+ "type": "string",
5887
+ "enum": [
5888
+ "3:2",
5889
+ "1:1",
5890
+ "2:3"
5891
+ ],
5892
+ "default": ""
5893
+ },
5884
5894
  "seriesEmphasis": {
5885
5895
  "description": "Multi-line only. Names one series key (matching a `y` key) to render at full strength — area fill + full-opacity line. Every other series drops its area fill and renders line-only at --chart-deemphasized-opacity. Empty (default) applies full treatment to every series, unchanged from pre-gh#561 behavior.",
5886
5896
  "type": "string",
@@ -5910,6 +5920,22 @@
5910
5920
  "description": "Y-axis key(s), comma-separated for multi-series",
5911
5921
  "type": "string",
5912
5922
  "default": ""
5923
+ },
5924
+ "yMax": {
5925
+ "description": "Pins the value-axis domain ceiling for cartesian types (bar, line, area, scatter, multi-line, stacked-bar, grouped-bar, composed). Unset (default, null) keeps today's auto-scale ceiling (the data max). Pairs with `yMin` to pin the full domain; either may be set alone. N/A for radial/part-to-whole types — see `yMin` (gh#1690). A datum outside the pinned domain is CLAMPED to the nearest edge for rendering (the value label/tooltip still shows the real value) rather than escaping the plot box.",
5926
+ "type": [
5927
+ "number",
5928
+ "null"
5929
+ ],
5930
+ "default": null
5931
+ },
5932
+ "yMin": {
5933
+ "description": "Pins the value-axis domain floor for cartesian types (bar, line, area, scatter, multi-line, stacked-bar, grouped-bar, composed). Unset (default, null) already floors a positive series at 0 (the pre-1690 behavior, unchanged) — a migrating d3 consumer's own \"pin the floor to 0\" habit is the built-in default, not something to configure. Pin `y-min` explicitly for three OTHER cases: (1) zooming a narrow window — e.g. `y-min=\"0.9\" y-max=\"1\"` magnifies a percent series clustered near 92-95%, which reads flatter against the full auto-scaled [0,1] domain; (2) a mixed-sign series, where the unpinned floor never goes below 0 and negative values clip at the axis; (3) aligning two charts to one shared scale for a fair comparison. N/A for radial/part-to-whole types (pie, donut, radar, radial-bar, gauge, funnel, treemap, sankey, segments, sparkline) — those never read a shared linear value axis, so this prop is a no-op there (gh#1690). A datum outside the pinned domain is CLAMPED to the nearest edge for rendering (the value label/tooltip still shows the real value) rather than escaping the plot box.",
5934
+ "type": [
5935
+ "number",
5936
+ "null"
5937
+ ],
5938
+ "default": null
5913
5939
  }
5914
5940
  },
5915
5941
  "required": [
@@ -6142,7 +6168,7 @@
6142
6168
  ],
6143
6169
  "properties": {
6144
6170
  "items": {
6145
- "description": "JSON array of {key, label, slot?, pct?} legend items. Takes precedence over [for] when both are provided.",
6171
+ "description": "JSON array of {key, label, slot?, pct?, deemphasized?} legend items. Takes precedence over [for] when both are provided. `deemphasized` (REQ-R-007) mutes the row's swatch/label to the same muted-neutral tier chart-ui's own internal legend uses for a de-emphasized series (e.g. `composed@1:1`'s secondary line) — mirrored automatically from a `[for]`-bound chart's own `.legendData`, or settable explicitly here.",
6146
6172
  "type": "string",
6147
6173
  "default": ""
6148
6174
  },
@@ -6169,7 +6195,7 @@
6169
6195
  "default": "hide"
6170
6196
  },
6171
6197
  "position": {
6172
- "description": "Layout hint — drives flex-direction. Actual placement follows DOM order.",
6198
+ "description": "Layout hint — drives flex-direction. Actual placement follows DOM order. Superseded by a resolved `ratio` bucket (below) whenever one is in effect; this is the pre-ADR-0074 fallback for a legend with neither an explicit `ratio` nor a `[for]`-bound chart.",
6173
6199
  "type": "string",
6174
6200
  "enum": [
6175
6201
  "top",
@@ -6179,6 +6205,16 @@
6179
6205
  ],
6180
6206
  "default": "bottom"
6181
6207
  },
6208
+ "ratio": {
6209
+ "description": "Chart 2.0 legend layout bucket (ADR-0074, gh#1624 plan step 5, REQ-R-005): 3:2 renders a horizontal row, 1:1 a wrapped 2-column grid, 2:3 a vertical stacked list. An explicit value PINS the bucket regardless of any bound chart. Unset (default) auto-mirrors the `[for]`-bound chart's own resolved `data-ratio-resolved` bucket — the legend never runs its own container-query classification, since its own box is typically a thin strip with no studied aspect of its own. With neither a pin nor a bound chart, no bucket resolves and `position`'s flex-direction rules apply unchanged. The resolved bucket reflects read-only onto the host as `data-ratio-resolved`, mirroring chart-ui's own convention.",
6210
+ "type": "string",
6211
+ "enum": [
6212
+ "3:2",
6213
+ "1:1",
6214
+ "2:3"
6215
+ ],
6216
+ "default": ""
6217
+ },
6182
6218
  "shape": {
6183
6219
  "description": "Swatch shape. Maps to badge-ui's icon for dot/square variants.",
6184
6220
  "type": "string",
@@ -6227,6 +6263,11 @@
6227
6263
  "a2ui": "[\n {\n \"id\": \"legend\", \"component\": \"ChartLegend\",\n \"shape\": \"dot\",\n \"items\": \"[{\\\"key\\\":\\\"revenue\\\",\\\"label\\\":\\\"Revenue\\\",\\\"slot\\\":0},{\\\"key\\\":\\\"users\\\",\\\"label\\\":\\\"Users\\\",\\\"slot\\\":1}]\"\n }\n]",
6228
6264
  "name": "standalone-with-items"
6229
6265
  },
6266
+ {
6267
+ "description": "Standalone legend pinned to the 1:1 (2-col grid) layout bucket.",
6268
+ "a2ui": "[\n {\n \"id\": \"legend\", \"component\": \"ChartLegend\",\n \"ratio\": \"1:1\",\n \"items\": \"[{\\\"key\\\":\\\"revenue\\\",\\\"label\\\":\\\"Revenue\\\",\\\"slot\\\":0},{\\\"key\\\":\\\"users\\\",\\\"label\\\":\\\"Users\\\",\\\"slot\\\":1},{\\\"key\\\":\\\"churn\\\",\\\"label\\\":\\\"Churn\\\",\\\"slot\\\":2},{\\\"key\\\":\\\"nps\\\",\\\"label\\\":\\\"NPS\\\",\\\"slot\\\":3}]\"\n }\n]",
6269
+ "name": "ratio-pinned-grid"
6270
+ },
6230
6271
  {
6231
6272
  "description": "Legend mirrors a named chart's series data.",
6232
6273
  "a2ui": "[\n {\n \"id\": \"root\", \"component\": \"Column\",\n \"children\": [\"chart\", \"legend\"]\n },\n {\n \"id\": \"chart\", \"component\": \"Chart\",\n \"type\": \"multi-line\", \"x\": \"month\", \"y\": \"revenue,users\"\n },\n {\n \"id\": \"legend\", \"component\": \"ChartLegend\",\n \"for\": \"chart\",\n \"shape\": \"line\",\n \"position\": \"bottom\"\n }\n]",
@@ -8367,6 +8408,9 @@
8367
8408
  "--combobox-match-bg": {
8368
8409
  "description": "Background for `<mark>` spans that highlight matched substrings"
8369
8410
  },
8411
+ "--combobox-min-width": {
8412
+ "description": "Intrinsic min-width floor on the host (default `20ch`, matching input-ui's own `--input-min-width` UA-baseline derivation) so a combobox in a shrinking flex/grid cell doesn't crush to unusable width. Set to `0` to opt back into full collapse (gh#1633)."
8413
+ },
8370
8414
  "--combobox-option-bg-active": {
8371
8415
  "description": "Background for the currently active (arrow-navigated) option"
8372
8416
  },
@@ -8852,7 +8896,7 @@
8852
8896
  },
8853
8897
  "ContextMenu": {
8854
8898
  "title": "ContextMenu",
8855
- "description": "Right-click activated menu — the OS-native context-menu pattern as a\nweb component. Distinct from `menu-ui` (which is button-triggered):\nsame item shape (`menu-item-ui` children), different trigger surface\n(`contextmenu` event), and pointer-anchored positioning instead of\nelement-anchored. Pattern: WAI-APG Menu.\n\nTwo binding modes:\n **A. Wrap.** Default-slot child becomes the target:\n `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.\n **B. Selector.** Point at one or more existing elements via [for]:\n `<context-menu-ui for=\"#my-table\">...items</context-menu-ui>`.\n\nOn `contextmenu` event on a target: `preventDefault()`, position the\nmenu at the pointer coords, show via Popover API. Touch long-press\n(configurable via [long-press-ms]) does the same. Shift+F10 / Menu\nkey opens at the focused target's center for keyboard users.\n",
8899
+ "description": "Right-click activated menu — the OS-native context-menu pattern as a\nweb component. Distinct from `menu-ui` (which is button-triggered):\nsame item shape (`menu-item-ui` children), different trigger surface\n(`contextmenu` event), and pointer-anchored positioning instead of\nelement-anchored. Pattern: WAI-APG Menu.\n\nTwo binding modes:\n **A. Wrap.** Default-slot child becomes the target:\n `<context-menu-ui><my-table>...</my-table>...items</context-menu-ui>`.\n **B. Selector.** Point at one or more existing elements via\n [target-selector]:\n `<context-menu-ui target-selector=\"#my-table\">...items</context-menu-ui>`.\n\nOn `contextmenu` event on a target: `preventDefault()`, position the\nmenu at the pointer coords, show via Popover API. Touch long-press\n(configurable via [long-press-ms]) does the same. Shift+F10 / Menu\nkey opens at the focused target's center for keyboard users.\n",
8856
8900
  "type": "object",
8857
8901
  "allOf": [
8858
8902
  {
@@ -8867,7 +8911,7 @@
8867
8911
  "const": "ContextMenu"
8868
8912
  },
8869
8913
  "for": {
8870
- "description": "CSS selector(s) for target element(s). Empty = use default-slot child.",
8914
+ "description": "DEPRECATED alias for [target-selector] — still a CSS selector, NOT an id-ref. Honored when [target-selector] is unset, with a one-shot console.warn pointing consumers at the replacement. New authoring should use [target-selector].",
8871
8915
  "type": "string",
8872
8916
  "default": ""
8873
8917
  },
@@ -8880,6 +8924,11 @@
8880
8924
  "description": "Programmatic open state. Set true to open at target center.",
8881
8925
  "type": "boolean",
8882
8926
  "default": false
8927
+ },
8928
+ "targetSelector": {
8929
+ "description": "CSS selector(s) for target element(s). Empty = use default-slot child. Ratified name (gh#1764/#1780, ADR-0079) for what [for] used to carry — [for] means an id-ref everywhere else in AdiaUI (table-toolbar-ui, chart-legend-ui, tooltip-ui); context-menu-ui's own selector meaning predates that convention and was a same-name/ different-contract collision (ADR-0053's no-shadowing doctrine).",
8930
+ "type": "string",
8931
+ "default": ""
8883
8932
  }
8884
8933
  },
8885
8934
  "required": [
@@ -8889,7 +8938,7 @@
8889
8938
  "x-adiaui": {
8890
8939
  "anti_patterns": [
8891
8940
  {
8892
- "fix": "Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui for=\"#my-target\">...items</context-menu-ui>`.",
8941
+ "fix": "Wrap a target: `<context-menu-ui><my-target></my-target>...items</context-menu-ui>` OR point at one: `<context-menu-ui target-selector=\"#my-target\">...items</context-menu-ui>`.",
8893
8942
  "why": "No target binding — the menu never opens.",
8894
8943
  "wrong": "<context-menu-ui>...just items...</context-menu-ui>"
8895
8944
  }
@@ -12931,6 +12980,11 @@
12931
12980
  "component": {
12932
12981
  "const": "Heatmap"
12933
12982
  },
12983
+ "data": {
12984
+ "description": "Cell data as a JSON array of `{ r, c, v, label? }` objects, or set the `.data` property directly with an already-parsed array. Parsed on connect if the attribute is present and `.data` hasn't already been set programmatically.",
12985
+ "type": "string",
12986
+ "default": ""
12987
+ },
12934
12988
  "noLegend": {
12935
12989
  "description": "Hide the Less/More legend strip",
12936
12990
  "type": "boolean",
@@ -13888,7 +13942,7 @@
13888
13942
  },
13889
13943
  {
13890
13944
  "fix": "{\"component\": \"CalendarPicker\", \"precision\": \"minute\"}\n",
13891
- "why": "Same gap as \"date\" — no native datetime picker rendered. Use\nCalendarPicker with precision=\"minute\" for combined date + time\ninput (DatetimePicker was a deprecated thin alias, cut in 0.8.43,\ngh#1617 — CalendarPicker is the sole tag now).\n",
13945
+ "why": "Same gap as \"date\" — no native datetime picker rendered. Use\nCalendarPicker with precision=\"minute\" for combined date + time\ninput (DatetimePicker was a deprecated thin alias, cut in 0.8.43,\ngh#1617 — CalendarPicker is the sole A2UI component now).\n",
13892
13946
  "wrong": "{\"component\": \"Input\", \"type\": \"datetime-local\"}\n"
13893
13947
  },
13894
13948
  {
@@ -13908,7 +13962,7 @@
13908
13962
  },
13909
13963
  {
13910
13964
  "fix": "{\"component\": \"ColorInput\"}\n",
13911
- "why": "No native color swatch/picker rendered — just a plain text\nsurface. Use ColorInput for a compact form-bearing swatch +\npopover, or ColorArea directly for the full OKLCH picker surface\n(ColorPicker was a deprecated thin alias, cut in 0.8.43, gh#1617 —\nColorArea is the sole tag now).\n",
13965
+ "why": "No native color swatch/picker rendered — just a plain text\nsurface. Use ColorInput for a compact form-bearing swatch +\npopover, or ColorArea directly for the full OKLCH picker surface\n(ColorPicker was a deprecated thin alias, cut in 0.8.43, gh#1617 —\nColorArea is the sole A2UI component now).\n",
13912
13966
  "wrong": "{\"component\": \"Input\", \"type\": \"color\"}\n"
13913
13967
  },
13914
13968
  {
@@ -16946,7 +17000,7 @@
16946
17000
  "default": false
16947
17001
  },
16948
17002
  "heading": {
16949
- "description": "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only.",
17003
+ "description": "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only. For a VISIBLE kicker inside a primary-variant rail — or more than one kicker per <nav-ui> — hand-place <span data-nav-label> in the default slot instead; see slots below.",
16950
17004
  "type": "string",
16951
17005
  "default": ""
16952
17006
  },
@@ -17027,7 +17081,7 @@
17027
17081
  ],
17028
17082
  "slots": {
17029
17083
  "default": {
17030
- "description": "Primary slot — accepts <nav-group-ui> + <nav-item-ui> children, plus <hr data-nav-divider> for hand-placed dividers."
17084
+ "description": "Primary slot — accepts <nav-group-ui> + <nav-item-ui> children, plus <hr data-nav-divider> for hand-placed dividers and <span data-nav-label> for hand-placed group-label kickers (titled runs of items/groups that aren't wrapped in a <nav-group-ui>). <span data-nav-label> renders with the same uppercase/tracking/muted kicker treatment as the [heading] ::before kicker, and is hidden alongside dividers whenever the primary-variant rail collapses ([collapsed] or ≤96px container width)."
17031
17085
  }
17032
17086
  },
17033
17087
  "states": [
@@ -17369,11 +17423,6 @@
17369
17423
  "type": "boolean",
17370
17424
  "default": false
17371
17425
  },
17372
- "port-size": {
17373
- "description": "Port indicator dot size in pixels",
17374
- "type": "number",
17375
- "default": 10
17376
- },
17377
17426
  "portSize": {
17378
17427
  "description": "Port indicator size",
17379
17428
  "type": "number",
@@ -17384,21 +17433,11 @@
17384
17433
  "type": "boolean",
17385
17434
  "default": false
17386
17435
  },
17387
- "show-ports": {
17388
- "description": "Show port indicator dots. Defaults to true when editable",
17389
- "type": "boolean",
17390
- "default": false
17391
- },
17392
17436
  "showPorts": {
17393
17437
  "description": "Show port indicators",
17394
17438
  "type": "boolean",
17395
17439
  "default": false
17396
17440
  },
17397
- "stroke-width": {
17398
- "description": "Stroke width in pixels",
17399
- "type": "number",
17400
- "default": 2
17401
- },
17402
17441
  "strokeWidth": {
17403
17442
  "description": "Line stroke width",
17404
17443
  "type": "number",
@@ -22411,6 +22450,11 @@
22411
22450
  "type": "string",
22412
22451
  "default": ""
22413
22452
  },
22453
+ "label-hidden": {
22454
+ "description": "When true, [label] still sets the accessible name (aria-label) but the visible `::before` text is suppressed via the canonical sr-only technique (gh#1748, mirrors check-ui's [label-hidden], gh#1010). Use when a sibling/ancestor composition already conveys the same name visually (e.g. a scope/view-switcher select in a table-toolbar-ui [slot=\"scope\"], where the toolbar's own title already names the view) and a second visible \"Scope\"-style label would paint it twice.",
22455
+ "type": "boolean",
22456
+ "default": false
22457
+ },
22414
22458
  "mark": {
22415
22459
  "description": "Renders the Adia brand mark (`<adia-mark-ui>`) as the leading visual — takes precedence over avatar and icon. Token-driven (light/dark handled internally), so it fits a scheme-switching workspace/app switcher where a static logo URL can't invert. Per-option `mark` on an `<option>`/options-array entry works the same way, scoped to that row.",
22416
22460
  "type": "boolean",
@@ -22646,7 +22690,11 @@
22646
22690
  ]
22647
22691
  },
22648
22692
  "tag": "select-ui",
22649
- "tokens": {},
22693
+ "tokens": {
22694
+ "--select-min-width": {
22695
+ "description": "Intrinsic min-width floor on the host (default `20ch`, matching input-ui's own `--input-min-width` UA-baseline derivation) so a select in a shrinking flex/grid cell doesn't crush to unusable width. Set to `0` to opt back into full collapse (gh#1633)."
22696
+ }
22697
+ },
22650
22698
  "traits": [],
22651
22699
  "version": 1
22652
22700
  }
@@ -24934,8 +24982,13 @@
24934
24982
  }
24935
24983
  ],
24936
24984
  "properties": {
24985
+ "clickable-rows": {
24986
+ "description": "Renders body rows with `cursor: pointer`. `row-click` fires unconditionally whether or not a listener is attached, so the cursor can't be inferred from listener presence — set this explicitly when row-click behavior is wired up (master-detail, open-flyout, navigation) so rows read as actionable.",
24987
+ "type": "boolean",
24988
+ "default": false
24989
+ },
24937
24990
  "columns": {
24938
- "description": "Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, valign?, accessor?, format?, render?, sortFn?, filterType?, meta?}. `valign` ('top'|'center') overrides the host [valign] for that column's body cells only — header cells always stay centered. Alternative to declarative <col-def> children (<col-def valign=\"top\">); col-def also accepts `meta` as a JSON attribute (gh#925) — e.g. <col-def type=\"badge\" meta='{\"variants\":{\"Online\":\"success\"}}'> — so cell-type meta (badge variant maps, currency codes) is expressible in static HTML.",
24991
+ "description": "Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, valign?, accessor?, format?, render?, sortFn?, filterType?, meta?}. `valign` ('top'|'center') overrides the host [valign] for that column's body cells only — header cells always stay centered. Alternative to declarative <col-def> children (<col-def valign=\"top\">); col-def also accepts `meta` as a JSON attribute (gh#925) — e.g. <col-def type=\"badge\" meta='{\"variants\":{\"Online\":\"success\"}}'> — so cell-type meta (badge variant maps, currency codes) is expressible in static HTML. Truncation contract for `render` cells (gh#1681) — a `render(value, row, cell, dataIndex)` function's returned markup isn't guaranteed to be a bare <span>/<a> the way built-in cell types are, so single-line ellipsis truncation isn't automatic; set `data-truncate` on whichever rendered element (any tag, including a custom element) should single-line-ellipsis to opt it into the same contract built-in cells get for free. `[wrap]` (host) / `[data-wrap]` (per-column) override it back to multi-line, matching built-in cells.",
24939
24992
  "$ref": "#/$defs/DynamicStringList"
24940
24993
  },
24941
24994
  "component": {
@@ -25671,7 +25724,7 @@
25671
25724
  },
25672
25725
  "TableToolbar": {
25673
25726
  "title": "TableToolbar",
25674
- "description": "Header / companion bar for a sibling table-ui. Renders an optional leading [slot=\"scope\"] region, title + count badge, an optional \"Showing X–Y of N\" range summary, filter / sort / columns popovers, a search input, an optional page-size select, and a trailing [slot=\"actions\"] region — all wired to the target table via an [for] id-ref. Modeled on chart-legend-ui's [for] binding pattern. Drop next to (or above) any table-ui to add the standard data-grid toolbar without re-implementing search, filter, sort, column visibility, or pagination summary/page-size. Filter rows auto-pick a primitive per column: ≤ 50 distinct values → multi-select (searchable when ≥ 12 options), id-like keys → free-text contains. The column descriptor's `filter` field overrides the auto-detect: `'select'` forces multi-select even on high-cardinality columns; `'text'` forces a contains input even on small enums.",
25727
+ "description": "Header / companion bar for a sibling table-ui. Renders an optional leading [slot=\"scope\"] region, title + count badge, an optional \"Showing X–Y of N\" range summary, filter / sort / columns popovers, a search input, an optional [slot=\"actions-leading\"] region, an optional page-size select, and a trailing [slot=\"actions\"] region — all wired to the target table via an [for] id-ref. Modeled on chart-legend-ui's [for] binding pattern. Drop next to (or above) any table-ui to add the standard data-grid toolbar without re-implementing search, filter, sort, column visibility, or pagination summary/page-size. Filter rows auto-pick a primitive per column: ≤ 50 distinct values → multi-select (searchable when ≥ 12 options), id-like keys → free-text contains. The column descriptor's `filter` field overrides the auto-detect: `'select'` forces multi-select even on high-cardinality columns; `'text'` forces a contains input even on small enums.",
25675
25728
  "type": "object",
25676
25729
  "allOf": [
25677
25730
  {
@@ -25682,6 +25735,11 @@
25682
25735
  }
25683
25736
  ],
25684
25737
  "properties": {
25738
+ "chromeOnly": {
25739
+ "description": "Suppress all four native controls/search at once (filter, sort, columns, search) as additive sugar over noFilter/noSort/noColumns/ noSearch (ADR-0076, ADIA2-9123). Precedence is pure, absolute OR — while set, all four stay off regardless of any individual no-* attribute's own value, with no partial re-enable path; to re-enable one control, remove chrome-only entirely and set the other three no-* attributes explicitly instead. The four granular attributes are not deprecated or removed — they remain the independently-addressable shipped API; chrome-only never replaces them, it's a convenience preset on top.",
25740
+ "type": "boolean",
25741
+ "default": false
25742
+ },
25685
25743
  "component": {
25686
25744
  "const": "TableToolbar"
25687
25745
  },
@@ -25744,6 +25802,18 @@
25744
25802
  "type": "number",
25745
25803
  "default": 0
25746
25804
  },
25805
+ "stage": {
25806
+ "description": "Explicit compaction-stage override (ADR-0076). Unset (the default, empty string) means auto-snap: the toolbar's own inline-size container query picks the nearest stage from its live width against the three --table-toolbar-bp-* breakpoints, snapping discretely, never interpolating. Setting stage explicitly pins that stage's rendering regardless of the container's actual width — overriding the container query, mirroring chart-ui's ratio override shape. Primary use: visual-eval fixtures that need a deterministic stage without resizing a real container, and a consumer embedding the toolbar inside a known-narrow panel who wants to skip auto-detection entirely. All four stages' CSS is now live (ADR-0076): search-tight steps the search field down (step 2); icon-only drops control labels and compacts/hides the range summary (steps 3+4); overflow collapses Filter/Sort/Columns into one \"More\" trigger routed through the same menu-ui/menu-item-ui pattern used elsewhere in the corpus (step 5). The resolved stage — the pin if set, otherwise the live width classification — also reflects onto the host as `data-stage-resolved` (step 7, informational only, mirrors chart-ui's `data-ratio-resolved` convention — never drives table-toolbar's own rendering, which stays 100% CSS-driven).",
25807
+ "type": "string",
25808
+ "enum": [
25809
+ "",
25810
+ "full",
25811
+ "search-tight",
25812
+ "icon-only",
25813
+ "overflow"
25814
+ ],
25815
+ "default": ""
25816
+ },
25747
25817
  "text": {
25748
25818
  "description": "Title text shown on the left.",
25749
25819
  "type": "string",
@@ -25774,6 +25844,7 @@
25774
25844
  "field-ui",
25775
25845
  "select-ui",
25776
25846
  "input-ui",
25847
+ "menu-ui",
25777
25848
  "menu-item-ui",
25778
25849
  "icon-ui",
25779
25850
  "check-ui"
@@ -25793,6 +25864,21 @@
25793
25864
  },
25794
25865
  "sort-change": {
25795
25866
  "description": "Sort state changed. Detail: { sortState }."
25867
+ },
25868
+ "toolbar-columns-set": {
25869
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.columns =` write. table-ui listens for this on itself. Detail: { columns }."
25870
+ },
25871
+ "toolbar-filter-clear": {
25872
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.clearFilters()` call. table-ui listens for this on itself. No detail."
25873
+ },
25874
+ "toolbar-filter-set": {
25875
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.setFilter()` call. table-ui listens for this on itself. Detail: { key, value, op }; a null `value` clears that one column's filter."
25876
+ },
25877
+ "toolbar-paginate": {
25878
+ "description": "gh#1764/#1780, ADR-0079 — dispatched directly at the resolved [for] target in place of the pre-#1780 direct `target.paginate =` write. table-ui listens for this on itself. Detail: { pageSize }."
25879
+ },
25880
+ "toolbar-search": {
25881
+ "description": "gh#1764/#1780, ADR-0079 (events-only interaction contract) — dispatched directly at the resolved [for] target (not bubbled from this element) in place of the pre-#1780 direct `.search =` write. table-ui listens for this on itself. Detail: { value }."
25796
25882
  }
25797
25883
  },
25798
25884
  "examples": [
@@ -25802,8 +25888,8 @@
25802
25888
  "name": "members-toolbar"
25803
25889
  },
25804
25890
  {
25805
- "description": "The ratified adiav2 list-page toolbar row (ADIA2-9123): scope menu, range summary, filter/columns triggers, page-size select, and a primary CTA — search and sort dropped per that contract.",
25806
- "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"3\", \"children\": [\"bar\", \"card\"]},\n {\"id\": \"bar\", \"component\": \"TableToolbar\", \"for\": \"accounts\", \"no-sort\": true, \"no-search\": true, \"range-start\": 1, \"range-end\": 25, \"range-total\": 320, \"page-size\": 25, \"page-size-options\": [10, 25, 50, 100]},\n {\"id\": \"card\", \"component\": \"Card\", \"children\": [\"sec\"]},\n {\"id\": \"sec\", \"component\": \"Section\", \"bleed\": true, \"children\": [\"tbl\"]},\n {\"id\": \"tbl\", \"component\": \"Table\", \"id\": \"accounts\", \"sortable\": true, \"raw\": true}\n]",
25891
+ "description": "The ratified adiav2 list-page toolbar row (ADIA2-9123, gh#1649): scope menu, range summary, app-owned Filter/Columns triggers (their own filter drawer, not the native popovers — [no-filter]/[no-columns]/ [no-sort]/[no-search] drop the native affordances), page-size select, and a primary CTA — [slot=\"actions-leading\"] carries the Filter/Columns triggers so they land BEFORE the page-size select, while [slot=\"actions\"] carries the CTA so it lands after.",
25892
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"3\", \"children\": [\"bar\", \"card\"]},\n {\"id\": \"bar\", \"component\": \"TableToolbar\", \"for\": \"accounts\", \"no-filter\": true, \"no-sort\": true, \"no-columns\": true, \"no-search\": true, \"range-start\": 1, \"range-end\": 25, \"range-total\": 320, \"page-size\": 25, \"page-size-options\": [10, 25, 50, 100], \"children\": [\"btn-filter\", \"btn-columns\", \"btn-new\"]},\n {\"id\": \"btn-filter\", \"component\": \"Button\", \"text\": \"Filter\", \"variant\": \"ghost\", \"slot\": \"actions-leading\"},\n {\"id\": \"btn-columns\", \"component\": \"Button\", \"text\": \"Columns\", \"variant\": \"ghost\", \"slot\": \"actions-leading\"},\n {\"id\": \"btn-new\", \"component\": \"Button\", \"text\": \"New account\", \"variant\": \"primary\", \"slot\": \"actions\"},\n {\"id\": \"card\", \"component\": \"Card\", \"children\": [\"sec\"]},\n {\"id\": \"sec\", \"component\": \"Section\", \"bleed\": true, \"children\": [\"tbl\"]},\n {\"id\": \"tbl\", \"component\": \"Table\", \"id\": \"accounts\", \"sortable\": true, \"raw\": true}\n]",
25807
25893
  "name": "adiav2-list-page-toolbar-row"
25808
25894
  }
25809
25895
  ],
@@ -25832,7 +25918,13 @@
25832
25918
  ],
25833
25919
  "slots": {
25834
25920
  "actions": {
25835
- "description": "Trailing action area — primary buttons (e.g. \"New row\") rendered after the search input."
25921
+ "description": "Trailing action area — primary buttons (e.g. \"New row\") rendered after the search input, [slot=\"actions-leading\"], and the page-size select."
25922
+ },
25923
+ "actions-leading": {
25924
+ "description": "Region rendered AFTER the search input and BEFORE the native page-size select (gh#1649) — for app-owned trigger buttons (e.g. custom Filter / Columns triggers that open the consumer's own drawer instead of the native filter/columns popovers) that must land ahead of the page-size select rather than after it, unlike [slot=\"actions\"]. Same real-insertion-point pattern as [slot=\"scope\"] / [slot=\"actions\"] — positioning is CSS by DOM order, not a template stamp. Empty (the default) has zero footprint on existing consumers."
25925
+ },
25926
+ "empty": {
25927
+ "description": "Renders in place of the range-summary text — the same [data-summary] region — when [range-total] is EXPLICITLY present and equal to \"0\" (checked via hasAttribute, never the computed value alone, so an unset range-total during loading never flashes this content; ADR-0076, REQ-E-001). Mutually exclusive with a positive [range-total]: the normal \"Showing X-Y of N\" text always wins whenever range-total is a positive number. Empty (the default) preserves today's exact behavior — [data-summary] hides on a confirmed range-total=\"0\" exactly as before this slot existed. Plain author-supplied markup (text, an empty-state-ui composite, an icon+text pair) — table-toolbar does not template or constrain its shape. Naming matches chart-ui's own existing `empty` slot convention."
25836
25928
  },
25837
25929
  "scope": {
25838
25930
  "description": "Leading region rendered BEFORE the [text]/[count] title cluster — e.g. a scope/view-switcher menu (an org's teams, a saved view). Positioning is CSS by DOM order, mirroring how [slot=\"actions\"] is a real, author-fillable insertion point rather than template-stamped content."
@@ -26705,10 +26797,10 @@
26705
26797
  "deck": "Sub-title under a `title`. One-line lead, slightly larger than body. Use for the lead sentence after a title.",
26706
26798
  "display": "Top-level hero / brand display. Tallest visual rank. Use for page-level hero one-liners.",
26707
26799
  "heading": "Major page heading (visual rank H2). 16-18px / bold. Use for major sub-section dividers.",
26708
- "kicker": "Eyebrow text above a `title`. UPPERCASE + small + tracking. Use for content eyebrows (NOT form labels — use `label` for those).",
26709
- "label": "Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized + medium-weight. Use for field labels bound to form controls.",
26800
+ "kicker": "Eyebrow text above a `title`. UPPERCASE + small + tracking, mono font stack at regular (400) weight. Use for content eyebrows (NOT form labels — use `label` for those).",
26801
+ "label": "Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized, mono font stack at regular (400) weight. Use for field labels bound to form controls.",
26710
26802
  "metric": "Numeric KPI / big-number stat. Bold + large. Use for dashboard metric numbers.",
26711
- "section": "Inline form-group / navlist heading (visual rank H4). Small-cap. Use for form group labels, nav list headings.",
26803
+ "section": "Inline form-group / navlist heading (visual rank H4). Mono font stack at medium (500) weight, normal casing. Use for form group labels, nav list headings.",
26712
26804
  "subsection": "Sub-landmark within a section (visual rank H3). 14px / semibold. Use for card titles within a section."
26713
26805
  }
26714
26806
  },
@@ -168,6 +168,7 @@
168
168
  - Standalone legend primitive — a row of <badge-ui>+<swatch-ui> chips that are keyboard-focusable and click-toggleable.
169
169
  - Pairs with <chart-ui> via [for] id-ref (auto-bidirectional series toggling) or via items= for standalone use.
170
170
  - position attribute (top|bottom|left|right) places legend relative to its chart; interactive="false" disables interactivity.
171
+ - ratio attribute (3:2|1:1|2:3, ADR-0074) selects the legend layout shape (row/grid/list); left unset it auto-mirrors a [for]-bound chart's own resolved ratio.
171
172
 
172
173
  ## Chart
173
174
  - Declarative SVG chart supporting 18 types via the type attribute (bar, line, pie, donut, radar, area, ...).
@@ -231,7 +232,7 @@
231
232
  ## ContextMenu
232
233
  - Use <context-menu-ui> for right-click menus on a target (table row, file item, canvas object). For button-triggered menus use <menu-ui>; for popover content that is not a menu use <popover-ui> directly.
233
234
  - Items are <menu-item-ui> children inside the default slot — same shape as <menu-ui> items.
234
- - Bind target via wrap (default-slot first non-menu-item-ui child) OR [for] selector. The selector form is useful for whole-table or whole-canvas menus where wrapping isn't practical.
235
+ - Bind target via wrap (default-slot first non-menu-item-ui child) OR [target-selector]. The selector form is useful for whole-table or whole-canvas menus where wrapping isn't practical. [for] still works as a deprecated alias but new authoring should use [target-selector] (gh#1764/#1780, ADR-0079).
235
236
 
236
237
  ## DateRangePicker
237
238
  - DateRangePicker.value MUST be `{from, to}` with both ISO 8601 dates, OR null. Either side null is invalid mid-state and the validator should reject it (use `input` event for partial state).
@@ -366,7 +367,7 @@
366
367
 
367
368
  ## Input
368
369
  - <input-ui> is the canonical single-line text input. The host IS the contenteditable surface — NEVER wrap a native <input>. The sole exception is type="password", which internally uses a real <input type="password"> for masking (per ADR-0025).
369
- - Wrap <input-ui> in <field-ui label="…" hint="…" error="…"> for the canonical stacked label / hint / error chrome. The inline [label] / [hint] / [error] props are also supported on the primitive for compact use.
370
+ - Wrap <input-ui> in <field-ui label="…" hint="…" error="…"> for the canonical stacked label / hint / error chrome. input-ui itself only supports inline [label] / [error] (gh#1669 [hint] is a field-ui-only prop; input-ui has no equivalent) use field-ui when a hint line is needed.
370
371
  - Form participation is implicit via UIFormElement. Set [name] for FormData submission; [required] / [disabled] / [readonly] reflect; listen for `change` (blur or Enter commit) and `input` (per keystroke). `submit` event fires when Enter commits the value (used by <chat-composer>'s `composer-submit` forwarding).
371
372
  - For numeric input use [type="number"] with [min] [max] [step] [precision] [prefix] / [suffix] — this stamps a contenteditable surface + <button-ui> / <icon-ui> stepper column with ARIA spinbutton semantics. Read `el.valueAsNumber` for the parsed Number. Never substitute a native <input type="number">.
372
373
  - Inside <chat-composer>, the canonical inner input is <chat-input-ui> (chat variant subclass — adds the send button + model picker + paste-to-attach plumbing). The plain <input-ui> primitive ALSO fires a bubbling `submit` event on Enter (unconditional, no opt-in attribute); <chat-input-ui> simply builds on that semantic.
@@ -479,7 +480,8 @@
479
480
  - Authoring options: default stamping renders icon+text+badge from attributes. Named slots `icon`, `text`, `trailing` are also supported for custom content (e.g. a `<kbd slot="trailing">⌘K</kbd>` shortcut hint on a command-trigger item — see the admin-shell playground). Do NOT nest <nav-item-ui> inside another <nav-item-ui>.
480
481
 
481
482
  ## Nav
482
- - Composition: place <nav-ui> inside <admin-sidebar slot="leading"> wrapped in <section-ui> for app sidebars; inside an <aside data-subnav> with variant="section" for section / subnav rails; standalone on docs / auth pages. Children: <nav-group-ui>, <nav-item-ui>, optional <hr data-nav-divider>.
483
+ - Composition: place <nav-ui> inside <admin-sidebar slot="leading"> wrapped in <section-ui> for app sidebars; inside an <aside data-subnav> with variant="section" for section / subnav rails; standalone on docs / auth pages. Children: <nav-group-ui>, <nav-item-ui>, optional <hr data-nav-divider>, optional <span data-nav-label> (hand-placed titled section header).
484
+ - Titled nav-item groups: hand-place <span data-nav-label>Text</span> directly in the default slot, before a run of <nav-item-ui> / <nav-group-ui> children, to render a section-header kicker — the same uppercase/tracking/muted treatment as the [heading] ::before kicker on variant="section", but usable anywhere in the child list (not limited to one kicker per <nav-ui>) and always visually rendered, unlike [heading] which is aria-only on variant="primary". Hidden automatically whenever the primary-variant rail collapses ([collapsed] or ≤96px container width), same as <hr data-nav-divider>.
483
485
  - Variants: variant="primary" (default) — app sidebar; ResizeObserver collapses to icon-only ≤96px; collapsible groups open as a popover when collapsed. variant="section" — subnav rail; quieter chrome; optional [heading] kicker rendered via ::before.
484
486
  - Section-variant cascade (ADR-0015 § Nav consolidation): variant="section" on <nav-ui> cascades visually to direct <nav-group-ui> / <nav-item-ui> descendants via CSS `:not([variant])`. Children's JS state is NOT mutated; the cascade is purely visual. Explicit [variant] on a child always wins — use it to escape the cascade or style a standalone group/item.
485
487
  - Decision rule: if the user navigates AWAY (different page, route, or anchor) → <nav-ui>. If the user switches VIEWS within the same logical page → <tabs-ui>. Never use <nav-ui> as an in-page section switcher.
@@ -706,10 +708,14 @@
706
708
  - Use slot="action" (or [slot="actions"]) for trailing primary buttons (Invite, Export, +New). Use [text] / [count] props for the left cluster, or slotted [slot="title"] / [slot="count"] when content is markup (a <span> + <badge-ui>, etc.).
707
709
  - Listen for toolbar events (`search`, `filter-change`, `sort-change`, `columns-change`, `page-size-change`) only to mirror state to URL / persistence / analytics. The toolbar already wires its changes into the bound table — you don't need to manually update the table.
708
710
  - Use [slot="scope"] for a leading scope/view switcher (an org's teams, a saved view) rendered before [text]/[count]. Use [range-start] / [range-end] / [range-total] together for a "Showing X–Y of N" summary — it renders independently of, and alongside, [text]/[count], never in place of them. Use [page-size] + [page-size-options] for a rows-per-page select; it applies directly to the bound table's [paginate] prop the same way filter/sort/columns changes apply directly to the target.
711
+ - Use [slot="actions-leading"] (gh#1649) for app-owned trigger buttons that must land BEFORE the page-size select — e.g. custom Filter / Columns triggers that open the consumer's own drawer instead of the native filter/columns popovers (drop [no-filter] [no-columns] to hide the native ones when doing this). [slot="actions"] stays the trailing region AFTER the page-size select, for a primary CTA.
712
+ - Use [chrome-only] (ADR-0076, ADIA2-9123) instead of stacking [no-filter] [no-sort] [no-columns] [no-search] individually when an app owns ALL FOUR affordances itself (its own drawer/menu). Pure OR, absolute while set — clearing an individual no-* attribute does NOT re-enable that one control; drop [chrome-only] entirely and set the three you still want off explicitly instead. Combine with [slot="actions-leading"] for the app's own Filter/Columns triggers.
713
+ - Use <span slot="empty"> (or any markup) inside a table-toolbar-ui that also sets [range-total="0"] to show a "no results" message in the range-summary's own position, without a layout shift versus the normal "Showing X-Y of N" text (ADR-0076, REQ-E). Only fires on an EXPLICIT range-total="0" — omitting range-* entirely (loading) never shows it.
714
+ - A plain <button-ui> slotted into [slot="actions-leading"] (e.g. the app-owned Filter/Columns triggers above) already collapses to icon-only at the SAME breakpoint as the native Filter/Sort/Columns buttons (gh#1748) — no consumer JS/CSS required. Read the host's [data-stage-resolved] attribute (ADR-0076 REQ-M-005, ADIA2-9123 S4-ii) only for compaction BEYOND plain icon-only reduction — e.g. hiding a slotted button entirely at the overflow stage, or reacting to the stage in a consumer's own drawer/menu — without re-deriving the same breakpoints yourself. Informational only — mirrors the CSS-decided compaction stage (full | search-tight | icon-only | overflow), never drives table-toolbar's own rendering. Reports the pinned [stage] value when set, the live width classification otherwise; updates on both a live resize and a [stage] attribute change.
709
715
 
710
716
  ## Table
711
717
  - Canonical composition: wrap <table-ui> in <card-ui><section bleed> for edge-to-edge tables. The [bleed] removes section padding so columns span the full card width (see apps/saas/members, billing, admin-dashboard).
712
- - Pair with <table-toolbar-ui for="<table-id>"> for any table that needs search / filter / sort / columns visibility. Do NOT re-implement those affordances in the card header — the toolbar auto-wires search/filter/sort/columns changes into the bound table.
718
+ - Pair with <table-toolbar-ui for="<table-id>"> for any table that needs search / filter / sort / columns visibility. Do NOT re-implement those affordances in the card header — the toolbar auto-wires search/filter/sort/columns changes into the bound table via `toolbar-*` CustomEvents (gh#1764/#1780, ADR-0079 — events-only interaction contract; table-ui listens for toolbar-search/toolbar-filter-set/toolbar-filter-clear/ toolbar-columns-set/toolbar-paginate on itself). Any consumer, not only table-toolbar-ui, may dispatch these events directly at a table-ui instance.
713
719
  - Cells truncate single-line by default (v0.6.21 §403 truncate-default). Opt out per-table with [wrap] for whole-table multiline, or per-cell with [data-wrap] on a single column / cell.
714
720
  - [raw] is a visual-and-lifecycle chrome reset for consumer-owned body markup — render()'s early return (`if (this.raw) return;`) is unconditional: no header injection, no .data/.columns reconciliation, no empty/loading overlays, no aggregation or pagination footers, even if .columns and .data are set. Reach for it only when the consumer authors 100% of the body (spreadsheet / inline-edit style patterns wrapping a native <table>) — never to "keep the data lifecycle but skip demo seeding."
715
721
  - Listen for the `sort` event with detail.key + detail.dir (NOT .column / .direction). `cell-click` detail carries {key, row, value, dataIndex}. Per ADR-0027, table-ui composes check-ui, icon-ui, progress-ui, pagination-ui, skeleton-ui, badge-ui — consumer pages must explicitly import the ones they use.
@@ -1,9 +1,9 @@
1
1
  {
2
- "catalogId": "adia.tiers@0.8.43",
2
+ "catalogId": "adia.tiers@0.8.45",
3
3
  "description": "Tier index of the AdiaUI catalog contract (ADR-0050). L0-L4 ladder; every tier-N entry is a declared composition of tier-(N-1) entries. L1 `composes` edges speak A2UI component TYPE names — the R-C7 catalogs’ `components` keys (genui-system SPEC R-C13). Derived — regenerate with `npm run build:catalog-tiers`, never hand-edit.",
4
4
  "extends": {
5
5
  "catalogId": "adia.base",
6
- "version": "0.8.43"
6
+ "version": "0.8.45"
7
7
  },
8
8
  "tiers": {
9
9
  "L0": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui",
3
- "version": "0.8.43",
3
+ "version": "0.8.45",
4
4
  "description": "The A2UI protocol — runtime (renderer, registry, streams, surface manifest, wiring primitives, dockable base classes) plus protocol-side validation. Framework-agnostic and dependency-free; pairs with any A2UI-conformant component set. Folded from @adia-ai/a2ui-runtime + the protocol half of @adia-ai/a2ui-validator (ADR-0048).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared `/`-delimited path-walking helpers consolidating renderer.js,
3
+ * surface.js, and wiring-registry.js's originally-separate get/set
4
+ * implementations — see path-pointer.js for the per-function edge-case
5
+ * divergences each export preserves.
6
+ */
7
+
8
+ /** renderer.js's original getByPath: root/falsy path returns `obj` itself;
9
+ * no type guard mid-traversal. */
10
+ export declare function getByPath(obj: object, path?: string): unknown;
11
+
12
+ /** renderer.js's original updateDataModel write helper: mutates `obj` in
13
+ * place, creating missing intermediate objects; no-op on a root/falsy
14
+ * path (the caller handles whole-model replacement itself). */
15
+ export declare function setByPath(obj: object, path: string | undefined, value: unknown): void;
16
+
17
+ /** surface.js's original getPath: same root handling as getByPath, but a
18
+ * looser splitter that doesn't drop empty segments (trailing/doubled
19
+ * `/` behaves differently than getByPath for the same input). */
20
+ export declare function getPath(obj: object, path?: string): unknown;
21
+
22
+ /** surface.js's original setPath — see getPath's splitter note. */
23
+ export declare function setPath(obj: object, path: string | undefined, value: unknown): void;
24
+
25
+ /** wiring-registry.js's original getModelValue: an empty-string path
26
+ * returns `undefined` (not `model`, unlike getByPath/getPath), and
27
+ * traversal stops at the first non-object `current`, returning
28
+ * `undefined` instead of falling through to primitive property access. */
29
+ export declare function getModelValue(model: object | null | undefined, path?: string): unknown;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Shared `/`-delimited path-walking helpers for `packages/gen-ui/a2ui`.
3
+ *
4
+ * Consolidates three independently-authored get/set implementations
5
+ * (reactivity review corpus, axis 3/4, R4 — gh#1763): renderer.js's
6
+ * `#getByPath`/`#updateDataModel`, surface.js's `getPath`/`setPath`, and
7
+ * wiring-registry.js's `getModelValue`. This is a MECHANICAL extraction —
8
+ * no call site's observable behavior changes. The three implementations
9
+ * disagreed on a few real edge cases (see per-function notes below); each
10
+ * disagreement is preserved here rather than silently unified, so this
11
+ * module exports one function PER call site's original semantics instead
12
+ * of a single "canonical" walker.
13
+ *
14
+ * Direction, not conformance: the vendored runtime
15
+ * (`packages/genui/renderer/dist/data-model.js`) implements full RFC 6901 —
16
+ * `~0`/`~1` escaping, `""`/`"/"` both meaning "whole document", immutable
17
+ * structural-sharing writes. None of the three functions below do any of
18
+ * that (no escaping, and the three don't even agree on what an empty path
19
+ * means), and this ticket does not add it — that would be a behavior
20
+ * change, and app-layer stores migrating to the vendored model is tracked
21
+ * separately. This module only stops the same non-conformant walk from
22
+ * being typed out three times.
23
+ */
24
+
25
+ /** True for a path that addresses the whole document under every one of
26
+ * the three original implementations: absent, `""`, or `"/"`. */
27
+ function isRootPath(path) {
28
+ return !path || path === '/';
29
+ }
30
+
31
+ /** renderer.js's and wiring-registry.js's original splitter: drops every
32
+ * empty segment, so a leading `/`, a trailing `/`, and a doubled `//` are
33
+ * all forgiving — `"/a//b/"` walks `['a', 'b']`. */
34
+ function splitPath(path) {
35
+ return path.split('/').filter(Boolean);
36
+ }
37
+
38
+ /** surface.js's original splitter: strips exactly one leading `/`, then
39
+ * splits on every `/` WITHOUT dropping empty segments — a trailing or
40
+ * doubled slash produces a `''` segment that looks up `undefined` rather
41
+ * than being skipped. Kept verbatim; it's a real divergence from
42
+ * {@link splitPath} (see `getPath`/`setPath` below), not a bug this
43
+ * ticket fixes. */
44
+ function splitPathLoose(path) {
45
+ return path.replace(/^\//, '').split('/');
46
+ }
47
+
48
+ // ── renderer.js semantics: getByPath / setByPath ───────────────────────
49
+ //
50
+ // Root/falsy path returns (or, for the setter, is left to the caller to
51
+ // handle — see updateDataModel's own root branch) the object itself.
52
+ // Traversal relies on optional chaining with no type guard, so indexing
53
+ // past a primitive mid-path (e.g. `/name/length` where `name` is a string)
54
+ // returns that primitive's own property instead of `undefined` — this
55
+ // differs from `getModelValue` below, which blocks that case explicitly.
56
+
57
+ /** @param {object} obj @param {string} path */
58
+ export function getByPath(obj, path) {
59
+ if (isRootPath(path)) return obj;
60
+ return splitPath(path).reduce((o, k) => o?.[k], obj);
61
+ }
62
+
63
+ /** Mutates `obj` in place along `path`, creating missing intermediate
64
+ * objects. No-op on a root/falsy path — renderer.js's `#updateDataModel`
65
+ * handles whole-model replacement itself (`surface.dataModel = value ??
66
+ * {}`), since that reassigns a variable this function has no reach to. */
67
+ export function setByPath(obj, path, value) {
68
+ if (isRootPath(path)) return;
69
+ const parts = splitPath(path);
70
+ let cur = obj;
71
+ for (let i = 0; i < parts.length - 1; i++) {
72
+ if (cur[parts[i]] == null) cur[parts[i]] = {};
73
+ cur = cur[parts[i]];
74
+ }
75
+ cur[parts[parts.length - 1]] = value;
76
+ }
77
+
78
+ // ── surface.js semantics: getPath / setPath ────────────────────────────
79
+ //
80
+ // Same root-path handling as getByPath/setByPath, but walks with
81
+ // splitPathLoose — so a trailing or doubled slash resolves differently
82
+ // than getByPath/setByPath/getModelValue for the same input string.
83
+
84
+ /** @param {object} obj @param {string} path */
85
+ export function getPath(obj, path) {
86
+ if (isRootPath(path)) return obj;
87
+ const keys = splitPathLoose(path);
88
+ let current = obj;
89
+ for (const key of keys) {
90
+ if (current == null) return undefined;
91
+ current = current[key];
92
+ }
93
+ return current;
94
+ }
95
+
96
+ /** @param {object} obj @param {string} path @param {unknown} value */
97
+ export function setPath(obj, path, value) {
98
+ if (isRootPath(path)) return;
99
+ const keys = splitPathLoose(path);
100
+ let current = obj;
101
+ for (let i = 0; i < keys.length - 1; i++) {
102
+ const key = keys[i];
103
+ if (current[key] == null) current[key] = {};
104
+ current = current[key];
105
+ }
106
+ current[keys[keys.length - 1]] = value;
107
+ }
108
+
109
+ // ── wiring-registry.js semantics: getModelValue ────────────────────────
110
+ //
111
+ // Two real divergences from getByPath: (1) an empty-string path (falsy,
112
+ // but not `"/"`) returns `undefined` here — getByPath/getPath return the
113
+ // object itself for ANY falsy path, including `""`. (2) traversal
114
+ // type-guards: once `current` is a non-null value that isn't an `object`,
115
+ // this returns `undefined` immediately rather than continuing property
116
+ // access, so `/name/length` on a string model value reads as `undefined`
117
+ // here vs. the string's own `.length` in getByPath/getPath.
118
+
119
+ /** @param {object} model @param {string} path — JSON Pointer (e.g. "/patient/name") */
120
+ export function getModelValue(model, path) {
121
+ if (!path || !model) return undefined;
122
+ const segments = splitPath(path);
123
+ let current = model;
124
+ for (const seg of segments) {
125
+ if (current == null || typeof current !== 'object') return undefined;
126
+ current = current[seg];
127
+ }
128
+ return current;
129
+ }
package/renderer.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { resolveTag, registry } from './registry.js';
11
11
  import { applyResolvedProp, toAttr } from './prop-apply.js';
12
+ import { getByPath, setByPath } from './path-pointer.js';
12
13
 
13
14
  export class A2UIRenderer {
14
15
  #container;
@@ -479,16 +480,11 @@ export class A2UIRenderer {
479
480
  if (typeof value !== 'object') return value;
480
481
  if (value.path) {
481
482
  const surface = surfaceId ? this.#surfaces.get(surfaceId) : null;
482
- return surface ? this.#getByPath(surface.dataModel, value.path) : value.path;
483
+ return surface ? getByPath(surface.dataModel, value.path) : value.path;
483
484
  }
484
485
  return value;
485
486
  }
486
487
 
487
- #getByPath(obj, path) {
488
- if (!path || path === '/') return obj;
489
- return path.split('/').filter(Boolean).reduce((o, k) => o?.[k], obj);
490
- }
491
-
492
488
  // ── updateDataModel ──
493
489
 
494
490
  #updateDataModel({ surfaceId, path, value }) {
@@ -498,13 +494,7 @@ export class A2UIRenderer {
498
494
  if (!path || path === '/') {
499
495
  surface.dataModel = value ?? {};
500
496
  } else {
501
- const parts = path.split('/').filter(Boolean);
502
- let cur = surface.dataModel;
503
- for (let i = 0; i < parts.length - 1; i++) {
504
- if (cur[parts[i]] == null) cur[parts[i]] = {};
505
- cur = cur[parts[i]];
506
- }
507
- cur[parts[parts.length - 1]] = value;
497
+ setByPath(surface.dataModel, path, value);
508
498
  }
509
499
 
510
500
  for (const [compId, comp] of surface.bindings) {
package/surface.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * their lifecycle and provides a shared context.
11
11
  */
12
12
 
13
+ import { getPath, setPath } from './path-pointer.js';
14
+
13
15
  // ── Dock order (lowest docks first, undocks last) ──
14
16
  const DOCK_ORDER = { provider: 0, controller: 1, source: 2, action: 3, lifecycle: 4 };
15
17
 
@@ -195,28 +197,3 @@ export class Surface {
195
197
  }));
196
198
  }
197
199
  }
198
-
199
- // ── JSON Pointer helpers (simplified, "/" delimited) ────────
200
-
201
- function getPath(obj, path) {
202
- if (!path || path === '/') return obj;
203
- const keys = path.replace(/^\//, '').split('/');
204
- let current = obj;
205
- for (const key of keys) {
206
- if (current == null) return undefined;
207
- current = current[key];
208
- }
209
- return current;
210
- }
211
-
212
- function setPath(obj, path, value) {
213
- if (!path || path === '/') return;
214
- const keys = path.replace(/^\//, '').split('/');
215
- let current = obj;
216
- for (let i = 0; i < keys.length - 1; i++) {
217
- const key = keys[i];
218
- if (current[key] == null) current[key] = {};
219
- current = current[key];
220
- }
221
- current[keys[keys.length - 1]] = value;
222
- }
@@ -12,6 +12,8 @@
12
12
  * A healthcare app registers CheckinController; a CRM registers PipelineController.
13
13
  */
14
14
 
15
+ import { getModelValue } from './path-pointer.js';
16
+
15
17
  // ═══════════════════════════════════════════════════════════════
16
18
  // REGISTRY
17
19
  // ═══════════════════════════════════════════════════════════════
@@ -293,23 +295,6 @@ function extractValue(descriptor, ctx) {
293
295
  }
294
296
  }
295
297
 
296
- /**
297
- * Get a value from a data model by JSON Pointer path.
298
- * @param {object} model
299
- * @param {string} path — JSON Pointer (e.g., "/patient/name")
300
- * @returns {unknown}
301
- */
302
- function getModelValue(model, path) {
303
- if (!path || !model) return undefined;
304
- const segments = path.split('/').filter(Boolean);
305
- let current = model;
306
- for (const seg of segments) {
307
- if (current == null || typeof current !== 'object') return undefined;
308
- current = current[seg];
309
- }
310
- return current;
311
- }
312
-
313
298
  // ═══════════════════════════════════════════════════════════════
314
299
  // DEFAULT RESOLVERS
315
300
  // ═══════════════════════════════════════════════════════════════