@adia-ai/web-components 0.8.43 → 0.8.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/components/avatar/avatar.class.js +12 -1
  3. package/components/button/button.d.ts +4 -0
  4. package/components/chart/chart.a2ui.json +26 -0
  5. package/components/chart/chart.class.js +1304 -222
  6. package/components/chart/chart.css +151 -1
  7. package/components/chart/chart.d.ts +6 -0
  8. package/components/chart/chart.yaml +57 -0
  9. package/components/chart-legend/chart-legend.a2ui.json +17 -2
  10. package/components/chart-legend/chart-legend.class.js +69 -4
  11. package/components/chart-legend/chart-legend.css +65 -1
  12. package/components/chart-legend/chart-legend.d.ts +4 -2
  13. package/components/chart-legend/chart-legend.examples.md +4 -4
  14. package/components/chart-legend/chart-legend.yaml +43 -3
  15. package/components/combobox/combobox.a2ui.json +3 -0
  16. package/components/combobox/combobox.class.js +9 -8
  17. package/components/combobox/combobox.css +25 -4
  18. package/components/combobox/combobox.yaml +6 -0
  19. package/components/command/command.css +17 -1
  20. package/components/context-menu/context-menu.a2ui.json +8 -3
  21. package/components/context-menu/context-menu.class.js +46 -5
  22. package/components/context-menu/context-menu.d.ts +8 -3
  23. package/components/context-menu/context-menu.examples.md +2 -2
  24. package/components/context-menu/context-menu.yaml +22 -5
  25. package/components/heatmap/heatmap.a2ui.json +5 -0
  26. package/components/heatmap/heatmap.d.ts +2 -0
  27. package/components/heatmap/heatmap.yaml +8 -0
  28. package/components/input/input.a2ui.json +2 -2
  29. package/components/input/input.class.js +12 -7
  30. package/components/input/input.yaml +6 -5
  31. package/components/integration-card/integration-card.d.ts +2 -0
  32. package/components/nav/nav.a2ui.json +2 -2
  33. package/components/nav/nav.css +1 -1
  34. package/components/nav/nav.d.ts +1 -1
  35. package/components/nav/nav.yaml +14 -3
  36. package/components/nav-group/nav-group.css +37 -3
  37. package/components/noodles/noodles.a2ui.json +0 -15
  38. package/components/noodles/noodles.yaml +0 -12
  39. package/components/number-format/number-format.class.js +10 -0
  40. package/components/pagination/pagination.class.js +140 -26
  41. package/components/popover/popover.d.ts +2 -0
  42. package/components/progress/progress.class.js +11 -6
  43. package/components/range/range.class.js +9 -1
  44. package/components/search/search.class.js +39 -5
  45. package/components/select/select.a2ui.json +10 -1
  46. package/components/select/select.class.js +34 -14
  47. package/components/select/select.css +39 -1
  48. package/components/select/select.d.ts +2 -0
  49. package/components/select/select.yaml +19 -1
  50. package/components/stream/stream.d.ts +2 -0
  51. package/components/table/cell-types.js +9 -0
  52. package/components/table/table.a2ui.json +6 -1
  53. package/components/table/table.class.js +265 -34
  54. package/components/table/table.css +38 -10
  55. package/components/table/table.d.ts +3 -1
  56. package/components/table/table.examples.md +2 -2
  57. package/components/table/table.yaml +15 -2
  58. package/components/table-toolbar/table-toolbar.a2ui.json +43 -4
  59. package/components/table-toolbar/table-toolbar.class.js +407 -28
  60. package/components/table-toolbar/table-toolbar.css +299 -0
  61. package/components/table-toolbar/table-toolbar.d.ts +15 -1
  62. package/components/table-toolbar/table-toolbar.yaml +162 -15
  63. package/components/tag/tag.class.js +13 -1
  64. package/components/text/text.a2ui.json +3 -3
  65. package/components/text/text.d.ts +5 -3
  66. package/components/text/text.yaml +3 -3
  67. package/components/textarea/textarea.class.js +9 -1
  68. package/components/tour/tour.d.ts +10 -0
  69. package/core/a11y.d.ts +22 -0
  70. package/core/a11y.js +64 -0
  71. package/core/anchor.js +9 -5
  72. package/core/data-stream.js +37 -2
  73. package/core/element.js +1 -1
  74. package/core/index.d.ts +1 -0
  75. package/core/index.js +1 -0
  76. package/core/provider.d.ts +9 -13
  77. package/core/provider.js +9 -113
  78. package/core/store.d.ts +46 -0
  79. package/core/store.js +89 -0
  80. package/custom-elements.json +127 -33
  81. package/dist/host.min.css +1 -1
  82. package/dist/host.sheet.js +1 -1
  83. package/dist/theme-provider.min.js +1 -1
  84. package/dist/web-components.min.css +1 -1
  85. package/dist/web-components.min.js +119 -119
  86. package/dist/web-components.sheet.js +1 -1
  87. package/package.json +1 -1
  88. package/patterns/access-requests/access-requests.examples.html +1 -1
  89. package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +7 -7
  90. package/patterns/agent-cost/agent-cost.examples.html +6 -6
  91. package/patterns/agent-memory/agent-memory.examples.html +3 -3
  92. package/patterns/agent-prompt-library/agent-prompt-library.examples.html +8 -8
  93. package/patterns/agent-tool-call/agent-tool-call.examples.html +1 -1
  94. package/patterns/approvals/approvals.examples.html +6 -6
  95. package/patterns/audit-log/audit-log.examples.html +1 -1
  96. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +4 -4
  97. package/patterns/chart-in-card/chart-in-card.examples.html +114 -0
  98. package/patterns/chart-in-card/chart-in-card.examples.js +37 -0
  99. package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +5 -5
  100. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +2 -2
  101. package/patterns/diff-review/diff-review.examples.html +6 -6
  102. package/patterns/export-flow/export-flow.examples.html +2 -2
  103. package/patterns/filter-bar/filter-bar.examples.html +2 -2
  104. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +2 -2
  105. package/patterns/inline-dialog/inline-dialog.examples.html +2 -2
  106. package/patterns/marketing-engagement/marketing-engagement.examples.html +4 -4
  107. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +1 -1
  108. package/patterns/permissions-matrix/permissions-matrix.examples.html +1 -1
  109. package/patterns/permissions-sharing/permissions-sharing.examples.html +2 -2
  110. package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +4 -4
  111. package/patterns/search-discovery/search-discovery.examples.html +2 -2
  112. package/styles/api/sizing.css +46 -0
  113. package/styles/type/roles.css +29 -9
@@ -96,10 +96,10 @@
96
96
  "deck": "Sub-title under a `title`. One-line lead, slightly larger than body. Use for the lead sentence after a title.",
97
97
  "display": "Top-level hero / brand display. Tallest visual rank. Use for page-level hero one-liners.",
98
98
  "heading": "Major page heading (visual rank H2). 16-18px / bold. Use for major sub-section dividers.",
99
- "kicker": "Eyebrow text above a `title`. UPPERCASE + small + tracking. Use for content eyebrows (NOT form labels — use `label` for those).",
100
- "label": "Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized + medium-weight. Use for field labels bound to form controls.",
99
+ "kicker": "Eyebrow text above a `title`. UPPERCASE + small + tracking, mono font stack at regular (400) weight. Use for content eyebrows (NOT form labels — use `label` for those).",
100
+ "label": "Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized, mono font stack at regular (400) weight. Use for field labels bound to form controls.",
101
101
  "metric": "Numeric KPI / big-number stat. Bold + large. Use for dashboard metric numbers.",
102
- "section": "Inline form-group / navlist heading (visual rank H4). Small-cap. Use for form group labels, nav list headings.",
102
+ "section": "Inline form-group / navlist heading (visual rank H4). Mono font stack at medium (500) weight, normal casing. Use for form group labels, nav list headings.",
103
103
  "subsection": "Sub-landmark within a section (visual rank H3). 14px / semibold. Use for card titles within a section."
104
104
  }
105
105
  },
@@ -25,13 +25,13 @@ export type UITextVariant =
25
25
  | 'display'
26
26
  /** Annotation under a primary line — smaller + muted. Use for image captions, footnotes. */
27
27
  | 'caption'
28
- /** Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized + medium-weight. Use for field labels bound to form controls. */
28
+ /** Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized, mono font stack at regular (400) weight. Use for field labels bound to form controls. */
29
29
  | 'label'
30
- /** Eyebrow text above a `title`. UPPERCASE + small + tracking. Use for content eyebrows (NOT form labels — use `label` for those). */
30
+ /** Eyebrow text above a `title`. UPPERCASE + small + tracking, mono font stack at regular (400) weight. Use for content eyebrows (NOT form labels — use `label` for those). */
31
31
  | 'kicker'
32
32
  /** Sub-title under a `title`. One-line lead, slightly larger than body. Use for the lead sentence after a title. */
33
33
  | 'deck'
34
- /** Inline form-group / navlist heading (visual rank H4). Small-cap. Use for form group labels, nav list headings. */
34
+ /** Inline form-group / navlist heading (visual rank H4). Mono font stack at medium (500) weight, normal casing. Use for form group labels, nav list headings. */
35
35
  | 'section'
36
36
  /** Numeric KPI / big-number stat. Bold + large. Use for dashboard metric numbers. */
37
37
  | 'metric'
@@ -47,6 +47,8 @@ export class UIText extends UIElement {
47
47
  size: 'sm' | 'md' | 'lg';
48
48
  /** When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy. */
49
49
  strong: boolean;
50
+ /** Override text alignment. Note: text-ui defaults to display:inline, so this only takes effect when text-ui is block-like (wrapping or parent display:block/grid). Added v0.6.18 (FB-10). Converged onto the global Enum·semantic `[text-align]` physical vocabulary (gh#1335) — `start`/`end` renamed to `left`/`right`; `justify` kept (a genuine physical alignment value, now also supported by the global grammar). */
51
+ textAlign: 'left' | 'center' | 'right' | 'justify';
50
52
  /** Display text content. The main payload field for Text components extracted from HTML. */
51
53
  textContent: string;
52
54
  /** Single-line truncation with ellipsis. Ignored when `lines` is set. */
@@ -103,10 +103,10 @@ props:
103
103
  title: Page title (visual rank H1). Largest under display. Use at the top of an authoritative page or dialog.
104
104
  heading: Major page heading (visual rank H2). 16-18px / bold. Use for major sub-section dividers.
105
105
  subsection: Sub-landmark within a section (visual rank H3). 14px / semibold. Use for card titles within a section.
106
- section: Inline form-group / navlist heading (visual rank H4). Small-cap. Use for form group labels, nav list headings.
106
+ section: Inline form-group / navlist heading (visual rank H4). Mono font stack at medium (500) weight, normal casing. Use for form group labels, nav list headings.
107
107
  caption: Annotation under a primary line — smaller + muted. Use for image captions, footnotes.
108
- label: Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized + medium-weight. Use for field labels bound to form controls.
109
- kicker: Eyebrow text above a `title`. UPPERCASE + small + tracking. Use for content eyebrows (NOT form labels — use `label` for those).
108
+ label: Form-control label (above an `<input-ui>` / `<select-ui>` etc). UI-sized, mono font stack at regular (400) weight. Use for field labels bound to form controls.
109
+ kicker: Eyebrow text above a `title`. UPPERCASE + small + tracking, mono font stack at regular (400) weight. Use for content eyebrows (NOT form labels — use `label` for those).
110
110
  deck: Sub-title under a `title`. One-line lead, slightly larger than body. Use for the lead sentence after a title.
111
111
  metric: Numeric KPI / big-number stat. Bold + large. Use for dashboard metric numbers.
112
112
  code: Inline monospace code reference. Use for inline code within prose.
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { UIFormElement } from '../../core/form.js';
21
+ import { syncAutoAriaLabel } from '../../core/a11y.js';
21
22
 
22
23
  export class UITextarea extends UIFormElement {
23
24
  static labelDeprecated = false; // §170 (v0.5.4): label is first-class per textarea.yaml
@@ -32,6 +33,10 @@ export class UITextarea extends UIFormElement {
32
33
  static template = () => null;
33
34
 
34
35
  #textEl = null;
36
+ // gh#1647: tracks the aria-label VALUE this element itself last derived
37
+ // from `label`/`placeholder` (undefined when none) — value-tracking
38
+ // guard ported from tag.class.js/select.class.js (gh#1644/#1646).
39
+ #lastAutoAriaLabel = undefined;
35
40
 
36
41
  connected() {
37
42
  super.connected();
@@ -82,7 +87,10 @@ export class UITextarea extends UIFormElement {
82
87
  const label = this.querySelector('[slot="label"]');
83
88
  if (label && this.label) label.setAttribute('label', this.label);
84
89
 
85
- this.setAttribute('aria-label', this.label || this.placeholder || '');
90
+ // A11y (gh#1647): never clobber a consumer's own aria-label
91
+ // (value-tracking guard, gh#1644/#1646).
92
+ const ariaSource = this.label || this.placeholder || '';
93
+ this.#lastAutoAriaLabel = syncAutoAriaLabel(this, this.#lastAutoAriaLabel, ariaSource);
86
94
  }
87
95
 
88
96
  #onInput = () => {
@@ -43,10 +43,20 @@ export class UITour extends UIElement {
43
43
  the spotlight + popover; setting to `false` tears down.
44
44
  */
45
45
  active: boolean;
46
+ /** Start the tour automatically when the element connects. Useful for
47
+ first-run flows gated by a storage flag on the consumer side.
48
+ */
49
+ autoStart: boolean;
46
50
  /** Current step index (0-based). Setting this property advances the
47
51
  tour to that step (updating spotlight + popover position).
48
52
  */
49
53
  step: number;
54
+ /** Optional localStorage key. When set, the tour records its
55
+ completion state (`"done"`) to that key on finish/skip — and
56
+ refuses to auto-start on subsequent loads if the key is "done".
57
+ Useful for "show this tour once per user" flows.
58
+ */
59
+ storageKey: string;
50
60
 
51
61
  addEventListener(type: 'tour-finish', listener: (ev: TourFinishEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
52
62
  addEventListener(type: 'tour-skip', listener: (ev: TourSkipEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
package/core/a11y.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * core/a11y.js — shared accessible-name authorship guard.
3
+ *
4
+ * See the runtime module's own JSDoc for the full contract and the usage
5
+ * pattern (gh#1644 / gh#1646 / gh#1647).
6
+ */
7
+
8
+ /**
9
+ * Reconcile an auto-derived `aria-label` against a possible consumer
10
+ * override, using a value-tracking guard rather than a plain boolean flag.
11
+ *
12
+ * @param el - the host element.
13
+ * @param lastAuto - the value this component wrote last time (`undefined`
14
+ * = no active auto-derived value, either never set or consumer-owned).
15
+ * @param source - the label this render would derive; falsy = no auto name.
16
+ * @returns the new `lastAuto` to store back in the caller's own field.
17
+ */
18
+ export function syncAutoAriaLabel(
19
+ el: HTMLElement,
20
+ lastAuto: string | undefined,
21
+ source: string,
22
+ ): string | undefined;
package/core/a11y.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * core/a11y.js — shared accessible-name authorship guard.
3
+ *
4
+ * Ports the value-tracking guard proven on `tag.class.js`'s
5
+ * `#syncAccessibleName` (gh#1644 / PR #1645) and `select.class.js`'s
6
+ * `labeledElsewhere` variant (gh#1646 / PR #1648) into one shared helper,
7
+ * so every primitive that derives its own `aria-label` doesn't need to
8
+ * reimplement the compare-against-what-we-last-wrote logic (gh#1647).
9
+ *
10
+ * A plain boolean "did we ever set it" flag isn't enough: a consumer can
11
+ * overwrite `aria-label` directly via `setAttribute` at any point BETWEEN
12
+ * renders, bypassing the flag entirely. This guard instead compares the
13
+ * CURRENT attribute value against the value THIS component last wrote —
14
+ * a mismatch means a consumer has taken ownership, and auto-derivation
15
+ * stops until the source value the caller passes in changes again.
16
+ *
17
+ * Per-component gating (e.g. select-ui's `labeledElsewhere` — don't
18
+ * auto-name when a visible `label` or `aria-labelledby` already names the
19
+ * element, or don't auto-name off a default/placeholder-only value) is
20
+ * the CALLER's job: resolve the source string to '' when no auto name
21
+ * should apply this render, and pass the real derived string otherwise.
22
+ * This helper only ever reconciles "what we'd write" against "what's
23
+ * there now, and did we write it" — it carries no per-component policy.
24
+ *
25
+ * Usage (per component):
26
+ *
27
+ * import { syncAutoAriaLabel } from '../../core/a11y.js';
28
+ *
29
+ * #lastAutoAriaLabel = undefined;
30
+ *
31
+ * #syncAccessibleName() {
32
+ * const source = <derive the label this render, or '' for none>;
33
+ * this.#lastAutoAriaLabel = syncAutoAriaLabel(this, this.#lastAutoAriaLabel, source);
34
+ * }
35
+ *
36
+ * Call `#syncAccessibleName()` early in `render()`, before any other
37
+ * aria-* bookkeeping that might read the (possibly just-changed)
38
+ * `aria-label` attribute.
39
+ *
40
+ * @param {HTMLElement} el
41
+ * @param {string | undefined} lastAuto - the value this component wrote
42
+ * last time (`undefined` = the component has never derived a name for
43
+ * the CURRENT attribute value, either because it never has, or because
44
+ * a consumer has since taken ownership).
45
+ * @param {string} source - the label the component would derive right
46
+ * now. Empty string (or any other falsy value) means "no auto name" —
47
+ * any auto-set label is removed.
48
+ * @returns {string | undefined} the new `lastAuto` value — store this
49
+ * back into the caller's own private field.
50
+ */
51
+ export function syncAutoAriaLabel(el, lastAuto, source) {
52
+ const current = el.getAttribute('aria-label');
53
+ if (lastAuto !== undefined && current !== lastAuto) {
54
+ lastAuto = undefined; // a consumer took ownership between renders
55
+ }
56
+ const authored = current !== null && lastAuto === undefined;
57
+ if (authored) return lastAuto;
58
+ if (source) {
59
+ if (current !== source) el.setAttribute('aria-label', source);
60
+ return source;
61
+ }
62
+ if (current !== null) el.removeAttribute('aria-label');
63
+ return undefined;
64
+ }
package/core/anchor.js CHANGED
@@ -127,11 +127,15 @@ function anchorNative(anchor, popover, { placement, gap, matchWidth }) {
127
127
  };
128
128
  }
129
129
 
130
- // Resolve a --popover-max-height declaration to px. The convention's consumers
131
- // declare px (JS-measured caps) or rem (stylesheet defaults); anything else
132
- // (calc, var chains, empty) returns NaN and the caller falls back to content
133
- // height. Kept deliberately narrow this feeds a fit DECISION, not layout.
134
- function resolveLengthPx(raw) {
130
+ // Resolve a px/rem CSS custom-property declaration to px. Originally scoped
131
+ // to --popover-max-height (consumers declare px, JS-measured caps, or rem,
132
+ // stylesheet defaults); exported (ADR-0076, table-toolbar-ui's stage-
133
+ // resolution breakpoints, PLAN step 7) since a second, unrelated consumer
134
+ // needs the identical px/rem-only resolution — anything else (calc, var
135
+ // chains, empty) returns NaN and the caller falls back to its own default.
136
+ // Kept deliberately narrow — this feeds a classification DECISION, not
137
+ // layout, in both consumers.
138
+ export function resolveLengthPx(raw) {
135
139
  const v = String(raw || '').trim();
136
140
  if (!v) return NaN;
137
141
  const m = /^(-?\d+(?:\.\d+)?)(px|rem)$/.exec(v);
@@ -37,6 +37,14 @@
37
37
  * stream-load — first signal value received for this element
38
38
  * stream-update — each subsequent value, detail.data = the new value
39
39
  * stream-error — transport-level error, detail.error = message
40
+ *
41
+ * Opting out (gh#1760):
42
+ * `data-stream-managed="false"` tells the document-level observer to
43
+ * never claim this element, even though it carries `data-stream-src`.
44
+ * For elements that own their own imperative fetch (e.g. the billing
45
+ * composites' `refresh()`) and must not also be claimed by this module —
46
+ * never mix an imperative fetch with an unmanaged `data-stream-src` on
47
+ * the same element (data-stream-protocol.md §10.1).
40
48
  */
41
49
 
42
50
  import { signal, effect, untracked } from './signals.js';
@@ -54,6 +62,11 @@ const ATTRS = {
54
62
  merge: 'data-stream-merge',
55
63
  format: 'data-stream-format',
56
64
  id: 'data-stream-id',
65
+ // gh#1760 — opt-out for elements that carry `data-stream-src` but manage
66
+ // their own fetch (e.g. the billing composites' hand-rolled `refresh()`).
67
+ // `"false"` tells the document-level observer to never claim this element,
68
+ // so exactly one owner (the element's own imperative fetch) ever runs.
69
+ managed: 'data-stream-managed',
57
70
  };
58
71
 
59
72
  const STREAMS = new Map(); /* streamId → { signal, refs, transport, opts } */
@@ -386,6 +399,7 @@ function applyData(el, raw, opts) {
386
399
  export function start(el) {
387
400
  stop(el);
388
401
  if (!el.isConnected) return;
402
+ if (attr(el, 'managed') === 'false') return; // gh#1760 — self-managed opt-out
389
403
  const src = attr(el, 'src');
390
404
  if (!src) return;
391
405
 
@@ -442,7 +456,8 @@ export function stop(el) {
442
456
  const ATTR_FILTER = Object.values(ATTRS);
443
457
 
444
458
  function isStreamingEl(node) {
445
- return node && node.nodeType === 1 && node.hasAttribute && node.hasAttribute(ATTRS.src);
459
+ return !!(node && node.nodeType === 1 && node.hasAttribute && node.hasAttribute(ATTRS.src)
460
+ && node.getAttribute(ATTRS.managed) !== 'false');
446
461
  }
447
462
 
448
463
  function visitSubtree(root, fn) {
@@ -476,11 +491,31 @@ const observer = typeof MutationObserver !== 'undefined'
476
491
 
477
492
  function bootstrap() {
478
493
  if (typeof document === 'undefined') return;
494
+ // Register the observer synchronously — no live mutation is missed.
479
495
  observer?.observe(document.documentElement, {
480
496
  childList: true, subtree: true,
481
497
  attributes: true, attributeFilter: ATTR_FILTER,
482
498
  });
483
- document.querySelectorAll(`[${ATTRS.src}]`).forEach(start);
499
+ // gh#1760 — defer the INITIAL sweep of already-present markup by one
500
+ // microtask. Without this, a page whose entry module imports the core
501
+ // barrel (this module) BEFORE a self-managed composite's own module
502
+ // (which sets `data-stream-managed="false"` synchronously from its
503
+ // upgrade-triggered `connectedCallback`) races: this sweep would claim
504
+ // pre-existing SSR markup before the composite ever gets a chance to
505
+ // opt out. Deferring to a microtask lets the rest of the SAME
506
+ // synchronous module-evaluation phase — including every sibling
507
+ // module's own top-level side effects — finish first, so by the time
508
+ // this sweep runs, every already-loaded composite has already set its
509
+ // marker. (A composite loaded asynchronously — a dynamic `import()` —
510
+ // after this microtask has already fired is not covered by this
511
+ // deferral alone; its own `connected()` marker-set plus the observer's
512
+ // live attribute-mutation handling still tears down a wrongly-started
513
+ // stream, bounding the damage to at most one extra fetch.)
514
+ queueMicrotask(() => {
515
+ document.querySelectorAll(`[${ATTRS.src}]`).forEach((el) => {
516
+ if (isStreamingEl(el)) start(el);
517
+ });
518
+ });
484
519
  }
485
520
 
486
521
  if (typeof document !== 'undefined') {
package/core/element.js CHANGED
@@ -91,7 +91,7 @@ function reflect(el, a, v, t) {
91
91
  }
92
92
 
93
93
  const parseAttr = (v, t) =>
94
- t === Boolean ? v !== null : t === Number ? (v === null ? null : +v) : v;
94
+ t === Boolean ? v !== null && v !== 'false' : t === Number ? (v === null ? null : +v) : v;
95
95
 
96
96
  function adoptStyles(ctor) {
97
97
  if (Object.hasOwn(ctor, '_sa')) return;
package/core/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './template.js';
9
9
  export * from './element.js';
10
10
  export * from './form.js';
11
11
  export * from './register.js';
12
+ export * from './store.js';
12
13
  // Mirror the explicit re-export in core/index.js — `streams` + `whenStream`
13
14
  // (the public surface of the data-stream attribute-driven ingestion module).
14
15
  // `export *` from data-stream.js would also expose internals; keep the
package/core/index.js CHANGED
@@ -18,6 +18,7 @@ export * from './signals.js';
18
18
  export * from './template.js';
19
19
  export * from './register.js';
20
20
  export * from './controller.js';
21
+ export * from './store.js';
21
22
  export * from './provider.js';
22
23
  export * from './anchor.js';
23
24
  export * from './icons.js';
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `<router-ui>` provider — declarative + imperative client-side
3
- * routing built on {@link RouteController} (re-declared here for
4
- * historical reasons; the canonical export lives in `core/controller.js`).
3
+ * routing built on {@link RouteController} (re-exported here for
4
+ * back-compat gh#1766 — the canonical implementation lives in
5
+ * `core/controller.js`).
5
6
  *
6
7
  * Two consumer paths:
7
8
  *
@@ -25,22 +26,17 @@
25
26
  */
26
27
 
27
28
  import { UIElement } from './element.js';
28
- import type { Route, RouteCommands, RouteState, ControllerSchema, RouteControllerOptions } from './controller.js';
29
- import { BaseController } from './controller.js';
29
+ import type { Route } from './controller.js';
30
+ import { RouteController } from './controller.js';
30
31
 
31
32
  /**
32
- * Routing-state controller. Re-declared from `core/controller.js` for
33
- * historical compatibilityboth files define the same class shape;
34
- * consumers can import either. New code should prefer
33
+ * Routing-state controller, re-exported from `core/controller.js`
34
+ * (gh#1766) for back-compat consumers can import either path.
35
+ * New code should prefer
35
36
  * `import { RouteController } from '@adia-ai/web-components/core/controller'`
36
37
  * for the canonical path.
37
38
  */
38
- export class RouteController extends BaseController {
39
- static schema: ControllerSchema;
40
- constructor(options?: RouteControllerOptions);
41
- getState(): RouteState;
42
- commands: RouteCommands;
43
- }
39
+ export { RouteController };
44
40
 
45
41
  /**
46
42
  * Optional async transform applied to fetched route content before
package/core/provider.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * AdiaUI Router — Self-contained vanilla JS bundle.
3
- * Includes: RouteController + router-ui component.
3
+ * Includes: router-ui component; re-exports `RouteController` for
4
+ * back-compat (gh#1766 — the canonical definition lives in
5
+ * `core/controller.js`, alongside its `BaseController` superclass).
4
6
  *
5
- * Depends on: core.js (UIElement), controllers.js (BaseController)
7
+ * Depends on: element.js (UIElement), controller.js (RouteController)
6
8
  *
7
9
  * Usage:
8
10
  * import { UIElement } from './core.js';
@@ -24,7 +26,7 @@
24
26
 
25
27
  import { UIElement } from './element.js';
26
28
  import { defineIfFree } from './register.js';
27
- import { BaseController } from './controller.js';
29
+ import { RouteController } from './controller.js';
28
30
  import { viewTransition } from '../traits/view-transition/view-transition.js';
29
31
 
30
32
  // ═══════════════════════════════════════════════════════════════
@@ -46,118 +48,12 @@ class UIProvider extends UIElement {
46
48
  }
47
49
 
48
50
  // ═══════════════════════════════════════════════════════════════
49
- // ROUTE CONTROLLER
51
+ // ROUTE CONTROLLER — re-exported for back-compat (gh#1766); the
52
+ // canonical definition lives in `core/controller.js`, which is
53
+ // where `BaseController` (its own superclass) already lives.
50
54
  // ═══════════════════════════════════════════════════════════════
51
55
 
52
- export class RouteController extends BaseController {
53
- static schema = {
54
- name: 'route',
55
- state: { path: 'string', params: 'object', route: 'object', previous: 'string' },
56
- commands: ['navigate', 'replace', 'back', 'forward', 'setRoutes'],
57
- attributes: ['data-route-path'],
58
- };
59
-
60
- #routes = [];
61
- #path = '';
62
- #previous = '';
63
- #params = {};
64
- #route = null;
65
- #historySync = true;
66
- #boundPopState = null;
67
-
68
- constructor({ routes = [], initial, historySync = true } = {}) {
69
- super();
70
- this.#routes = routes;
71
- this.#historySync = historySync;
72
- this.#path = initial ?? location.pathname;
73
- this.#match();
74
- }
75
-
76
- getState() {
77
- return {
78
- path: this.#path,
79
- params: { ...this.#params },
80
- route: this.#route,
81
- previous: this.#previous,
82
- };
83
- }
84
-
85
- reflect() {
86
- const host = this.host;
87
- if (!host) return;
88
- host.setAttribute('data-route-path', this.#path);
89
- }
90
-
91
- onConnect(host) {
92
- if (this.#historySync) {
93
- this.#boundPopState = () => {
94
- this.#previous = this.#path;
95
- this.#path = location.pathname;
96
- this.#match();
97
- this.notify();
98
- };
99
- window.addEventListener('popstate', this.#boundPopState);
100
- }
101
- }
102
-
103
- onDisconnect() {
104
- if (this.#boundPopState) {
105
- window.removeEventListener('popstate', this.#boundPopState);
106
- this.#boundPopState = null;
107
- }
108
- }
109
-
110
- #match() {
111
- this.#params = {};
112
- this.#route = null;
113
- for (const route of this.#routes) {
114
- const match = this.#matchPath(route.path, this.#path);
115
- if (match) {
116
- this.#params = match.params;
117
- this.#route = route;
118
- return;
119
- }
120
- }
121
- }
122
-
123
- #matchPath(pattern, path) {
124
- const patternParts = pattern.split('/').filter(Boolean);
125
- const pathParts = path.split('/').filter(Boolean);
126
- if (!pattern.includes(':')) return pattern === path ? { params: {} } : null;
127
- if (patternParts.length !== pathParts.length) return null;
128
- const params = {};
129
- for (let i = 0; i < patternParts.length; i++) {
130
- if (patternParts[i].startsWith(':')) params[patternParts[i].slice(1)] = pathParts[i];
131
- else if (patternParts[i] !== pathParts[i]) return null;
132
- }
133
- return { params };
134
- }
135
-
136
- commands = {
137
- navigate: (path) => {
138
- if (path === this.#path) return;
139
- this.#previous = this.#path;
140
- this.#path = path;
141
- this.#match();
142
- if (this.#historySync) history.pushState(null, '', path);
143
- this.notify();
144
- },
145
- replace: (path) => {
146
- this.#previous = this.#path;
147
- this.#path = path;
148
- this.#match();
149
- if (this.#historySync) history.replaceState(null, '', path);
150
- this.notify();
151
- },
152
- back: () => { if (this.#historySync) history.back(); },
153
- forward: () => { if (this.#historySync) history.forward(); },
154
- setRoutes: (routes) => {
155
- this.#routes = routes;
156
- this.#match();
157
- this.notify();
158
- },
159
- };
160
- }
56
+ export { RouteController };
161
57
 
162
58
  // ═══════════════════════════════════════════════════════════════
163
59
  // ROUTER-UI — content fragment renderer
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared app store — the blessed R2 primitive. Signal-backed, with a
3
+ * Set-of-listeners-compatible `subscribe()` channel for imperative
4
+ * (non-`effect()`) consumers.
5
+ *
6
+ * @see ./store.js (runtime SoT)
7
+ * @see ../USAGE.md
8
+ */
9
+
10
+ /**
11
+ * A `signal()`-backed store. `.value` composes with `computed()`/`effect()`
12
+ * exactly like a plain signal; `subscribe()` is the extra imperative
13
+ * channel the seven hand-rolled app-layer stores already share (see
14
+ * `store.js`'s header for the full rationale and the rejected
15
+ * BaseController alternative).
16
+ */
17
+ export interface Store<T> {
18
+ /** Tracked read/write, same semantics as `Signal<T>.value`. */
19
+ value: T;
20
+ /** Read without subscribing the surrounding `effect()`. */
21
+ peek(): T;
22
+ /**
23
+ * Subscribe to changes. `cb` receives the new value on every write that
24
+ * doesn't fail the `Object.is` no-notify guard. Returns an unsubscribe
25
+ * function — safe to call more than once. Compatible with
26
+ * `UIElement`'s `controller` setter (`element.controller = store`).
27
+ */
28
+ subscribe(cb: (value: T) => void): () => void;
29
+ }
30
+
31
+ /**
32
+ * Create a shared app store.
33
+ *
34
+ * @example
35
+ * const count = createStore(0);
36
+ * const stop = count.subscribe((v) => console.log('count is', v));
37
+ * count.value = 1; // logs "count is 1"
38
+ * count.value = 1; // no-op — Object.is guard, no log
39
+ * stop();
40
+ *
41
+ * @example Controller-setter interop
42
+ * class MyEl extends UIElement {}
43
+ * const el = new MyEl();
44
+ * el.controller = createStore({ ready: false }); // re-renders on change
45
+ */
46
+ export function createStore<T>(initial: T): Store<T>;
package/core/store.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * AdiaUI shared app store — the blessed R2 primitive.
3
+ *
4
+ * A signal-backed replacement for the Set-of-listeners pub/sub pattern
5
+ * hand-rolled across the app layer: plan-store, data-client, patient-visit
6
+ * record, persona store, task-service, a2ui BaseController, a2ui Surface
7
+ * watchers (reactivity review, .claude/docs/reports/2026-08-20-reactivity-
8
+ * review/03-app-layer-stores.md §4). Doctrine's pattern menu row 1 says
9
+ * reactive state -> signals; `createStore` is `signal()` plus the one thing
10
+ * those seven stores actually needed on top of it: an imperative
11
+ * `subscribe(cb) -> unsubscribe` channel for consumers that are closures or
12
+ * classes rather than `effect()` bodies.
13
+ *
14
+ * `.value` reads/writes pass straight through to a real `signal()`, so a
15
+ * store composes with `computed()`/`effect()` exactly like any other signal
16
+ * (tracked reads, `Object.is` no-notify, microtask-batched effect re-runs).
17
+ * `subscribe()` is the separate, synchronous, imperative channel the seven
18
+ * hand-rolled stores already share — same shape, so migrating one onto this
19
+ * primitive is a mechanical swap (opportunistic, later, per store; this
20
+ * primitive lands with NO migration).
21
+ *
22
+ * Interops with `UIElement`'s `controller` setter (`core/element.js`)
23
+ * out of the box: `element.controller = store` wires `store.subscribe(fn)`
24
+ * into the element's own render-trigger machinery — the setter calls
25
+ * `c.connect?.(this)` / `c.subscribe?.(...)` / `c.disconnect?.(this)`, all
26
+ * optional-chained, and `subscribe()` alone satisfies that contract.
27
+ *
28
+ * Rejected alternative: promoting/absorbing `BaseController`
29
+ * (`core/controller.js`) into this role, per the reactivity review's F5.
30
+ * BaseController's `connect()` bakes in schema/`getState()` validation
31
+ * (dev-warns without a static `schema`, dev-errors without an overridden
32
+ * `getState()`) that exists to serve DOM-reflecting UI controllers
33
+ * (`RouteController`'s attributes/commands surface) — semantics a plain
34
+ * data store has no use for and would either have to satisfy with a dummy
35
+ * schema or suppress. The one thing BaseController actually offers this
36
+ * role — the `subscribe(fn) -> unsubscribe` shape the controller setter
37
+ * expects — costs nothing to reimplement directly on `signal()`, and doing
38
+ * so keeps the store from inheriting a validation path built for a
39
+ * different kind of thing. BaseController stays as-is; still one consumer
40
+ * (`RouteController` / `<router-ui>`), still F5's to resolve separately.
41
+ *
42
+ * @see ../USAGE.md
43
+ * @see ./element.js (the `controller` setter this interops with)
44
+ * @see ./controller.js (BaseController — the rejected alternative above)
45
+ */
46
+
47
+ import { signal } from './signals.js';
48
+
49
+ export function createStore(initial) {
50
+ const s = signal(initial);
51
+ const listeners = new Set();
52
+
53
+ return {
54
+ get value() {
55
+ return s.value;
56
+ },
57
+ set value(next) {
58
+ // Object.is no-notify guard — matches persona-store's proven
59
+ // behavior (packages/llm/persona/src/store.ts) and the underlying
60
+ // signal's own equality cutoff. Checked here, not inferred from
61
+ // signal()'s silent no-op, so an identical write never reaches
62
+ // `listeners` either.
63
+ const changed = !Object.is(s.peek(), next);
64
+ s.value = next;
65
+ if (changed) {
66
+ // Pass the captured `next`, never re-read `s.peek()` here — a
67
+ // listener that re-entrantly writes `store.value` during this
68
+ // loop would otherwise make every later listener observe THAT
69
+ // write's value instead of this one's, silently skipping the
70
+ // value this write actually delivered.
71
+ for (const cb of listeners) cb(next);
72
+ }
73
+ },
74
+ /** Read without subscribing the surrounding `effect()`. */
75
+ peek() {
76
+ return s.peek();
77
+ },
78
+ /**
79
+ * Set-of-listeners-compatible subscribe. `cb` receives the new value;
80
+ * callers that only care that *something* changed (the
81
+ * `UIElement.controller` setter among them) simply ignore the
82
+ * argument. Returns an unsubscribe function.
83
+ */
84
+ subscribe(cb) {
85
+ listeners.add(cb);
86
+ return () => listeners.delete(cb);
87
+ },
88
+ };
89
+ }