@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.
- package/agents/catalog.md +6 -4
- package/agents/components/avatar-rail/avatar-rail.spec.json +19 -0
- package/agents/components/banner/banner.family.json +3 -1
- package/agents/components/banner/banner.md +54 -1
- package/agents/components/banner/banner.spec.json +24 -1
- package/agents/components/button/check.spec.json +19 -0
- package/agents/components/button/fab.spec.json +19 -0
- package/agents/components/button/icon.spec.json +19 -0
- package/agents/components/button/standard.spec.json +19 -0
- package/agents/components/button/text.spec.json +19 -0
- package/agents/components/button/toggle.spec.json +19 -0
- package/agents/components/chip/filter.spec.json +19 -0
- package/agents/components/chip/tag.spec.json +19 -0
- package/agents/components/empty-state/empty-state.family.json +28 -0
- package/agents/components/empty-state/empty-state.md +69 -0
- package/agents/components/empty-state/empty-state.spec.json +87 -0
- package/agents/components/form-field/input.spec.json +8 -1
- package/agents/components/form-field/search.spec.json +8 -1
- package/agents/components/form-field/select.spec.json +9 -1
- package/agents/components/form-field/textarea.spec.json +8 -1
- package/agents/components/list/accordion.spec.json +9 -0
- package/agents/components/list/entry.spec.json +19 -0
- package/agents/components/list/radio.spec.json +19 -0
- package/agents/components/list/standard.md +46 -0
- package/agents/components/list/standard.spec.json +37 -2
- package/agents/components/nav-card/nav-card.spec.json +9 -0
- package/agents/components/page-shell/page-shell.family.json +1 -1
- package/agents/components/page-shell/page-shell.md +33 -0
- package/agents/components/page-shell/page-shell.spec.json +85 -0
- package/agents/components/spinner/spinner.family.json +27 -0
- package/agents/components/spinner/spinner.md +98 -0
- package/agents/components/spinner/spinner.spec.json +82 -0
- package/agents/components/switch/switch.spec.json +9 -0
- package/agents/components/tab-bar/tab-bar.spec.json +16 -0
- package/agents/components/tabs/rounded.spec.json +19 -0
- package/agents/components/tabs/underline.spec.json +19 -0
- package/agents/manifest.json +8 -6
- package/agents/usage.json +12 -0
- package/dist/index.cjs +340 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -2
- package/dist/index.d.ts +46 -2
- package/dist/index.js +339 -61
- package/dist/index.js.map +1 -1
- package/dist/styles.css +182 -0
- package/package.json +1 -1
package/agents/catalog.md
CHANGED
|
@@ -10,7 +10,7 @@ Reverse index from natural-language intent to family + sub-component. Read *befo
|
|
|
10
10
|
|
|
11
11
|
Each family declares `visualReuse: "open" | "locked"` in its `<family>.family.json`. The catalog respects that flag.
|
|
12
12
|
|
|
13
|
-
- **Open (default,
|
|
13
|
+
- **Open (default, 30 families)** — `avatar-rail`, `badge`, `banner`, `bubble`, `button`, `carousel`, `chip`, `directory-list`, `divider`, `empty-state`, `feed`, `header`, `list`, `metadata`, `nav-card`, `nav-list`, `navigation-bar`, `page-shell`, `pagination`, `profile-header`, `progress`, `side-sheet`, `skeleton`, `spinner`, `status-tag`, `suggestion-list`, `switch`, `tab-bar`, `tabs`, `thumbnail`. The intent table is the *first* suggestion, but the agent MAY reach for these on visual-fit grounds even when the brief's intent does not match the row verbatim — e.g. `<Feed>` as a generic article-card surface, `<Banner>` as a tonal aside outside a literal "notice", `<Carousel>` as any labelled editorial block. Anatomy invariants (slot grammar, token bindings, intrinsic geometry) still apply.
|
|
14
14
|
- **Locked (5 families)** — `dialog`, `bottom-sheet`, `toast`, `tooltip`, `form-field`. MUST only be used when the brief's intent matches a row. Their contract IS the interaction — focus trap, auto-dismiss, ARIA live region, form semantics, hover/focus trigger. Borrowing the visual shape without the role breaks behavior. Marked *(locked)* below.
|
|
15
15
|
|
|
16
16
|
When in doubt: open families are recipes, locked families are rules.
|
|
@@ -71,13 +71,13 @@ When in doubt: open families are recipes, locked families are rules.
|
|
|
71
71
|
| avatar-anchored rows (channels, DMs) | `list / entry` | `full-bleed` |
|
|
72
72
|
| drill-in rows with trailing chevron | `list / standard` (or `/ entry`, `/ radio`) with `nav: true` | `full-bleed` |
|
|
73
73
|
| standalone drill-in card (single row) | `nav-card` | **`inline`** |
|
|
74
|
-
| expandable titled sections (FAQ, T&C) | `accordion`
|
|
74
|
+
| expandable titled sections (FAQ, T&C) | `list / accordion` | `full-bleed` |
|
|
75
75
|
| authored content stream (posts, comments) | `feed / feed` | `full-bleed` |
|
|
76
76
|
| follow suggestions block (swipeable peek) | `suggestion-list` | `full-bleed` |
|
|
77
77
|
| follow directory (full vertical scroll) | `directory-list` | `full-bleed` |
|
|
78
78
|
| label-only nav list with chevron rows | `nav-list` | `full-bleed` |
|
|
79
79
|
|
|
80
|
-
**Disambiguate**: `feed` = authored content (author, body, footer). `list` = menus/settings/pickers (stacked rows, hairline divider). `nav-card` = a SINGLE drill-in row as its own bounded outlined card — reach for it when one drill-in needs to read as its own affordance, not one entry in a stack. `accordion` = stacked rows that EXPAND in place rather than drill-in — for short content the user opens to read (FAQ, T&C, expandable filter). `suggestion-list` = labelled swipeable block of follow-suggestions (channels, people, companies, topics — same anatomy). `directory-list` = the same row anatomy at the `large` (48) rung but rendered as a full vertical scroll (no pager) — reach for it when the surface should expose the whole follow-able set. `nav-list` = a labelled vertical list of label-only chevron rows; same wrapper-of-Header + List composition, but for route navigation rather than follow.
|
|
80
|
+
**Disambiguate**: `feed` = authored content (author, body, footer). `list` = menus/settings/pickers (stacked rows, hairline divider). `nav-card` = a SINGLE drill-in row as its own bounded outlined card — reach for it when one drill-in needs to read as its own affordance, not one entry in a stack. `list / accordion` = stacked rows that EXPAND in place rather than drill-in — for short content the user opens to read (FAQ, T&C, expandable filter). `suggestion-list` = labelled swipeable block of follow-suggestions (channels, people, companies, topics — same anatomy). `directory-list` = the same row anatomy at the `large` (48) rung but rendered as a full vertical scroll (no pager) — reach for it when the surface should expose the whole follow-able set. `nav-list` = a labelled vertical list of label-only chevron rows; same wrapper-of-Header + List composition, but for route navigation rather than follow.
|
|
81
81
|
|
|
82
82
|
### Entity directory rows + author attribution
|
|
83
83
|
|
|
@@ -165,10 +165,12 @@ Each row resolves to a typed React component — `<FormField variant="search" pl
|
|
|
165
165
|
|
|
166
166
|
| Intent | Family + sub |
|
|
167
167
|
| ----------------------------------------------------- | --------------------- |
|
|
168
|
+
| indeterminate sub-second load (no known ratio) | `spinner` |
|
|
168
169
|
| in-flight content placeholder (mirrors content shape) | `skeleton` |
|
|
170
|
+
| surface with no data yet (empty feed / search / inbox) | `empty-state` |
|
|
169
171
|
| linear progress for a known long-running task | `progress` |
|
|
170
172
|
|
|
171
|
-
**Disambiguate**: `skeleton` = *in-flight* tonal block previewing where content will land. For loading data the host would otherwise paint as empty. NOT for empty states (no data yet) — those use illustration + body copy. `progress` = slim track for a *long-running, identifiable* task with a known ratio (upload, onboarding step, background sync).
|
|
173
|
+
**Disambiguate**: `spinner` = rotating arc for a *brief, indeterminate* wait (under ~1s, no measurable ratio) — a button submit, an inline action, a first-paint loader. Reserve one per view. `skeleton` = *in-flight* tonal block previewing where content will land. For loading data the host would otherwise paint as empty. NOT for empty states (no data yet) — those use illustration + body copy. `empty-state` = the durable *no-data* surface — a centered illustration + headline + body + one primary CTA, painted where the real content would go (an empty feed, a search with no results, a fresh inbox); reach for it instead of leaving a no-data surface blank. `progress` = slim track for a *long-running, identifiable* task with a known ratio (upload, onboarding step, background sync). Pick by what you know: nothing measurable → `spinner`; the content's shape → `skeleton`; no data yet → `empty-state`; a ratio → `progress`.
|
|
172
174
|
|
|
173
175
|
## Shadcn / Lovable name translation
|
|
174
176
|
|
|
@@ -119,6 +119,25 @@
|
|
|
119
119
|
},
|
|
120
120
|
"note": "Underline persists."
|
|
121
121
|
},
|
|
122
|
+
"focused": {
|
|
123
|
+
"overlay": {
|
|
124
|
+
"color": "label",
|
|
125
|
+
"opacity": "sys.state.focus"
|
|
126
|
+
},
|
|
127
|
+
"focusRing": {
|
|
128
|
+
"composition": "inward",
|
|
129
|
+
"layer": "::after/::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
|
|
130
|
+
"innerCounterRing": {
|
|
131
|
+
"width": "sys.borderWidth.hairline",
|
|
132
|
+
"color": "sys.color.focusInset"
|
|
133
|
+
},
|
|
134
|
+
"outerRing": {
|
|
135
|
+
"width": "sys.borderWidth.thin",
|
|
136
|
+
"color": "sys.color.focus"
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"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 item is in; never via plain mouse click."
|
|
140
|
+
},
|
|
122
141
|
"disabled": {
|
|
123
142
|
"containerOpacity": "sys.state.disabled",
|
|
124
143
|
"suppressUnderline": true,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 🇰🇷 한국어: [`i18n/ko/schema/components/banner/banner.md`](../../../i18n/ko/schema/components/banner/banner.md)
|
|
4
4
|
|
|
5
|
-
An in-body explanation block — a tinted card sitting within the reading flow with an optional heading line, a short paragraph, and an optional follow-through link.
|
|
5
|
+
An in-body explanation block — a tinted card sitting within the reading flow with an optional heading line, a short paragraph, and an optional follow-through link. Five axes: **appearance** (`default` / `accent` / `destructive`), **foreground** (tonal, or `neutralBody` to lay the Default neutral text over the accent fill), **outline** (`outlined` / none), **leading slot** (`icon` / `thumbnail` / none), **trailing slot** (`trailingIcon` / `trailingAction` Text Button / none).
|
|
6
6
|
|
|
7
7
|
**Reach for this when** a passage needs a brief aside the reader can scan or skip. **Skip when** the message demands a decision ([Dialog](../dialog/dialog.md) / [Bottom sheet](../bottom-sheet/bottom-sheet.md)) or confirms a recent user action ([Toast](../toast/toast.md)).
|
|
8
8
|
|
|
@@ -44,6 +44,25 @@ import { Banner } from '@teamblind-chorus/ui';
|
|
|
44
44
|
</Banner>
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
### Accent with neutral body
|
|
48
|
+
|
|
49
|
+
The `accent` fill kept, but the copy re-toned to the **Default** appearance's neutral foreground — title + body in `sys.color.onSurface`, action stepping to `sys.color.primary`. Pass `neutralBody`. This decouples the background tone from the text tone: the `primaryContainer` tint still pulls the eye, but the copy reads as quiet, high-legibility body text rather than tonal `onPrimaryContainer`. Reach for it on longer explainers or denser asides where primary-family body copy would tire the reader. No effect on `default` (already `onSurface`) or `destructive` (the warning tone must carry through the copy).
|
|
50
|
+
|
|
51
|
+
```preview
|
|
52
|
+
banner/accent-neutral-body
|
|
53
|
+
---
|
|
54
|
+
import { Banner } from '@teamblind-chorus/ui';
|
|
55
|
+
|
|
56
|
+
<Banner
|
|
57
|
+
appearance="accent"
|
|
58
|
+
neutralBody
|
|
59
|
+
title="Level up faster"
|
|
60
|
+
action={{ label: 'How levels work', href: '#level' }}
|
|
61
|
+
>
|
|
62
|
+
Stay active in the community to level up and unlock more of what the app offers.
|
|
63
|
+
</Banner>
|
|
64
|
+
```
|
|
65
|
+
|
|
47
66
|
### Destructive
|
|
48
67
|
|
|
49
68
|
The error-tinted appearance — `errorContainer` fill with `onErrorContainer` foreground. Reach for it when the aside is a blocking error or rejection (failed approvals, integration outages, billing). Use sparingly.
|
|
@@ -136,6 +155,37 @@ import { ForwardCircleFillIcon } from '@teamblind-chorus/ui/icons';
|
|
|
136
155
|
</Banner>
|
|
137
156
|
```
|
|
138
157
|
|
|
158
|
+
### With trailing action (Text Button)
|
|
159
|
+
|
|
160
|
+
A [Text Button](../button/text.md) (`<Button variant="text">`) in the trailing slot, vertically centred against the block — a compact inline commit beside the copy (*Dismiss*, *Enable*, *Undo*), distinct from `action` (the follow-through link below the body). The button keeps full control of its own `size` and `appearance` per the button/text spec, but **default the appearance to the banner's colour family** so the commit reads as part of the tinted block — `accent` banner → `appearance="accent"`, `default` banner → `appearance="default"`, `destructive` banner → the Text Button `destructive` flavor. It also keeps the button/text `leadingIcon` / `trailingIcon` slots, so the commit can carry an in-button glyph — e.g. a trailing `ChevronRightIcon` on a *Enable* / *Continue* commit. When both `trailingAction` and the banner-level `trailingIcon` are passed, the action wins the slot.
|
|
161
|
+
|
|
162
|
+
```preview
|
|
163
|
+
banner/with-trailing-action
|
|
164
|
+
---
|
|
165
|
+
import { Banner, Button } from '@teamblind-chorus/ui';
|
|
166
|
+
import { ChevronRightIcon } from '@teamblind-chorus/ui/icons';
|
|
167
|
+
|
|
168
|
+
// vertical 8 between sibling banners is the parent column's job (safe zone)
|
|
169
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sys-layout-stack-xs)' }}>
|
|
170
|
+
<Banner
|
|
171
|
+
appearance="accent"
|
|
172
|
+
trailingAction={(
|
|
173
|
+
<Button variant="text" appearance="accent" size="small" trailingIcon={<ChevronRightIcon />}>
|
|
174
|
+
Enable
|
|
175
|
+
</Button>
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
Turn on notifications to hear back the moment someone replies.
|
|
179
|
+
</Banner>
|
|
180
|
+
<Banner
|
|
181
|
+
appearance="default"
|
|
182
|
+
trailingAction={<Button variant="text" size="small">Dismiss</Button>}
|
|
183
|
+
>
|
|
184
|
+
Stay active in the community to level up and unlock more of what the app offers.
|
|
185
|
+
</Banner>
|
|
186
|
+
</div>
|
|
187
|
+
```
|
|
188
|
+
|
|
139
189
|
### With icon
|
|
140
190
|
|
|
141
191
|
A 16 × 16 (`sys.icon.md`) glyph at the leading edge, painted in `currentColor`. The slot is sized to the body's first-line height so the glyph centres on the first line — multi-line bodies keep the icon anchored to the first-line cap, not the block centre. Reach for it when the aside leads with a meaning-bearing glyph rather than a brand image.
|
|
@@ -163,6 +213,7 @@ Two appearances on the *emphasis* axis (plus `destructive` for errors). Banner c
|
|
|
163
213
|
|---------------|---------------------------------------------------------------------------------------------|--------------------------------------------------------------------|----------------------------|------------------------------------------------------------------------------|
|
|
164
214
|
| `default` | `sys.color.scrimSubtle` (translucent inverse-tone scrim — ~8% black light / ~8% white dark) | body in `sys.color.onSurface`, action steps to `sys.color.primary` | `sys.color.outlineVariant` (subtle gray) | Supplementary asides the reader can pass over without missing the main flow. |
|
|
165
215
|
| `accent` | `sys.color.primaryContainer` | body in `onPrimaryContainer`, action inherits | `sys.color.primary` at 40% (soft blue) | Asides worth pulling the eye toward — new-feature explainers, capability nudges. |
|
|
216
|
+
| `accent` + `neutralBody` | `sys.color.primaryContainer` | title + body in `onSurface`, action steps to `sys.color.primary` | `sys.color.primary` at 40% (soft blue) | Accent tint pulls the eye, but the copy stays quiet, high-legibility body text — longer explainers, dense asides. |
|
|
166
217
|
| `destructive` | `sys.color.errorContainer` | body in `onErrorContainer`, action inherits | `sys.color.error` at 40% | Blocking errors or rejections — failed approvals, outages, billing. |
|
|
167
218
|
|
|
168
219
|
## Slots
|
|
@@ -175,6 +226,7 @@ Two appearances on the *emphasis* axis (plus `destructive` for errors). Banner c
|
|
|
175
226
|
- **body** — explanation copy. `body.sm` / Regular / inherits container foreground. Required.
|
|
176
227
|
- **action** *(optional)* — follow-through link below the body. `label.md` / Semibold / underlined.
|
|
177
228
|
- **trailingIcon** *(optional)* — 16 × 16 glyph at the trailing edge, vertically centred against the container. Paints in `currentColor`.
|
|
229
|
+
- **trailingAction** *(optional)* — a [Text Button](../button/text.md) at the trailing edge, vertically centred. Owns its own size + appearance; default the appearance to the banner's colour family. Takes precedence over `trailingIcon`.
|
|
178
230
|
|
|
179
231
|
## Anatomy
|
|
180
232
|
|
|
@@ -188,6 +240,7 @@ Two appearances on the *emphasis* axis (plus `destructive` for errors). Banner c
|
|
|
188
240
|
| body | `sys.typo.body.sm` (14 / Regular), color inherits |
|
|
189
241
|
| action | `sys.typo.label.md` (14 / Semibold), underlined. Steps to `sys.color.primary` in `default`; inherits in `accent` / `destructive`. |
|
|
190
242
|
| trailingIcon | `sys.icon.md` (16 × 16) glyph, `align-self: center` against the container, `color: currentColor` |
|
|
243
|
+
| trailingAction | [Text Button](../button/text.md) (`<Button variant="text">`), `flex: 0 0 auto`, `align-self: center`. Size + appearance owned by the Button; default appearance to the banner's colour family. Wins the slot over `trailingIcon` |
|
|
191
244
|
|
|
192
245
|
## States
|
|
193
246
|
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
"default": false,
|
|
22
22
|
"description": "Paints a `sys.borderWidth.hairline` (1) inset stroke around the container, toned to the appearance's color family and kept deliberately faint so it reads as a soft edge of the same tint, not a frame — the subtle gray hairline (`sys.color.outlineVariant`) on `default`'s gray-tinted fill, `primary` at 40% (`color-mix(sys.color.primary, 40%)`) on `accent`'s blue-tinted fill, `error` at 40% on `destructive`. Rendered as an inset box-shadow, never a real border, so toggling it cannot change the banner's footprint (see DESIGN.md → Border & Stroke). Reach for it when the tinted fill alone doesn't separate the banner from its host surface."
|
|
23
23
|
},
|
|
24
|
+
"neutralBody": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"optional": true,
|
|
27
|
+
"default": false,
|
|
28
|
+
"appliesTo": "accent",
|
|
29
|
+
"description": "On `accent`, paints the title + body in the neutral default foreground (`sys.color.onSurface`) and steps the action to `sys.color.primary` — i.e. the **Default appearance's** foreground treatment laid over the accent fill, decoupling the background tone from the text tone. Reach for it when the `primaryContainer` tint should still pull the eye but the copy should read as quiet, high-legibility body text rather than tonal `onPrimaryContainer` primary-family text (long-form explainers, dense asides). No effect on `default` (already `onSurface`) or `destructive` (the warning tone must carry through the copy)."
|
|
30
|
+
},
|
|
24
31
|
"title": {
|
|
25
32
|
"type": "node",
|
|
26
33
|
"optional": true,
|
|
@@ -46,6 +53,11 @@
|
|
|
46
53
|
"optional": true,
|
|
47
54
|
"description": "{ label, href? , onClick? } — a follow-through link rendered as a block child below the body."
|
|
48
55
|
},
|
|
56
|
+
"trailingAction": {
|
|
57
|
+
"type": "node",
|
|
58
|
+
"optional": true,
|
|
59
|
+
"description": "A [Text Button](../button/text.md) (`<Button variant=\"text\">`) rendered at the container's trailing edge, vertically centered against the whole block (`align-self: center`). Distinct from `action` (a follow-through link below the body): `trailingAction` is a compact inline commit that sits beside the copy — Dismiss, Enable, Undo. The button keeps full control of its own `size` and `appearance` per the button/text spec; **by default pick the appearance whose color family matches the banner fill** so the commit reads as part of the tinted block — `accent` banner → `appearance=\"accent\"`, `default` banner → `appearance=\"default\"`, `destructive` banner → the Text Button `destructive` flavor. Override only when a denser rung (`size=\"small\"` / `\"xsmall\"`) or a different emphasis is deliberately wanted. The button also keeps its own `leadingIcon` / `trailingIcon` slots, so the commit can carry an in-button glyph (e.g. a trailing `ChevronRightIcon` on an *Enable* / *Continue* commit). Takes precedence over the banner-level `trailingIcon` when both are passed."
|
|
60
|
+
},
|
|
49
61
|
"children": {
|
|
50
62
|
"type": "node",
|
|
51
63
|
"required": true,
|
|
@@ -104,6 +116,13 @@
|
|
|
104
116
|
"accepts": [
|
|
105
117
|
"icon"
|
|
106
118
|
]
|
|
119
|
+
},
|
|
120
|
+
"trailingAction": {
|
|
121
|
+
"required": false,
|
|
122
|
+
"description": "Trailing-edge slot hosting a Text Button (`<Button variant=\"text\">`). Footprint-preserving (`flex: 0 0 auto`) and vertically centered against the container (`align-self: center`). The Button owns its own size + appearance; default the appearance to the banner's color family (accent → `accent`, default → `default`, destructive → `destructive` flavor). Takes precedence over `trailingIcon`.",
|
|
123
|
+
"accepts": [
|
|
124
|
+
"button"
|
|
125
|
+
]
|
|
107
126
|
}
|
|
108
127
|
},
|
|
109
128
|
"sizing": {
|
|
@@ -149,7 +168,7 @@
|
|
|
149
168
|
"foreground": "sys.color.onPrimaryContainer",
|
|
150
169
|
"actionColor": "inherit",
|
|
151
170
|
"outlineColor": "color-mix(sys.color.primary, 40%)",
|
|
152
|
-
"note": "Both body and action paint in the primary family so the whole banner reads as one highlighted block. Reach for `accent` when the aside should pull more attention — feature explainers, capability nudges."
|
|
171
|
+
"note": "Both body and action paint in the primary family so the whole banner reads as one highlighted block. Reach for `accent` when the aside should pull more attention — feature explainers, capability nudges. Pass `neutralBody` to keep the accent fill but swap the copy to the Default appearance's neutral foreground (`onSurface` body, `primary` action) when the tint should pull the eye while the text stays quiet, high-legibility body copy."
|
|
153
172
|
},
|
|
154
173
|
"destructive": {
|
|
155
174
|
"background": "sys.color.errorContainer",
|
|
@@ -161,9 +180,13 @@
|
|
|
161
180
|
},
|
|
162
181
|
"behavior": {
|
|
163
182
|
"actionLink": "When present, renders as an <a> and accepts either href (browser navigation) or onClick (consumer-controlled). Underline persists at rest so the link reads as actionable inside the muted block.",
|
|
183
|
+
"trailingAction": "A `<Button variant=\"text\">` in the trailing slot is a real interactive control (not aria-hidden, unlike the trailing icon). It carries its own size + appearance per the button/text spec; the default appearance follows the banner's color family so the commit reads as part of the tinted block (accent → accent, default → default, destructive → destructive flavor). When both `trailingAction` and `trailingIcon` are passed, the action wins the slot.",
|
|
184
|
+
"neutralForeground": "`neutralBody` re-tones only the accent appearance: the container foreground becomes `onSurface` (title + body) and the action steps to `primary`, matching the Default appearance's foreground treatment. Ignored on `default` and `destructive`.",
|
|
164
185
|
"role": "Container carries role='note' so screen readers announce the banner as an aside."
|
|
165
186
|
},
|
|
166
187
|
"forbidden": [
|
|
188
|
+
"banner trailing-edge commit rendered as a raw <a> / <button> or a filled/outlined Button — the trailing action is button/text, defaulted to the banner's color family",
|
|
189
|
+
"neutralBody applied to default or destructive — it only decouples the accent fill from its foreground; default is already onSurface and destructive must carry the warning tone through the copy",
|
|
167
190
|
"default banner background painted with sys.color.brandContainer — informational banners use sys.color.primaryContainer; promotional banners use sys.color.surfaceContainerLow",
|
|
168
191
|
"banner thumbnail slot omitted when banner role carries imagery — empty image area is forbidden, fall back to /placeholder.png",
|
|
169
192
|
"banner used for transient confirmations — that role is the `toast` family (locked)",
|
|
@@ -124,6 +124,25 @@
|
|
|
124
124
|
"opacity": "sys.state.pressed"
|
|
125
125
|
}
|
|
126
126
|
},
|
|
127
|
+
"focused": {
|
|
128
|
+
"overlay": {
|
|
129
|
+
"color": "label",
|
|
130
|
+
"opacity": "sys.state.focus"
|
|
131
|
+
},
|
|
132
|
+
"focusRing": {
|
|
133
|
+
"composition": "outward",
|
|
134
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
135
|
+
"innerCounterRing": {
|
|
136
|
+
"width": "sys.borderWidth.hairline",
|
|
137
|
+
"color": "sys.color.focusInset"
|
|
138
|
+
},
|
|
139
|
+
"outerRing": {
|
|
140
|
+
"width": "sys.borderWidth.thin",
|
|
141
|
+
"color": "sys.color.focus"
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
"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 button is in; never via plain mouse click."
|
|
145
|
+
},
|
|
127
146
|
"disabled": {
|
|
128
147
|
"overlay": null,
|
|
129
148
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -79,6 +79,25 @@
|
|
|
79
79
|
"color": "label",
|
|
80
80
|
"opacity": "sys.state.pressed"
|
|
81
81
|
}
|
|
82
|
+
},
|
|
83
|
+
"focused": {
|
|
84
|
+
"overlay": {
|
|
85
|
+
"color": "label",
|
|
86
|
+
"opacity": "sys.state.focus"
|
|
87
|
+
},
|
|
88
|
+
"focusRing": {
|
|
89
|
+
"composition": "outward",
|
|
90
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
91
|
+
"innerCounterRing": {
|
|
92
|
+
"width": "sys.borderWidth.hairline",
|
|
93
|
+
"color": "sys.color.focusInset"
|
|
94
|
+
},
|
|
95
|
+
"outerRing": {
|
|
96
|
+
"width": "sys.borderWidth.thin",
|
|
97
|
+
"color": "sys.color.focus"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"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 FAB is in; never via plain mouse click."
|
|
82
101
|
}
|
|
83
102
|
},
|
|
84
103
|
"focusIndicator": {
|
|
@@ -128,6 +128,25 @@
|
|
|
128
128
|
"opacity": "sys.state.pressed"
|
|
129
129
|
}
|
|
130
130
|
},
|
|
131
|
+
"focused": {
|
|
132
|
+
"overlay": {
|
|
133
|
+
"color": "icon",
|
|
134
|
+
"opacity": "sys.state.focus"
|
|
135
|
+
},
|
|
136
|
+
"focusRing": {
|
|
137
|
+
"composition": "outward",
|
|
138
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
139
|
+
"innerCounterRing": {
|
|
140
|
+
"width": "sys.borderWidth.hairline",
|
|
141
|
+
"color": "sys.color.focusInset"
|
|
142
|
+
},
|
|
143
|
+
"outerRing": {
|
|
144
|
+
"width": "sys.borderWidth.thin",
|
|
145
|
+
"color": "sys.color.focus"
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"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 button is in; never via plain mouse click."
|
|
149
|
+
},
|
|
131
150
|
"disabled": {
|
|
132
151
|
"overlay": null,
|
|
133
152
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -161,6 +161,25 @@
|
|
|
161
161
|
"opacity": "sys.state.pressed"
|
|
162
162
|
}
|
|
163
163
|
},
|
|
164
|
+
"focused": {
|
|
165
|
+
"overlay": {
|
|
166
|
+
"color": "label",
|
|
167
|
+
"opacity": "sys.state.focus"
|
|
168
|
+
},
|
|
169
|
+
"focusRing": {
|
|
170
|
+
"composition": "outward",
|
|
171
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
172
|
+
"innerCounterRing": {
|
|
173
|
+
"width": "sys.borderWidth.hairline",
|
|
174
|
+
"color": "sys.color.focusInset"
|
|
175
|
+
},
|
|
176
|
+
"outerRing": {
|
|
177
|
+
"width": "sys.borderWidth.thin",
|
|
178
|
+
"color": "sys.color.focus"
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
"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 button is in; never via plain mouse click."
|
|
182
|
+
},
|
|
164
183
|
"disabled": {
|
|
165
184
|
"overlay": null,
|
|
166
185
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -166,6 +166,25 @@
|
|
|
166
166
|
"opacity": "sys.state.pressed"
|
|
167
167
|
}
|
|
168
168
|
},
|
|
169
|
+
"focused": {
|
|
170
|
+
"overlay": {
|
|
171
|
+
"color": "label",
|
|
172
|
+
"opacity": "sys.state.focus"
|
|
173
|
+
},
|
|
174
|
+
"focusRing": {
|
|
175
|
+
"composition": "outward",
|
|
176
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
177
|
+
"innerCounterRing": {
|
|
178
|
+
"width": "sys.borderWidth.hairline",
|
|
179
|
+
"color": "sys.color.focusInset"
|
|
180
|
+
},
|
|
181
|
+
"outerRing": {
|
|
182
|
+
"width": "sys.borderWidth.thin",
|
|
183
|
+
"color": "sys.color.focus"
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
"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 button is in; never via plain mouse click."
|
|
187
|
+
},
|
|
169
188
|
"disabled": {
|
|
170
189
|
"overlay": null,
|
|
171
190
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -92,6 +92,25 @@
|
|
|
92
92
|
"opacity": "sys.state.pressed"
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
|
+
"focused": {
|
|
96
|
+
"overlay": {
|
|
97
|
+
"color": "label",
|
|
98
|
+
"opacity": "sys.state.focus"
|
|
99
|
+
},
|
|
100
|
+
"focusRing": {
|
|
101
|
+
"composition": "outward",
|
|
102
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
103
|
+
"innerCounterRing": {
|
|
104
|
+
"width": "sys.borderWidth.hairline",
|
|
105
|
+
"color": "sys.color.focusInset"
|
|
106
|
+
},
|
|
107
|
+
"outerRing": {
|
|
108
|
+
"width": "sys.borderWidth.thin",
|
|
109
|
+
"color": "sys.color.focus"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"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 button is in; never via plain mouse click."
|
|
113
|
+
},
|
|
95
114
|
"disabled": {
|
|
96
115
|
"overlay": null,
|
|
97
116
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -92,6 +92,25 @@
|
|
|
92
92
|
"opacity": "sys.state.pressed"
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
|
+
"focused": {
|
|
96
|
+
"overlay": {
|
|
97
|
+
"color": "label",
|
|
98
|
+
"opacity": "sys.state.focus"
|
|
99
|
+
},
|
|
100
|
+
"focusRing": {
|
|
101
|
+
"composition": "outward",
|
|
102
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
103
|
+
"innerCounterRing": {
|
|
104
|
+
"width": "sys.borderWidth.hairline",
|
|
105
|
+
"color": "sys.color.focusInset"
|
|
106
|
+
},
|
|
107
|
+
"outerRing": {
|
|
108
|
+
"width": "sys.borderWidth.thin",
|
|
109
|
+
"color": "sys.color.focus"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"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 chip is in; never via plain mouse click."
|
|
113
|
+
},
|
|
95
114
|
"disabled": {
|
|
96
115
|
"overlay": null,
|
|
97
116
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -73,6 +73,25 @@
|
|
|
73
73
|
"opacity": "sys.state.pressed"
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
|
+
"focused": {
|
|
77
|
+
"overlay": {
|
|
78
|
+
"color": "label",
|
|
79
|
+
"opacity": "sys.state.focus"
|
|
80
|
+
},
|
|
81
|
+
"focusRing": {
|
|
82
|
+
"composition": "outward",
|
|
83
|
+
"layer": "::after overlay — position:absolute, inset:0, no reflow (DESIGN.md Focus ring composition)",
|
|
84
|
+
"innerCounterRing": {
|
|
85
|
+
"width": "sys.borderWidth.hairline",
|
|
86
|
+
"color": "sys.color.focusInset"
|
|
87
|
+
},
|
|
88
|
+
"outerRing": {
|
|
89
|
+
"width": "sys.borderWidth.thin",
|
|
90
|
+
"color": "sys.color.focus"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"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 chip is in; never via plain mouse click."
|
|
94
|
+
},
|
|
76
95
|
"disabled": {
|
|
77
96
|
"overlay": null,
|
|
78
97
|
"containerOpacity": "sys.state.disabled",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "empty-state",
|
|
4
|
+
"name": "EmptyState",
|
|
5
|
+
"description": "No-data placeholder — the centered composition a surface paints when it has no content yet (an empty feed, a search with no results, a fresh inbox). Fills the space the real data would occupy with an optional monochrome illustration, a required headline, optional body copy, and an optional primary CTA that performs the one action that would fill the surface. Single-spec family. Distinct from `skeleton` (in-flight tonal placeholder for data that is loading) — EmptyState is the durable 'there is nothing here yet, here is how to start' surface.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"surface with no data yet",
|
|
8
|
+
"empty feed / inbox / list",
|
|
9
|
+
"search with no results",
|
|
10
|
+
"first-run / zero state",
|
|
11
|
+
"cleared / dismissed-everything state"
|
|
12
|
+
],
|
|
13
|
+
"visualReuse": "open",
|
|
14
|
+
"layoutInset": "inline",
|
|
15
|
+
"spec": "empty-state.md",
|
|
16
|
+
"usage": {
|
|
17
|
+
"note": "Headline is the `headline` prop (required); `body` is the `body` prop; the CTA is the `action` object ({ label, href?/onClick? }) which renders a default-size primary Button — there is NO `cta` slot to fill with your own button. Pass an icon node to `illustration`.",
|
|
18
|
+
"example": "<EmptyState illustration={<ChatIcon />} headline=\"No posts yet\" body=\"Conversations you start or join will appear here.\" action={{ label: 'Start a post', onClick: () => {} }} />"
|
|
19
|
+
},
|
|
20
|
+
"subcomponents": [
|
|
21
|
+
{
|
|
22
|
+
"slug": "empty-state",
|
|
23
|
+
"spec": "empty-state.spec.json",
|
|
24
|
+
"md": "empty-state.md",
|
|
25
|
+
"default": true
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# EmptyState
|
|
2
|
+
|
|
3
|
+
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 that performs the one action that fills the surface.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** a feed, list, inbox, or search result would otherwise paint blank — a fresh account's empty feed, a search returning nothing, a notifications surface with nothing unread. **Skip when** the data is still loading (use [Skeleton](../skeleton/skeleton.md) — an in-flight tonal placeholder) or when the message is a transient confirmation (use [Toast](../toast/toast.md)).
|
|
6
|
+
|
|
7
|
+
**Layout inset.** inline — EmptyState ships no surface fill or chrome of its own and claims no page rail. It centers its column inside the host surface that would otherwise hold the data; the host supplies the surface tier and the bounding box. The surrounding inset is the host's responsibility.
|
|
8
|
+
|
|
9
|
+
## Default
|
|
10
|
+
|
|
11
|
+
The full composition — illustration, headline, body, and a primary CTA. Three lines of copy at most: what the surface is for, why it is empty, and the one action that fills it. The CTA is the surface's primary action, composed as a default-size primary [Button](../button/button.md).
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
empty-state/default
|
|
15
|
+
---
|
|
16
|
+
import { EmptyState } from '@teamblind-chorus/ui';
|
|
17
|
+
import { ChatIcon } from '@teamblind-chorus/ui/icons';
|
|
18
|
+
|
|
19
|
+
<EmptyState
|
|
20
|
+
illustration={<ChatIcon />}
|
|
21
|
+
headline="No posts yet"
|
|
22
|
+
body="Conversations you start or join will appear here."
|
|
23
|
+
action={{ label: 'Start a post', onClick: () => {} }}
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use cases
|
|
28
|
+
|
|
29
|
+
### Without illustration
|
|
30
|
+
|
|
31
|
+
The illustration is optional — omit it for a tighter, text-led zero state where a glyph would add nothing. The headline still leads; the body and CTA follow the same stack rhythm.
|
|
32
|
+
|
|
33
|
+
```preview
|
|
34
|
+
empty-state/no-illustration
|
|
35
|
+
---
|
|
36
|
+
import { EmptyState } from '@teamblind-chorus/ui';
|
|
37
|
+
|
|
38
|
+
<EmptyState
|
|
39
|
+
headline="No results"
|
|
40
|
+
body="No channels match that search. Try a different keyword."
|
|
41
|
+
action={{ label: 'Clear search', onClick: () => {} }}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Slots
|
|
46
|
+
|
|
47
|
+
- **container** — centered flex column holding the whole composition. `align-items: center`, `text-align: center`. No surface fill of its own. `role="status"` so the empty state is announced without yanking focus.
|
|
48
|
+
- **illustration** *(optional)* — centered glyph / illustration above the headline. `ref.space.600` (48) box, painted monochrome in `sys.color.onSurfaceVariant`.
|
|
49
|
+
- **headline** — required lead line. `sys.typo.heading.sm` / `sys.color.onSurface`.
|
|
50
|
+
- **body** *(optional)* — supporting line below the headline. `sys.typo.body.sm` / `sys.color.onSurfaceVariant`.
|
|
51
|
+
- **action** *(optional)* — the primary CTA, composed from the `action` object as a default-size primary [Button](../button/button.md). There is no free `cta` slot.
|
|
52
|
+
|
|
53
|
+
## Anatomy
|
|
54
|
+
|
|
55
|
+
| Slot | Token bindings |
|
|
56
|
+
|--------------|----------------|
|
|
57
|
+
| container | Centered flex column, `align-items: center`, `text-align: center`, no fill; `role="status"` |
|
|
58
|
+
| illustration | `ref.space.600` (48 × 48) box, `sys.color.onSurfaceVariant` (`currentColor`), `sys.layout.stack.sm` (12) below it to the headline |
|
|
59
|
+
| headline | `sys.typo.heading.sm`, `sys.color.onSurface` |
|
|
60
|
+
| body | `sys.typo.body.sm`, `sys.color.onSurfaceVariant`, `sys.layout.stack.2xs` (4) above it from the headline |
|
|
61
|
+
| action | Default-size primary [Button](../button/button.md), `sys.layout.stack.md` (16) above it from the body |
|
|
62
|
+
|
|
63
|
+
## Behavior
|
|
64
|
+
|
|
65
|
+
- **Centered in the host.** The composition is centered block + inline inside the surface that would otherwise hold the data. EmptyState owns no surface fill — the host supplies the surface tier and bounding box.
|
|
66
|
+
- **Headline required, the rest optional.** Only the headline is required. The illustration, body, and CTA fill in as the copy needs them.
|
|
67
|
+
- **Three lines of copy, one action.** Headline + body read as at most three lines (what the surface is for · why it is empty · the one action that fills it). The CTA is that one action.
|
|
68
|
+
- **CTA is composed, not slotted.** Pass `action={{ label, href?/onClick? }}` — EmptyState renders the default-size primary Button so the fill action always reads as primary.
|
|
69
|
+
- **Not a loading state.** For data that is still arriving, reach for [Skeleton](../skeleton/skeleton.md); EmptyState is the durable no-data surface.
|