@teamblind-chorus/ui 1.1.0 → 1.2.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 (46) hide show
  1. package/agents/catalog.md +6 -4
  2. package/agents/components/avatar-rail/avatar-rail.spec.json +19 -0
  3. package/agents/components/banner/banner.family.json +3 -1
  4. package/agents/components/banner/banner.md +54 -1
  5. package/agents/components/banner/banner.spec.json +24 -1
  6. package/agents/components/button/check.spec.json +19 -0
  7. package/agents/components/button/fab.spec.json +19 -0
  8. package/agents/components/button/icon.spec.json +19 -0
  9. package/agents/components/button/standard.spec.json +19 -0
  10. package/agents/components/button/text.spec.json +19 -0
  11. package/agents/components/button/toggle.spec.json +19 -0
  12. package/agents/components/chip/filter.spec.json +19 -0
  13. package/agents/components/chip/tag.spec.json +19 -0
  14. package/agents/components/empty-state/empty-state.family.json +28 -0
  15. package/agents/components/empty-state/empty-state.md +69 -0
  16. package/agents/components/empty-state/empty-state.spec.json +87 -0
  17. package/agents/components/form-field/input.spec.json +8 -1
  18. package/agents/components/form-field/search.spec.json +8 -1
  19. package/agents/components/form-field/select.spec.json +9 -1
  20. package/agents/components/form-field/textarea.spec.json +8 -1
  21. package/agents/components/list/accordion.spec.json +9 -0
  22. package/agents/components/list/entry.spec.json +19 -0
  23. package/agents/components/list/radio.spec.json +19 -0
  24. package/agents/components/list/standard.md +46 -0
  25. package/agents/components/list/standard.spec.json +37 -2
  26. package/agents/components/nav-card/nav-card.spec.json +9 -0
  27. package/agents/components/page-shell/page-shell.family.json +1 -1
  28. package/agents/components/page-shell/page-shell.md +33 -0
  29. package/agents/components/page-shell/page-shell.spec.json +85 -0
  30. package/agents/components/spinner/spinner.family.json +27 -0
  31. package/agents/components/spinner/spinner.md +98 -0
  32. package/agents/components/spinner/spinner.spec.json +82 -0
  33. package/agents/components/switch/switch.spec.json +9 -0
  34. package/agents/components/tab-bar/tab-bar.spec.json +16 -0
  35. package/agents/components/tabs/rounded.spec.json +19 -0
  36. package/agents/components/tabs/underline.spec.json +19 -0
  37. package/agents/manifest.json +8 -6
  38. package/agents/usage.json +12 -0
  39. package/dist/index.cjs +340 -60
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.cts +46 -2
  42. package/dist/index.d.ts +46 -2
  43. package/dist/index.js +339 -61
  44. package/dist/index.js.map +1 -1
  45. package/dist/styles.css +182 -0
  46. package/package.json +1 -1
@@ -0,0 +1,87 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "EmptyState",
4
+ "family": "empty-state",
5
+ "description": "The centered composition a surface paints when it holds no data yet — an optional monochrome illustration, a required headline, optional body copy, and an optional primary CTA — stacked and centered inside the space the real content would occupy. Three lines of copy at most: what the surface is for, why it is empty, and the one action that fills it (the CTA). Reach for it whenever a feed, list, inbox, or search result would otherwise paint blank; the system hard-rule forbids leaving a no-data surface empty. Distinct from Skeleton (an in-flight tonal placeholder for data that is loading), this is the durable zero-state surface.",
6
+ "element": "div",
7
+ "props": {
8
+ "illustration": {
9
+ "type": "node",
10
+ "optional": true,
11
+ "description": "Optional leading glyph or illustration, centered above the headline. Sized to a `ref.space.600` (48) box — larger than `sys.icon.lg` (24), realizing DESIGN.md's `icon.xl` or larger intent (no `icon.xl` icon-size rung exists; the icon scale stops at `lg`). Painted in `sys.color.onSurfaceVariant` via `currentColor` so it reads as quiet, monochrome chrome — illustrations stay monochrome unless they carry deliberate brand-moment intent. Separated from the headline by `sys.layout.stack.sm` (12)."
12
+ },
13
+ "headline": {
14
+ "type": "node",
15
+ "required": true,
16
+ "description": "The required lead line. `sys.typo.heading.sm` in `sys.color.onSurface`. Names what the surface is for / why it is empty in one short line (e.g. 'No posts yet')."
17
+ },
18
+ "body": {
19
+ "type": "node",
20
+ "optional": true,
21
+ "description": "Optional supporting line below the headline. `sys.typo.body.sm` in `sys.color.onSurfaceVariant`, separated from the headline by `sys.layout.stack.2xs` (4). One sentence — the second of the three lines (e.g. 'Conversations you start or join will appear here')."
22
+ },
23
+ "action": {
24
+ "type": "object",
25
+ "optional": true,
26
+ "description": "{ label, href?, onClick? } — the primary CTA. Renders a default-size primary `Button` (the surface's primary action — the one thing that fills the empty surface). Placed below the body with a `sys.layout.stack.md` (16) gap. There is NO `cta` slot to fill with a custom button; pass the action object so the primary Button is composed for you."
27
+ }
28
+ },
29
+ "slots": {
30
+ "container": {
31
+ "required": true,
32
+ "description": "Centered flex column holding the whole composition. `align-items: center`, `text-align: center`. Ships no surface fill or chrome of its own — it sits inside the host surface that would otherwise hold the data. `role='status'` so assistive tech announces the empty state without yanking focus.",
33
+ "intrinsic": true
34
+ },
35
+ "illustration": {
36
+ "required": false,
37
+ "description": "Optional centered glyph / illustration above the headline. `ref.space.600` (48) box, painted in `sys.color.onSurfaceVariant` (monochrome). `sys.layout.stack.sm` (12) below it to the headline.",
38
+ "accepts": ["icon"]
39
+ },
40
+ "headline": {
41
+ "required": true,
42
+ "description": "Required headline line. `sys.typo.heading.sm` / `sys.color.onSurface`.",
43
+ "accepts": ["text"]
44
+ },
45
+ "body": {
46
+ "required": false,
47
+ "description": "Optional supporting line. `sys.typo.body.sm` / `sys.color.onSurfaceVariant`. `sys.layout.stack.2xs` (4) above it from the headline.",
48
+ "accepts": ["text"]
49
+ },
50
+ "action": {
51
+ "required": false,
52
+ "description": "Optional primary CTA. A default-size primary `Button` composed from the `action` object. `sys.layout.stack.md` (16) above it from the body.",
53
+ "accepts": ["button"]
54
+ }
55
+ },
56
+ "sizing": {
57
+ "containerAlign": "center",
58
+ "illustrationSize": "ref.space.600",
59
+ "illustrationColor": "sys.color.onSurfaceVariant",
60
+ "illustrationGap": "sys.layout.stack.sm",
61
+ "headlineTypo": "sys.typo.heading.sm",
62
+ "headlineColor": "sys.color.onSurface",
63
+ "bodyTypo": "sys.typo.body.sm",
64
+ "bodyColor": "sys.color.onSurfaceVariant",
65
+ "bodyGap": "sys.layout.stack.2xs",
66
+ "actionGap": "sys.layout.stack.md"
67
+ },
68
+ "appearance": {
69
+ "illustration": "sys.color.onSurfaceVariant",
70
+ "headline": "sys.color.onSurface",
71
+ "body": "sys.color.onSurfaceVariant",
72
+ "note": "No emphasis axis — EmptyState has one quiet appearance. The illustration and body sit in the muted `onSurfaceVariant` tone; the headline steps up to `onSurface`. The only chromatic emphasis is the CTA, which is a primary `Button` (its own `sys.color.primary` fill) so the single fill-the-surface action reads as primary both in intent and visually."
73
+ },
74
+ "behavior": {
75
+ "centered": "The whole composition is centered (block + inline) inside the host surface that would otherwise hold the data. EmptyState owns no surface fill — the host supplies the surface tier and the bounding box; EmptyState only centers its column inside it.",
76
+ "role": "Container carries `role='status'` so the empty state is announced to assistive tech without grabbing focus.",
77
+ "ctaComposition": "The CTA is not a free slot — pass `action={{ label, href?/onClick? }}` and EmptyState composes a default-size primary Button. This keeps the 'one primary action' rule enforced (one CTA, primary appearance) rather than letting callers drop in an arbitrary control."
78
+ },
79
+ "forbidden": [
80
+ "a no-data surface left blank — every empty surface paints an EmptyState (illustration optional, headline + the one fill action required by the copy rule); the system hard-rule forbids an unaddressed empty surface",
81
+ "a dead-end empty state with no path forward — when the surface has a fill action, omit neither the body's explanation nor the `action` CTA; do not strand the user on a blank wall with only a headline",
82
+ "more than three lines of copy — headline + body must read as at most three lines total (what the surface is for · why it is empty · the one action that fills it); longer prose belongs in a Banner or a help surface",
83
+ "the CTA rendered as anything but a default-size primary Button — the fill action is the surface's primary action and must read as primary; do not down-rank it to a text or outlined button, and do not render two competing CTAs",
84
+ "EmptyState used as a loading placeholder for data that is still arriving — that is `skeleton` (an in-flight tonal block). EmptyState is the durable no-data surface, not a transient one",
85
+ "the illustration painted in a chromatic tone for non-brand empty states — it stays monochrome `sys.color.onSurfaceVariant` unless it deliberately carries a brand moment"
86
+ ]
87
+ }
@@ -155,12 +155,19 @@
155
155
  "nestedActionScope": "The pressed overlay (and hover stroke) is suppressed while the pointer presses / hovers the trailing clear button — that '×' is an independent nested action, so the small control owns the state and the large field does not also read as pressed. The visual-state boundary matches the action boundary."
156
156
  },
157
157
  "active": {
158
+ "isFocusState": true,
158
159
  "overlay": null,
159
160
  "border": "borderActive",
160
161
  "strokeWeight": "activeStrokeWeight",
161
162
  "caret": "visible",
162
163
  "showsClearWhenValue": true,
163
- "note": "The stroke steps from its rest `hairline` (1px) to `activeStrokeWeight` (2px) and re-tones to `borderActive` — but it is an inset `box-shadow`, not a `border`, so the box model is untouched: the field's footprint, caret, and text position are pixel-stable as it goes active. Nothing reflows. The clear button is shown only in this state (and only when the value is non-empty)."
164
+ "focusRing": {
165
+ "composition": "outward",
166
+ "layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
167
+ "innerCounterRing": { "width": "sys.borderWidth.hairline", "color": "sys.color.focusInset" },
168
+ "outerRing": { "width": "sys.borderWidth.thin", "color": "sys.color.focus" }
169
+ },
170
+ "note": "This IS the field's keyboard/input-focus state — `active` (caret visible, input engaged) and `:focus-visible` coincide for a text field, so there is no separate `focused` state; the focus ring described here (and in the parallel `focusIndicator` block) is the focus affordance. The stroke steps from its rest `hairline` (1px) to `activeStrokeWeight` (2px) and re-tones to `borderActive` — but it is an inset `box-shadow`, not a `border`, so the box model is untouched: the field's footprint, caret, and text position are pixel-stable as it goes active. Nothing reflows. The clear button is shown only in this state (and only when the value is non-empty)."
164
171
  },
165
172
  "disabled": {
166
173
  "overlay": null,
@@ -96,12 +96,19 @@
96
96
  "nestedActionScope": "The pressed overlay (and hover stroke) is suppressed while the pointer presses / hovers the trailing clear button — that '×' is an independent nested action, so the small control owns the state and the large field does not also read as pressed. The visual-state boundary matches the action boundary."
97
97
  },
98
98
  "active": {
99
+ "isFocusState": true,
99
100
  "overlay": null,
100
101
  "border": "borderActive",
101
102
  "strokeWeight": "activeStrokeWeight",
102
103
  "caret": "visible",
103
104
  "showsClearWhenValue": true,
104
- "note": "The stroke steps from its rest `hairline` (1px) to `activeStrokeWeight` (2px) and re-tones to `borderActive` — but it is an inset `box-shadow`, not a `border`, so the box model is untouched: the field's footprint, caret, and text position are pixel-stable as it goes active. Nothing reflows. The clear button is shown only in this state (and only when the value is non-empty)."
105
+ "focusRing": {
106
+ "composition": "outward",
107
+ "layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
108
+ "innerCounterRing": { "width": "sys.borderWidth.hairline", "color": "sys.color.focusInset" },
109
+ "outerRing": { "width": "sys.borderWidth.thin", "color": "sys.color.focus" }
110
+ },
111
+ "note": "This IS the field's keyboard/input-focus state — `active` (caret visible, input engaged) and `:focus-visible` coincide for a text field, so there is no separate `focused` state; the focus ring described here (and in the parallel `focusIndicator` block) is the focus affordance. The stroke steps from its rest `hairline` (1px) to `activeStrokeWeight` (2px) and re-tones to `borderActive` — but it is an inset `box-shadow`, not a `border`, so the box model is untouched: the field's footprint, caret, and text position are pixel-stable as it goes active. Nothing reflows. The clear button is shown only in this state (and only when the value is non-empty)."
105
112
  },
106
113
  "disabled": {
107
114
  "overlay": null,
@@ -150,9 +150,17 @@
150
150
  }
151
151
  },
152
152
  "active": {
153
+ "isFocusState": true,
153
154
  "overlay": null,
154
155
  "border": "borderActive",
155
- "strokeWeight": "activeStrokeWeight"
156
+ "strokeWeight": "activeStrokeWeight",
157
+ "focusRing": {
158
+ "composition": "outward",
159
+ "layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
160
+ "innerCounterRing": { "width": "sys.borderWidth.hairline", "color": "sys.color.focusInset" },
161
+ "outerRing": { "width": "sys.borderWidth.thin", "color": "sys.color.focus" }
162
+ },
163
+ "note": "This IS the trigger's keyboard-focus / open state — `:focus-visible` and the engaged (open) state coincide for the select trigger, so there is no separate `focused` state; the focus ring described here (and in the parallel `focusIndicator` block) is the focus affordance. The stroke re-tones to `borderActive` at `activeStrokeWeight` (2px) as an inset box-shadow, pixel-stable (no reflow)."
156
164
  },
157
165
  "disabled": {
158
166
  "overlay": null,
@@ -137,11 +137,18 @@
137
137
  "nestedActionScope": "The pressed overlay (and hover stroke) is suppressed while the pointer presses / hovers the trailing clear button — that '×' is an independent nested action, so the small control owns the state and the large field does not also read as pressed. The visual-state boundary matches the action boundary."
138
138
  },
139
139
  "active": {
140
+ "isFocusState": true,
140
141
  "overlay": null,
141
142
  "border": "borderActive",
142
143
  "strokeWeight": "activeStrokeWeight",
143
144
  "caret": "visible",
144
- "note": "Stroke steps from `hairline` (1px) to `activeStrokeWeight` (2px) as an inset box-shadow — same pixel-stable contract as input."
145
+ "focusRing": {
146
+ "composition": "outward",
147
+ "layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
148
+ "innerCounterRing": { "width": "sys.borderWidth.hairline", "color": "sys.color.focusInset" },
149
+ "outerRing": { "width": "sys.borderWidth.thin", "color": "sys.color.focus" }
150
+ },
151
+ "note": "This IS the field's keyboard/input-focus state — `active` (caret visible, input engaged) and `:focus-visible` coincide for a text field, so there is no separate `focused` state; the focus ring described here (and in the parallel `focusIndicator` block) is the focus affordance. Stroke steps from `hairline` (1px) to `activeStrokeWeight` (2px) as an inset box-shadow — same pixel-stable contract as input."
145
152
  },
146
153
  "disabled": {
147
154
  "overlay": null,
@@ -158,6 +158,15 @@
158
158
  "pressed": {
159
159
  "overlay": { "color": "label", "opacity": "sys.state.pressed" }
160
160
  },
161
+ "focused": {
162
+ "focusRing": {
163
+ "composition": "inward",
164
+ "layer": "::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
165
+ "innerCounterRing": { "width": "sys.borderWidth.hairline", "color": "sys.color.focusInset" },
166
+ "outerRing": { "width": "sys.borderWidth.thin", "color": "sys.color.focus" }
167
+ },
168
+ "note": "Keyboard-focus (:focus-visible) visual — a three-layer inward ring inside the trigger's footprint, with no state-overlay tint (the ring alone carries focus here). Mirrors the `focusIndicator` block for spec-only renderers. Composes over the lifecycle state the trigger is in."
169
+ },
161
170
  "disabled": {
162
171
  "containerOpacity": "sys.state.disabled",
163
172
  "pointerEvents": "none"
@@ -194,6 +194,25 @@
194
194
  "opacity": "sys.state.pressed"
195
195
  }
196
196
  },
197
+ "focused": {
198
+ "overlay": {
199
+ "color": "label",
200
+ "opacity": "sys.state.focus"
201
+ },
202
+ "focusRing": {
203
+ "composition": "inward",
204
+ "layer": "::after/::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
205
+ "innerCounterRing": {
206
+ "width": "sys.borderWidth.hairline",
207
+ "color": "sys.color.focusInset"
208
+ },
209
+ "outerRing": {
210
+ "width": "sys.borderWidth.thin",
211
+ "color": "sys.color.focus"
212
+ }
213
+ },
214
+ "note": "Keyboard-focus (:focus-visible) visual. Mirrors the `focusIndicator` block (the external-reader contract); kept here so spec-only renderers see focus in the states map. Composes over the lifecycle state the row is in; never via plain mouse click."
215
+ },
197
216
  "nestedActionScope": "The hover / pressed overlay is suppressed while the pointer sits on the independent trailing action (a favorite / follow / overflow control). The small control owns the state; the large row does NOT also read as hovered / pressed. The visual-state boundary matches the event boundary (the trailing action already stops propagation).",
198
217
  "disabled": {
199
218
  "containerOpacity": "sys.state.disabled",
@@ -142,6 +142,25 @@
142
142
  "opacity": "sys.state.pressed"
143
143
  }
144
144
  },
145
+ "focused": {
146
+ "overlay": {
147
+ "color": "label",
148
+ "opacity": "sys.state.focus"
149
+ },
150
+ "focusRing": {
151
+ "composition": "inward",
152
+ "layer": "::after/::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
153
+ "innerCounterRing": {
154
+ "width": "sys.borderWidth.hairline",
155
+ "color": "sys.color.focusInset"
156
+ },
157
+ "outerRing": {
158
+ "width": "sys.borderWidth.thin",
159
+ "color": "sys.color.focus"
160
+ }
161
+ },
162
+ "note": "Keyboard-focus (:focus-visible) visual. Mirrors the `focusIndicator` block (the external-reader contract); kept here so spec-only renderers see focus in the states map. Composes over the lifecycle state the row is in; never via plain mouse click."
163
+ },
145
164
  "selected": {
146
165
  "leading": "Filled primary indicator; row foreground stays at onSurface. No fill change on the row itself."
147
166
  },
@@ -218,6 +218,51 @@ import { List } from '@teamblind-chorus/ui';
218
218
  />
219
219
  ```
220
220
 
221
+ ### With an embedded Banner
222
+
223
+ Pass a `banner` on a row and a [Banner](../banner/banner.md) renders **below** the row's text group, `8px` (`sys.layout.stack.xs`) down, spanning the row's full content width (aligned to the same 16px inline inset as the label above it). The row flips from a single line to a vertical stack — text group on top, Banner underneath. Reach for it when a row needs an in-row call-out tied to *that row's* subject (a follow-up prompt, a capability nudge, a single-line CTA), rather than a separate full-width Banner detached from the row.
224
+
225
+ The Banner keeps its full prop surface — here `appearance="accent"` + `neutralBody` for the quiet-tint shape, a blue `CheckCircleFillIcon` leading the single-line body, and a `trailingAction` Text Button with a trailing chevron. The Banner is a nested-action region: its button never commits the row, and the row's hover / pressed overlay is suppressed over it. The rows lead with fill-type category glyphs (`BriefcaseFillIcon`, `FlagFillIcon`) that the leading slot tones to `onSurfaceVariant`.
226
+
227
+ ```preview
228
+ list/standard-embedded-banner
229
+ ---
230
+ import { Banner, Button, List } from '@teamblind-chorus/ui';
231
+ import { BriefcaseFillIcon, CheckCircleFillIcon, ChevronRightIcon, FlagFillIcon } from '@teamblind-chorus/ui/icons';
232
+
233
+ <List
234
+ aria-label="Career"
235
+ items={[
236
+ {
237
+ value: 'major',
238
+ label: 'My major: Computer Science',
239
+ strong: true,
240
+ icon: <BriefcaseFillIcon />,
241
+ banner: (
242
+ <Banner
243
+ appearance="accent"
244
+ neutralBody
245
+ icon={<CheckCircleFillIcon size={16} style={{ color: 'var(--sys-color-primary)' }} />}
246
+ trailingAction={(
247
+ <Button variant="text" appearance="accent" size="small" trailingIcon={<ChevronRightIcon />}>
248
+ Expert Q&A
249
+ </Button>
250
+ )}
251
+ >
252
+ Ask a professional in this field
253
+ </Banner>
254
+ ),
255
+ },
256
+ {
257
+ value: 'roadmap',
258
+ label: 'View career roadmap',
259
+ icon: <FlagFillIcon />,
260
+ nav: true,
261
+ },
262
+ ]}
263
+ />
264
+ ```
265
+
221
266
  ## Slots
222
267
 
223
268
  - **container** — outer vertical stack (delegates to family).
@@ -231,6 +276,7 @@ import { List } from '@teamblind-chorus/ui';
231
276
  - **trailingIcon** *(optional, per-row)* — consumer-supplied node at the trailing edge. Each row decides independently. Canonical fills: a 16px icon (e.g. an external-link mark), or `<Button variant="text" appearance="accent">` (Follow / Invite) on leading-image rows. A status badge does **not** go here — it tiles next to the label via `count`. Its own hit target: a tap on this slot stops propagating before it reaches the row's `onClick`. Overrides the nav chevron on the same row.
232
277
  - **navChevron** *(optional, per-row)* — auto-rendered 16px right-pointing chevron, painted when the row sets `nav: true`. `onSurfaceVariant`, decorative; never a separate hit target.
233
278
  - **divider** *(optional, per-row)* — pass `divider: false` to suppress the row's bottom hairline. Use when a visual group ends mid-stack and the divider would visually fence off the next group from its label.
279
+ - **banner** *(optional, per-row)* — an embedded [Banner](../banner/banner.md) below the row's text group, `8px` (`sys.layout.stack.xs`) down, spanning the row's full content width. The row stacks (text group over Banner). A nested-action region: its own controls never commit the row, and the row's hover / pressed overlay is suppressed over it. Canonical fill: `<Banner appearance="accent" neutralBody icon={…} trailingAction={…}>` — an in-row call-out tied to the row's subject.
234
280
 
235
281
  ## States
236
282
 
@@ -3,7 +3,7 @@
3
3
  "name": "List",
4
4
  "family": "list",
5
5
  "subcomponent": "standard",
6
- "description": "The default List variant — display or navigation rows over the shared List anatomy. The whole row is the click target; no selection model. A row is text-only by default (no leading slot); it opts into a **leading image** (the image type) by passing a `thumbnail`, which renders a 40px [Thumbnail](../thumbnail/thumbnail.md) at the leading edge with a 12px (`sys.layout.inline.lg`) gap to the text group, or into a **leading icon** (the icon type) by passing an `icon`, which renders a 24px (`sys.icon.lg`) glyph with an 8px (`sys.layout.inline.md`) gap. A row opts into a trailing drill-in chevron with `nav: true` (the drill-in case — the row routes the user to another surface); a per-item `trailingIcon` overrides the auto chevron. A row opts into an inline **count badge** to the right of the label via `count` — separated from the label by `sys.layout.inline.sm` (4), the unread / status-count case — and it composes with the chevron / trailing slot on the same row. For the richer directory shape (selectable 32/48/56 avatar + stacked `secondary` identity line) reach for [list/entry](./entry.md); Standard's leading image is single-density at the 40 rung.",
6
+ "description": "The default List variant — display or navigation rows over the shared List anatomy. The whole row is the click target; no selection model. A row is text-only by default (no leading slot); it opts into a **leading image** (the image type) by passing a `thumbnail`, which renders a 40px [Thumbnail](../thumbnail/thumbnail.md) at the leading edge with a 12px (`sys.layout.inline.lg`) gap to the text group, or into a **leading icon** (the icon type) by passing an `icon`, which renders a 24px (`sys.icon.lg`) glyph with an 8px (`sys.layout.inline.md`) gap. A row opts into a trailing drill-in chevron with `nav: true` (the drill-in case — the row routes the user to another surface); a per-item `trailingIcon` overrides the auto chevron. A row opts into an inline **count badge** to the right of the label via `count` — separated from the label by `sys.layout.inline.sm` (4), the unread / status-count case — and it composes with the chevron / trailing slot on the same row. For the richer directory shape (selectable 32/48/56 avatar + stacked `secondary` identity line) reach for [list/entry](./entry.md); Standard's leading image is single-density at the 40 rung. A row opts into an embedded **`banner`** — a [Banner](../banner/banner.md) painted below the row's text group at the row's full content width, `sys.layout.stack.xs` (8) below it — for an in-row call-out tied to that row's subject.",
7
7
  "element": "ul",
8
8
  "props": {
9
9
  "embedded": {
@@ -67,6 +67,13 @@
67
67
  "navChevron": {
68
68
  "required": false,
69
69
  "description": "Auto-rendered 16px right-pointing chevron at the trailing edge, painted when the row sets `nav: true` — the drill-in affordance signalling the row routes to another surface. `onSurfaceVariant` tone, decorative (`aria-hidden`); never a separate hit target — the whole row is the click target. A per-item `trailingIcon` replaces it."
70
+ },
71
+ "banner": {
72
+ "required": false,
73
+ "description": "Optional embedded [Banner](../banner/banner.md) painted **below** the row's text group, separated by `sys.layout.stack.xs` (8). The row flips from a single horizontal line to a vertical stack — the normal leading + label + trailing line on top, the Banner spanning the row's **full content width** (aligned to the same 16px inline inset as the label above it) underneath. Reach for it when a list row needs an in-row call-out tied to that row's subject — a follow-up prompt, a capability nudge, a single-line CTA — rather than a separate full-width Banner detached from the row. The Banner keeps its own fill / radius / padding and its full prop surface (use `appearance=\"accent\"` + `neutralBody` for the quiet-tint call-out shape, a leading `icon`, a `trailingAction` Text Button). It is a **nested-action region**: clicks inside it stop propagating before they reach the row's `onClick`, and the row's hover / pressed overlay is suppressed while the pointer sits on it — same contract as `trailingIcon`.",
74
+ "accepts": [
75
+ "banner"
76
+ ]
70
77
  }
71
78
  },
72
79
  "rowProps": {
@@ -102,6 +109,11 @@
102
109
  "optional": true,
103
110
  "description": "Trailing-edge node. Wrapped in a slot that stops click propagation so the slot is its own hit target separate from the row's `onClick`."
104
111
  },
112
+ "banner": {
113
+ "type": "node",
114
+ "optional": true,
115
+ "description": "Embedded `<Banner>` rendered below the row's text group at the row's full content width, `sys.layout.stack.xs` (8) below it. A nested-action region: its own controls (e.g. a `trailingAction` Text Button) never commit the row, and the row's hover / pressed overlay is suppressed over it. Canonical fill: `<Banner appearance=\"accent\" neutralBody icon={…} trailingAction={…}>…</Banner>`."
116
+ },
105
117
  "divider": {
106
118
  "type": "boolean",
107
119
  "default": true,
@@ -147,6 +159,8 @@
147
159
  "leadingIconGapNote": "8px (`sys.layout.inline.md`) between a leading 24px (`sys.icon.lg`) icon (the icon type) and the text group — the icon-leading rung of the family's role-based row spacing (the base leading gap), narrower than the 12px image rung. Applies only to rows that carry an `icon`.",
148
160
  "trailingActionGap": "sys.layout.inline.md",
149
161
  "trailingActionGapNote": "Fixed 8px (`sys.layout.inline.md`) between the text group and a trailing icon / nav chevron — the family-wide trailing gap, identical across every List variant.",
162
+ "bannerGap": "sys.layout.stack.xs",
163
+ "bannerGapNote": "8px (`sys.layout.stack.xs`) vertical gap between the row's text group and an embedded `banner` below it. The Banner spans the row's full content width (the 16px inline inset is the row's own padding, not paid again by the Banner — see banner.md § Layout inset).",
150
164
  "labelToCountGap": "sys.layout.inline.sm",
151
165
  "labelToCountGapNote": "4px (`sys.layout.inline.sm`) between the label and an inline `count` badge — they tile flush on the primary line as one label+count block, narrower than the 8px trailing gap. Mirrors list/entry's identity-group spacing.",
152
166
  "dividerWidth": "sys.borderWidth.hairline",
@@ -179,6 +193,25 @@
179
193
  "opacity": "sys.state.pressed"
180
194
  }
181
195
  },
196
+ "focused": {
197
+ "overlay": {
198
+ "color": "label",
199
+ "opacity": "sys.state.focus"
200
+ },
201
+ "focusRing": {
202
+ "composition": "inward",
203
+ "layer": "::after/::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
204
+ "innerCounterRing": {
205
+ "width": "sys.borderWidth.hairline",
206
+ "color": "sys.color.focusInset"
207
+ },
208
+ "outerRing": {
209
+ "width": "sys.borderWidth.thin",
210
+ "color": "sys.color.focus"
211
+ }
212
+ },
213
+ "note": "Keyboard-focus (:focus-visible) visual. Mirrors the `focusIndicator` block (the external-reader contract); kept here so spec-only renderers see focus in the states map. Composes over the lifecycle state the row is in; never via plain mouse click."
214
+ },
182
215
  "nestedActionScope": "The hover / pressed overlay is suppressed while the pointer sits on an independent trailing action (a `trailingIcon` button — favorite / mute / Follow). The small control owns the state; the large row does NOT also read as hovered / pressed. The decorative nav chevron is exempt — it is the row's own drill-in affordance, so hovering it still lights the whole row. The visual-state boundary matches the event boundary (the trailing action already stops propagation).",
183
216
  "disabled": {
184
217
  "containerOpacity": "sys.state.disabled",
@@ -217,6 +250,8 @@
217
250
  "nav chevron as a separate hit target — the drill-in chevron is decorative; the whole row is the click target",
218
251
  "leading thumbnail at a size other than the row's intrinsic 40 rung",
219
252
  "compact directory rows (selectable 32/48/56 avatar + stacked secondary line + optional toggle) built as a leading-image Standard row — that directory anatomy is [list/entry](./entry.md); Standard's image type is single-density at the 40 rung. (Standard does carry an inline `count` badge next to the label for unread / status counts, but not the avatar-rung directory shape.)",
220
- "raw `border:` on the row — list seam is the family's bottom divider via outlineVariant"
253
+ "raw `border:` on the row — list seam is the family's bottom divider via outlineVariant",
254
+ "embedded `banner` painted with a per-child `margin-block` / `padding-block` wrapper to fake the 8px gap — the text-group↔Banner gap is the row stack's `gap: sys.layout.stack.xs`, and the Banner's horizontal inset is the row's own 16px padding; Banner ships no outer margin",
255
+ "embedded `banner` as a separate `<List>` row beneath the header — the call-out belongs to its row's subject, so it nests inside that row via `item.banner`, not as a sibling row that the divider would fence off"
221
256
  ]
222
257
  }
@@ -126,6 +126,15 @@
126
126
  "pressed": {
127
127
  "overlay": { "color": "label", "opacity": "sys.state.pressed" }
128
128
  },
129
+ "focused": {
130
+ "focusRing": {
131
+ "composition": "outward",
132
+ "layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
133
+ "innerCounterRing": { "width": "sys.borderWidth.hairline", "color": "sys.color.focusInset" },
134
+ "outerRing": { "width": "sys.borderWidth.thin", "color": "sys.color.focus" }
135
+ },
136
+ "note": "Keyboard-focus (:focus-visible) visual — a three-layer outward ring on the card's outer edge, with no state-overlay tint (the ring alone carries focus here). Mirrors the `focusIndicator` block for spec-only renderers. Composes over the lifecycle state the card is in."
137
+ },
129
138
  "disabled": {
130
139
  "containerOpacity": "sys.state.disabled",
131
140
  "pointerEvents": "none"
@@ -17,6 +17,6 @@
17
17
  },
18
18
  "spec": "page-shell.md",
19
19
  "subcomponents": [
20
- { "slug": "page-shell", "md": "page-shell.md", "default": true, "specMissing": true }
20
+ { "slug": "page-shell", "spec": "page-shell.spec.json", "md": "page-shell.md", "default": true }
21
21
  ]
22
22
  }
@@ -33,6 +33,39 @@ renders:
33
33
  </div>
34
34
  ```
35
35
 
36
+ ## Default
37
+
38
+ `NavigationBar` pins to the top, `TabBar` pins to the bottom, and only the `<main>` body scrolls. Scroll the list inside the window — the bars stay put while the content moves.
39
+
40
+ ```preview
41
+ page-shell/default
42
+ ---
43
+ import { PageShell, NavigationBar, TabBar, List } from '@teamblind-chorus/ui';
44
+
45
+ <PageShell
46
+ nav={<NavigationBar variant="main" title={…} />}
47
+ tabBar={<TabBar value={tab} onChange={setTab} items={items} />}
48
+ >
49
+ <List items={settings} />
50
+ </PageShell>
51
+ ```
52
+
53
+ ### Inline body
54
+
55
+ When the screen carries inline (non-full-bleed) content, pass `bodyProps` to add the page gutter to `<main>` so the body sits inset from the screen edge while the bars stay pinned.
56
+
57
+ ```preview
58
+ page-shell/inline-body
59
+ ---
60
+ <PageShell
61
+ bodyProps={{ style: { paddingInline: 'var(--sys-layout-page-md)' } }}
62
+ nav={<NavigationBar variant="sub" title="About" />}
63
+ tabBar={<TabBar value={tab} onChange={setTab} items={items} />}
64
+ >
65
+ {/* inline content — honors the page gutter */}
66
+ </PageShell>
67
+ ```
68
+
36
69
  ## Props
37
70
 
38
71
  | prop | type | notes |
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "PageShell",
4
+ "family": "page-shell",
5
+ "description": "The app scaffold that PINS `NavigationBar` (top) and `TabBar` (bottom) while only the body scrolls. Chorus bars render in flow and do NOT self-pin; PageShell is the pinning mechanism — a full-height (100dvh) flex column whose middle `<main>` is the sole scroll region (`flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain`). The `nav` and `tabBar` slots render as flow children at their natural height; without this skeleton the whole page scrolls as one piece and both bars drift off-screen on long content. The shell owns ONLY the pin/scroll mechanics — not a content gutter — so the body honors the normal full-bleed / inline padding contract; pass `bodyProps` to add the page gutter to `<main>` when the screen carries inline (non-full-bleed) content. Never give the bars `position: sticky` / `fixed`: that double-applies their own `env(safe-area-inset-*)`. Single-spec family.",
6
+ "element": "div",
7
+ "props": {
8
+ "nav": {
9
+ "type": "node",
10
+ "optional": true,
11
+ "description": "Rendered in flow at the top — a `NavigationBar`. Pays its own `safe-area-inset-top`; the shell does NOT re-pay it. Pinned by the flex column, never by `position: sticky` / `fixed`."
12
+ },
13
+ "tabBar": {
14
+ "type": "node",
15
+ "optional": true,
16
+ "description": "Rendered in flow at the bottom — a `TabBar`. Pays its own `safe-area-inset-bottom`; the shell does NOT re-pay it. Pinned by the flex column, never by `position: sticky` / `fixed`."
17
+ },
18
+ "children": {
19
+ "type": "node",
20
+ "required": true,
21
+ "description": "The scrolling body content — the sole scroll region. Rendered inside `<main class=\"chorus-page-shell__body\">`."
22
+ },
23
+ "bodyProps": {
24
+ "type": "object",
25
+ "optional": true,
26
+ "description": "Spread onto `<main>` — use to add a page gutter (`style={{ paddingInline: 'var(--sys-layout-page-md)' }}`) when the screen carries inline (non-full-bleed) content. `className` composes with `chorus-page-shell__body`; the rest spread as-is. Do NOT use it to re-pay a bar's safe-area inset."
27
+ },
28
+ "className": {
29
+ "type": "string",
30
+ "optional": true,
31
+ "description": "Composes with the shell root's own `chorus-page-shell` class. Use for placement only; never to override the pin/scroll mechanics."
32
+ }
33
+ },
34
+ "slots": {
35
+ "nav": {
36
+ "required": false,
37
+ "description": "Flow child at the top of the column, rendered at its natural height (typically a `NavigationBar`). Stays pinned because only the body scrolls beneath it. Pays its own viewport-top safe-area inset.",
38
+ "omittedBehavior": "collapse",
39
+ "accepts": [
40
+ "navigation-bar"
41
+ ]
42
+ },
43
+ "body": {
44
+ "required": true,
45
+ "intrinsic": true,
46
+ "description": "The `<main>` element — the ONLY scroll region. `flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain`. Takes the remaining column height between the bars and scrolls its children; the `min-height: 0` line is load-bearing (without it the flex body refuses to shrink and the page scrolls as one piece). The shell renders this element itself and fills it with `children`.",
47
+ "omittedBehavior": "error"
48
+ },
49
+ "tabBar": {
50
+ "required": false,
51
+ "description": "Flow child at the bottom of the column, rendered at its natural height (typically a `TabBar`). Stays pinned because only the body scrolls above it. Pays its own viewport-bottom safe-area inset.",
52
+ "omittedBehavior": "collapse",
53
+ "accepts": [
54
+ "tab-bar"
55
+ ]
56
+ }
57
+ },
58
+ "sizing": {
59
+ "shellDisplay": "flex",
60
+ "shellDirection": "column",
61
+ "shellHeight": "100dvh",
62
+ "bodyFlex": "1 1 auto",
63
+ "bodyMinHeight": "0",
64
+ "bodyOverflowY": "auto",
65
+ "bodyOverscrollBehavior": "contain",
66
+ "bodyGutter": "sys.layout.page.md",
67
+ "note": "The shell paints no fill of its own — it is a transparent flex column; `nav`, `body`, and `tabBar` each carry their own surface. The dvh / flex / min-height values are fixed layout mechanics, not a size axis. `bodyGutter` (`sys.layout.page.md`) is NOT applied by default — it is the canonical page-rail value the consumer opts into via `bodyProps` when the body carries inline (non-full-bleed) content; full-bleed screens leave the body gutter at 0."
68
+ },
69
+ "behavior": {
70
+ "pinMechanism": "A full-height flex column (`display: flex; flex-direction: column; height: 100dvh`) where `nav` and `tabBar` are flow children at their natural height and `<main>` takes the remaining space as the sole scroll region. The bars stay put on long lists because the body — not the shell or the page — is what scrolls.",
71
+ "soleScrollRegion": "Only `<main class=\"chorus-page-shell__body\">` scrolls (`overflow-y: auto`). `min-height: 0` lets the body shrink below its content height so it (not the shell) becomes the scroll container; `overscroll-behavior: contain` keeps scroll chaining from leaking to the document.",
72
+ "barsDoNotSelfPin": "Chorus bars render in flow and pay only their own `env(safe-area-inset-*)`; pinning is the shell's job. Never give `NavigationBar` / `TabBar` `position: sticky` / `fixed` — that double-applies their safe-area insets. A dev-only `usePinnedBarGuard` warns in the console when a bar is rendered inside a scrolling region instead of a shell.",
73
+ "noContentGutter": "PageShell owns only the pin/scroll mechanics, not a content gutter — the body honors the normal full-bleed / inline padding contract. Pass `bodyProps={{ style: { paddingInline: 'var(--sys-layout-page-md)' } }}` to add the page gutter when the screen carries inline content.",
74
+ "overlayNavExempt": "The overlay `NavigationBar` (floating over a `ProfileHeader` cover) is the exception — it scrolls with the hero by design and is NOT pinned by the shell; place it inside the body, not the `nav` slot."
75
+ },
76
+ "forbidden": [
77
+ "NavigationBar / TabBar given position: sticky / fixed inside the shell — the flex column is the pin; sticky/fixed double-applies the bar's safe-area inset",
78
+ "a bar rendered inside the scrolling body (nav/tabBar passed as children) instead of the nav / tabBar slot — it scrolls away with the content on long lists",
79
+ "hand-rolling the flex-column recipe instead of using PageShell — the min-height:0 / overflow-y:auto / overscroll-behavior:contain mechanics ship in the component, not a copy-paste CSS recipe",
80
+ "omitting min-height:0 on the body (or overriding it) — without it the flex body refuses to shrink and the whole page scrolls as one piece, drifting the bars off-screen",
81
+ "wrapping the shell in another scroll container or giving the shell root overflow:auto — the body is the sole scroll region; a second scroller breaks the pin",
82
+ "re-paying a bar's safe-area inset via bodyProps padding-top / padding-bottom — each bar owns its own viewport inset; the shell and body never re-pay it",
83
+ "adding a default content gutter to the shell — PageShell owns no gutter; inline padding is opted into per-screen via bodyProps, never baked into the shell"
84
+ ]
85
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "spinner",
4
+ "name": "Spinner",
5
+ "description": "Indeterminate loading indicator — a rotating arc in `sys.color.primary` that signals a short, progress-unknown wait (under ~1 second of expected delay) on a neutral host surface. Two rungs ride the `icon.*` size ladder: `medium` (`sys.icon.lg` / 24px, default) and `small` (`sys.icon.md` / 16px). An optional `label` slot lets a single line of loading copy sit beside the arc. Reserved to one Spinner per view — for content-shaped waits use `skeleton`, for a known ratio use `progress`. Single-spec family.",
6
+ "useCases": [
7
+ "sub-second indeterminate wait (button submit, inline action)",
8
+ "small surface where a skeleton would be heavier than the wait",
9
+ "centered first-paint loader before a screen's data resolves",
10
+ "loading copy beside a rotating indicator"
11
+ ],
12
+ "visualReuse": "open",
13
+ "layoutInset": "inline",
14
+ "spec": "spinner.md",
15
+ "usage": {
16
+ "note": "indeterminate only — never bind it to a progress ratio (use Progress). Reserve to one Spinner per view. aria-label defaults to 'Loading'; pass a label child for visible loading copy beside the arc.",
17
+ "example": "<Spinner aria-label=\"Loading\" />"
18
+ },
19
+ "subcomponents": [
20
+ {
21
+ "slug": "spinner",
22
+ "spec": "spinner.spec.json",
23
+ "md": "spinner.md",
24
+ "default": true
25
+ }
26
+ ]
27
+ }