@adia-ai/a2ui 0.8.44 → 0.8.46

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,23 @@
1
1
  # Changelog — @adia-ai/a2ui
2
2
 
3
+ ## [0.8.46] — 2026-08-22
4
+
5
+
6
+ ### Maintenance
7
+ - **`catalog/` touched in this release window** (3 file(s), e.g. `catalog/catalog-a2ui_0_9.json`) — carried by the entries above.
8
+
9
+ ## [0.8.45] — 2026-08-20
10
+
11
+ ### Added
12
+ - **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.
13
+ - **ADR-0079 ratified: `for=`-carrying components normalize to an events-only interaction contract (gh#1764/#1780).**
14
+
15
+ ### Changed
16
+ - **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.**
17
+
18
+ ### Maintenance
19
+ - **`catalog/` touched in this release window** (3 file(s), e.g. `catalog/catalog-a2ui_0_9.json`) — carried by the entries above.
20
+
3
21
  ## [0.8.44] — 2026-08-20
4
22
 
5
23
  ### Maintenance
@@ -5836,7 +5836,7 @@
5836
5836
  "default": null
5837
5837
  },
5838
5838
  "data": {
5839
- "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.",
5839
+ "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. A datum object carrying a truthy `provisional` key (ADR-0081) renders that period's type-specific incomplete-period treatment (hollow/dashed bar, dashed line + faded area, hollow ring dot) on cartesian types; a no-op on radial/part-to-whole types and sparkline (gh#1690's N/A list). Provisional state is per-datum data-shape, not a chart-ui attribute — see ADR-0081 §3.",
5840
5840
  "$ref": "#/$defs/DynamicObjectList"
5841
5841
  },
5842
5842
  "dots": {
@@ -5861,6 +5861,16 @@
5861
5861
  ],
5862
5862
  "default": "abbr"
5863
5863
  },
5864
+ "labels": {
5865
+ "description": "Chart 2.0 axis-label mode (ADR-0081). Unset (default, \"\") always resolves to `outside` — today's pre-2.0 outside-the-plot axis labels, byte-for-byte unchanged. Set explicitly to `chip` to opt into the pill-treated overlay labels inset within the plot box (never hanging outside it) — the resolved value reflects onto the host as `data-labels-resolved=\"chip\"|\"outside\"`. Deliberately explicit-opt-in rather than auto-detected from full-bleed ancestry (`section[bleed]`/ `card-ui[padding=\"none\"]`): an earlier draft auto-activated chip mode on any full-bleed composition, which silently changed the rendering of every ALREADY-SHIPPED full-bleed chart the moment it shipped — this build's own 60-fixture Charts visual-eval floor caught it (2 `comp-chart-in-card-n-*` regressions). `chart-in-card` compositions opt in explicitly once their own examples are updated (gh#1805).",
5866
+ "type": "string",
5867
+ "enum": [
5868
+ "",
5869
+ "chip",
5870
+ "outside"
5871
+ ],
5872
+ "default": ""
5873
+ },
5864
5874
  "loading": {
5865
5875
  "description": "Show a skeleton placeholder instead of the chart body. Set to true while data is being fetched; clear once data arrives. Preserves the element's aspect-ratio dimensions so the skeleton occupies the same space the chart will fill. Parity with stat-ui[loading] and table-ui[loading] (§FB-12, v0.6.27).",
5866
5876
  "type": "boolean",
@@ -5911,6 +5921,11 @@
5911
5921
  "type": "number",
5912
5922
  "default": 0.4
5913
5923
  },
5924
+ "today": {
5925
+ "description": "Names the datum whose x-axis value marks \"today\" (ADR-0081). Unset (default, \"\") renders no marker — additive. Compared against each datum's x-key value via a loose string comparison (`String(datum[x]) === String(today)`), so category axes (\"month\", \"region\") work the same as literal date strings; chart-ui never auto-derives today from the clock. Renders a baseline dot + a short band tick (REQ-F-008) on cartesian types (bar, line, area, scatter, multi-line, stacked-bar, grouped-bar, composed); a no-op on every radial/part-to-whole type and on sparkline (gh#1690's N/A list). Fallback (not the primary mechanism): a datum already carrying a truthy `today` key is treated as an equivalent marker even with this attribute unset.",
5926
+ "type": "string",
5927
+ "default": ""
5928
+ },
5914
5929
  "x": {
5915
5930
  "description": "Data key for x-axis (category) values",
5916
5931
  "type": "string",
@@ -8896,7 +8911,7 @@
8896
8911
  },
8897
8912
  "ContextMenu": {
8898
8913
  "title": "ContextMenu",
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 [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",
8914
+ "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",
8900
8915
  "type": "object",
8901
8916
  "allOf": [
8902
8917
  {
@@ -8911,7 +8926,7 @@
8911
8926
  "const": "ContextMenu"
8912
8927
  },
8913
8928
  "for": {
8914
- "description": "CSS selector(s) for target element(s). Empty = use default-slot child.",
8929
+ "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].",
8915
8930
  "type": "string",
8916
8931
  "default": ""
8917
8932
  },
@@ -8924,6 +8939,11 @@
8924
8939
  "description": "Programmatic open state. Set true to open at target center.",
8925
8940
  "type": "boolean",
8926
8941
  "default": false
8942
+ },
8943
+ "targetSelector": {
8944
+ "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).",
8945
+ "type": "string",
8946
+ "default": ""
8927
8947
  }
8928
8948
  },
8929
8949
  "required": [
@@ -8933,7 +8953,7 @@
8933
8953
  "x-adiaui": {
8934
8954
  "anti_patterns": [
8935
8955
  {
8936
- "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>`.",
8956
+ "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>`.",
8937
8957
  "why": "No target binding — the menu never opens.",
8938
8958
  "wrong": "<context-menu-ui>...just items...</context-menu-ui>"
8939
8959
  }
@@ -16995,7 +17015,7 @@
16995
17015
  "default": false
16996
17016
  },
16997
17017
  "heading": {
16998
- "description": "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only.",
17018
+ "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.",
16999
17019
  "type": "string",
17000
17020
  "default": ""
17001
17021
  },
@@ -17076,7 +17096,7 @@
17076
17096
  ],
17077
17097
  "slots": {
17078
17098
  "default": {
17079
- "description": "Primary slot — accepts <nav-group-ui> + <nav-item-ui> children, plus <hr data-nav-divider> for hand-placed dividers."
17099
+ "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)."
17080
17100
  }
17081
17101
  },
17082
17102
  "states": [
@@ -22445,6 +22465,11 @@
22445
22465
  "type": "string",
22446
22466
  "default": ""
22447
22467
  },
22468
+ "label-hidden": {
22469
+ "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.",
22470
+ "type": "boolean",
22471
+ "default": false
22472
+ },
22448
22473
  "mark": {
22449
22474
  "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.",
22450
22475
  "type": "boolean",
@@ -24993,6 +25018,11 @@
24993
25018
  "type": "boolean",
24994
25019
  "default": false
24995
25020
  },
25021
+ "filteredCount": {
25022
+ "description": "Read-only. The row count AFTER search + column filters apply but BEFORE pagination slices it (gh#1807, ADR-0080, REQ-W-006) — a plain JS getter, no setter, never a reflected attribute. table-footer-ui's client-mode range-total derivation reads this directly; a raw `.data.length` read (the toolbar's own `count` fallback precedent) would double-count a filtered-out row. In server mode ([range-total] set, gh#1754, ADR-0082), filteredCount stays page-scoped — the loaded page's rows after local filters, NOT the server total across all pages; [range-total] is the server total, filteredCount never is (REQ-D-006).",
25023
+ "type": "number",
25024
+ "readOnly": true
25025
+ },
24996
25026
  "frameless": {
24997
25027
  "description": "Drop the table's outer 1px perimeter and nothing else (gh#511) — row dividers are untouched (the perimeter is split onto its own --table-perimeter token, defaulting to --table-border). For card-composed tables: the card owns the frame, so the table's square perimeter otherwise renders a doubled edge (flush bleed) or a stray inner box (with content above). Standalone tables keep their chrome by default. Theming path without the attribute: set --table-perimeter to transparent per instance. Note: `<card-ui><section bleed>` already drops a direct-child table-ui's chrome automatically (card.css, gh#796) — `frameless`/`raw` on the instance still applies and is honored, but is no longer required just to avoid the doubled-edge look in that composition.",
24998
25028
  "type": "boolean",
@@ -25003,8 +25033,18 @@
25003
25033
  "type": "boolean",
25004
25034
  "default": false
25005
25035
  },
25036
+ "no-pager": {
25037
+ "description": "Hide the internal pagination bar while leaving [paginate] slicing, page state, the `page` event, and the `footer-page` command listener all intact (gh#1807, ADR-0080). The anti-doubled-pager mechanism for a table-footer-ui composition, client- or server-mode alike (gh#1754, ADR-0082): without it, a bound footer's own composed pager and the table's own internal one would both render for any non-empty paginated table. Explicit author intent — never suppressed automatically just because a footer happens to be bound.",
25038
+ "type": "boolean",
25039
+ "default": false
25040
+ },
25006
25041
  "paginate": {
25007
- "description": "Rows per page. 0 = show all rows without pagination. When > 0, renders a pagination bar below the table.",
25042
+ "description": "Rows per page. 0 = show all rows without pagination. When > 0, renders the internal pagination bar below the table — UNLESS [no-pager] is also set (gh#1807, ADR-0080), which keeps [paginate]'s slicing/page-state/`page`-event/`footer-page`-listener behavior but suppresses only the bar itself (the anti-doubled-pager path for a table-footer-ui composition). When [range-total] is also set (gh#1754, ADR-0082), [paginate] becomes purely presentational — it supplies only the page-size for pager math; no local slicing runs.",
25043
+ "type": "number",
25044
+ "default": 0
25045
+ },
25046
+ "range-total": {
25047
+ "description": "Total row count across ALL server pages (gh#1754, ADR-0082) — the table-authoritative server-mode data contract. Presence-gated: the attribute's PRESENCE, not its value, is the mode switch (checked via hasAttribute, never the coerced value alone — the same discipline table-footer-ui's own [range-total] ships). Absent (the default) means client mode: today's behavior exactly, [paginate] slices `.data` as always. Present with [paginate] > 0 means server mode: no local slicing (`.data` IS the current page and renders whole, after local search/sort/filter), the internal pager's page count becomes `max(1, ceil(range-total / paginate))` (one calc site, consolidated), and the internal page-reset sites (`data` set, `setFilter`, `clearFilters`) are suppressed so a fetch write-back never fights the pager back to page 0 — page state then moves only via pager interaction, the `footer-page` command, or `setState()`. The existing `page` event (0-based, unchanged) becomes the fetch trigger: the consumer listens for it, fetches that server page, and writes `.data` back. Explicit `range-total=\"0\"` is server-confirmed empty (pager hidden), never conflated with absent. With [paginate] absent/0, [range-total] is inert for this table's own rendering but stays readable by a bound table-footer-ui (REQ-W-006 branch 2) for its count-only label — see the footer-authoritative shape below, which remains lawful and is unaffected by this attribute. Named identically to table-toolbar-ui's and table-footer-ui's own [range-total] (rows, never `pagination-ui[total]`'s pages — the ADR-0063 B5 collision rule); a third instance of one name for one concept.",
25008
25048
  "type": "number",
25009
25049
  "default": 0
25010
25050
  },
@@ -25014,7 +25054,7 @@
25014
25054
  "default": false
25015
25055
  },
25016
25056
  "search": {
25017
- "description": "Global search/filter string. Filters visible rows across all columns using case-insensitive substring matching. Resets to page 1 on change.",
25057
+ "description": "Global search/filter string. Filters visible rows across all columns using case-insensitive substring matching. Does NOT reset the current page (verified against source at the gh#1754/ADR-0082 build — unlike setFilter()/clearFilters(), no internal page reset ever ran for a search change; this description previously claimed \"resets to page 1 on change,\" which the source never did). Aligning the doc to observed behavior here, not a behavior change — a page reset on search, if wanted, is a separate client-mode ticket.",
25018
25058
  "type": "string",
25019
25059
  "default": ""
25020
25060
  },
@@ -25100,10 +25140,10 @@
25100
25140
  }
25101
25141
  },
25102
25142
  "page": {
25103
- "description": "Fired when the user navigates to a different page.",
25143
+ "description": "Fired when the table's own internal page state changes — an internal pager click, or a `footer-page` command applied (REQ-W-002). This is a RESYNC notification a bound `<table-footer-ui>` listens to for its own `page` attribute (REQ-W-004) — it is NOT the public server-mode fetch trigger a consumer should listen to; use `<table-footer-ui>`'s own `page-change` event for that (1-based, gh#1754/ADR-0082 REQ-W-003). Listening to both risks a duplicate fetch or an index-base mismatch.",
25104
25144
  "detail": {
25105
25145
  "page": {
25106
- "description": "New active page index (1-based).",
25146
+ "description": "New active page index (0-based — gh#1808; the `footer-page` command and the `page` attribute on <table-footer-ui> are 1-based, this event's own detail is not).",
25107
25147
  "type": "number"
25108
25148
  }
25109
25149
  }
@@ -25554,6 +25594,154 @@
25554
25594
  "version": 1
25555
25595
  }
25556
25596
  },
25597
+ "TableFooter": {
25598
+ "title": "TableFooter",
25599
+ "description": "Footer / companion bar for a sibling table-ui (gh#1807, ADR-0080) — a \"Showing X–Y of N\" range label paired with a composed pagination-ui. Derivation-model API: set `page` / `page-size` / `range-total` and the footer derives total pages and the range window itself, so it never drifts out of sync with its own pager. Wired to the target table via a [for] id-ref and CustomEvents only (ADR-0079/ADR-0080) — the footer never writes a property or calls a method on the bound table. Drop below (or inside a card-ui footer alongside) any table-ui to add the standard data-grid pager + range summary without re-implementing pagination-ui's ellipsis truncation, keyboard handling, or ARIA.",
25600
+ "type": "object",
25601
+ "allOf": [
25602
+ {
25603
+ "$ref": "#/$defs/ComponentCommon"
25604
+ },
25605
+ {
25606
+ "$ref": "#/$defs/CatalogComponentCommon"
25607
+ }
25608
+ ],
25609
+ "properties": {
25610
+ "component": {
25611
+ "const": "TableFooter"
25612
+ },
25613
+ "for": {
25614
+ "description": "id-ref of the table-ui to control. Falls back to the first sibling table-ui within the same parent when omitted.",
25615
+ "type": "string",
25616
+ "default": ""
25617
+ },
25618
+ "page": {
25619
+ "description": "Current page, 1-based. Explicit attributes always win over derivation (per-attribute, not all-or-nothing) — see page-size/range-total.",
25620
+ "type": "number",
25621
+ "default": 1
25622
+ },
25623
+ "page-size": {
25624
+ "description": "Rows per page. Default 0 (unknown) is the ORDINARY default state, not an exotic edge case: with page-size absent/0 and range-total present, the range label renders in count-only form (\"128 items\") and the pager does not render — the derivation math (pages = range-total / page-size) is undefined without a page size. In a client-mode composition (the resolved target has [paginate] > 0) and this attribute is unset, the footer derives page-size from the target's own [paginate] value.",
25625
+ "type": "number",
25626
+ "default": 0
25627
+ },
25628
+ "range-total": {
25629
+ "description": "Total ROWS across all pages. Absent (the default) means \"not yet known\" (loading) — never \"zero\"; both the range label and the pager stay hidden until this is set. Explicit range-total=\"0\" is the confirmed-empty state (pager hidden; the `empty` slot renders in the range label's position when supplied). Named identically to table-toolbar-ui's [range-total] and table-ui's own [range-total] (gh#1754, ADR-0082) and deliberately NOT `total` (pagination-ui's `total` is total PAGES — the ADR-0063 B5 collision rule). When this attribute is unset, the footer derives it from the resolved target via a two-branch ladder (REQ-W-006, amended 0.2.0/ADR-0082): (1) the target's own [range-total] attribute, when present — the table-authoritative SERVER mode (gh#1754 Shape 1's second lawful composition); the identity rule: the footer's range-total derives from the table's range-total, never the table's filteredCount, and this branch wins even when the target's [paginate] is also > 0; (2) otherwise, when the target has [paginate] > 0, CLIENT mode: the target's filtered row count (search + column filters applied, before pagination). Refreshes on the target's `page` / `filter-change` / `sort` events in either branch. Widened named staleness edge: a programmatic `.data` swap on the target — or a `range-total` update on the target without an accompanying page move — emits no event; either re-set this footer's own attributes after such a change, or accept staleness until the target's next event.",
25630
+ "type": "number",
25631
+ "default": 0
25632
+ },
25633
+ "siblings": {
25634
+ "description": "Passed through to the composed pagination-ui's own [siblings] (page-number window size on each side of the current page).",
25635
+ "type": "number",
25636
+ "default": 1
25637
+ },
25638
+ "size": {
25639
+ "description": "Passed through to the composed pagination-ui's own [size] (sm | md | lg, the universal size system).",
25640
+ "type": "string",
25641
+ "default": "md"
25642
+ }
25643
+ },
25644
+ "required": [
25645
+ "component"
25646
+ ],
25647
+ "unevaluatedProperties": false,
25648
+ "x-adiaui": {
25649
+ "anti_patterns": [
25650
+ {
25651
+ "fix": "<table-footer-ui page=\"1\" page-size=\"10\" range-total=\"128\"></table-footer-ui>",
25652
+ "why": "table-footer-ui is derivation-only (REQ-F-002) — there is no [range-start]/[range-end] attribute pair (that shape belongs to table-toolbar-ui, which has no page/page-size knowledge of its own). Setting [page] + [page-size] + [range-total] derives the range window instead; a permanent three-way drift hazard is exactly what the derivation model exists to avoid.",
25653
+ "wrong": "<table-footer-ui range-start=\"1\" range-end=\"10\" range-total=\"128\"></table-footer-ui>"
25654
+ }
25655
+ ],
25656
+ "category": "agent",
25657
+ "composes": [
25658
+ "pagination-ui"
25659
+ ],
25660
+ "events": {
25661
+ "footer-page": {
25662
+ "description": "gh#1807, ADR-0080 (events-only interaction contract, extending ADR-0079's sender-prefixed toolbar-* precedent to a second sender) — dispatched directly at the resolved [for] target on every pager interaction. Detail: { page } (1-based). table-ui listens for this on itself, applied only when [paginate] > 0 (clamped to the valid page range; a non-numeric/NaN detail.page is a no-op); at [paginate=\"0\"] the command is a documented no-op on the table. Additive: any consumer, not only table-footer-ui, may dispatch this at a table-ui."
25663
+ },
25664
+ "page-change": {
25665
+ "description": "The footer's own consumer-facing notification — a distinct name from the inbound footer-page command (ADR-0079's command≠notification separation). Fires on every pager interaction, in both client- and server-mode. Detail: { page } (1-based). This is the server-mode fetch hook: a consumer listens for it, fetches the new page, then sets `table.data` and the footer's own `page` (and `range-total` if the server's total moved) — the footer never optimistically advances its own state in server-mode."
25666
+ }
25667
+ },
25668
+ "examples": [
25669
+ {
25670
+ "description": "Server-mode footer (gh#1754 Shape 1) paired with a for=-bound table-ui.",
25671
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"0\", \"children\": [\"tests\", \"ftr\"]},\n {\"id\": \"tests\", \"component\": \"Table\", \"raw\": true},\n {\"id\": \"ftr\", \"component\": \"TableFooter\", \"for\": \"tests\", \"page\": 1, \"page-size\": 10, \"range-total\": 128}\n]",
25672
+ "name": "table-footer-server-mode"
25673
+ },
25674
+ {
25675
+ "description": "Client-mode footer bound to a [paginate]d table-ui with its own internal pager suppressed via [no-pager].",
25676
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"0\", \"children\": [\"tests-c\", \"ftr\"]},\n {\"id\": \"tests-c\", \"component\": \"Table\", \"paginate\": 10, \"no-pager\": true, \"raw\": true},\n {\"id\": \"ftr\", \"component\": \"TableFooter\", \"for\": \"tests-c\"}\n]",
25677
+ "name": "table-footer-client-mode"
25678
+ }
25679
+ ],
25680
+ "keywords": [
25681
+ "table-footer",
25682
+ "pagination",
25683
+ "pager",
25684
+ "range",
25685
+ "showing",
25686
+ "data-grid",
25687
+ "directory",
25688
+ "admin",
25689
+ "backoffice",
25690
+ "listing",
25691
+ "records"
25692
+ ],
25693
+ "name": "UITableFooter",
25694
+ "parts": {},
25695
+ "related": [
25696
+ "table",
25697
+ "table-toolbar",
25698
+ "pagination",
25699
+ "card"
25700
+ ],
25701
+ "slots": {
25702
+ "empty": {
25703
+ "description": "Renders in place of the range label — the same [data-range] 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). Mutually exclusive with a positive [range-total]: the normal \"Showing X–Y of N\" (or count-only) text always wins whenever range-total resolves positive. Plain author-supplied markup — table-footer-ui does not template or constrain its shape. Naming matches table-toolbar-ui's own `empty` slot convention."
25704
+ }
25705
+ },
25706
+ "states": [
25707
+ {
25708
+ "description": "Default, ready for interaction.",
25709
+ "name": "idle"
25710
+ }
25711
+ ],
25712
+ "status": "beta",
25713
+ "synonyms": {
25714
+ "pager": [
25715
+ "table-footer",
25716
+ "pagination"
25717
+ ],
25718
+ "pagination": [
25719
+ "table-footer",
25720
+ "pagination"
25721
+ ]
25722
+ },
25723
+ "tag": "table-footer-ui",
25724
+ "tokens": {
25725
+ "--table-footer-gap": {
25726
+ "description": "Gap between the range label and the pager"
25727
+ },
25728
+ "--table-footer-px": {
25729
+ "description": "Horizontal padding"
25730
+ },
25731
+ "--table-footer-py": {
25732
+ "description": "Vertical padding"
25733
+ },
25734
+ "--table-footer-summary-fg": {
25735
+ "description": "Range-label text color"
25736
+ },
25737
+ "--table-footer-summary-size": {
25738
+ "description": "Range-label font size"
25739
+ }
25740
+ },
25741
+ "traits": [],
25742
+ "version": 1
25743
+ }
25744
+ },
25557
25745
  "TableOfContents": {
25558
25746
  "title": "TableOfContents",
25559
25747
  "description": "Auto-generated in-page table of contents. Scans a target container\nfor headings (default `h2,h3`), ensures each has an `id` (slugifies\nthe text content if missing), and stamps a `<nav>` list of anchor\nlinks. An `IntersectionObserver` tracks the active heading and\napplies `[data-active]` to the matching link so consumers can style\nthe currently-visible section. Smooth-scroll on click is handled by\nthe global `scroll-behavior: smooth` set in resets.css.\n\nPair with a sticky container (`position: sticky; top: <offset>;`) in\nan aside / right rail for the classic docs-site outline pattern.\n",
@@ -25854,6 +26042,21 @@
25854
26042
  },
25855
26043
  "sort-change": {
25856
26044
  "description": "Sort state changed. Detail: { sortState }."
26045
+ },
26046
+ "toolbar-columns-set": {
26047
+ "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 }."
26048
+ },
26049
+ "toolbar-filter-clear": {
26050
+ "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."
26051
+ },
26052
+ "toolbar-filter-set": {
26053
+ "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."
26054
+ },
26055
+ "toolbar-paginate": {
26056
+ "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 }."
26057
+ },
26058
+ "toolbar-search": {
26059
+ "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 }."
25857
26060
  }
25858
26061
  },
25859
26062
  "examples": [
@@ -158,6 +158,7 @@
158
158
  - Heading slot accepts inline badges/metadata: <span slot="heading"><text-ui strong>Title</text-ui><badge-ui text="New" variant="primary"></badge-ui></span> renders title + badge on one row.
159
159
  - Description slot also accepts bare <p> or <small> elements as siblings of the heading — they participate in the grid's row 2 without needing slot="description".
160
160
  - Multiple <section> siblings are allowed and stack vertically. [bleed] on a section removes its margin for edge-to-edge content (tables, charts); [padding] adds a canvas-scrim background for hero regions.
161
+ - [bleed] is unconditionally author-controlled — card.css never restores the section's inset on your behalf (gh#1801, operator ruling 2026-08-20; supersedes gh#1095's removed auto-restore guard). A full-bleed <chart-ui> that still draws gridlines or value labels, or is paired with a <chart-legend-ui>, can clip that text under the card's own corner-rounded overflow:hidden — keeping it clear of the card frame is YOUR responsibility as the author, e.g. by rendering value/ guide text as overlay chips INSET within the plot area rather than hanging outside it, or by placing a <chart-legend-ui> in its own non-bled section. See chart-in-card.examples.html for the pattern.
161
162
  - When neither [raw] nor [frameless] is present, a <table-ui> placed directly inside a <section bleed> automatically renders chromeless (background/box-shadow/border-radius reset; card.css, gh#796): the card already owns the frame, so the table doesn't need its own. table.yaml's [raw]/[frameless] docs describe the same composition; an explicit [raw] or [frameless] on the table still applies and is honored. A plain (non-bleed) <section> is unaffected — its own padding keeps a nested table's frame legible as an intentional nested surface.
162
163
  - [grow] on a section makes it fill the card's remaining height (flex:1 — same semantics as col-ui[grow]/row-ui[grow]). The card becomes a flex column so the section absorbs the leftover space after the header/footer. Requires the card to have a definite height (inline height, grid row track, or flex parent) — a content-sized card has nothing to fill. Combine with [bleed] for edge-to-edge fill: <section bleed grow>. A <chart-ui> directly inside a <section bleed> auto-grows without [grow] (zero-config convenience for the common chart case).
163
164
  - Footer with a [slot="description"] + [slot="action"] pair triggers justify-content: space-between — useful for a "Last saved …" note on the left and a Save/Cancel button group on the right.
@@ -232,7 +233,7 @@
232
233
  ## ContextMenu
233
234
  - 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.
234
235
  - Items are <menu-item-ui> children inside the default slot — same shape as <menu-ui> items.
235
- - 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.
236
+ - 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).
236
237
 
237
238
  ## DateRangePicker
238
239
  - 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).
@@ -480,7 +481,8 @@
480
481
  - 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>.
481
482
 
482
483
  ## Nav
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>.
484
+ - 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).
485
+ - 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>.
484
486
  - 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.
485
487
  - 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.
486
488
  - 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.
@@ -700,25 +702,33 @@
700
702
  - Binary on/off only — no third state. For tri-state controls use <check-ui> with [indeterminate].
701
703
  - Use for settings toggles, feature flags, mode switches; for form gates / consent / multi-select use <check-ui> instead.
702
704
 
705
+ ## TableFooter
706
+ - Pair <table-footer-ui> with <table-ui> via [for="<table-id>"] (or rely on first-sibling fallback when both are inside the same parent). Client-mode composition (a [paginate]-sliced table): set [no-pager] on the table-ui so its own internal pagination bar doesn't render alongside the footer's composed one — table-footer-ui never auto-suppresses it for you.
707
+ - Server-mode composition (gh#1754 Shape 1 — the server returns the page's rows plus the total row count): set [range-total] to the server's total, [page] / [page-size] from your own request state, and listen for the footer's `page-change` event to fetch the next page — the footer does not optimistically advance its own [page] in server-mode; write it back after the fetch resolves.
708
+ - The toolbar's [count] badge and this footer's range label coexist by design — they answer different questions (the collection summary vs. the page-window position) and neither replaces the other. In a composition that includes a table-footer-ui, leave the toolbar's [range-start]/[range-end]/[range-total] unset — the range summary's home is the footer.
709
+ - Canonical composition: card-ui header (title + primary action) → a plain <section> wrapping a title-less <table-toolbar-ui for> → a <section bleed> wrapping the <table-ui id> → a <footer divider> wrapping this <table-footer-ui for> — see the table-in-card pattern.
710
+ - Use <span slot="empty"> (or any markup) inside a table-footer-ui that also sets [range-total="0"] to show a "no results" message in the range label's own position, without a layout shift versus the normal range text. Only fires on an EXPLICIT range-total="0" — omitting range-total entirely (loading) never shows it.
711
+
703
712
  ## TableToolbar
704
- - Pair <table-toolbar-ui> with <table-ui> via [for="<table-id>"] (or rely on first-sibling fallback when both are inside the same parent). One toolbar per table. Do NOT also use <card-ui>'s <header> on the same card that produces a doubled chrome row.
713
+ - Pair <table-toolbar-ui> with <table-ui> via [for="<table-id>"] (or rely on first-sibling fallback when both are inside the same parent). One toolbar per table. The doubled-chrome hazard is a TITLE COLLISION, not a blanket card-header ban (gh#1807, SPEC REQ-C-004): a toolbar carrying [text] must not share a card with a heading-slotted <card-ui> <header> that pairing doubles the title row. A title-less toolbar (no [text]) inside a card whose <header> owns the title is the canonical table-in-card composition (see the table-in-card pattern) and is NOT doubled chrome — the toolbar renders only its count/search/filter/sort/ columns cluster, with the title living in the card header alone.
705
714
  - All four affordances (search, filter, sort, columns) default ON. Opt out individually via [no-search] / [no-filter] / [no-sort] / [no-columns]. The previous [searchable] / [filterable] attributes are deprecated — do NOT emit them.
706
- - Place the toolbar ABOVE the <card-ui> containing the table-ui, or use [variant="card"] when standing alone outside a card-ui parent (the variant wraps the toolbar in card-style chrome).
715
+ - Three legitimate placements: ABOVE the <card-ui> containing the table-ui; standing alone outside a card-ui parent with [variant="card"] (wraps the toolbar in card-style chrome); or INSIDE the card, in its own plain (non-bleed) <section> between the card header and the bleed table section — the canonical table-in-card shape (gh#1807) — title-less (no [text]) since the card header already owns the title (see the title-collision rule above).
707
716
  - 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.).
708
717
  - 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.
709
718
  - 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.
710
719
  - 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.
711
720
  - 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.
712
721
  - 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.
713
- - Read the host's [data-stage-resolved] attribute (ADR-0076 REQ-M-005, ADIA2-9123 S4-ii) to compact a consumer's own [slot="actions-leading"] content in sympathy with table-toolbar's native regions, 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.
722
+ - 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.
714
723
 
715
724
  ## Table
716
725
  - 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).
717
- - 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.
726
+ - 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.
727
+ - Pair with <table-footer-ui for="<table-id>"> for the pager + "Showing X-Y of N" range label (gh#1807, ADR-0080; gh#1754, ADR-0082) — table-ui listens for its `footer-page` command (detail {page}, 1-based) on itself, applied whenever [paginate] > 0 — client mode AND table-authoritative server mode ([range-total] set) alike — clamped to the valid page range (server-derived when [range-total] is set). Only at [paginate="0"] does the table treat it as a documented no-op, in either mode. Set [no-pager] on the table when a composition also binds a footer (client- or server-mode), or the internal pagination bar and the footer's composed one both render — [no-pager] leaves slicing/page-state/the `page` event untouched, it only hides the internal bar.
718
728
  - 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.
719
729
  - [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."
720
730
  - 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.
721
- - For large datasets (100+ rows), use [paginate="N"] to limit rendered rows and add a pagination bar. [virtual] is NOT a recognized prop and is silently ignored — there is no DOM-level virtual scrolling in table-ui. [paginate] is the supported performance mechanism. Wire [paginate] with optional [search] and the `page` event for server-driven pagination.
731
+ - For large datasets (100+ rows), use [paginate="N"] to limit rendered rows and add a pagination bar. [virtual] is NOT a recognized prop and is silently ignored — there is no DOM-level virtual scrolling in table-ui. [paginate] is the supported performance mechanism. For server-driven pagination (gh#1754, ADR-0082), set [paginate="N"] (page size) + [range-total="M"] (server total row count) together — [paginate] then becomes purely presentational (no local slicing), `.data` holds exactly the current server page. The table's own `page` event (0-based) is an internal resync notification, not the fetch trigger — a bound <table-footer-ui> listens to it to keep its own [page] in sync. The PUBLIC fetch trigger is <table-footer-ui>'s own `page-change` event (1-based): listen for it, fetch that server page, then write the rows back to `.data` and the footer's own [page]. Listening to the table's `page` event instead risks a duplicate fetch or an index-base mismatch (0-based vs 1-based). [search]/[sort]/column filters, when used alongside server paging, still operate on the loaded page only — not a server-side query.
722
732
 
723
733
  ## Tab
724
734
  - <tab-ui> only renders inside <tabs-ui>. Never use it standalone. The parent reads each tab's [text] + [icon] + [value] to render the button strip; the tab's default slot is the panel content that the parent auto-hides when inactive.
@@ -1,14 +1,14 @@
1
1
  {
2
- "catalogId": "adia.tiers@0.8.44",
2
+ "catalogId": "adia.tiers@0.8.46",
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.44"
6
+ "version": "0.8.46"
7
7
  },
8
8
  "tiers": {
9
9
  "L0": {
10
10
  "composesTier": null,
11
- "count": 189,
11
+ "count": 190,
12
12
  "entries": {
13
13
  "A2UIRoot": {
14
14
  "category": "container",
@@ -1000,6 +1000,12 @@
1000
1000
  "origin": "primitive",
1001
1001
  "tag": "table-ui"
1002
1002
  },
1003
+ "TableFooter": {
1004
+ "category": "agent",
1005
+ "composes": [],
1006
+ "origin": "primitive",
1007
+ "tag": "table-footer-ui"
1008
+ },
1003
1009
  "TableOfContents": {
1004
1010
  "category": "navigation",
1005
1011
  "composes": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui",
3
- "version": "0.8.44",
3
+ "version": "0.8.46",
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/registry.js CHANGED
@@ -339,6 +339,8 @@ export const registry = new Map([
339
339
  ['Spinner', 'spinner-ui'],
340
340
  ['StepProgress', 'step-progress-ui'],
341
341
  ['Swatch', 'swatch-ui'],
342
+ // gh#1807, ADR-0080 — footer/companion bar for a sibling table-ui.
343
+ ['TableFooter', 'table-footer-ui'],
342
344
  // sidecar directory is `toc/toc.a2ui.json`; catalog title is TableOfContents
343
345
  // but the component's own tag is the shorter `toc-ui` (x-adiaui.tag) — not
344
346
  // the naive `table-of-contents-ui` kebab expansion of the type name.
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
  // ═══════════════════════════════════════════════════════════════