@adia-ai/a2ui 0.8.44 → 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,17 @@
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
+
3
15
  ## [0.8.44] — 2026-08-20
4
16
 
5
17
  ### Maintenance
@@ -8896,7 +8896,7 @@
8896
8896
  },
8897
8897
  "ContextMenu": {
8898
8898
  "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",
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",
8900
8900
  "type": "object",
8901
8901
  "allOf": [
8902
8902
  {
@@ -8911,7 +8911,7 @@
8911
8911
  "const": "ContextMenu"
8912
8912
  },
8913
8913
  "for": {
8914
- "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].",
8915
8915
  "type": "string",
8916
8916
  "default": ""
8917
8917
  },
@@ -8924,6 +8924,11 @@
8924
8924
  "description": "Programmatic open state. Set true to open at target center.",
8925
8925
  "type": "boolean",
8926
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": ""
8927
8932
  }
8928
8933
  },
8929
8934
  "required": [
@@ -8933,7 +8938,7 @@
8933
8938
  "x-adiaui": {
8934
8939
  "anti_patterns": [
8935
8940
  {
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>`.",
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>`.",
8937
8942
  "why": "No target binding — the menu never opens.",
8938
8943
  "wrong": "<context-menu-ui>...just items...</context-menu-ui>"
8939
8944
  }
@@ -16995,7 +17000,7 @@
16995
17000
  "default": false
16996
17001
  },
16997
17002
  "heading": {
16998
- "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.",
16999
17004
  "type": "string",
17000
17005
  "default": ""
17001
17006
  },
@@ -17076,7 +17081,7 @@
17076
17081
  ],
17077
17082
  "slots": {
17078
17083
  "default": {
17079
- "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)."
17080
17085
  }
17081
17086
  },
17082
17087
  "states": [
@@ -22445,6 +22450,11 @@
22445
22450
  "type": "string",
22446
22451
  "default": ""
22447
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
+ },
22448
22458
  "mark": {
22449
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.",
22450
22460
  "type": "boolean",
@@ -25854,6 +25864,21 @@
25854
25864
  },
25855
25865
  "sort-change": {
25856
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 }."
25857
25882
  }
25858
25883
  },
25859
25884
  "examples": [
@@ -232,7 +232,7 @@
232
232
  ## ContextMenu
233
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.
234
234
  - 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.
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).
236
236
 
237
237
  ## DateRangePicker
238
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).
@@ -480,7 +480,8 @@
480
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>.
481
481
 
482
482
  ## 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>.
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>.
484
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.
485
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.
486
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.
@@ -710,11 +711,11 @@
710
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.
711
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.
712
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.
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.
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.
714
715
 
715
716
  ## Table
716
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).
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.
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.
718
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.
719
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."
720
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.44",
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.44"
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.44",
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
  // ═══════════════════════════════════════════════════════════════