@adia-ai/web-components 0.8.47 → 0.8.50

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 (68) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/MIGRATION.md +136 -0
  3. package/components/card/card.css +25 -2
  4. package/components/chart/chart.a2ui.json +1 -1
  5. package/components/chart/chart.class.js +41 -10
  6. package/components/chart/chart.d.ts +1 -1
  7. package/components/chart/chart.yaml +6 -3
  8. package/components/input/input.a2ui.json +2 -2
  9. package/components/input/input.css +45 -16
  10. package/components/input/input.yaml +11 -9
  11. package/components/link/link.css +3 -2
  12. package/components/richtext/richtext.css +2 -2
  13. package/components/search/search.a2ui.json +1 -1
  14. package/components/search/search.class.js +43 -0
  15. package/components/search/search.css +28 -0
  16. package/components/search/search.yaml +4 -1
  17. package/components/select/select.class.js +4 -1
  18. package/components/select/select.css +13 -0
  19. package/components/stat/stat.a2ui.json +30 -3
  20. package/components/stat/stat.css +88 -0
  21. package/components/stat/stat.d.ts +2 -2
  22. package/components/stat/stat.yaml +99 -6
  23. package/components/table/table.a2ui.json +5 -2
  24. package/components/table/table.class.js +36 -1
  25. package/components/table/table.d.ts +2 -2
  26. package/components/table/table.yaml +17 -1
  27. package/components/table-footer/table-footer.a2ui.json +20 -2
  28. package/components/table-footer/table-footer.class.js +94 -8
  29. package/components/table-footer/table-footer.d.ts +6 -2
  30. package/components/table-footer/table-footer.yaml +57 -6
  31. package/components/table-toolbar/table-toolbar.a2ui.json +28 -2
  32. package/components/table-toolbar/table-toolbar.class.js +69 -2
  33. package/components/table-toolbar/table-toolbar.css +36 -2
  34. package/components/table-toolbar/table-toolbar.d.ts +8 -2
  35. package/components/table-toolbar/table-toolbar.examples.md +6 -4
  36. package/components/table-toolbar/table-toolbar.yaml +140 -26
  37. package/components/theme-provider/theme-provider.a2ui.json +3 -5
  38. package/components/theme-provider/theme-provider.class.js +9 -30
  39. package/components/theme-provider/theme-provider.d.ts +3 -5
  40. package/components/theme-provider/theme-provider.yaml +4 -8
  41. package/core/element.js +16 -3
  42. package/custom-elements.json +52 -10
  43. package/dist/host.min.css +1 -1
  44. package/dist/host.sheet.js +1 -1
  45. package/dist/theme-provider.min.js +8 -8
  46. package/dist/themes.min.css +1 -1
  47. package/dist/themes.sheet.js +1 -1
  48. package/dist/web-components.min.css +1 -1
  49. package/dist/web-components.min.js +70 -69
  50. package/dist/web-components.sheet.js +1 -1
  51. package/index.css +1 -2
  52. package/package.json +1 -1
  53. package/styles/README.md +1 -1
  54. package/styles/api/sizing.css +1 -1
  55. package/styles/colors/material-static.css +34 -12
  56. package/styles/host.css +1 -4
  57. package/styles/index.css +2 -2
  58. package/styles/scale.css +19 -26
  59. package/styles/themes.css +2678 -2
  60. package/styles/type/elements.css +1 -0
  61. package/styles/type/roles.css +3 -3
  62. package/styles/typography.css +6 -5
  63. package/dist/prose.min.css +0 -1
  64. package/dist/prose.sheet.js +0 -11
  65. package/dist/verse.min.css +0 -1
  66. package/dist/verse.sheet.js +0 -11
  67. package/styles/prose.css +0 -211
  68. package/styles/verse.css +0 -151
@@ -55,6 +55,21 @@ export class UISearch extends UIFormElement {
55
55
 
56
56
  static template = () => null;
57
57
 
58
+ // ADR-0077 / spec-form-control-sizing-posture — `[inline]` is a CSS-only
59
+ // universal layout attribute (ADR-0037 §2: no yaml, no static properties
60
+ // entry on THIS host). But search-ui composes input-ui via light-DOM
61
+ // child stamping (connected()'s innerHTML below), so a plain CSS
62
+ // `:scope[inline]` on input-ui's own @scope can't reach across that
63
+ // composition boundary — the host's `[inline]` has to be forwarded onto
64
+ // the internal <input-ui> in JS. Hand-rolled `observedAttributes` +
65
+ // `attributeChangedCallback`, same shape as tags-input.class.js's `value`
66
+ // and drilldown.class.js's `path` — a non-declared-property attribute
67
+ // that still needs a JS reaction.
68
+ static get observedAttributes() {
69
+ const base = super.observedAttributes;
70
+ return base.includes('inline') ? base : [...base, 'inline'];
71
+ }
72
+
58
73
  #inputEl = null;
59
74
  #suffixEl = null;
60
75
  #timer = null;
@@ -77,6 +92,7 @@ export class UISearch extends UIFormElement {
77
92
  suffix="x-circle"
78
93
  placeholder="${this.placeholder}"
79
94
  ${this.disabled ? 'disabled' : ''}
95
+ ${this.hasAttribute('inline') ? 'inline' : ''}
80
96
  ${size ? `size="${size}"` : ''}
81
97
  ></input-ui>`;
82
98
  }
@@ -106,11 +122,38 @@ export class UISearch extends UIFormElement {
106
122
  setAttrIfChanged(this.#inputEl, 'placeholder', this.placeholder);
107
123
  if (this.disabled) setAttrIfChanged(this.#inputEl, 'disabled', '');
108
124
  else removeAttrIfPresent(this.#inputEl, 'disabled');
125
+ this.#syncInline();
109
126
 
110
127
  // Reflect value for CSS (clear button visibility)
111
128
  setAttrIfChanged(this, 'value', this.value || '');
112
129
  }
113
130
 
131
+ /**
132
+ * Forward this host's own `[inline]` (a CSS-only universal attribute,
133
+ * ADR-0037 §2 — never a declared property) onto the internal <input-ui>.
134
+ * Same idempotent-write shape as the placeholder/disabled sync above
135
+ * (gh#1755): setAttrIfChanged/removeAttrIfPresent so a byte-identical
136
+ * SSR-rendered child survives upgrade with zero attribute mutations.
137
+ */
138
+ #syncInline() {
139
+ if (!this.#inputEl) return;
140
+ if (this.hasAttribute('inline')) setAttrIfChanged(this.#inputEl, 'inline', '');
141
+ else removeAttrIfPresent(this.#inputEl, 'inline');
142
+ }
143
+
144
+ /**
145
+ * `[inline]` isn't a declared property (see `observedAttributes` above),
146
+ * so a post-connect `el.setAttribute('inline', '')` wouldn't otherwise
147
+ * trigger a re-render — render()'s effect only tracks this element's own
148
+ * reactive signals. Sync it directly here so a later toggle still
149
+ * propagates to the already-created child, same guarantee `.disabled =`
150
+ * gets for free from the declared-property/render-effect path.
151
+ */
152
+ attributeChangedCallback(name, oldValue, newValue) {
153
+ super.attributeChangedCallback(name, oldValue, newValue);
154
+ if (name === 'inline') this.#syncInline();
155
+ }
156
+
114
157
  #onInput = () => {
115
158
  this.value = this.#inputEl.value;
116
159
  this.syncValue(this.value);
@@ -9,6 +9,34 @@
9
9
 
10
10
  :scope {
11
11
  display: block;
12
+ /* ADR-0077 / spec-form-control-sizing-posture REQ-P-001/REQ-R-001
13
+ (search-ui row): fill by default, matching input-ui's own posture
14
+ (input.css). `display: block` alone gives no width once this host
15
+ becomes a flex item (row-ui) — flex items size from content, not
16
+ display type — so the fill is explicit. The inner <input-ui> (see
17
+ the `input-ui` override rule below) carries the matching
18
+ `width: 100%` already shipped on its own default :scope, so it
19
+ fills THIS now-100%-wide
20
+ host in turn — two widths, one contract. No host floor here: no
21
+ `--search-min-width` token is minted (REQ-F-003's documented
22
+ build-time choice) because the inner input's own hug-state floor
23
+ (input.css's --input-min-width, forwarded via [inline] below)
24
+ already delivers the family's legibility guarantee. */
25
+ width: 100%;
26
+ }
27
+
28
+ /* ADR-0077 / spec-form-control-sizing-posture REQ-P-003 (hug, via
29
+ [inline]): ADR-0037 §2 display-flip mechanics + `width: fit-content`
30
+ (not the mere absence of the fill rule — a grid item is blockified/
31
+ stretch-aligned by default, so hug needs the explicit rule to win
32
+ there too). search.class.js forwards this host's own `[inline]`
33
+ attribute onto the internal <input-ui> (light-DOM composition — CSS
34
+ `:scope[inline]` can't reach across that boundary on its own), so the
35
+ inner input's own :scope[inline] floor (--input-min-width, 20ch)
36
+ becomes this control's visible hug-state legibility floor. */
37
+ :scope[inline] {
38
+ display: inline-block;
39
+ width: fit-content;
12
40
  }
13
41
 
14
42
  /* Hide clear icon when empty */
@@ -7,7 +7,10 @@ status: stable
7
7
  component: Search
8
8
  category: input
9
9
  version: 1
10
- description: Search input with magnifying-glass icon, clear button, and debounced search event.
10
+ description: Search input with magnifying-glass icon, clear button, and debounced search event. Fills its
11
+ container's available inline width by default (ADR-0077 / spec-form-control-sizing-posture) — in a flex row
12
+ (row-ui) or grid track it tracks the container all the way down. `[inline]` opts into the old shrink-wrap
13
+ behavior and forwards onto the internal input-ui so its 20ch legibility floor applies.
11
14
  props:
12
15
  name:
13
16
  description: Form field name
@@ -755,7 +755,10 @@ export class UISelect extends UIFormElement {
755
755
  if (!opt) return '';
756
756
  if (opt.mark) return `<adia-mark-ui data-select-mark size="sm"></adia-mark-ui>`;
757
757
  if (opt.avatar) return `<img data-option-avatar src="${escapeHTML(opt.avatar)}" alt="" />`;
758
- if (opt.icon) return `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>`;
758
+ // gh#1857 — data-select-icon mirrors data-select-mark: a marker the
759
+ // trigger/option-row icon-sizing rule (select.css) keys off, so this
760
+ // stamped leading icon renders at the same 1.25rem diameter as the mark.
761
+ if (opt.icon) return `<icon-ui data-select-icon name="${escapeHTML(opt.icon)}"></icon-ui>`;
759
762
  return '';
760
763
  }
761
764
 
@@ -622,3 +622,16 @@ select-ui adia-mark-ui[data-select-leading][size],
622
622
  select-ui adia-mark-ui[data-select-mark][size] {
623
623
  --adia-mark-size: 1.25rem;
624
624
  }
625
+
626
+ /* gh#1857 — stamped leading icons match the mark's 1.25rem diameter above
627
+ (visual parity between the two leading-visual kinds). The trigger's
628
+ icon-ui already carries [data-select-leading] (shared with mark/avatar,
629
+ select.class.js #syncLeading()); the option-row icon-ui carries the
630
+ parallel [data-select-icon] marker (#optionLeadHTML()). Both selectors
631
+ add the [role="option"] / tag qualifiers needed to strictly outspecify
632
+ the generic `select-ui [role="option"] icon-ui` (--a-ui-size) default
633
+ above without relying on source order. */
634
+ select-ui icon-ui[data-select-leading],
635
+ select-ui [role="option"] icon-ui[data-select-icon] {
636
+ --a-icon-size: 1.25rem;
637
+ }
@@ -14,12 +14,12 @@
14
14
  ],
15
15
  "properties": {
16
16
  "band": {
17
- "description": "Chart-band KPI layout. With a `slot=\"chart\"` child, label / value / change stack full width top to bottom and the chart forms a short full-width band between the value and the change row (the Gmail/Vercel/shadcn stat-card shape). Composes with `icon` exactly as the base layout does. No effect without a chart slot.",
17
+ "description": "Chart-band KPI layout. With a `slot=\"chart\"` child, label / value / change stack full width top to bottom and the chart forms a short full-width band between the value and the change row (the Gmail/Vercel/shadcn stat-card shape). Composes with `icon` exactly as the base layout does. Set together with `bleed` (`band bleed`) for the KPI tile: the band bleeds to the card's inline-start, inline-end, and block-end edges, and the `change` delta overlays it as a chip instead of stacking below it (ADR-0083) — the tile's `stat-ui` must be the card's last content (no trailing footer/section). No effect without a chart slot.",
18
18
  "type": "boolean",
19
19
  "default": false
20
20
  },
21
21
  "bleed": {
22
- "description": "Horizontal-bleed KPI layout. With a `slot=\"chart\"` child, the value / label / change stack on the left while the chart fills the right column at full height and bleeds to the card's top / right / bottom edges (the horizontal counterpart to a chart in a card `<section bleed>`). No effect without a chart slot.",
22
+ "description": "The chart reaches the card's edges — which edges depends on `band`: a column bleeding top/right/bottom without it, a three-edge (inline-start, inline-end, block-end) bottom band with it. With `slot=\"chart\"` alone (no `band`), the value / label / change stack on the left while the chart fills the right column at full height and bleeds to the card's top / right / bottom edges (the horizontal counterpart to a chart in a card `<section bleed>`). Set together with `band` for the full-bleed KPI tile — see `band`. No effect without a chart slot.",
23
23
  "type": "boolean",
24
24
  "default": false
25
25
  },
@@ -76,6 +76,11 @@
76
76
  "description": "Basic Stat usage",
77
77
  "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"children\": [\n \"sec\"\n ]\n },\n {\n \"id\": \"sec\",\n \"component\": \"Section\",\n \"children\": [\n \"comp\"\n ]\n },\n {\n \"id\": \"comp\",\n \"component\": \"Stat\",\n \"label\": \"Example Stat\",\n \"value\": \"\"\n }\n]",
78
78
  "name": "basic-stat"
79
+ },
80
+ {
81
+ "description": "KPI tile — band bleed with an overlaid delta chip (ADR-0083)",
82
+ "a2ui": "[\n {\n \"id\": \"root\",\n \"component\": \"Card\",\n \"children\": [\n \"sec\"\n ]\n },\n {\n \"id\": \"sec\",\n \"component\": \"Section\",\n \"children\": [\n \"comp\"\n ]\n },\n {\n \"id\": \"comp\",\n \"component\": \"Stat\",\n \"band\": true,\n \"bleed\": true,\n \"label\": \"Sessions\",\n \"value\": \"84,232\",\n \"change\": \"+57%\",\n \"trend\": \"up\",\n \"children\": [\n \"chart\"\n ]\n },\n {\n \"id\": \"chart\",\n \"component\": \"Chart\",\n \"slot\": \"chart\",\n \"type\": \"area\",\n \"x\": \"d\",\n \"y\": \"v\",\n \"noGrid\": true,\n \"noValues\": true,\n \"data\": [\n { \"d\": \"Mon\", \"v\": 12 },\n { \"d\": \"Tue\", \"v\": 18 },\n { \"d\": \"Wed\", \"v\": 15 },\n { \"d\": \"Thu\", \"v\": 27 },\n { \"d\": \"Fri\", \"v\": 22 },\n { \"d\": \"Sat\", \"v\": 31 },\n { \"d\": \"Sun\", \"v\": 29 }\n ]\n }\n]",
83
+ "name": "tile"
79
84
  }
80
85
  ],
81
86
  "keywords": [
@@ -96,7 +101,8 @@
96
101
  "telemetry",
97
102
  "observability",
98
103
  "performance",
99
- "scoreboard"
104
+ "scoreboard",
105
+ "tile"
100
106
  ],
101
107
  "name": "UIStat",
102
108
  "parts": {},
@@ -236,6 +242,27 @@
236
242
  },
237
243
  "tag": "stat-ui",
238
244
  "tokens": {
245
+ "--stat-change-chip-bg": {
246
+ "description": "Background of the `band bleed` tile's change chip. Falls back to `chart-ui`'s `--chart-chip-bg`, so an ancestor override re-themes chart axis chips and this delta chip together."
247
+ },
248
+ "--stat-change-chip-fg": {
249
+ "description": "Text color of the `band bleed` tile's change chip when no canonical `trend` is set (a set `trend` keeps painting via `--stat-up-fg` / `--stat-down-fg`). Falls back to `chart-ui`'s `--chart-chip-fg`."
250
+ },
251
+ "--stat-change-chip-font-size": {
252
+ "description": "Font size of the `band bleed` tile's change chip. Falls back to `chart-ui`'s `--chart-chip-font-size`."
253
+ },
254
+ "--stat-change-chip-pad-x": {
255
+ "description": "Inline padding of the `band bleed` tile's change chip. Falls back to `chart-ui`'s `--chart-chip-pad-x`."
256
+ },
257
+ "--stat-change-chip-pad-y": {
258
+ "description": "Block padding of the `band bleed` tile's change chip. Falls back to `chart-ui`'s `--chart-chip-pad-y`."
259
+ },
260
+ "--stat-change-chip-radius": {
261
+ "description": "Corner radius of the `band bleed` tile's change chip. Falls back to `chart-ui`'s `--chart-chip-radius`."
262
+ },
263
+ "--stat-change-inset": {
264
+ "description": "Clearance of the `band bleed` tile's change chip from the chart band's edges. Aliases the same ladder rung as `chart-ui`'s `--chart-chrome-inset`."
265
+ },
239
266
  "--stat-change-size": {
240
267
  "description": "Font size for the change badge"
241
268
  },
@@ -19,6 +19,19 @@
19
19
 
20
20
  /* ── Chart-band mode ── */
21
21
  --stat-chart-band-height: 3rem;
22
+
23
+ /* ── Tile mode (band + bleed, ADR-0083) — change-chip overlay ──
24
+ Stat-scoped aliases falling back to the exact rung chart.css
25
+ resolves the corresponding --chart-chip-* token to, so an
26
+ ancestor override re-themes both chart axis chips and this delta
27
+ chip in one stroke (ADR-0083 decision 3). */
28
+ --stat-change-inset: var(--a-space-2);
29
+ --stat-change-chip-bg: var(--chart-chip-bg, var(--md-sys-color-neutral-container-high));
30
+ --stat-change-chip-fg: var(--chart-chip-fg, var(--md-sys-color-neutral-on-surface));
31
+ --stat-change-chip-radius: var(--chart-chip-radius, var(--a-radius-sm));
32
+ --stat-change-chip-pad-x: var(--chart-chip-pad-x, var(--a-space-1-5));
33
+ --stat-change-chip-pad-y: var(--chart-chip-pad-y, var(--a-space-1));
34
+ --stat-change-chip-font-size: var(--chart-chip-font-size, var(--a-ui-tiny));
22
35
  }
23
36
 
24
37
  :scope {
@@ -166,6 +179,81 @@
166
179
  aspect-ratio: auto;
167
180
  }
168
181
 
182
+ /* ── Tile layout: band + bleed together (ADR-0083) ──
183
+ The chart-band from `[band]` above, generalized to bleed three edges
184
+ (inline-start, inline-end, block-end) the way `[bleed]` already
185
+ bleeds horizontally, with the `change` delta overlaid as a chip
186
+ inside the band instead of stacking below it. Declared AFTER the
187
+ shipped `[band]` block above so it wins the equal-specificity tie
188
+ the undefined `band bleed` combination used to lose — `[band]`'s
189
+ grid-template-areas is replaced wholesale (the `change` row is
190
+ removed; the chip lives in the chart grid area instead). Host
191
+ precondition (author-controlled, undetected — the same posture
192
+ gh#1801 ratified for `section[bleed]`): this stat-ui must be the
193
+ last content before the card's block-end edge, no trailing
194
+ footer/section — a following region would collide with the
195
+ negative block-end margin below. Compose inside a card section:
196
+ <stat-ui band bleed value=… label=… change=… trend=…>
197
+ <chart-ui slot="chart" type="area" …></chart-ui>
198
+ </stat-ui> */
199
+ :scope[band][bleed]:has([slot="chart"]) {
200
+ grid-template-columns: minmax(0, 1fr) auto;
201
+ grid-template-areas:
202
+ "label icon"
203
+ "value value"
204
+ "chart chart";
205
+ align-items: baseline;
206
+ }
207
+ :scope[band][bleed] [slot="chart"] {
208
+ grid-area: chart;
209
+ align-self: stretch;
210
+ height: var(--stat-chart-band-height);
211
+ aspect-ratio: auto;
212
+ /* Three-edge bleed via the --card-inset negative-extent technique
213
+ [bleed] already uses horizontally (see the horizontal-bleed block
214
+ above for the width/margin-block rationale — chart-ui pins its own
215
+ width:100%, so the box is explicitly sized rather than stretched). */
216
+ margin-inline: calc(-1 * var(--card-inset, 0px));
217
+ margin-block-end: calc(-1 * var(--card-inset, 0px));
218
+ width: calc(100% + 2 * var(--card-inset, 0px));
219
+ min-width: 0;
220
+ }
221
+ /* Change chip: overlaid in the chart's own grid area, above it in
222
+ paint order, anchored block-start / inline-end of the band (logical
223
+ properties — RTL mirrors for free). Corner clearance from the
224
+ card's rounded corners is structural: the band's rounded corners are
225
+ block-end only (its block-start edge is mid-card, square), so a
226
+ block-start-anchored chip can never meet one. pointer-events: none —
227
+ it's text, not a control, so it never blocks the chart's own
228
+ hover/tooltip hit-testing beneath it. */
229
+ :scope[band][bleed] [slot="change"] {
230
+ grid-area: chart;
231
+ justify-self: end;
232
+ align-self: start;
233
+ z-index: 1;
234
+ inset-block-start: var(--stat-change-inset);
235
+ inset-inline-end: var(--stat-change-inset);
236
+ position: relative;
237
+ pointer-events: none;
238
+ background: var(--stat-change-chip-bg);
239
+ border-radius: var(--stat-change-chip-radius);
240
+ padding: var(--stat-change-chip-pad-y) var(--stat-change-chip-pad-x);
241
+ font-size: var(--stat-change-chip-font-size);
242
+ }
243
+ /* Chip text fallback — deliberately LOW specificity via :where() (0,1,0,
244
+ same as the generic [slot="change"] rule below it) so the
245
+ [trend="up"/"down"] rules further down this file (0,2,0 — they win
246
+ regardless of source order) keep painting the chip's text + arrow
247
+ whenever a canonical trend is set. --stat-change-chip-fg applies only
248
+ when no trend is set — the fallback, not an override. Splitting this
249
+ one declaration out of the block above (which stays 0,3,0 for its
250
+ geometry/surface properties, none of which trend re-paints) is the
251
+ only way to give color a lower specificity than the rest of that
252
+ block while keeping this selector, not a shape-widening rewrite. */
253
+ :where(:scope[band][bleed]) [slot="change"] {
254
+ color: var(--stat-change-chip-fg);
255
+ }
256
+
169
257
  /* ── Label (eyebrow) ── */
170
258
  [slot="label"] {
171
259
  grid-area: label;
@@ -13,9 +13,9 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export class UIStat extends UIElement {
16
- /** Chart-band KPI layout. With a `slot="chart"` child, label / value / change stack full width top to bottom and the chart forms a short full-width band between the value and the change row (the Gmail/Vercel/shadcn stat-card shape). Composes with `icon` exactly as the base layout does. No effect without a chart slot. */
16
+ /** Chart-band KPI layout. With a `slot="chart"` child, label / value / change stack full width top to bottom and the chart forms a short full-width band between the value and the change row (the Gmail/Vercel/shadcn stat-card shape). Composes with `icon` exactly as the base layout does. Set together with `bleed` (`band bleed`) for the KPI tile: the band bleeds to the card's inline-start, inline-end, and block-end edges, and the `change` delta overlays it as a chip instead of stacking below it (ADR-0083) — the tile's `stat-ui` must be the card's last content (no trailing footer/section). No effect without a chart slot. */
17
17
  band: boolean;
18
- /** Horizontal-bleed KPI layout. With a `slot="chart"` child, the value / label / change stack on the left while the chart fills the right column at full height and bleeds to the card's top / right / bottom edges (the horizontal counterpart to a chart in a card `<section bleed>`). No effect without a chart slot. */
18
+ /** The chart reaches the card's edges — which edges depends on `band`: a column bleeding top/right/bottom without it, a three-edge (inline-start, inline-end, block-end) bottom band with it. With `slot="chart"` alone (no `band`), the value / label / change stack on the left while the chart fills the right column at full height and bleeds to the card's top / right / bottom edges (the horizontal counterpart to a chart in a card `<section bleed>`). Set together with `band` for the full-bleed KPI tile — see `band`. No effect without a chart slot. */
19
19
  bleed: boolean;
20
20
  /** Change indicator text (e.g. '+12%', '-3%') */
21
21
  change: string;
@@ -51,11 +51,14 @@ props:
51
51
  default: ""
52
52
  bleed:
53
53
  description: >-
54
- Horizontal-bleed KPI layout. With a `slot="chart"` child, the value /
55
- label / change stack on the left while the chart fills the right column
56
- at full height and bleeds to the card's top / right / bottom edges (the
57
- horizontal counterpart to a chart in a card `<section bleed>`). No effect
58
- without a chart slot.
54
+ The chart reaches the card's edges which edges depends on `band`: a
55
+ column bleeding top/right/bottom without it, a three-edge (inline-start,
56
+ inline-end, block-end) bottom band with it. With `slot="chart"` alone
57
+ (no `band`), the value / label / change stack on the left while the
58
+ chart fills the right column at full height and bleeds to the card's
59
+ top / right / bottom edges (the horizontal counterpart to a chart in a
60
+ card `<section bleed>`). Set together with `band` for the full-bleed KPI
61
+ tile — see `band`. No effect without a chart slot.
59
62
  type: boolean
60
63
  default: false
61
64
  band:
@@ -64,7 +67,12 @@ props:
64
67
  change stack full width top to bottom and the chart forms a short
65
68
  full-width band between the value and the change row (the
66
69
  Gmail/Vercel/shadcn stat-card shape). Composes with `icon` exactly as
67
- the base layout does. No effect without a chart slot.
70
+ the base layout does. Set together with `bleed` (`band bleed`) for the
71
+ KPI tile: the band bleeds to the card's inline-start, inline-end, and
72
+ block-end edges, and the `change` delta overlays it as a chip instead
73
+ of stacking below it (ADR-0083) — the tile's `stat-ui` must be the
74
+ card's last content (no trailing footer/section). No effect without a
75
+ chart slot.
68
76
  type: boolean
69
77
  default: false
70
78
  events: {}
@@ -112,6 +120,37 @@ tokens:
112
120
  description: Font size for the primary value
113
121
  --stat-value-weight:
114
122
  description: Font weight for the primary value
123
+ --stat-change-inset:
124
+ description: >-
125
+ Clearance of the `band bleed` tile's change chip from the chart band's
126
+ edges. Aliases the same ladder rung as `chart-ui`'s
127
+ `--chart-chrome-inset`.
128
+ --stat-change-chip-bg:
129
+ description: >-
130
+ Background of the `band bleed` tile's change chip. Falls back to
131
+ `chart-ui`'s `--chart-chip-bg`, so an ancestor override re-themes chart
132
+ axis chips and this delta chip together.
133
+ --stat-change-chip-fg:
134
+ description: >-
135
+ Text color of the `band bleed` tile's change chip when no canonical
136
+ `trend` is set (a set `trend` keeps painting via `--stat-up-fg` /
137
+ `--stat-down-fg`). Falls back to `chart-ui`'s `--chart-chip-fg`.
138
+ --stat-change-chip-radius:
139
+ description: >-
140
+ Corner radius of the `band bleed` tile's change chip. Falls back to
141
+ `chart-ui`'s `--chart-chip-radius`.
142
+ --stat-change-chip-pad-x:
143
+ description: >-
144
+ Inline padding of the `band bleed` tile's change chip. Falls back to
145
+ `chart-ui`'s `--chart-chip-pad-x`.
146
+ --stat-change-chip-pad-y:
147
+ description: >-
148
+ Block padding of the `band bleed` tile's change chip. Falls back to
149
+ `chart-ui`'s `--chart-chip-pad-y`.
150
+ --stat-change-chip-font-size:
151
+ description: >-
152
+ Font size of the `band bleed` tile's change chip. Falls back to
153
+ `chart-ui`'s `--chart-chip-font-size`.
115
154
  requiredIcons:
116
155
  - arrow-up
117
156
  - arrow-down
@@ -123,6 +162,8 @@ a2ui:
123
162
  reason: 'Different visual + semantic role.'
124
163
  - rule: 'Delta indicator uses positive/negative semantic tokens; pass change= attribute with sign.'
125
164
  reason: 'Built-in trend coloring; no manual styling needed.'
165
+ - rule: 'For the KPI tile — big value over a full-bleed sparkline/area band with the delta as a chip — set `band bleed` together on the stat-ui; the tile must be the card''s last content.'
166
+ reason: 'band bleed composes the shipped chart-band and horizontal-bleed layouts into the full-bleed tile with the change delta overlaid inside the band as a chip (ADR-0083); a trailing footer/section after it collides with the band''s negative block-end margin.'
126
167
  anti_patterns: []
127
168
  examples:
128
169
  - name: basic-stat
@@ -150,6 +191,57 @@ examples:
150
191
  "value": ""
151
192
  }
152
193
  ]
194
+ - name: tile
195
+ description: KPI tile — band bleed with an overlaid delta chip (ADR-0083)
196
+ a2ui: >-
197
+ [
198
+ {
199
+ "id": "root",
200
+ "component": "Card",
201
+ "children": [
202
+ "sec"
203
+ ]
204
+ },
205
+ {
206
+ "id": "sec",
207
+ "component": "Section",
208
+ "children": [
209
+ "comp"
210
+ ]
211
+ },
212
+ {
213
+ "id": "comp",
214
+ "component": "Stat",
215
+ "band": true,
216
+ "bleed": true,
217
+ "label": "Sessions",
218
+ "value": "84,232",
219
+ "change": "+57%",
220
+ "trend": "up",
221
+ "children": [
222
+ "chart"
223
+ ]
224
+ },
225
+ {
226
+ "id": "chart",
227
+ "component": "Chart",
228
+ "slot": "chart",
229
+ "type": "area",
230
+ "x": "d",
231
+ "y": "v",
232
+ "noGrid": true,
233
+ "noValues": true,
234
+ "data": [
235
+ { "d": "Mon", "v": 12 },
236
+ { "d": "Tue", "v": 18 },
237
+ { "d": "Wed", "v": 15 },
238
+ { "d": "Thu", "v": 27 },
239
+ { "d": "Fri", "v": 22 },
240
+ { "d": "Sat", "v": 31 },
241
+ { "d": "Sun", "v": 29 }
242
+ ]
243
+ }
244
+ ]
153
245
  keywords:
154
246
  - stat
155
247
  - stats
@@ -169,6 +261,7 @@ keywords:
169
261
  - observability
170
262
  - performance
171
263
  - scoreboard
264
+ - tile
172
265
  synonyms:
173
266
  analytics:
174
267
  - chart
@@ -60,8 +60,11 @@
60
60
  "default": 0
61
61
  },
62
62
  "range-total": {
63
- "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.",
64
- "type": "number",
63
+ "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. Explicit `range-total=\"?\"` (gh#1877, ADR-0082 Amendment) is the OPEN/unproven-total state — cursor/hasMore server paging with no known row count yet: still server mode (still presence-gated), but the internal pager's page count stays exactly one page ahead of wherever the pager currently sits (never a fixed ceil(...) total), keeping `next` enabled indefinitely instead of hiding the pager the way a confirmed `range-total=\"0\"` would. Resolves to the normal finite server-mode behavior the instant a real number replaces the \"?\" — every reader downstream (table-toolbar-ui, table-footer-ui) shares this same distinction. Note: this prop is Number-typed and reflected; \"?\" parses to NaN (`+\"?\"`), and the coerced numeric PROPERTY is what carries the open/unproven signal (non-finite) — every reader checks the property, never `getAttribute(...) === '?'`. The DOM attribute itself keeps the authored \"?\" (gh#1895 — the framework's attribute-reflection leaves a non-finite Number value unwritten rather than stringifying it back as the literal \"NaN\").",
64
+ "type": [
65
+ "number",
66
+ "string"
67
+ ],
65
68
  "default": 0
66
69
  },
67
70
  "raw": {
@@ -41,6 +41,16 @@
41
41
  * conflated with absent. With [paginate] absent/0, [range-total] is
42
42
  * inert for this table's own rendering but stays readable by a
43
43
  * bound table-footer-ui for its count-only label.
44
+ * range-total="?" — gh#1877/ADR-0082 Amendment (2026-08-22) — the OPEN/
45
+ * unproven-total state: server mode with no known total yet
46
+ * (cursor/hasMore paging). Still presence-gated into server
47
+ * mode like any other [range-total] value; #pageCount stays
48
+ * one page ahead of the current page (never a fixed number)
49
+ * so the pager's `next` stays enabled indefinitely, and
50
+ * pagination visibility no longer requires a positive
51
+ * #safeRangeTotal() while open. Resolves to a normal finite
52
+ * server-mode table the moment a real range-total replaces
53
+ * the "?".
44
54
  * loading — show loading overlay
45
55
  * search — global search filter string
46
56
  *
@@ -326,6 +336,20 @@ export class UITable extends UIElement {
326
336
  // reset-suppression call sites for why that widened scope is safe).
327
337
  get #serverMode() { return this.hasAttribute('range-total'); }
328
338
 
339
+ // gh#1877/ADR-0082 Amendment — the open/unproven-total state. NOT keyed
340
+ // on the raw attribute string: `rangeTotal` is a Number-typed reflected
341
+ // prop (installProps in core/element.js), so setting range-total="?" is
342
+ // parsed via `+"?"` → NaN. gh#1895 — reflect() (core/element.js) leaves
343
+ // a non-finite Number value unwritten instead of stringifying it back
344
+ // onto the attribute, so by the time #draw()/render() runs,
345
+ // `getAttribute('range-total')` still reads the authored "?". The
346
+ // coerced PROPERTY still holds NaN stably, though (Object.is(NaN, NaN)
347
+ // is true, so re-parsing the unchanged attribute never re-triggers a
348
+ // write either) — that's what #pageCount/showPagination below need to
349
+ // tell "no total yet" apart from "confirmed empty" (range-total="0", a
350
+ // finite 0).
351
+ get #rangeOpen() { return this.#serverMode && !Number.isFinite(Number(this.rangeTotal)); }
352
+
329
353
  // gh#1754/ADR-0082 REQ-D-003 (CodeRabbit, PR #1828) — a raw
330
354
  // `Number(x) || 0` accepts negative and non-finite values verbatim (both
331
355
  // are truthy), which can drive #pageCount/the footer's own range text
@@ -933,6 +957,13 @@ export class UITable extends UIElement {
933
957
  // derived, not the loaded page's own length (the exact dual-math drift
934
958
  // gh#1754's evidence quoted — this is now the ONE calc site;
935
959
  // #renderPagination reads this getter instead of recomputing).
960
+ // gh#1877/ADR-0082 Amendment — open/unproven total: no real total to
961
+ // derive from, so pageCount stays exactly one page ahead of wherever
962
+ // the pager currently is. That keeps `next` enabled indefinitely
963
+ // (never a false "last page") without inventing a fake total; the
964
+ // moment range-total resolves to a real number, this branch stops
965
+ // firing and the real ceil(...) math above takes over.
966
+ if (this.#rangeOpen) return this.#page + 2;
936
967
  if (this.#serverMode) return Math.max(1, Math.ceil(this.#safeRangeTotal() / this.paginate));
937
968
  // pageCount should reflect filtered data, not raw data
938
969
  const filteredCount = this.#getProcessedIndices().length;
@@ -1054,8 +1085,12 @@ export class UITable extends UIElement {
1054
1085
  // `data.length > 0` term: an in-flight fetch (momentarily empty `.data`)
1055
1086
  // must not flicker the pager away, so visibility gates on `range-total`
1056
1087
  // instead.
1088
+ // gh#1877/ADR-0082 Amendment — an open/unproven total (REQ-D-003
1089
+ // widened) always shows the pager while server mode is active: there's
1090
+ // no confirmed-zero signal to gate on yet, and hiding it would read as
1091
+ // "no more rows" when the real answer is "unknown, keep going".
1057
1092
  const showPagination = this.paginate > 0 && !this.noPager
1058
- && (this.#serverMode ? this.#safeRangeTotal() > 0 : this.#data.length > 0);
1093
+ && (this.#serverMode ? (this.#rangeOpen || this.#safeRangeTotal() > 0) : this.#data.length > 0);
1059
1094
  let footer = this.querySelector(':scope > [data-footer]');
1060
1095
 
1061
1096
  if (showPagination) {
@@ -104,8 +104,8 @@ export class UITable extends UIElement {
104
104
  noPager: boolean;
105
105
  /** 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. */
106
106
  paginate: number;
107
- /** 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. */
108
- rangeTotal: number;
107
+ /** 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. Explicit `range-total="?"` (gh#1877, ADR-0082 Amendment) is the OPEN/unproven-total state — cursor/hasMore server paging with no known row count yet: still server mode (still presence-gated), but the internal pager's page count stays exactly one page ahead of wherever the pager currently sits (never a fixed ceil(...) total), keeping `next` enabled indefinitely instead of hiding the pager the way a confirmed `range-total="0"` would. Resolves to the normal finite server-mode behavior the instant a real number replaces the "?" — every reader downstream (table-toolbar-ui, table-footer-ui) shares this same distinction. Note: this prop is Number-typed and reflected; "?" parses to NaN (`+"?"`), and the coerced numeric PROPERTY is what carries the open/unproven signal (non-finite) — every reader checks the property, never `getAttribute(...) === '?'`. The DOM attribute itself keeps the authored "?" (gh#1895 — the framework's attribute-reflection leaves a non-finite Number value unwritten rather than stringifying it back as the literal "NaN"). */
108
+ rangeTotal: number | string;
109
109
  /** Visual-only passthrough — applies `<table-ui>`'s chrome reset (background / border / border-radius all transparent) AND short-circuits the data lifecycle entirely. The consumer owns the body shape: no header injection, no row reconciliation from `.data`, no empty-state / loading overlays, no aggregation or pagination footers. Use raw when embedding `<table-ui>` inside surfaces that supply their own chrome (e.g. `<card-ui><section bleed>`), or when wrapping a consumer-authored native `<table>` for design-token styling without the framework's data semantics. Note: a `<table-ui>` placed directly inside `<card-ui><section bleed>` drops the table's visual chrome automatically (card.css, gh#796) even without `raw` — `raw` is still required to also short-circuit the data lifecycle. Pre-v0.6.33 (FB-53 §2) `raw` was visual-only and the data lifecycle still ran — wrapping a `.data`-unset native table produced a phantom "No data" overlay. v0.6.33+ matches the documented contract. */
110
110
  raw: boolean;
111
111
  /** 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. */
@@ -98,7 +98,23 @@ props:
98
98
  to table-toolbar-ui's and table-footer-ui's own [range-total]
99
99
  (rows, never `pagination-ui[total]`'s pages — the ADR-0063 B5
100
100
  collision rule); a third instance of one name for one concept.
101
- type: number
101
+ Explicit `range-total="?"` (gh#1877, ADR-0082 Amendment) is the
102
+ OPEN/unproven-total state — cursor/hasMore server paging with no
103
+ known row count yet: still server mode (still presence-gated), but
104
+ the internal pager's page count stays exactly one page ahead of
105
+ wherever the pager currently sits (never a fixed ceil(...) total),
106
+ keeping `next` enabled indefinitely instead of hiding the pager the
107
+ way a confirmed `range-total="0"` would. Resolves to the normal
108
+ finite server-mode behavior the instant a real number replaces the
109
+ "?" — every reader downstream (table-toolbar-ui, table-footer-ui)
110
+ shares this same distinction. Note: this prop is Number-typed and
111
+ reflected; "?" parses to NaN (`+"?"`), and the coerced numeric
112
+ PROPERTY is what carries the open/unproven signal (non-finite) —
113
+ every reader checks the property, never `getAttribute(...) === '?'`.
114
+ The DOM attribute itself keeps the authored "?" (gh#1895 — the
115
+ framework's attribute-reflection leaves a non-finite Number value
116
+ unwritten rather than stringifying it back as the literal "NaN").
117
+ type: [number, string]
102
118
  default: 0
103
119
  reflect: true
104
120
  filteredCount: