@adia-ai/adia-ui-factory 0.2.3 → 0.8.0

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 (144) hide show
  1. package/.claude-plugin/plugin.json +5 -7
  2. package/.mcp.json +1 -1
  3. package/CHANGELOG.md +27 -63
  4. package/README.md +32 -53
  5. package/agents/app-architect.md +28 -0
  6. package/agents/routing-corpus.json +202 -0
  7. package/agents/screen-composer.md +27 -0
  8. package/bin/adia-lint +17 -2
  9. package/commands/adia-genui.md +5 -8
  10. package/commands/adia-migrate.md +5 -6
  11. package/commands/adia-orient.md +5 -10
  12. package/commands/adia-scaffold.md +7 -13
  13. package/commands/adia-verify.md +5 -6
  14. package/package.json +5 -6
  15. package/references/a2ui-mcp-tools.md +11 -8
  16. package/references/authoring-components.md +25 -1
  17. package/references/component-model.md +52 -4
  18. package/references/contracts/a2ui-mcp-surface.md +35 -0
  19. package/references/contracts/migration-guide-format.md +34 -0
  20. package/references/data-and-hydration.md +8 -5
  21. package/references/genui-a2ui.md +12 -6
  22. package/references/llm.md +22 -39
  23. package/references/migration.md +110 -36
  24. package/references/project-shapes.md +11 -13
  25. package/references/shell-admin.md +5 -2
  26. package/references/shell-chat.md +5 -3
  27. package/references/shell-editor.md +1 -1
  28. package/references/shell-embed.md +40 -26
  29. package/references/shell-simple.md +5 -2
  30. package/references/spa-architecture.md +28 -73
  31. package/references/ssr-integration.md +1 -1
  32. package/references/verification.md +8 -12
  33. package/skills/adia-compose/SKILL.md +52 -0
  34. package/skills/{adia-ui-kit → adia-compose}/assets/figma-make/guidelines/Guidelines.md +3 -3
  35. package/skills/{adia-ui-kit → adia-compose}/assets/figma-make/guidelines/components.md +3 -3
  36. package/skills/{adia-ui-kit → adia-compose}/assets/figma-make/guidelines/styles.md +1 -1
  37. package/skills/{adia-ui-kit → adia-compose}/assets/figma-make/guidelines/tokens.md +3 -3
  38. package/skills/{adia-ui-kit → adia-compose}/assets/templates/FEEDBACK.template.md +7 -7
  39. package/skills/{adia-ui-kit → adia-compose}/assets/templates/RESPONSE.template.md +6 -7
  40. package/skills/adia-compose/references/composition-traps.md +58 -0
  41. package/skills/adia-compose/references/feedback-discipline.md +20 -0
  42. package/skills/adia-compose/references/spec-to-ui-reasoning.md +158 -0
  43. package/skills/adia-data/SKILL.md +78 -0
  44. package/skills/adia-genui/SKILL.md +83 -0
  45. package/skills/adia-host/SKILL.md +119 -0
  46. package/skills/adia-llm/SKILL.md +36 -0
  47. package/skills/adia-migrate/SKILL.md +76 -0
  48. package/skills/adia-orient/SKILL.md +123 -0
  49. package/skills/adia-project/SKILL.md +83 -0
  50. package/skills/adia-shells/SKILL.md +74 -0
  51. package/skills/adia-verify/SKILL.md +43 -0
  52. package/skills/adia-verify/references/verification.md +42 -0
  53. package/commands/adia-compose.md +0 -10
  54. package/commands/adia-wire.md +0 -13
  55. package/skills/adia-ui-compose/SKILL.md +0 -57
  56. package/skills/adia-ui-data/SKILL.md +0 -62
  57. package/skills/adia-ui-factory/SKILL.md +0 -113
  58. package/skills/adia-ui-genui/SKILL.md +0 -74
  59. package/skills/adia-ui-kit/CHANGELOG.md +0 -41
  60. package/skills/adia-ui-kit/SKILL.md +0 -442
  61. package/skills/adia-ui-kit/assets/team-scaffold/archive-README.md +0 -30
  62. package/skills/adia-ui-kit/assets/team-scaffold/drafts-README.md +0 -57
  63. package/skills/adia-ui-kit/assets/team-scaffold/inbox-README.md +0 -76
  64. package/skills/adia-ui-kit/assets/team-scaffold/outbox-README.md +0 -46
  65. package/skills/adia-ui-kit/assets/team-scaffold/team-root.README.md +0 -51
  66. package/skills/adia-ui-kit/assets/team-scaffold/tickets-README.md +0 -208
  67. package/skills/adia-ui-kit/assets/team-scaffold/tickets-REGISTRY.md +0 -81
  68. package/skills/adia-ui-kit/assets/templates/AGENTS.template.md +0 -128
  69. package/skills/adia-ui-kit/assets/templates/CLAUDE.template.md +0 -1
  70. package/skills/adia-ui-kit/assets/templates/figma-make-guidelines.md +0 -90
  71. package/skills/adia-ui-kit/evals/adversarial-content-trust.json +0 -83
  72. package/skills/adia-ui-kit/evals/evals.json +0 -55
  73. package/skills/adia-ui-kit/evals/routing-corpus.json +0 -121
  74. package/skills/adia-ui-kit/evals/teach-routing-cases.json +0 -92
  75. package/skills/adia-ui-kit/references/capabilities.md +0 -245
  76. package/skills/adia-ui-kit/references/catalog-lookup.md +0 -186
  77. package/skills/adia-ui-kit/references/changelog-archive/CHANGELOG-v1.md +0 -8
  78. package/skills/adia-ui-kit/references/changelog-archive/CHANGELOG-v2.md +0 -3585
  79. package/skills/adia-ui-kit/references/changelog-archive/CHANGELOG-v3.md +0 -1899
  80. package/skills/adia-ui-kit/references/common-gotchas-consumer.md +0 -522
  81. package/skills/adia-ui-kit/references/component-literacy.md +0 -291
  82. package/skills/adia-ui-kit/references/composition.md +0 -245
  83. package/skills/adia-ui-kit/references/correction-loop.md +0 -462
  84. package/skills/adia-ui-kit/references/css-policy.md +0 -343
  85. package/skills/adia-ui-kit/references/decision-model.md +0 -158
  86. package/skills/adia-ui-kit/references/diagnostic-output.md +0 -57
  87. package/skills/adia-ui-kit/references/docs-catalog.md +0 -239
  88. package/skills/adia-ui-kit/references/exclusions.md +0 -225
  89. package/skills/adia-ui-kit/references/feedback-authoring.md +0 -466
  90. package/skills/adia-ui-kit/references/figma-make.md +0 -244
  91. package/skills/adia-ui-kit/references/flows-auth.md +0 -307
  92. package/skills/adia-ui-kit/references/flows-content.md +0 -206
  93. package/skills/adia-ui-kit/references/flows-data.md +0 -348
  94. package/skills/adia-ui-kit/references/flows-interaction.md +0 -181
  95. package/skills/adia-ui-kit/references/flows-shells.md +0 -336
  96. package/skills/adia-ui-kit/references/gap-detection.md +0 -266
  97. package/skills/adia-ui-kit/references/harness.md +0 -284
  98. package/skills/adia-ui-kit/references/loading-protocol.md +0 -406
  99. package/skills/adia-ui-kit/references/local-link.md +0 -198
  100. package/skills/adia-ui-kit/references/mcp.md +0 -401
  101. package/skills/adia-ui-kit/references/migration.md +0 -116
  102. package/skills/adia-ui-kit/references/mission.md +0 -194
  103. package/skills/adia-ui-kit/references/mode-routing.md +0 -298
  104. package/skills/adia-ui-kit/references/patterns-agent.md +0 -223
  105. package/skills/adia-ui-kit/references/patterns-data.md +0 -1066
  106. package/skills/adia-ui-kit/references/patterns-forms.md +0 -223
  107. package/skills/adia-ui-kit/references/patterns-layout.md +0 -326
  108. package/skills/adia-ui-kit/references/patterns-recipes.md +0 -1416
  109. package/skills/adia-ui-kit/references/patterns-shells.md +0 -1097
  110. package/skills/adia-ui-kit/references/patterns-traits.md +0 -259
  111. package/skills/adia-ui-kit/references/patterns-wiring.md +0 -339
  112. package/skills/adia-ui-kit/references/pev-rationale.md +0 -137
  113. package/skills/adia-ui-kit/references/reasoning-gate.md +0 -146
  114. package/skills/adia-ui-kit/references/reasoning-methodologies.md +0 -112
  115. package/skills/adia-ui-kit/references/recon.md +0 -192
  116. package/skills/adia-ui-kit/references/rendering-model.md +0 -431
  117. package/skills/adia-ui-kit/references/response-authoring.md +0 -398
  118. package/skills/adia-ui-kit/references/session-log-schema.md +0 -203
  119. package/skills/adia-ui-kit/references/session-log-template.md +0 -118
  120. package/skills/adia-ui-kit/references/setup.md +0 -652
  121. package/skills/adia-ui-kit/references/skill-hygiene.md +0 -135
  122. package/skills/adia-ui-kit/references/spec-to-ui-reasoning.md +0 -394
  123. package/skills/adia-ui-kit/references/state.md +0 -229
  124. package/skills/adia-ui-kit/references/teach-protocol.md +0 -421
  125. package/skills/adia-ui-kit/references/theme.md +0 -142
  126. package/skills/adia-ui-kit/references/version-check.md +0 -69
  127. package/skills/adia-ui-kit/scripts/audit-decision-trace.mjs +0 -198
  128. package/skills/adia-ui-kit/scripts/audit-kit-roster.mjs +0 -366
  129. package/skills/adia-ui-kit/scripts/changelog-archive.mjs +0 -126
  130. package/skills/adia-ui-kit/scripts/check-adia-versions.py +0 -404
  131. package/skills/adia-ui-kit/scripts/run-skill-evals.mjs +0 -358
  132. package/skills/adia-ui-kit/scripts/validate-session-log.mjs +0 -242
  133. package/skills/adia-ui-kit/skill.json +0 -102
  134. package/skills/adia-ui-llm/SKILL.md +0 -51
  135. package/skills/adia-ui-migrate/SKILL.md +0 -64
  136. package/skills/adia-ui-migration/CHANGELOG.md +0 -47
  137. package/skills/adia-ui-migration/SKILL.md +0 -381
  138. package/skills/adia-ui-migration/skill.json +0 -29
  139. package/skills/adia-ui-project/SKILL.md +0 -77
  140. package/skills/adia-ui-shells/SKILL.md +0 -62
  141. package/skills/adia-ui-spa/SKILL.md +0 -52
  142. package/skills/adia-ui-ssr/SKILL.md +0 -52
  143. package/skills/adia-ui-verify/SKILL.md +0 -44
  144. /package/skills/{adia-ui-kit → adia-compose}/assets/templates/FEEDBACK-OMISSIONS-DEFENSE.template.md +0 -0
@@ -1,1066 +0,0 @@
1
- # Data & Dashboard Patterns
2
-
3
- ## Composition rule: no `<style>`, no inline `style=`
4
-
5
- Training-data and gen-UI example pages are expected to be **pure primitive
6
- composition**: the HTML contains only AdiaUI component tags + native HTML
7
- (h1-h6, p, article, section, etc.). A `<style>` block or any `style="…"`
8
- attribute in a page is a signal that the page has drifted — either the
9
- author hasn't discovered the right primitive, or the layout needs a new
10
- primitive extracted.
11
-
12
- Concretely:
13
-
14
- - **Layout comes from `col-ui`, `row-ui`, `grid-ui`, `stack-ui`** — not from
15
- `display: flex; flex-direction: column` on a `<div>`.
16
- - **Width constraints are set via design-token CSS variables on a
17
- controller-owned element, not inline style.** For a 48rem reading column
18
- on a doc-template page, setup.js does
19
- `section.style.setProperty('--page-content-max-width', '48rem')` on the
20
- existing `[data-content-body] > section` instead of declaring a local
21
- `<style>` block.
22
- - **Asymmetric ratios use `grid-ui[columns="N"]` + `[span="M"]`** — never
23
- `style="grid-template-columns: …"`. See "Asymmetric Grid" below.
24
- - **Child sizing inside row-ui** uses `[grow]` — never `style="flex:1"`.
25
- - **"Fake" elements** — custom tag names dressed up in page-local CSS
26
- (`<tool-call-ui>`, `<citation-ui>`, `<link-preview-ui>`, `<map-ui>` used
27
- purely as styled `<div>` replacements without a real component class)
28
- are a hard reject. Either promote them to a real component with its own
29
- CSS file, or replace with a primitive composition (`accordion-ui` for
30
- collapsibles, `tag-ui` for chips, `card-ui` + `[slot="media"]` for link
31
- previews, `image-ui` for map placeholders).
32
-
33
- JS-driven hydration via `setup.js` (setting data, attaching listeners,
34
- reading URL state, setting CSS variables via `setProperty`) is always
35
- allowed. The rule isn't "no JS styling" — it's "no styling stored in the
36
- HTML page."
37
-
38
- **Recognition:** if grep of an example page finds `<style`, `style="`, or a
39
- custom tag name not present in `packages/web-components/components/`, the
40
- page needs a rewrite before it's fit for training-data extraction.
41
-
42
- ## Stat Cards (KPI Grid)
43
-
44
- ```html
45
- <grid-ui columns="4" gap="2">
46
- <card-ui>
47
- <header><span slot="heading" variant="label"><small>Revenue</small></span></header>
48
- <section><col-ui gap="1">
49
- <strong variant="title">$48,200</strong>
50
- <badge-ui text="+12.5%" variant="success" size="xs"></badge-ui>
51
- </col-ui></section>
52
- </card-ui>
53
- <card-ui>
54
- <header><span slot="heading" variant="label"><small>Users</small></span></header>
55
- <section><col-ui gap="1">
56
- <strong variant="title">2,340</strong>
57
- <badge-ui text="+8%" variant="success" size="xs"></badge-ui>
58
- </col-ui></section>
59
- </card-ui>
60
- <card-ui>
61
- <header><span slot="heading" variant="label"><small>Bounce Rate</small></span></header>
62
- <section><col-ui gap="1">
63
- <strong variant="title">24.3%</strong>
64
- <badge-ui text="-3.1%" variant="danger" size="xs"></badge-ui>
65
- </col-ui></section>
66
- </card-ui>
67
- <card-ui>
68
- <header><span slot="heading" variant="label"><small>Avg Session</small></span></header>
69
- <section><col-ui gap="1">
70
- <strong variant="title">4m 23s</strong>
71
- <badge-ui text="+15s" variant="info" size="xs"></badge-ui>
72
- </col-ui></section>
73
- </card-ui>
74
- </grid-ui>
75
- ```
76
-
77
- Key rule: `Grid > Card > Header + Section > Column`. Never Grid inside Grid for stats.
78
-
79
- ## Data Table
80
-
81
- Table data is set via JS properties. **Pair every non-trivial table with
82
- `<table-toolbar-ui for="…">`** for filter / sort / columns-visibility /
83
- search affordances — the toolbar already wires those into the bound table
84
- + auto-detects column types. Use **cell-types** in the column descriptors
85
- to delegate formatting + alignment + sort to the table itself.
86
-
87
- ### Canonical shape — toolbar above card (per `table-toolbar.yaml` a2ui.rules)
88
-
89
- The yaml-preferred composition: toolbar OUTSIDE `<card-ui>`, paired via a
90
- `<col-ui gap="3">`. Card holds only the table, in a `<section bleed>` for
91
- edge-to-edge rows:
92
-
93
- ```html
94
- <col-ui gap="3">
95
- <table-toolbar-ui for="orders" text="Recent orders"></table-toolbar-ui>
96
- <card-ui>
97
- <section bleed>
98
- <table-ui id="orders" sortable selectable></table-ui>
99
- </section>
100
- <footer>
101
- <pagination-ui total="100" page="1" siblings="2"></pagination-ui>
102
- </footer>
103
- </card-ui>
104
- </col-ui>
105
- ```
106
-
107
- ### Alternative — toolbar inside card, two-`<section>` split
108
-
109
- When the surface already has a card-ui header (title + non-toolbar
110
- actions like "Export" or "New"), keep the toolbar **inside** the same
111
- card. The composition uses **two adjacent `<section>` slots**: toolbar
112
- in normal-inset section, table in `<section bleed>`. Substrate-supported,
113
- intentional — the `bleed` attribute is a per-section knob, not a
114
- per-card knob.
115
-
116
- ```html
117
- <card-ui>
118
- <header>
119
- <span slot="heading" variant="section">Recent orders</span>
120
- <button-ui slot="action" text="Export" variant="ghost" size="sm" icon="download"></button-ui>
121
- </header>
122
- <section>
123
- <table-toolbar-ui for="orders"></table-toolbar-ui>
124
- </section>
125
- <section bleed>
126
- <table-ui id="orders" sortable selectable></table-ui>
127
- </section>
128
- <footer>
129
- <pagination-ui total="100" page="1" siblings="2"></pagination-ui>
130
- </footer>
131
- </card-ui>
132
- ```
133
-
134
- > ⚠️ **Do NOT place `<table-toolbar-ui>` and `<table-ui>` inside the same
135
- > `<section bleed>`.** Both get edge-to-edge treatment; the toolbar's
136
- > Filter / Sort / Columns / Search controls then touch the card border
137
- > with zero inset. The right shape uses **two** sections — `<section>`
138
- > (normal inset) for the toolbar, `<section bleed>` (opt-out) for the
139
- > table.
140
- >
141
- > Likewise, do NOT place both inside a single bare `<section>` (no
142
- > bleed). The table rows then get the card's content padding on every
143
- > row — wasted horizontal real estate on a data-dense surface.
144
- >
145
- > Reference: `card-ui.yaml` `<section[bleed]>` slot; `table-toolbar.yaml`
146
- > a2ui.rules: "Place the toolbar ABOVE the `<card-ui>` containing the
147
- > table-ui, or use `[variant="card"]` when standing alone outside a
148
- > card-ui parent."
149
-
150
- ### Anti-pattern — hand-rolled toolbar children
151
-
152
- `<table-toolbar-ui>` **always auto-renders** Filter / Sort / Columns /
153
- Search (each opt-out-able via `no-filter` / `no-sort` / `no-columns` /
154
- `no-search` booleans). Slotted filter children render in **addition**
155
- to the auto-chrome — produces a doubled toolbar:
156
-
157
- ```html
158
- <!-- WRONG — double chrome -->
159
- <table-toolbar-ui for="orders">
160
- <input-ui placeholder="Search…" icon="magnifying-glass"></input-ui>
161
- <select-ui placeholder="Status"></select-ui> <!-- redundant -->
162
- <select-ui placeholder="Tags"></select-ui> <!-- redundant -->
163
- </table-toolbar-ui>
164
- ```
165
-
166
- The right shape: drop the children, let the toolbar own its chrome.
167
- For trailing primary actions (Invite / Export / +New), use the
168
- `slot="actions"` slot:
169
-
170
- ```html
171
- <table-toolbar-ui for="orders" text="Recent orders">
172
- <button-ui slot="actions" text="New" variant="primary" icon="plus"></button-ui>
173
- </table-toolbar-ui>
174
- ```
175
-
176
- Equally wrong: re-implementing filter / search affordances in the parent
177
- `<card-ui>`'s `<header>` while `<table-toolbar-ui>` is mounted — same
178
- doubled-chrome class. The toolbar owns those affordances; card-header
179
- buttons should be page-level actions (Export, +New, Invite), not data
180
- controls.
181
-
182
- Reference: `table-toolbar.yaml` props lines 32-81 (six boolean opt-outs
183
- + `for=` binding); a2ui.rules: "Do NOT also use `<card-ui>`'s `<header>`
184
- on the same card — that produces a doubled chrome row."
185
-
186
- ### Column descriptors — pass raw values, use cell-types
187
-
188
- `<table-ui>` ships **12 built-in cell types** + a `render(value, row, cell, meta)`
189
- escape hatch for custom rendering. Each cell type handles formatting,
190
- default alignment, and the sort comparator automatically — pre-formatting
191
- values in the data layer **defeats the cell-types system + leaves columns
192
- left-aligned by default**.
193
-
194
- ```js
195
- const STATUS_VARIANTS = {
196
- 'submitted': 'info',
197
- 'in-review': 'warning',
198
- 'approved': 'success',
199
- 'denied': 'danger',
200
- 'paid': 'success',
201
- };
202
-
203
- const table = document.querySelector('#orders');
204
- table.columns = [
205
- { key: 'id', label: 'Order ID' },
206
- { key: 'customer', label: 'Customer' },
207
- { key: 'status', label: 'Status', type: 'badge',
208
- meta: { variants: STATUS_VARIANTS } },
209
- { key: 'amount', label: 'Amount', type: 'currency',
210
- meta: { currency: 'USD' } },
211
- { key: 'submitted', label: 'Submitted', type: 'date' },
212
- { key: 'tags', label: 'Tags', render: renderTagsCell,
213
- sortable: false },
214
- ];
215
- table.data = [
216
- { id: '#4021', customer: 'Jane Doe', status: 'approved',
217
- amount: 240.00, submitted: '2026-05-19T22:34:03Z',
218
- tags: [{ id: 't1', label: 'priority', color: 'warning' }] },
219
- { id: '#4022', customer: 'John Smith', status: 'in-review',
220
- amount: 89.50, submitted: '2026-05-20T08:12:11Z',
221
- tags: [] },
222
- ];
223
-
224
- function renderTagsCell(value, row, cell) {
225
- cell.replaceChildren();
226
- const tags = Array.isArray(value) ? value : [];
227
- if (tags.length === 0) return;
228
- const chips = document.createElement('row-ui');
229
- chips.setAttribute('gap', '1');
230
- for (const t of tags) {
231
- const chip = document.createElement('tag-ui');
232
- chip.setAttribute('variant', t.color ?? 'neutral');
233
- chip.setAttribute('size', 'sm');
234
- chip.textContent = t.label;
235
- chips.appendChild(chip);
236
- }
237
- cell.appendChild(chips);
238
- }
239
- ```
240
-
241
- Note: `row.amount` is a raw `number` (not `"$240.00"`), `row.submitted`
242
- is a raw ISO string (not `"May 19, 2026"`), `row.tags` is a raw array
243
- (not `"priority, paid"`). The data layer stays domain-shaped; the
244
- cell-type formats on render — and the `currency` type right-aligns
245
- the column automatically (consumer-set `align: 'end'` on the column
246
- descriptor has no effect without a matching `type:`).
247
-
248
- ### Built-in cell types
249
-
250
- Source-of-truth: `node_modules/@adia-ai/web-components/components/table/cell-types.js`.
251
- The 12 registered types:
252
-
253
- | Type | Value shape | Renders as | Align | Sort |
254
- |---|---|---|---|---|
255
- | `text` (default) | string | flat text | left | alphanumeric |
256
- | `number` | number | `Intl.NumberFormat()` | right | numeric |
257
- | `currency` | number | `Intl.NumberFormat(currency)` (default USD; override via `meta.currency`) | right | numeric |
258
- | `percent` | number | `${value}%` | right | numeric |
259
- | `date` | ISO string \| Date | localized date (`Intl.DateTimeFormat`, year/month/day) | left | datetime |
260
- | `datetime` | ISO string \| Date | localized date + time | left | datetime |
261
- | `boolean` | boolean | `<check-ui disabled>` (checked if truthy) | center | basic |
262
- | `badge` | string (also lookup key) | `<badge-ui>` with variant from `meta.variants[value]` | left | alphanumeric |
263
- | `avatar` | string (name) | `<avatar-ui>` + name span | left | alphanumeric |
264
- | `link` | string (URL or email) | `<a href>` (auto `mailto:` when string contains `@`); use `meta.label` to override display text | left | alphanumeric |
265
- | `progress` | number (0-100) | `<progress-ui>` + `${value}%` label | left | numeric |
266
- | `actions` | (no value) — uses `meta.actions: [{ icon, label, event }]` | `<row-ui gap="1">` of `<button-ui icon variant="ghost" size="sm">`; click dispatches `CustomEvent(action.event, { detail: { row } })` | center | (no sort) |
267
-
268
- The `meta` field on a column descriptor is per-cell-type configuration:
269
- `meta.variants` for `badge`, `meta.currency` for `currency`,
270
- `meta.actions` for `actions`, `meta.label` for `link`.
271
-
272
- ### Custom rendering — the `render` escape hatch
273
-
274
- For cells that need multiple elements, complex layout, or a primitive
275
- not in the built-in registry (multi-tag chip rows, avatar stacks,
276
- inline editors), use `render(value, row, cell, meta)`:
277
-
278
- ```js
279
- { key: 'tags', label: 'Tags', sortable: false,
280
- render(value, row, cell, meta) {
281
- cell.replaceChildren();
282
- // ...build DOM into `cell`...
283
- },
284
- format(value /* , row, meta */) {
285
- return (value ?? []).map(t => t.label).join(', ');
286
- },
287
- }
288
- ```
289
-
290
- Constraints:
291
-
292
- - `render` receives the cell element directly — write into it via
293
- `cell.replaceChildren()` + `cell.appendChild(...)`. Don't return a
294
- string or a node.
295
- - `render` is called on every re-render (filter / sort / paginate /
296
- data refresh). Memoize expensive content per row; reuse existing
297
- child nodes when possible. The built-in `badge` / `avatar` /
298
- `progress` cell types model this pattern (re-attach to the cached
299
- child element rather than recreate).
300
- - Set `sortable: false` on columns whose value isn't a sortable scalar
301
- (multi-element cells like tag-chip rows have no obvious scalar to
302
- sort on).
303
- - Provide `format(value, row, meta)` as the **text-export fallback** —
304
- used by CSV export, accessibility (screen reader), and aria-label
305
- composition. Without it, `render`-only cells export as empty strings.
306
-
307
- ### Toolbar — opt-outs
308
-
309
- `<table-toolbar-ui>` shows search + filter + sort + columns by default.
310
- Hide individually via boolean attributes (each `default: false`,
311
- `reflect: true`):
312
-
313
- | Attribute | Hides |
314
- |---|---|
315
- | `no-filter` | Filter popover button |
316
- | `no-sort` | Sort popover button |
317
- | `no-columns` | Columns visibility popover button |
318
- | `no-search` | Search input |
319
-
320
- Plus: `variant="card"` wraps the toolbar in card-ui-style chrome when
321
- standing alone outside a parent `<card-ui>` (used when the toolbar
322
- floats above a bare table without its own card frame).
323
-
324
- Filter rows auto-pick a primitive per column from the bound table's
325
- `.columns` metadata:
326
-
327
- - ≤ 50 distinct values → multi-select (searchable when ≥ 12 options)
328
- - id-like keys → free-text contains input
329
- - Override per column via the column descriptor's `filterType: 'select' | 'text'`
330
-
331
- Use `<section bleed>` for edge-to-edge table inside a card.
332
- Table `kind` options: `traditional` (default), `data-list`, `spreadsheet`.
333
-
334
- ## Chart
335
-
336
- ```html
337
- <card-ui>
338
- <header>
339
- <span slot="heading" variant="section">Revenue</span>
340
- <span slot="description">Last 6 months</span>
341
- </header>
342
- <section>
343
- <chart-ui id="revenue" type="bar" x="month" y="revenue"></chart-ui>
344
- </section>
345
- </card-ui>
346
- ```
347
-
348
- ```js
349
- document.querySelector('#revenue').data = [
350
- { month: 'Jan', revenue: 4200 },
351
- { month: 'Feb', revenue: 5100 },
352
- { month: 'Mar', revenue: 4800 },
353
- ];
354
- ```
355
-
356
- Chart palette: `--a-chart-1` through `--a-chart-10` (OKLCH categorical colors, 36 degree spacing).
357
-
358
- ## Progress & Loading
359
-
360
- ```html
361
- <!-- Linear progress -->
362
- <col-ui gap="1">
363
- <row-ui justify="between">
364
- <small>Storage</small><small>6.5 / 10 GB</small>
365
- </row-ui>
366
- <progress-ui value="65"></progress-ui>
367
- </col-ui>
368
-
369
- <!-- Skeleton placeholder -->
370
- <card-ui>
371
- <section><col-ui gap="4">
372
- <skeleton-ui height="1.5rem" width="60%"></skeleton-ui>
373
- <skeleton-ui height="1rem" width="80%"></skeleton-ui>
374
- <skeleton-ui height="200px"></skeleton-ui>
375
- </col-ui></section>
376
- </card-ui>
377
- ```
378
-
379
- ## Live Data (DataStreamController)
380
-
381
- ```js
382
- import { DataStreamController } from '@adiahealth/web-components/controllers';
383
-
384
- const ctrl = new DataStreamController({ max: 50, throttle: 200 });
385
- ctrl.connect(chartElement);
386
- ctrl.commands.connect('sse', '/api/metrics/stream');
387
- // Chart auto-updates as data arrives via ctrl.notify()
388
- ```
389
-
390
- Supports: `connect('sse'|'ws', url)`, `poll(url, interval)`, `consume(asyncIterable)`, `push(point)`.
391
-
392
- ## Dashboard Layout
393
-
394
- When this fragment is hosted inside an `<admin-shell>` route fragment,
395
- wrap it in `<admin-page-body>` → `<section>` per the shell-anatomy
396
- contract (see `patterns-shells.md` §Wrapper-vs-column + recipes-route-
397
- fragment). The `<col-ui gap="4">` below is the **content owner inside
398
- the `<section>`**, not the direct child of `<admin-page-body>`.
399
-
400
- ```html
401
- <admin-page>
402
- <admin-page-header>
403
- <header>
404
- <div>
405
- <h1>Dashboard</h1>
406
- <div data-actions>
407
- <select-ui size="sm">
408
- <option>Last 7 days</option>
409
- <option>Last 30 days</option>
410
- </select-ui>
411
- <button-ui text="Export" variant="outline" size="sm" icon="download"></button-ui>
412
- </div>
413
- </div>
414
- </header>
415
- </admin-page-header>
416
- <admin-page-body>
417
- <section>
418
- <col-ui gap="4">
419
-
420
- <!-- KPI row -->
421
- <grid-ui columns="4" gap="2"><!-- stat cards --></grid-ui>
422
-
423
- <!-- Charts -->
424
- <grid-ui columns="2" gap="4">
425
- <card-ui>
426
- <header><span slot="heading" variant="section">Revenue</span></header>
427
- <section><chart-ui id="rev" type="bar" x="month" y="value"></chart-ui></section>
428
- </card-ui>
429
- <card-ui>
430
- <header><span slot="heading" variant="section">Users</span></header>
431
- <section><chart-ui id="users" type="bar" x="month" y="value"></chart-ui></section>
432
- </card-ui>
433
- </grid-ui>
434
-
435
- <!-- Activity table — toolbar above card per table-toolbar.yaml canonical -->
436
- <col-ui gap="3">
437
- <table-toolbar-ui for="activity" text="Recent activity"></table-toolbar-ui>
438
- <card-ui>
439
- <section bleed><table-ui id="activity" sortable></table-ui></section>
440
- <footer><pagination-ui total="100" page="1" siblings="2"></pagination-ui></footer>
441
- </card-ui>
442
- </col-ui>
443
-
444
- </col-ui>
445
- </section>
446
- </admin-page-body>
447
- </admin-page>
448
- ```
449
-
450
- The `<section>` inside `<admin-page-body>` owns the page-content inset
451
- (`--card-inset`). The `<col-ui gap="4">` is the content column inside
452
- that inset — never a direct child of `<admin-page-body>`. Same contract
453
- as `patterns-shells.md` §Wrapper-vs-column.
454
-
455
- ## Asymmetric Grid (3:2, 2:1)
456
-
457
- Dashboards routinely want charts wider than sidebars. Express the split via
458
- `grid-ui[columns="N"]` + `[span="M"]` on children. Do **not** use inline
459
- `style="grid-template-columns: …"`.
460
-
461
- ```html
462
- <!-- 2:1 — main chart + sidebar donut -->
463
- <grid-ui columns="3" gap="4">
464
- <card-ui span="2">
465
- <header><h3>Traffic</h3></header>
466
- <section><chart-ui type="line" ...></chart-ui></section>
467
- </card-ui>
468
- <card-ui>
469
- <header><h3>Sources</h3></header>
470
- <section><chart-ui type="donut" ...></chart-ui></section>
471
- </card-ui>
472
- </grid-ui>
473
-
474
- <!-- 3:2 — overview + recent activity -->
475
- <grid-ui columns="5" gap="4">
476
- <card-ui span="3">
477
- <header><h3>Overview</h3></header>
478
- <section><chart-ui type="bar" ...></chart-ui></section>
479
- </card-ui>
480
- <card-ui span="2">
481
- <header><h3>Recent Sales</h3></header>
482
- <section><list-ui divider>...</list-ui></section>
483
- </card-ui>
484
- </grid-ui>
485
- ```
486
-
487
- Available ratios out of the box: 1:1 (`columns="2"`), 2:1 (`columns="3"` +
488
- `span="2"`), 3:1 (`columns="4"` + `span="3"`), 3:2 (`columns="5"` +
489
- `span="3"+"2"`), 4:1 (`columns="5"` + `span="4"`). Don't reach for 7:5 or
490
- similar — round to 3:2 instead.
491
-
492
- ## Dashboard Tabs (with routing)
493
-
494
- Declare tabs in the header; put each tab's body in a `[data-tab-panel]`
495
- sibling. A 10-line setup.js switches visibility on change.
496
-
497
- ```html
498
- <div data-content-header>
499
- <header>
500
- <div>
501
- <h1>Analytics</h1>
502
- <div data-actions>
503
- <segmented-ui value="30d">
504
- <segment-ui value="7d" text="7d"></segment-ui>
505
- <segment-ui value="30d" text="30d"></segment-ui>
506
- <segment-ui value="90d" text="90d"></segment-ui>
507
- </segmented-ui>
508
- </div>
509
- </div>
510
- <p>Visitor behavior, acquisition, and conversion.</p>
511
- <tabs-ui value="audience">
512
- <tab-ui value="audience" text="Audience"></tab-ui>
513
- <tab-ui value="acquisition" text="Acquisition"></tab-ui>
514
- <tab-ui value="behavior" text="Behavior"></tab-ui>
515
- <tab-ui value="conversion" text="Conversion"></tab-ui>
516
- </tabs-ui>
517
- </header>
518
- </div>
519
-
520
- <div data-content-body>
521
- <section>
522
- <div data-dashboard-content>
523
-
524
- <!-- cross-tab filter bar lives OUTSIDE the panels -->
525
- <grid-ui><card-ui><section>...</section></card-ui></grid-ui>
526
-
527
- <div data-tab-panel="audience">...</div>
528
- <div data-tab-panel="acquisition">...</div>
529
- <div data-tab-panel="behavior">...</div>
530
- <div data-tab-panel="conversion">...</div>
531
-
532
- </div>
533
- </section>
534
- </div>
535
- ```
536
-
537
- ```javascript
538
- // index.setup.js — 10-line tab wiring (identical on every dashboard)
539
- const tabs = router.querySelector('tabs-ui');
540
- if (tabs) {
541
- const panels = router.querySelectorAll('[data-tab-panel]');
542
- const sync = (value) => {
543
- for (const p of panels) p.hidden = p.dataset.tabPanel !== value;
544
- };
545
- tabs.addEventListener('change', (e) => sync(e.detail.value));
546
- sync(tabs.value || 'audience');
547
- }
548
- ```
549
-
550
- Cross-tab chrome (filter bars, date range pickers, subtitles) goes OUTSIDE
551
- the panels so it persists across tab switches.
552
-
553
- ## Row-Click → Detail Drawer
554
-
555
- Populated tables can open a detail drawer on row click via `cell-click`.
556
- The drawer pre-declares structure; setup.js fills data-attribute-marked
557
- fields from the clicked row.
558
-
559
- ```html
560
- <!-- Markup -->
561
- <table-ui id="tx-table" sortable striped raw paginate="5"></table-ui>
562
-
563
- <drawer-ui id="drawer-transaction" side="right" size="md">
564
- <header>
565
- <span slot="icon"><avatar-ui icon="receipt"></avatar-ui></span>
566
- <span slot="heading">
567
- <text-ui strong data-tx-customer>Transaction</text-ui>
568
- <badge-ui data-tx-status text="Completed" variant="success"></badge-ui>
569
- </span>
570
- <span slot="action"></span>
571
- </header>
572
- <section>
573
- <text-ui color="subtle" size="sm" data-tx-meta>Details.</text-ui>
574
- </section>
575
- <section>
576
- <grid-ui columns="2" gap="3">
577
- <card-ui size="sm"><section><stat-ui label="Amount" data-tx-amount value="—"></stat-ui></section></card-ui>
578
- <card-ui size="sm"><section><stat-ui label="Date" data-tx-date value="—"></stat-ui></section></card-ui>
579
- </grid-ui>
580
- <divider-ui></divider-ui>
581
- <col-ui gap="3">
582
- <text-ui strong>Customer</text-ui>
583
- <input-ui label="Name" data-tx-name readonly></input-ui>
584
- <input-ui label="Email" data-tx-email readonly></input-ui>
585
- </col-ui>
586
- </section>
587
- <footer slot="footer">
588
- <grid-ui>
589
- <button-ui text="Refund" variant="danger"></button-ui>
590
- <button-ui text="Close" variant="ghost" onclick="this.closest('drawer-ui').open = false"></button-ui>
591
- <button-ui text="View receipt" variant="primary"></button-ui>
592
- </grid-ui>
593
- </footer>
594
- </drawer-ui>
595
- ```
596
-
597
- ```javascript
598
- // setup.js — the fill() helper handles adia-form + text-element asymmetry
599
- const VALUE_TAGS = new Set(['input-ui', 'textarea-ui', 'select-ui', 'stat-ui']);
600
- const fill = (scope, attr, val) => {
601
- const el = scope.querySelector(`[${attr}]`);
602
- if (!el) return;
603
- const tag = el.localName;
604
- if (tag === 'input-ui' || tag === 'textarea-ui') {
605
- // input-ui/textarea-ui sync their [slot="text"] + data-empty from `value`
606
- // (0.6.44+). Do NOT also mirror [slot="text"].textContent — pre-writing it
607
- // makes input-ui's update() see no textContent change, so it skips clearing
608
- // data-empty → the placeholder renders over the value (FB-87).
609
- el.value = val;
610
- } else if (VALUE_TAGS.has(tag)) {
611
- el.setAttribute('value', val);
612
- } else {
613
- el.textContent = val;
614
- }
615
- };
616
-
617
- table.addEventListener('cell-click', (e) => {
618
- const row = e.detail?.row;
619
- if (!row) return;
620
- fill(drawer, 'data-tx-customer', row.customer);
621
- fill(drawer, 'data-tx-meta', `${row.email} · ${row.date}`);
622
- fill(drawer, 'data-tx-name', row.customer);
623
- fill(drawer, 'data-tx-email', row.email);
624
- fill(drawer, 'data-tx-amount', row.amount);
625
- fill(drawer, 'data-tx-date', row.date);
626
- // Status badge uses attribute for both text and variant.
627
- const status = drawer.querySelector('[data-tx-status]');
628
- const variant = { Completed:'success', Pending:'warning', Failed:'danger' }[row.status] || '';
629
- status?.setAttribute('text', row.status);
630
- status?.setAttribute('variant', variant);
631
- drawer.open = true;
632
- });
633
- ```
634
-
635
- ### Editable drawer fields — wrap with `<field-ui>`
636
-
637
- The recipe above shows the **read-only** display case (`input-ui readonly`)
638
- and **numeric KPIs in cards** (`stat-ui` for Amount/Date). For drawers
639
- that ALSO have **editable string/numeric fields** (CRUD detail panels —
640
- edit claim, edit project, edit invoice — the most common admin-shell
641
- drawer shape), wrap each control in `<field-ui>`:
642
-
643
- ```html
644
- <section>
645
- <col-ui gap="3">
646
- <text-ui strong>Claim Details</text-ui>
647
- <grid-ui columns="2" gap="3">
648
- <field-ui label="Amount (USD)">
649
- <input-ui id="drawer-amount" data-tx-amount></input-ui>
650
- </field-ui>
651
- <field-ui label="Status">
652
- <select-ui id="drawer-status" data-tx-status>
653
- <option value="submitted">Submitted</option>
654
- <option value="approved">Approved</option>
655
- <option value="denied">Denied</option>
656
- </select-ui>
657
- </field-ui>
658
- </grid-ui>
659
- <field-ui label="Diagnosis codes (ICD-10)" hint="Comma-separated">
660
- <input-ui id="drawer-diagnosis" data-tx-diagnosis></input-ui>
661
- </field-ui>
662
- <field-ui label="Notes">
663
- <textarea-ui id="drawer-notes" data-tx-notes rows="3"></textarea-ui>
664
- </field-ui>
665
- </col-ui>
666
- </section>
667
- ```
668
-
669
- **Why `<field-ui>` is the canonical wrapper (not `input-ui label=…` bare):**
670
-
671
- `input-ui`'s `label=` attribute is **inline mode** — the label renders
672
- as a leading caption *inside* the input chrome, between any prefix and
673
- the value (per `input.yaml` prop description). When a value is set, the
674
- inline label concatenates visually with the value — e.g.
675
- `<input-ui label="Amount (USD)">` with `value="5475.59"` renders as
676
- "Amount (USD)5475.59" inside the input chrome with no visual separator.
677
- This is correct for compact single-line UI (filter bars, toolbar
678
- inputs), wrong for drawer detail panels where each field needs a
679
- visible stacked label.
680
-
681
- `<field-ui label="…">` is the **stacked label wrapper**: composes a
682
- `<label for="…">` above the control (auto-minting an `id` on the
683
- slotted control when missing, so clicking the label focuses the
684
- control — better accessibility than bare `input-ui label=…`).
685
-
686
- **`<select-ui>`'s `label=` IS already stacked** (renders above the
687
- trigger per `select.yaml`), but the canonical
688
- `packages/web-components/patterns/form-drawer/form-drawer.examples.html`
689
- still wraps `<select-ui>` in `<field-ui>` for visual + accessibility
690
- consistency with input/textarea siblings — the `<label for=…>` binding
691
- that field-ui provides is a strict accessibility upgrade. Use
692
- `<field-ui>` uniformly for all three (`input-ui`, `select-ui`,
693
- `textarea-ui`); don't mix bare `label=…` on some and `<field-ui>`
694
- wrap on others.
695
-
696
- **Authoritative exemplar**: `packages/web-components/patterns/form-drawer/form-drawer.examples.html`
697
- in the substrate — uses `<field-ui>` 17 times across "simple create
698
- form" + "edit form with destructive action" + multi-step variants.
699
- That's the canonical reference if you need more permutations.
700
-
701
- ### ⚠️ Do NOT substitute `<stat-ui>` for `<input-ui readonly>` on string fields
702
-
703
- When the user complains "too many inputs" about a drawer's read-only
704
- display fields, the wrong fix is to replace `<input-ui readonly>` with
705
- `<stat-ui label="…" value="…">`. `<stat-ui>` is a **KPI display
706
- primitive** — value + label rendered as a stacked block with prominent
707
- value in title-weight CSS (per `stat.yaml`). This is correct for
708
- numeric metrics like "766 claims" or "$48,200 revenue" displayed in
709
- dashboard cards (the §Row-Click recipe above uses `<stat-ui>` for
710
- "Amount" + "Date" cards, which IS a legitimate KPI use). It is
711
- **visually catastrophic for string values** (names, IDs, statuses,
712
- diagnosis codes) at prose scale inside a drawer panel — the name
713
- renders as headline-weight text and the drawer reads as a KPI dashboard
714
- tile instead of a detail panel.
715
-
716
- **The right alternatives**:
717
-
718
- | Use case | Right primitive |
719
- |----------|-----------------|
720
- | Numeric metric in a dashboard card / drawer summary tile | `<stat-ui label value>` (KPI weight) |
721
- | Editable form field with stacked label | `<field-ui label><input-ui></input-ui></field-ui>` |
722
- | Read-only mirror of a form field (intentional "looks like an input, isn't editable") | `<field-ui label><input-ui readonly></input-ui></field-ui>` or bare `<input-ui readonly label>` |
723
- | Lightweight key-value pair (label / value at prose scale, no input chrome) | `<col-ui gap="0"><text-ui size="sm" color="subtle">Label</text-ui><text-ui>Value</text-ui></col-ui>` |
724
- | Definition list (multiple compact key-value pairs) | `<dl>` + `<dt>` / `<dd>` (native HTML) OR a `<grid-ui columns="2">` with `<text-ui>` pairs |
725
-
726
- The "too many inputs" complaint usually means the visual density is too
727
- high (form-chrome on every read-only field). The fix is **either** (a)
728
- accept the form-chrome as intentional design-system style (the panel is
729
- an editor surface; readonly inputs read as "you could edit this but
730
- the row is locked"), **or** (b) drop to the lightweight `<col-ui>` +
731
- `<text-ui>` pair shown in the table above for fields that genuinely
732
- don't belong in form chrome. Do NOT reach for `<stat-ui>` — the
733
- title-weight is wrong at prose scale.
734
-
735
- ## Shared Detail Drawer — Per-Row Hydration (no table)
736
-
737
- When a list of cards (not a table) needs a "drill-down" drawer, mount ONE
738
- drawer and re-populate it from each row's click. Use a CustomEvent for the
739
- hydrate step so the drawer doesn't need to know which row triggered it.
740
-
741
- ```html
742
- <!-- Row cards — each carries its own payload in the onclick -->
743
- <card-ui>
744
- <header>
745
- <span slot="icon"><avatar-ui icon="envelope"></avatar-ui></span>
746
- <span slot="heading">
747
- <text-ui strong>james@acme.com</text-ui>
748
- <badge-ui text="Member" variant="info"></badge-ui>
749
- </span>
750
- <span slot="action">
751
- <button-ui
752
- text="Manage"
753
- variant="outline"
754
- size="sm"
755
- onclick="
756
- const d = document.getElementById('drawer-invite');
757
- d.dataset.email = 'james@acme.com';
758
- d.dataset.role = 'Member';
759
- d.dataset.invitedBy = 'Alex Chen';
760
- d.dispatchEvent(new CustomEvent('hydrate'));
761
- d.open = true;
762
- "
763
- ></button-ui>
764
- </span>
765
- </header>
766
- </card-ui>
767
-
768
- <!-- Shared drawer — same layout for every row -->
769
- <drawer-ui id="drawer-invite" side="right" size="md">
770
- <header>
771
- <span slot="icon"><avatar-ui icon="envelope"></avatar-ui></span>
772
- <span slot="heading">
773
- <text-ui strong data-invite-email>Invite</text-ui>
774
- <badge-ui data-invite-role text="Member" variant="info"></badge-ui>
775
- </span>
776
- <span slot="action"></span>
777
- </header>
778
- <section>
779
- <col-ui gap="3">
780
- <input-ui label="Email" data-invite-email readonly></input-ui>
781
- <select-ui label="Role" data-invite-role value="Member">…</select-ui>
782
- <input-ui label="Invited by" data-invite-inviter readonly></input-ui>
783
- </col-ui>
784
- </section>
785
- <footer slot="footer">
786
- <grid-ui>
787
- <button-ui text="Revoke" variant="danger"></button-ui>
788
- <button-ui text="Cancel" variant="ghost"></button-ui>
789
- <button-ui text="Resend" variant="primary"></button-ui>
790
- </grid-ui>
791
- </footer>
792
- </drawer-ui>
793
- ```
794
-
795
- ```javascript
796
- // setup.js — wire the hydrate handler once.
797
- const drawer = router.querySelector('#drawer-invite');
798
-
799
- // querySelectorAll (not querySelector) because the same data-* hook may
800
- // appear on BOTH a [data-…] text-ui heading AND a [data-…] input-ui field.
801
- // Missing the second occurrence is the most common fill-helper bug.
802
- const fill = (attr, val) => {
803
- for (const el of drawer.querySelectorAll(`[${attr}]`)) {
804
- const tag = el.localName;
805
- if (tag === 'input-ui' || tag === 'textarea-ui') {
806
- // input-ui/textarea-ui sync [slot="text"] + data-empty from `value` (0.6.44+);
807
- // mirroring textContent defeats update()'s change check and leaves data-empty
808
- // stale → placeholder over the value (FB-87).
809
- el.value = val;
810
- } else if (tag === 'stat-ui' || tag === 'select-ui') {
811
- el.setAttribute('value', val);
812
- } else if (tag === 'badge-ui') {
813
- el.setAttribute('text', val);
814
- } else {
815
- el.textContent = val;
816
- }
817
- }
818
- };
819
-
820
- drawer.addEventListener('hydrate', () => {
821
- const { email, role, invitedBy } = drawer.dataset;
822
- fill('data-invite-email', email);
823
- fill('data-invite-role', role);
824
- fill('data-invite-inviter', invitedBy);
825
- });
826
- ```
827
-
828
- **When to pick this over the `cell-click` variant:**
829
- - Row data lives in HTML (static list) — no table columns / data binding.
830
- - You want each button to carry explicit payload rather than derive it
831
- from a shared row object.
832
- - The same drawer is reused across multiple discrete list surfaces on the
833
- page (e.g. pending invites + revoked invites both reusing `#drawer-invite`).
834
-
835
- For tables where a row object already exists, prefer the `cell-click` +
836
- `e.detail.row` pattern from "Row-Click → Detail Drawer".
837
-
838
- ## State-Coverage Menu (Default / Loading / Empty / Error)
839
-
840
- To demonstrate all async states on one card, add a `menu-ui` "View as"
841
- trigger in the card's action slot. `action` event drives an `applyState()`
842
- that swaps the card's section children.
843
-
844
- ```html
845
- <card-ui>
846
- <header>
847
- <h3>Recent Transactions</h3>
848
- <text-ui slot="description" color="subtle">Manage and track recent orders</text-ui>
849
- <span slot="action">
850
- <menu-ui id="tx-state-menu">
851
- <button-ui slot="trigger" text="View as" icon="caret-down" variant="ghost" size="sm"></button-ui>
852
- <menu-item-ui text="Default" value="default"></menu-item-ui>
853
- <menu-item-ui text="Loading" value="loading" icon="spinner"></menu-item-ui>
854
- <menu-item-ui text="Empty" value="empty" icon="tray"></menu-item-ui>
855
- <menu-item-ui text="Error" value="error" icon="warning"></menu-item-ui>
856
- </menu-ui>
857
- </span>
858
- </header>
859
- <section bleed data-tx-surface>
860
- <table-ui id="dash-transactions" sortable striped raw paginate="5"></table-ui>
861
- </section>
862
- </card-ui>
863
- ```
864
-
865
- ```javascript
866
- const table = router.querySelector('#dash-transactions');
867
- const surface = router.querySelector('[data-tx-surface]');
868
- const menu = router.querySelector('#tx-state-menu');
869
-
870
- const applyState = (state) => {
871
- // Reset — re-mount the table, clear any overlays, drop loading.
872
- if (!surface.contains(table)) surface.replaceChildren(table);
873
- for (const o of surface.querySelectorAll('[data-tx-overlay]')) o.remove();
874
- table.removeAttribute('loading');
875
-
876
- if (state === 'loading') {
877
- table.setAttribute('loading', '');
878
- } else if (state === 'empty') {
879
- table.data = [];
880
- const empty = document.createElement('empty-state-ui');
881
- empty.setAttribute('icon', 'receipt');
882
- empty.setAttribute('heading', 'No transactions yet');
883
- empty.setAttribute('description', 'Transactions appear here once customers check out.');
884
- empty.setAttribute('data-tx-overlay', '');
885
- empty.innerHTML = '<button-ui slot="action" text="Set up payments" variant="primary"></button-ui>';
886
- surface.replaceChildren(empty);
887
- } else if (state === 'error') {
888
- const alert = document.createElement('alert-ui');
889
- alert.setAttribute('variant', 'danger');
890
- alert.setAttribute('icon', 'warning');
891
- alert.setAttribute('heading', 'Could not load transactions');
892
- alert.setAttribute('text', 'The payments API returned a 502.');
893
- alert.setAttribute('data-tx-overlay', '');
894
- alert.innerHTML = '<row-ui slot="action" gap="2"><button-ui text="Retry" variant="primary" size="sm"></button-ui></row-ui>';
895
- surface.replaceChildren(alert);
896
- } else {
897
- table.data = populatedData;
898
- }
899
- };
900
- applyState('default');
901
- menu?.addEventListener('action', (e) => applyState(e.detail.value));
902
- ```
903
-
904
- Pattern notes:
905
- - `empty-state-ui` carries its own icon/heading/description API; the action
906
- slot is author-owned (a primary CTA).
907
- - `alert-ui variant="danger"` with `slot="action"` holds retry + status
908
- buttons for the error state.
909
- - `loading` is a built-in attribute on `table-ui`; no overlay needed.
910
-
911
- ## Interactive Filter Bar
912
-
913
- Filter chips above a data view. Handle add/remove/clear; let consumers
914
- listen for `filter-change` to persist to URL or query.
915
-
916
- ```html
917
- <row-ui gap="2" align="center" wrap data-filter-bar>
918
- <text-ui color="subtle" weight="semibold">Filters:</text-ui>
919
- <tag-ui text="Country: United States" removable></tag-ui>
920
- <tag-ui text="Device: Desktop" removable></tag-ui>
921
- <button-ui data-filter-add text="+ Add filter" variant="ghost" size="sm"></button-ui>
922
- <span data-spacer></span>
923
- <button-ui data-filter-clear text="Clear all" variant="ghost" size="sm"></button-ui>
924
- </row-ui>
925
- ```
926
-
927
- ```javascript
928
- const filterRow = router.querySelector('[data-filter-bar]');
929
- const addBtn = filterRow.querySelector('[data-filter-add]');
930
- const clearBtn = filterRow.querySelector('[data-filter-clear]');
931
- const presets = ['Country: Germany', 'Device: Mobile', 'Source: Organic', /* … */];
932
- let idx = 0;
933
-
934
- const addFilter = (text) => {
935
- const tag = document.createElement('tag-ui');
936
- tag.setAttribute('text', text);
937
- tag.setAttribute('removable', '');
938
- filterRow.insertBefore(tag, addBtn);
939
- tag.addEventListener('remove', (e) => {
940
- router.dispatchEvent(new CustomEvent('filter-change', { detail: { removed: e.detail.text } }));
941
- });
942
- };
943
-
944
- addBtn.addEventListener('press', () => addFilter(presets[idx++ % presets.length]));
945
- clearBtn.addEventListener('press', () => {
946
- for (const t of filterRow.querySelectorAll('tag-ui[removable]')) t.remove();
947
- });
948
-
949
- // Make authored chips participate in the same event channel.
950
- for (const t of filterRow.querySelectorAll('tag-ui[removable]')) {
951
- t.addEventListener('remove', (e) => {
952
- router.dispatchEvent(new CustomEvent('filter-change', { detail: { removed: e.detail.text } }));
953
- });
954
- }
955
- ```
956
-
957
- Key: `tag-ui[removable]` dispatches a `remove` CustomEvent and then removes
958
- itself, so the listener is a signal-only hook. `button-ui` uses a `press`
959
- event (not `click`) — fires on both click and keyboard activation.
960
-
961
- ## Conversion Funnel (composition recipe)
962
-
963
- A funnel without a `funnel-ui` component. Top step is a plain row showing
964
- 100% as the anchor; remaining steps are `progress-row-ui`.
965
-
966
- ```html
967
- <card-ui>
968
- <header>
969
- <h3>Conversion funnel</h3>
970
- <text-ui slot="description" color="subtle">Visit → Sign up → Activate → Convert</text-ui>
971
- </header>
972
- <section>
973
- <col-ui gap="3">
974
- <row-ui align="center" gap="3">
975
- <col-ui gap="0" grow>
976
- <text-ui strong>Visited landing page</text-ui>
977
- <text-ui color="subtle">28,432 visitors</text-ui>
978
- </col-ui>
979
- <text-ui strong>100%</text-ui>
980
- </row-ui>
981
- <progress-row-ui label="Signed up" value="42" meta="11,942 · 42%"></progress-row-ui>
982
- <progress-row-ui label="Activated" value="28" meta="7,961 · 28%"></progress-row-ui>
983
- <progress-row-ui label="Converted to paid" value="8" meta="2,274 · 8.0%"></progress-row-ui>
984
- </col-ui>
985
- </section>
986
- </card-ui>
987
- ```
988
-
989
- Meta format: `"{count} · {percent}"`. Count first reads as data, percent
990
- second reads as insight. Step-over-step delta is an optional badge in a
991
- sibling column: `warning` for 20–40% drop, `danger` above 40%.
992
-
993
- ## Retention Cohort (composition recipe)
994
-
995
- A retention "heatmap" as a regular `table-ui` with a badge-cell renderer.
996
- Each percentage wraps in a `badge-ui` variant keyed to three buckets.
997
-
998
- ```javascript
999
- const formatPct = (v) => {
1000
- if (v == null) return ''; // partial cohort → blank, never zero
1001
- const n = Math.round(v);
1002
- const variant = n >= 60 ? 'success' : n >= 30 ? 'warning' : 'muted';
1003
- return `<badge-ui text="${n}%" variant="${variant}"></badge-ui>`;
1004
- };
1005
-
1006
- const columns = [
1007
- { key: 'week', label: 'Signup week' },
1008
- { key: 'size', label: 'Users' },
1009
- { key: 'w1', label: 'W1', render: formatPct },
1010
- { key: 'w2', label: 'W2', render: formatPct },
1011
- { key: 'w4', label: 'W4', render: formatPct },
1012
- { key: 'w8', label: 'W8', render: formatPct },
1013
- { key: 'w12', label: 'W12', render: formatPct },
1014
- ];
1015
- ```
1016
-
1017
- Bucket thresholds: 60/30 for SaaS; 40/20 for transactional products.
1018
- Canonical column set: `Signup week · Users · W1 · W2 · W3 · W4 · W8 · W12`.
1019
-
1020
- ## Radio Cards (rich radio-group)
1021
-
1022
- For "choose a plan"-style selection with rich labels + descriptions, wrap
1023
- each option in `card-ui` with a `<radio-ui slot="icon">`, then wrap the whole
1024
- list in `role="radiogroup"` so `radio-ui`'s sibling lookup works across
1025
- containers.
1026
-
1027
- ```html
1028
- <div role="radiogroup" aria-label="Plan" data-plan-picker
1029
- onclick="const r = event.target.closest('card-ui')?.querySelector('radio-ui'); r && r.click()">
1030
- <list-ui divider>
1031
- <list-item-ui>
1032
- <card-ui>
1033
- <header>
1034
- <radio-ui slot="icon" name="plan" value="pro" checked></radio-ui>
1035
- <span slot="heading">
1036
- <text-ui strong>Pro · $49/mo</text-ui>
1037
- <badge-ui text="Current" variant="accent"></badge-ui>
1038
- </span>
1039
- <span slot="description">
1040
- <text-ui color="subtle" size="sm">Unlimited conversations, advanced charts, 100 GB storage</text-ui>
1041
- </span>
1042
- </header>
1043
- </card-ui>
1044
- </list-item-ui>
1045
- <!-- additional options -->
1046
- </list-ui>
1047
- </div>
1048
- ```
1049
-
1050
- The inline delegate forwards any card-click to the card's inner radio;
1051
- radio-ui's own click handler no-ops on an already-checked radio, so
1052
- click-through is safe.
1053
-
1054
- ## Alerts & Badges
1055
-
1056
- ```html
1057
- <alert-ui variant="success" icon="check-circle" text="Changes saved"></alert-ui>
1058
- <alert-ui variant="danger" icon="warning" text="Failed to load"></alert-ui>
1059
- <alert-ui variant="warning" icon="info" text="Trial expires in 3 days"></alert-ui>
1060
-
1061
- <badge-ui text="Active" variant="success"></badge-ui>
1062
- <badge-ui text="Pending" variant="warning"></badge-ui>
1063
- <badge-ui text="Failed" variant="danger"></badge-ui>
1064
- <badge-ui status="New"></badge-ui> <!-- auto-maps to info variant -->
1065
- <badge-ui variant="info" size="xs"></badge-ui>
1066
- ```