@teamblind-chorus/ui 1.0.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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/agents/AGENTS.md +143 -0
  4. package/agents/DESIGN.md +1311 -0
  5. package/agents/LOVABLE.md +472 -0
  6. package/agents/anti-patterns.md +533 -0
  7. package/agents/catalog.md +232 -0
  8. package/agents/components/avatar-rail/avatar-rail.family.json +46 -0
  9. package/agents/components/avatar-rail/avatar-rail.md +103 -0
  10. package/agents/components/avatar-rail/avatar-rail.spec.json +160 -0
  11. package/agents/components/badge/badge.family.json +45 -0
  12. package/agents/components/badge/badge.md +10 -0
  13. package/agents/components/badge/role.md +100 -0
  14. package/agents/components/badge/role.spec.json +75 -0
  15. package/agents/components/badge/update.md +132 -0
  16. package/agents/components/badge/update.spec.json +114 -0
  17. package/agents/components/banner/banner.family.json +28 -0
  18. package/agents/components/banner/banner.md +136 -0
  19. package/agents/components/banner/banner.spec.json +136 -0
  20. package/agents/components/bottom-sheet/bottom-sheet.family.json +29 -0
  21. package/agents/components/bottom-sheet/bottom-sheet.md +176 -0
  22. package/agents/components/bottom-sheet/bottom-sheet.spec.json +168 -0
  23. package/agents/components/bubble/bubble.family.json +29 -0
  24. package/agents/components/bubble/bubble.md +134 -0
  25. package/agents/components/bubble/bubble.spec.json +91 -0
  26. package/agents/components/button/button.family.json +76 -0
  27. package/agents/components/button/button.md +31 -0
  28. package/agents/components/button/check.md +138 -0
  29. package/agents/components/button/check.spec.json +161 -0
  30. package/agents/components/button/fab.md +161 -0
  31. package/agents/components/button/fab.spec.json +106 -0
  32. package/agents/components/button/icon.md +141 -0
  33. package/agents/components/button/icon.spec.json +164 -0
  34. package/agents/components/button/standard.md +219 -0
  35. package/agents/components/button/standard.spec.json +205 -0
  36. package/agents/components/button/text.md +186 -0
  37. package/agents/components/button/text.spec.json +215 -0
  38. package/agents/components/button/toggle.md +108 -0
  39. package/agents/components/button/toggle.spec.json +124 -0
  40. package/agents/components/button/toolbar.md +189 -0
  41. package/agents/components/button/toolbar.spec.json +109 -0
  42. package/agents/components/carousel/carousel.family.json +41 -0
  43. package/agents/components/carousel/carousel.md +40 -0
  44. package/agents/components/carousel/post.md +148 -0
  45. package/agents/components/carousel/post.spec.json +229 -0
  46. package/agents/components/carousel/profile.md +184 -0
  47. package/agents/components/carousel/profile.spec.json +219 -0
  48. package/agents/components/chip/chip.family.json +37 -0
  49. package/agents/components/chip/chip.md +10 -0
  50. package/agents/components/chip/filter.md +212 -0
  51. package/agents/components/chip/filter.spec.json +124 -0
  52. package/agents/components/chip/tag.md +137 -0
  53. package/agents/components/chip/tag.spec.json +104 -0
  54. package/agents/components/dialog/dialog.family.json +29 -0
  55. package/agents/components/dialog/dialog.md +113 -0
  56. package/agents/components/dialog/dialog.spec.json +156 -0
  57. package/agents/components/directory-list/directory-list.family.json +46 -0
  58. package/agents/components/directory-list/directory-list.md +87 -0
  59. package/agents/components/directory-list/directory-list.spec.json +104 -0
  60. package/agents/components/divider/divider.family.json +28 -0
  61. package/agents/components/divider/divider.md +78 -0
  62. package/agents/components/divider/divider.spec.json +51 -0
  63. package/agents/components/feed/ad.md +108 -0
  64. package/agents/components/feed/ad.spec.json +187 -0
  65. package/agents/components/feed/feed.family.json +48 -0
  66. package/agents/components/feed/feed.md +30 -0
  67. package/agents/components/feed/post.md +240 -0
  68. package/agents/components/feed/post.spec.json +361 -0
  69. package/agents/components/form-field/form-field.family.json +50 -0
  70. package/agents/components/form-field/form-field.md +11 -0
  71. package/agents/components/form-field/input.md +198 -0
  72. package/agents/components/form-field/input.spec.json +202 -0
  73. package/agents/components/form-field/search.md +81 -0
  74. package/agents/components/form-field/search.spec.json +135 -0
  75. package/agents/components/form-field/select.md +101 -0
  76. package/agents/components/form-field/select.spec.json +194 -0
  77. package/agents/components/form-field/textarea.md +89 -0
  78. package/agents/components/form-field/textarea.spec.json +176 -0
  79. package/agents/components/header/header.family.json +43 -0
  80. package/agents/components/header/header.md +18 -0
  81. package/agents/components/header/main.md +101 -0
  82. package/agents/components/header/main.spec.json +117 -0
  83. package/agents/components/header/sub.md +129 -0
  84. package/agents/components/header/sub.spec.json +81 -0
  85. package/agents/components/list/accordion.md +183 -0
  86. package/agents/components/list/accordion.spec.json +201 -0
  87. package/agents/components/list/entry.md +280 -0
  88. package/agents/components/list/entry.spec.json +237 -0
  89. package/agents/components/list/list.family.json +75 -0
  90. package/agents/components/list/list.md +24 -0
  91. package/agents/components/list/radio.md +144 -0
  92. package/agents/components/list/radio.spec.json +186 -0
  93. package/agents/components/list/standard.md +262 -0
  94. package/agents/components/list/standard.spec.json +221 -0
  95. package/agents/components/metadata/compact.md +69 -0
  96. package/agents/components/metadata/compact.spec.json +69 -0
  97. package/agents/components/metadata/metadata.family.json +42 -0
  98. package/agents/components/metadata/metadata.md +26 -0
  99. package/agents/components/metadata/standard.md +104 -0
  100. package/agents/components/metadata/standard.spec.json +152 -0
  101. package/agents/components/nav-card/nav-card.family.json +29 -0
  102. package/agents/components/nav-card/nav-card.md +179 -0
  103. package/agents/components/nav-card/nav-card.spec.json +161 -0
  104. package/agents/components/nav-list/nav-list.family.json +46 -0
  105. package/agents/components/nav-list/nav-list.md +91 -0
  106. package/agents/components/nav-list/nav-list.spec.json +107 -0
  107. package/agents/components/navigation-bar/main.md +201 -0
  108. package/agents/components/navigation-bar/main.spec.json +109 -0
  109. package/agents/components/navigation-bar/navigation-bar.family.json +44 -0
  110. package/agents/components/navigation-bar/navigation-bar.md +21 -0
  111. package/agents/components/navigation-bar/search.md +96 -0
  112. package/agents/components/navigation-bar/search.spec.json +142 -0
  113. package/agents/components/navigation-bar/sub.md +174 -0
  114. package/agents/components/navigation-bar/sub.spec.json +123 -0
  115. package/agents/components/page-shell/page-shell.family.json +22 -0
  116. package/agents/components/page-shell/page-shell.md +51 -0
  117. package/agents/components/profile-header/profile-header.family.json +29 -0
  118. package/agents/components/profile-header/profile-header.md +149 -0
  119. package/agents/components/profile-header/profile-header.spec.json +200 -0
  120. package/agents/components/progress/progress.family.json +27 -0
  121. package/agents/components/progress/progress.md +38 -0
  122. package/agents/components/progress/progress.spec.json +67 -0
  123. package/agents/components/side-sheet/side-sheet.family.json +30 -0
  124. package/agents/components/side-sheet/side-sheet.md +154 -0
  125. package/agents/components/side-sheet/side-sheet.spec.json +109 -0
  126. package/agents/components/skeleton/skeleton.family.json +28 -0
  127. package/agents/components/skeleton/skeleton.md +123 -0
  128. package/agents/components/skeleton/skeleton.spec.json +73 -0
  129. package/agents/components/status-tag/status-tag.family.json +26 -0
  130. package/agents/components/status-tag/status-tag.md +114 -0
  131. package/agents/components/status-tag/status-tag.spec.json +69 -0
  132. package/agents/components/suggestion-list/suggestion-list.family.json +46 -0
  133. package/agents/components/suggestion-list/suggestion-list.md +91 -0
  134. package/agents/components/suggestion-list/suggestion-list.spec.json +178 -0
  135. package/agents/components/switch/switch.family.json +27 -0
  136. package/agents/components/switch/switch.md +114 -0
  137. package/agents/components/switch/switch.spec.json +123 -0
  138. package/agents/components/tab-bar/tab-bar.family.json +27 -0
  139. package/agents/components/tab-bar/tab-bar.md +178 -0
  140. package/agents/components/tab-bar/tab-bar.spec.json +184 -0
  141. package/agents/components/tabs/rounded.md +150 -0
  142. package/agents/components/tabs/rounded.spec.json +140 -0
  143. package/agents/components/tabs/segmented.md +114 -0
  144. package/agents/components/tabs/segmented.spec.json +100 -0
  145. package/agents/components/tabs/tabs.family.json +59 -0
  146. package/agents/components/tabs/tabs.md +18 -0
  147. package/agents/components/tabs/underline.md +147 -0
  148. package/agents/components/tabs/underline.spec.json +139 -0
  149. package/agents/components/thumbnail/thumbnail.family.json +28 -0
  150. package/agents/components/thumbnail/thumbnail.md +152 -0
  151. package/agents/components/thumbnail/thumbnail.spec.json +172 -0
  152. package/agents/components/toast/toast.family.json +28 -0
  153. package/agents/components/toast/toast.md +133 -0
  154. package/agents/components/toast/toast.spec.json +89 -0
  155. package/agents/components/tooltip/tooltip.family.json +29 -0
  156. package/agents/components/tooltip/tooltip.md +139 -0
  157. package/agents/components/tooltip/tooltip.spec.json +110 -0
  158. package/agents/compose.md +240 -0
  159. package/agents/icons.json +831 -0
  160. package/agents/images.md +66 -0
  161. package/agents/manifest.json +87 -0
  162. package/agents/patterns/README.md +59 -0
  163. package/agents/patterns/actions.md +50 -0
  164. package/agents/patterns/browsing.md +52 -0
  165. package/agents/patterns/communications.md +56 -0
  166. package/agents/patterns/layout.md +72 -0
  167. package/agents/patterns/modals.md +50 -0
  168. package/agents/patterns/visual.md +55 -0
  169. package/agents/reconstruct.md +55 -0
  170. package/agents/scoped-adoption.md +111 -0
  171. package/agents/tokens.usage.json +1657 -0
  172. package/agents/usage.json +422 -0
  173. package/dist/icons/index.cjs +1332 -0
  174. package/dist/icons/index.cjs.map +1 -0
  175. package/dist/icons/index.d.cts +228 -0
  176. package/dist/icons/index.d.ts +228 -0
  177. package/dist/icons/index.js +1114 -0
  178. package/dist/icons/index.js.map +1 -0
  179. package/dist/index.cjs +5905 -0
  180. package/dist/index.cjs.map +1 -0
  181. package/dist/index.d.cts +896 -0
  182. package/dist/index.d.ts +896 -0
  183. package/dist/index.js +5847 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/styles.css +5765 -0
  186. package/eslint/README.md +79 -0
  187. package/eslint/index.js +78 -0
  188. package/eslint/rules.js +472 -0
  189. package/eslint/test.mjs +135 -0
  190. package/package.json +96 -0
  191. package/placeholder.png +0 -0
@@ -0,0 +1,154 @@
1
+ # SideSheet
2
+
3
+ Off-canvas content column anchored to the leading or trailing edge of the viewport. Pairs with [BottomSheet](../bottom-sheet/bottom-sheet.md) as the Sheet family's other anchor — BottomSheet for committed-sheet flows, SideSheet for off-canvas navigation columns, settings panes, channel directories, filter rails.
4
+
5
+ **Reach for this when** an off-canvas column belongs next to the current page — a navigation drawer, a channel / topic / saved-item directory, a filter rail, a settings pane. **Skip when** the surface is a committed-sheet flow (use [BottomSheet](../bottom-sheet/bottom-sheet.md)), a confirmation prompt (use [Dialog](../dialog/dialog.md)), a labelled in-flow block (use [Section](../section/section.md)), or a permanent app-shell navigation (use [TabBar](../tab-bar/tab-bar.md) / [NavigationBar](../navigation-bar/navigation-bar.md)).
6
+
7
+ **Layout inset.** `bounded-surface` — a portal-rendered off-canvas card (same family as `BottomSheet` / `Dialog`), mounted into a `document.body` portal. Its body is **full-bleed**: it pays no inline gutter, so each child owns its own `container.md` (16) rail and aligns at 16 with row backgrounds bleeding the full column. Full-bleed children (List, Feed) need **no** negative-margin opt-out here — that idiom is only for the inline-gutter surfaces (Dialog / BottomSheet); a bounded child needing an inset supplies its own inline margin. See [AGENTS.md § Composition rules](../../../AGENTS.md#composition-rules).
8
+
9
+ Composition is free-form via `children` — canonical fill is a [Header](../header/header.md) (`size="medium"`) heading + an embedded [list/entry](../list/entry.md) directory stack (40 avatar + label + inline count Badge + optional trailing icon toggle), optionally followed by another Header + List(entry) pair and a pinned footer commit.
10
+
11
+ ## Default
12
+
13
+ A left-anchored navigation drawer composed of three Header (medium) + List(entry) directory pairs and a pinned footer commit. The canonical "channels directory" off-canvas pattern.
14
+
15
+ ```preview
16
+ side-sheet/default
17
+ ---
18
+ import { Badge, Button, Header, List, SideSheet, SideSheetGroup } from '@teamblind-chorus/ui';
19
+ import { StarFillIcon } from '@teamblind-chorus/ui/icons';
20
+
21
+ <SideSheet
22
+ inline
23
+ open
24
+ onClose={() => {}}
25
+ aria-label="Channels drawer"
26
+ footer={
27
+ <Button variant="text" size="small" appearance="accent" onClick={() => {}}>
28
+ Browse all channels
29
+ </Button>
30
+ }
31
+ >
32
+ <SideSheetGroup>
33
+ <Header
34
+ size="medium"
35
+ label="My channels"
36
+ headerAction={{ label: '+ Create', onClick: () => {} }}
37
+ />
38
+ <List
39
+ variant="entry"
40
+ embedded
41
+ items={[
42
+ { value: 'team-blind', label: 'Team Blind', thumbnail: { alt: 'Team Blind' } },
43
+ { value: 'startup-lounge', label: 'Startup Lounge', count: <Badge size="small" count={64} />, thumbnail: { alt: 'Startup Lounge' } },
44
+ { value: 'it-pm', label: 'IT PM · Management', count: <Badge size="small" count={12} />, thumbnail: { alt: 'IT PM · Management' } },
45
+ ]}
46
+ />
47
+ </SideSheetGroup>
48
+
49
+ <SideSheetGroup>
50
+ <Header size="medium" label="Favorites" />
51
+ <List
52
+ variant="entry"
53
+ embedded
54
+ items={[
55
+ {
56
+ value: 'sourdough',
57
+ label: 'Sourdough Bakers',
58
+ count: <Badge size="small" count={12} />,
59
+ thumbnail: { alt: 'Sourdough Bakers' },
60
+ trailingIcon: <Button variant="icon" size="medium" aria-label="Favorited" aria-pressed="true" icon={<StarFillIcon />} style={{ color: 'var(--sys-color-icon-yellow)' }} onClick={() => {}} />,
61
+ },
62
+ {
63
+ value: 'stocks',
64
+ label: 'Stocks & Investing',
65
+ count: <Badge size="small" count={142} />,
66
+ thumbnail: { alt: 'Stocks & Investing' },
67
+ trailingIcon: <Button variant="icon" size="medium" aria-label="Favorited" aria-pressed="true" icon={<StarFillIcon />} style={{ color: 'var(--sys-color-icon-yellow)' }} onClick={() => {}} />,
68
+ },
69
+ {
70
+ value: 'movie-talk',
71
+ label: 'Movie Talk',
72
+ count: <Badge size="small" count={24} />,
73
+ thumbnail: { alt: 'Movie Talk' },
74
+ trailingIcon: <Button variant="icon" size="medium" aria-label="Favorited" aria-pressed="true" icon={<StarFillIcon />} style={{ color: 'var(--sys-color-icon-yellow)' }} onClick={() => {}} />,
75
+ },
76
+ ]}
77
+ />
78
+ </SideSheetGroup>
79
+
80
+ <SideSheetGroup>
81
+ <Header size="medium" label="Following" />
82
+ <List
83
+ variant="entry"
84
+ embedded
85
+ items={[
86
+ {
87
+ value: 'career',
88
+ label: 'Career & Jobs',
89
+ count: <Badge size="small" count={24} />,
90
+ thumbnail: { alt: 'Career & Jobs' },
91
+ trailingIcon: <Button variant="icon" size="medium" aria-label="Favorite" aria-pressed="false" icon={<StarFillIcon />} style={{ color: 'var(--sys-color-icon-muted)' }} onClick={() => {}} />,
92
+ },
93
+ {
94
+ value: 'marketplace',
95
+ label: 'Marketplace',
96
+ count: <Badge size="small" count={12} />,
97
+ thumbnail: { alt: 'Marketplace' },
98
+ trailingIcon: <Button variant="icon" size="medium" aria-label="Favorite" aria-pressed="false" icon={<StarFillIcon />} style={{ color: 'var(--sys-color-icon-muted)' }} onClick={() => {}} />,
99
+ },
100
+ {
101
+ value: 'fashion',
102
+ label: 'Fashion & Beauty',
103
+ thumbnail: { alt: 'Fashion & Beauty' },
104
+ trailingIcon: <Button variant="icon" size="medium" aria-label="Favorite" aria-pressed="false" icon={<StarFillIcon />} style={{ color: 'var(--sys-color-icon-muted)' }} onClick={() => {}} />,
105
+ },
106
+ ]}
107
+ />
108
+ </SideSheetGroup>
109
+ </SideSheet>
110
+ ```
111
+
112
+ ## Use cases
113
+
114
+ ### Single section
115
+
116
+ One `SideSheetGroup` with a Header + List(entry) directory. Use for filter rails, settings groups, sub-navigation overlays.
117
+
118
+ ### With pinned commit
119
+
120
+ Set the `footer` prop with a Text Button to pin a primary commit at the bottom (e.g. "Browse all channels", "Apply filters"). Footer stays flush while the body scrolls.
121
+
122
+ ## Slots
123
+
124
+ - **scrim** — backdrop. Translucent black (`ref.palette.black.600`); dismisses on tap.
125
+ - **card** — off-canvas column. Fixed width, full viewport height, flush against the `anchor` edge. `sys.color.surface` fill + `sys.elevation.sheet` shadow.
126
+ - **body** — vertical scroll surface inside the card. **Full-bleed** — no inline gutter; each child pays its own `container.md` (16) rail. Block padding is `0` at the top (each group's leading Header supplies the 24 top inset itself) and `24px` at the bottom; the body adds no inter-child gap — the Header-driven block rhythm is the single source of truth.
127
+ - **group** *(SideSheetGroup)* — bundle of one Header + a directory primitive (canonical: an embedded [list/entry](../list/entry.md) stack) inside the body. The full-bleed Header drives the rhythm: its `24px` (`layout.stack.lg`) block-start is both the gap **between groups** and the body's top inset for the first group; its `16px` (`layout.stack.md`) block-end is the header↔directory gap. The group adds no sibling margin of its own. Because the body is full-bleed, the `<List variant="entry">` aligns at the same 16 rail as the Header with no opt-out.
128
+ - **footer** *(optional)* — pinned bottom action rail. Single Text Button or compact action node; separated by an `outlineVariant` hairline.
129
+
130
+ ## Trailing favorite-star contract
131
+
132
+ When a channel / directory row carries a favorite toggle on its trailing edge, use a single fill-only glyph (`StarFillIcon`) whose **colour** communicates the pressed state — never swap between outline (`StarIcon`) and fill (`StarFillIcon`) for the same affordance:
133
+
134
+ | State | Icon | Colour | aria-pressed |
135
+ |----------|-----------------|---------------------------------|--------------|
136
+ | Active | `StarFillIcon` | `var(--sys-color-icon-yellow)` | `true` |
137
+ | Inactive | `StarFillIcon` | `var(--sys-color-icon-muted)` | `false` |
138
+
139
+ The shape stays constant so the trailing edge has a stable hit-target footprint; only the colour token flips. This is the canonical pattern across Side Sheet, channel lists, directory rows.
140
+
141
+ ## Behavior
142
+
143
+ - **Portal** — renders into a `document.body` portal when `open` (SSR-safe; target resolves on mount). `inline` mode bypasses the portal for docs previews.
144
+ - **Scroll lock** — body scroll is locked while open.
145
+ - **Focus** — on open, focus moves to the first focusable inside the card. On close, focus returns to the trigger.
146
+ - **Dismiss** — backdrop tap and Escape key both invoke `onClose`. The component never closes itself; consumers own the open state.
147
+ - **Full-bleed body** — the body pays no inline padding, so full-bleed children (List, Feed) rendered directly inside it pay their own `container.md` (16) rail and align at 16 with the Header, row backgrounds bleeding the full column — no negative-margin opt-out needed (unlike the inline-gutter Dialog / BottomSheet). A *bounded* child that needs an inset (a tinted Banner block) supplies its own inline margin since the body won't inset it.
148
+
149
+ ## Forbidden
150
+
151
+ - `open` without `onClose` — the sheet must own a dismiss path.
152
+ - Fixed positioning inside children — the body owns the scroll surface.
153
+ - Multiple SideSheets open at once — nest a BottomSheet inside if a sub-step is needed.
154
+ - `anchor="top"` / `anchor="bottom"` — top is out of scope; bottom belongs to BottomSheet.
@@ -0,0 +1,109 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "SideSheet",
4
+ "family": "side-sheet",
5
+ "subcomponent": "side-sheet",
6
+ "exportAlias": "SideDrawer",
7
+ "description": "Off-canvas content column. Renders into a body portal when open, locks body scroll, returns focus to the trigger on close, dismisses on Escape / backdrop tap. The card is a fixed-width column flush against the leading or trailing edge of the viewport; the body owns its own surface padding so consumers compose primitives directly inside without paying padding twice. Lists inside the body negate the inline padding so row leading edges land flush.",
8
+ "element": "aside",
9
+ "props": {
10
+ "open": {
11
+ "type": "boolean",
12
+ "required": true,
13
+ "description": "Controlled open state."
14
+ },
15
+ "onClose": {
16
+ "type": "function",
17
+ "required": true,
18
+ "description": "Fired on backdrop tap, Escape key, or any consumer-triggered close."
19
+ },
20
+ "anchor": {
21
+ "type": "literal",
22
+ "values": ["left", "right"],
23
+ "default": "left",
24
+ "description": "Edge the card sits flush against. `left` (default) — the off-canvas navigation / channel directory pattern. `right` — filter rail, settings pane, or sub-nav overlay on right-handed surfaces."
25
+ },
26
+ "width": {
27
+ "type": "number | string",
28
+ "default": 320,
29
+ "description": "Card width. Number → pixels; string → any CSS length (`'80vw'`, `'min(320px, 80vw)'`). Mobile-first: defaults to 320 so the trailing 40-ish px peek lets the user see the page underneath at a glance."
30
+ },
31
+ "footer": {
32
+ "type": "node",
33
+ "optional": true,
34
+ "description": "Pinned bottom action rail. Hosts a single Text Button or any compact action node (e.g. \"Browse all channels\"). Stays flush at the card bottom while the body scrolls."
35
+ },
36
+ "children": {
37
+ "type": "node",
38
+ "required": true,
39
+ "description": "Body content. Free-form composition; the canonical fill is one or more Header (size=\"medium\") + embedded [list/entry](../list/entry.md) directory stack pairs (40 avatar + label + inline count Badge + optional trailing icon toggle)."
40
+ },
41
+ "inline": {
42
+ "type": "boolean",
43
+ "default": false,
44
+ "description": "Docs-only — renders the sheet inline (no portal, no fixed positioning, no body scroll lock) so the card can be shown next to other primitives in a documentation preview without occluding the page. Never use in production."
45
+ }
46
+ },
47
+ "slots": {
48
+ "scrim": {
49
+ "required": true,
50
+ "description": "Backdrop. Translucent black overlay (`ref.palette.black.600`) that absorbs all clicks outside the card and dismisses the sheet on tap."
51
+ },
52
+ "card": {
53
+ "required": true,
54
+ "description": "Off-canvas column. Fixed width, full viewport height, flush against the `anchor` edge. `surface` fill + `elevation.sheet` shadow."
55
+ },
56
+ "body": {
57
+ "required": true,
58
+ "description": "Vertical scroll surface inside the card. Pays its own gutter (16px inline / 24px block via `layout.container.md` / `layout.container.lg`) and stacks children at 24px (`layout.stack.lg`) — the rhythm BETWEEN groups."
59
+ },
60
+ "group": {
61
+ "required": false,
62
+ "description": "Header + List bundle (the `SideSheetGroup` primitive). Internal gap 16px (`layout.stack.md`) so the heading reads attached to its items; body-level 24px rhythm then separates one group from the next. Wrap each (Header + List) pair in a group so the visual grouping reads consistently across the drawer column."
63
+ },
64
+ "footer": {
65
+ "required": false,
66
+ "description": "Pinned bottom action rail. Separated from the body by a `outlineVariant` hairline. `16px` inline + block padding."
67
+ }
68
+ },
69
+ "sizing": {
70
+ "scrimColor": "ref.palette.black.600",
71
+ "cardBg": "sys.color.surface",
72
+ "cardElevation": "sys.elevation.sheet",
73
+ "cardDefaultWidth": "320px",
74
+ "bodyPadding": "sys.layout.container.lg (24) block × sys.layout.container.md (16) inline",
75
+ "bodyStackGap": "sys.layout.stack.lg (24) — between groups",
76
+ "groupStackGap": "sys.layout.stack.md (16) — within group, Header → List",
77
+ "footerPadding": "sys.layout.container.md (16)",
78
+ "footerDivider": "sys.borderWidth.hairline / sys.color.outlineVariant"
79
+ },
80
+ "states": {
81
+ "open": {
82
+ "description": "Card rendered into a body portal; body scroll locked; focus moves to the first focusable inside the card."
83
+ },
84
+ "closed": {
85
+ "description": "Component returns null; focus returns to the last-focused element before open."
86
+ }
87
+ },
88
+ "behavior": {
89
+ "portal": "Renders into a `document.body` portal when `open` and not `inline`. SSR-safe: the portal target resolves on mount.",
90
+ "scrollLock": "Body scroll is locked while open (and not inline).",
91
+ "focus": "On open, focus moves to the first focusable inside the card and is trapped there (see accessibility.focusTrap). On close, focus returns to the trigger.",
92
+ "dismiss": "Backdrop tap and Escape key both invoke `onClose`. The component never closes itself — consumers own the open state.",
93
+ "fullBleedListInside": "Lists rendered as direct children of the body negate the body's 16px inline padding (margin-inline negative + width opt-out) so row leading edges land flush with the body's leading inset, same precedent as BottomSheet → overflow / nested-step."
94
+ },
95
+ "accessibility": {
96
+ "role": "the scrim-backed sheet is a blocking modal, so the `aside` element carries role='dialog' (which overrides `aside`'s default complementary-landmark semantics) plus aria-modal='true'. The bare `aside` semantics alone are wrong for a surface that inerts the page behind a scrim. (If a non-scrim, docked/standard variant is later added, that variant keeps the plain `aside` complementary landmark and omits aria-modal — see the standard-side-sheet coverage note.)",
97
+ "ariaModal": "aria-modal='true' on the card — content behind the scrim is inert to assistive tech, mirroring the scrim's pointer-event block.",
98
+ "labelling": "aria-labelledby points at the heading composed at the top of the body when present; otherwise the consumer MUST supply an aria-label so the modal is never announced unnamed.",
99
+ "focusTrap": "While open, Tab / Shift+Tab cycle only through the card's focusable descendants and wrap at the ends — focus never reaches the inert page behind the scrim. Required by the APG dialog-modal pattern; pairs with the existing focus-restore-to-trigger on close.",
100
+ "escape": "Esc invokes onClose (see behavior.dismiss)."
101
+ },
102
+ "forbidden": [
103
+ "open without onClose — the sheet must own a dismiss path",
104
+ "fixed positioning inside children — the body owns the scroll surface, children must not break out of it",
105
+ "non-Sheet-family chrome to imitate this shape (raw `<aside>` + Tailwind, custom drawer libraries) — reach for SideSheet",
106
+ "multiple SideSheets open at once — sheets are one-at-a-time; nest BottomSheet inside if a sub-step is needed",
107
+ "anchor=\"top\" / anchor=\"bottom\" — top is out of scope (no mobile pattern needs it), bottom belongs to BottomSheet"
108
+ ]
109
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "skeleton",
4
+ "name": "Skeleton",
5
+ "description": "Loading placeholder — a tonal block on `surfaceContainerHighest` with a slow opacity pulse that previews the footprint of content still being fetched. Three shapes share one anatomy and diverge only on the default footprint and corner radius: `text` (16px-line block, full-width default), `block` (rectangular fill — image, card body), `circle` (avatar-sized round). Compose multiple Skeletons inside `SkeletonGroup` to mirror the rhythm of a list row, feed post, or card. Single-spec family.",
6
+ "useCases": [
7
+ "list row loading state",
8
+ "feed post loading state",
9
+ "card / image-area placeholder",
10
+ "avatar placeholder",
11
+ "deferred-fetch chrome before the data resolves"
12
+ ],
13
+ "visualReuse": "open",
14
+ "layoutInset": "inline",
15
+ "spec": "skeleton.md",
16
+ "usage": {
17
+ "note": "shape defaults to text; width/height are consumer-supplied. Stack multiples in SkeletonGroup to mirror the loading content's rhythm.",
18
+ "example": "<Skeleton shape=\"block\" height={120} />"
19
+ },
20
+ "subcomponents": [
21
+ {
22
+ "slug": "skeleton",
23
+ "spec": "skeleton.spec.json",
24
+ "md": "skeleton.md",
25
+ "default": true
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,123 @@
1
+ # Skeleton
2
+
3
+ A tonal placeholder block that previews where real content will render. Paints with a translucent `scrimSubtle` overlay (~8% black in light mode, ~8% white in dark) and a slow opacity pulse — visible on every host surface tier without colliding with a fixed neutral step. Three shapes — `text` (default 16-line block), `block` (image / card body), `circle` (avatar). Compose multiple Skeletons inside `SkeletonGroup` to mirror the loading content's rhythm.
4
+
5
+ **Reach for this when** a list row, feed post, card cover, or avatar is being fetched and the host would otherwise paint empty. **Skip when** the wait is sub-300ms, the data is unavailable rather than loading (use an empty-state illustration), or the loading scope is the whole screen (use a centered progress indicator at page level).
6
+
7
+ **Layout inset.** `inline` — Skeleton ships no padding or container chrome of its own. Sits as a leaf inside whichever surface it stands in for. Width and height are consumer-supplied. `SkeletonGroup` adds an 8px gap between stacked siblings.
8
+
9
+ ## Default
10
+
11
+ A single text-line placeholder. 16px high, full-width by default.
12
+
13
+ ```preview
14
+ skeleton/default
15
+ ---
16
+ import { Skeleton } from '@teamblind-chorus/ui';
17
+
18
+ <Skeleton />
19
+ ```
20
+
21
+ ## Use cases
22
+
23
+ ### Block
24
+
25
+ A rectangular tonal block. 80px high by default — reads as a card cover, image area, or card body placeholder. Pass `height` to match the real content's footprint.
26
+
27
+ ```preview
28
+ skeleton/block
29
+ ---
30
+ import { Skeleton } from '@teamblind-chorus/ui';
31
+
32
+ <Skeleton shape="block" height={120} />
33
+ ```
34
+
35
+ ### Circle
36
+
37
+ An avatar placeholder. 40 × 40 by default — matches a 40-rung [Thumbnail](../thumbnail/thumbnail.md). Override `width` / `height` for a different rung.
38
+
39
+ ```preview
40
+ skeleton/circle
41
+ ---
42
+ import { Skeleton } from '@teamblind-chorus/ui';
43
+
44
+ <Skeleton shape="circle" />
45
+ ```
46
+
47
+ ### List row
48
+
49
+ A list-row loading state — leading 40-circle next to two stacked text lines. Use the same widths as the real row so the swap to live data doesn't reflow.
50
+
51
+ ```preview
52
+ skeleton/list-row
53
+ ---
54
+ import { Skeleton, SkeletonGroup } from '@teamblind-chorus/ui';
55
+
56
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sys-layout-inline-md)', padding: 'var(--sys-layout-container-xs) var(--sys-layout-container-md)' }}>
57
+ <Skeleton shape="circle" />
58
+ <SkeletonGroup aria-label="Loading row" style={{ flex: 1 }}>
59
+ <Skeleton width="60%" />
60
+ <Skeleton width="40%" />
61
+ </SkeletonGroup>
62
+ </div>
63
+ ```
64
+
65
+ ### Feed post
66
+
67
+ A feed-post loading state — author row (avatar + name + meta), title, two body lines, and a 16:9 cover block. Mirrors the rhythm of a real feed/post.
68
+
69
+ ```preview
70
+ skeleton/feed-post
71
+ ---
72
+ import { Skeleton, SkeletonGroup } from '@teamblind-chorus/ui';
73
+
74
+ <SkeletonGroup aria-label="Loading post" style={{ padding: 'var(--sys-layout-container-md)' }}>
75
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sys-layout-inline-md)' }}>
76
+ <Skeleton shape="circle" width={32} height={32} />
77
+ <SkeletonGroup style={{ flex: 1 }}>
78
+ <Skeleton width="40%" />
79
+ <Skeleton width="25%" />
80
+ </SkeletonGroup>
81
+ </div>
82
+ <Skeleton width="85%" />
83
+ <Skeleton />
84
+ <Skeleton width="70%" />
85
+ <Skeleton shape="block" height={180} />
86
+ </SkeletonGroup>
87
+ ```
88
+
89
+ ## Slots
90
+
91
+ - **container** — the tonal block. `sys.color.scrimSubtle` fill (translucent inverse-tone overlay — black in light, white in dark), shape-dependent radius. Carries the pulse animation. `role='status'` + `aria-live='polite'` so screen readers announce the loading state without yanking focus.
92
+
93
+ ## Anatomy
94
+
95
+ | Slot | Token bindings |
96
+ |------------|----------------|
97
+ | container | `sys.color.scrimSubtle` fill, `sys.radius.xs` corners (`text` / `block`) or `sys.radius.full` (`circle`), no stroke |
98
+ | text | Default `ref.space.200` (16px) height × 100% width |
99
+ | block | Default `ref.space.1000` (80px) height × 100% width |
100
+ | circle | Default `ref.space.500` × `ref.space.500` (40 × 40) round |
101
+ | group gap | `SkeletonGroup` flex column, `sys.layout.stack.xs` (8px) between stacked Skeletons |
102
+
103
+ ## Shapes
104
+
105
+ | Shape | Default footprint | Corners | When to reach |
106
+ |----------|-------------------|--------------------|-------------------------------------------------------------------------|
107
+ | `text` | `ref.space.200` × 100% | `sys.radius.xs` | Single line of body copy — the canonical default. |
108
+ | `block` | `ref.space.1000` × 100% | `sys.radius.xs` | Image area, card cover, multi-line body block. Override `height` to fit. |
109
+ | `circle` | `ref.space.500` × `ref.space.500` | `sys.radius.full` | Avatar placeholder — matches a 40-rung [Thumbnail](../thumbnail/thumbnail.md). |
110
+
111
+ ## States
112
+
113
+ | State | Animation | Notes |
114
+ |-----------|--------------------------------------|-------|
115
+ | `default` | `pulse 1.6s ease-in-out infinite` | Opacity oscillates between `0.5` and `1`. No hue shift. |
116
+ | reduced-motion | suppressed | Under `prefers-reduced-motion: reduce` the pulse halts and the block stays at full opacity. |
117
+
118
+ ## Behavior
119
+
120
+ - **Footprint matches content.** Pass `width` / `height` so the placeholder matches the data it replaces — swap should not reflow.
121
+ - **Group for multi-line.** Stack Skeletons inside a `SkeletonGroup` to keep the 8px sibling gap consistent.
122
+ - **Single screen role.** `role='status'` + `aria-live='polite'` announces loading without interrupting focus. Announcement carries the `aria-label` (defaults to `'Loading'`).
123
+ - **Atomic swap.** Replace the Skeleton with real content in one render — no cross-fade.
@@ -0,0 +1,73 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "Skeleton",
4
+ "family": "skeleton",
5
+ "description": "Single tonal placeholder block painted on `sys.color.scrimSubtle` (~8% inverse-tone overlay — black scrim in light mode, white scrim in dark) with a slow opacity pulse (`0.5 → 1 → 0.5` over 1.6s). The translucent fill stays visible on every host surface tier, so the placeholder reads cleanly whether it sits on a plain page surface, an elevated container, a hero band, or a coloured card. Three shapes select the default footprint and corner radius: `text` (`ref.space.200` / 16px high, full-width — single line of body copy), `block` (`ref.space.1000` / 80px high, full-width — image / card body) and `circle` (`ref.space.500` × `ref.space.500` / 40 × 40, fully rounded — avatar). Consumer-supplied `width` / `height` props override the default footprint. Compose multiple Skeletons inside `SkeletonGroup` to mirror the rhythm of the content being loaded.",
6
+ "element": "span",
7
+ "props": {
8
+ "shape": {
9
+ "type": "enum",
10
+ "values": ["text", "block", "circle"],
11
+ "default": "text",
12
+ "description": "Selects the default footprint and corner radius. `text` and `block` paint at `sys.radius.xs`; `circle` paints at `sys.radius.full`."
13
+ },
14
+ "width": {
15
+ "type": "union",
16
+ "members": ["number", "string"],
17
+ "optional": true,
18
+ "description": "Overrides the default inline footprint. Numbers are coerced to `px`; strings pass through (`100%`, `'12rem'`, …). When omitted, `text` and `block` stretch to 100% of the host; `circle` keeps a `ref.space.500` (40 × 40) footprint."
19
+ },
20
+ "height": {
21
+ "type": "union",
22
+ "members": ["number", "string"],
23
+ "optional": true,
24
+ "description": "Overrides the default block footprint. Numbers are coerced to `px`. When omitted, `text` is `ref.space.200` (16px), `block` is `ref.space.1000` (80px), `circle` is `ref.space.500` (40px)."
25
+ },
26
+ "aria-label": {
27
+ "type": "string",
28
+ "optional": true,
29
+ "description": "Accessible label announced by screen readers. Defaults to `'Loading'`."
30
+ }
31
+ },
32
+ "slots": {
33
+ "container": {
34
+ "required": true,
35
+ "description": "Tonal block. `sys.color.scrimSubtle` fill (translucent black/white inverse-tone overlay — visible on every host surface), shape-dependent radius, no stroke. Carries the pulse animation. `role='status'` + `aria-live='polite'` so screen readers announce the loading state without yanking focus.",
36
+ "intrinsic": true
37
+ }
38
+ },
39
+ "sizing": {
40
+ "background": "sys.color.scrimSubtle",
41
+ "radiusText": "sys.radius.xs",
42
+ "radiusBlock": "sys.radius.xs",
43
+ "radiusCircle": "sys.radius.full",
44
+ "defaultTextHeight": "ref.space.200",
45
+ "defaultBlockHeight": "ref.space.1000",
46
+ "defaultCircleSize": "ref.space.500",
47
+ "groupGap": "sys.layout.stack.xs"
48
+ },
49
+ "appearances": {
50
+ "default": {
51
+ "background": "sys.color.scrimSubtle",
52
+ "note": "The only fill tone — Skeleton has no emphasis axis. Painted as a translucent ~8% inverse-tone overlay (black in light mode, white in dark) so the placeholder stays visible on every host surface tier (surface, surfaceContainer, surfaceContainerHigh, hero, …) without colliding with a fixed neutral step. The pulse modulates opacity, not hue, so the placeholder reads as anonymous chrome."
53
+ }
54
+ },
55
+ "states": {
56
+ "default": {
57
+ "animation": "chorus-skeleton-pulse 1.6s ease-in-out infinite",
58
+ "note": "Opacity oscillates between 0.5 and 1 over 1.6s. The 0.5 floor is a family constant — no semantic token rung is allocated for the opacity-pulse minimum since it is unique to Skeleton. The animation respects `prefers-reduced-motion: reduce` — pulse halts and the block stays at full opacity."
59
+ }
60
+ },
61
+ "behavior": {
62
+ "ariaLive": "Container carries `role='status'` and `aria-live='polite'` so screen readers announce the loading state without interrupting the user's current focus. The visible block is decorative — the announcement comes from `aria-label` (defaults to `'Loading'`).",
63
+ "reducedMotion": "Under `@media (prefers-reduced-motion: reduce)` the pulse animation is suppressed entirely; the block stays at full opacity.",
64
+ "footprintOverrides": "`width` / `height` props win over the shape's default footprint, including for `circle` (a circle Skeleton at `width=60 height=60` stays a 60-rung round)."
65
+ },
66
+ "forbidden": [
67
+ "skeleton fill stepped to `sys.color.primary` or any chromatic tone — the placeholder must read as anonymous chrome, not as a content tone",
68
+ "shimmer / gradient sweep animation — Chorus skeletons modulate opacity only (one motion axis keeps the tier readable under reduced-motion fallback)",
69
+ "skeleton stacked vertically without a SkeletonGroup wrapper — group via SkeletonGroup so the 8px gap stays consistent across compositions",
70
+ "skeleton kept on-screen after the real content resolves — the placeholder must swap out atomically, not cross-fade with the loaded data",
71
+ "skeleton used as a permanent low-priority placeholder (empty-state / no-data illustration) — that role is an inline illustration + body copy, not a pulsing placeholder"
72
+ ]
73
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "status-tag",
4
+ "name": "StatusTag",
5
+ "description": "Small inline status pill — a tonal label-style mark sized for the trailing edge of a row label (10px text, 4px inline / 2px block padding, `sys.radius.xs` corners). Two appearances on a single emphasis axis: `neutral` (quiet `surfaceContainerHighest` + `onSurfaceVariant` — the informational default: pending, draft, queued) and `error` (`errorContainer` + `onErrorContainer` — the rejection / blocked state). Decorative — never an interactive surface. Distinct from [chip/tag](../chip/tag.md): Chip is a 32px-rung selectable / dismissable pill that lives in chip rows; StatusTag is a 16px-rung non-interactive mark that sits inline next to a row label. Single-spec family.",
6
+ "useCases": [
7
+ "row-label status annotation (pending / approved / rejected)",
8
+ "trailing inline status mark on a list / settings row",
9
+ "inline state pill in a feed-post header"
10
+ ],
11
+ "visualReuse": "open",
12
+ "layoutInset": "inline",
13
+ "spec": "status-tag.md",
14
+ "usage": {
15
+ "note": "Decorative — never interactive. Takes label text as children; not a Chip and not a button.",
16
+ "example": "<StatusTag appearance=\"error\">Rejected</StatusTag>"
17
+ },
18
+ "subcomponents": [
19
+ {
20
+ "slug": "status-tag",
21
+ "spec": "status-tag.spec.json",
22
+ "md": "status-tag.md",
23
+ "default": true
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,114 @@
1
+ # Status tag
2
+
3
+ A small inline status pill — a tonal mark sized for the trailing edge of a row label. Two **appearances**: `neutral` (quiet informational default) and `error` (rejection / blocked). Decorative — never interactive.
4
+
5
+ **Reach for this when** a row label needs a short state annotation inline — "pending" next to a user channel, "rejected" next to a failed approval, "draft" next to an in-progress post. **Skip when** the state is the row's primary content (use [List/standard](../list/standard.md) with `supportingText`), when the mark must be tappable (host row owns the click target), inside a chip row (use [chip/filter](../chip/filter.md) or [chip/tag](../chip/tag.md)), or when the mark names *who the person is* rather than a workflow state — 채널장 / 현직자 belong to [badge/role](../badge/role.md).
6
+
7
+ **Layout inset.** `inline` — StatusTag ships no padding outside its pill chrome. Sits next to a host label with `sys.layout.container.2xs` (4px) inline gap supplied by the host column.
8
+
9
+ ## Default
10
+
11
+ The `neutral` appearance — `sys.color.scrimSubtle` fill (the translucent inverse-tone scrim — ~8% black in light, ~8% white in dark) with `onSurfaceVariant` foreground. The quiet informational state.
12
+
13
+ ```preview
14
+ status-tag/default
15
+ ---
16
+ import { StatusTag } from '@teamblind-chorus/ui';
17
+
18
+ <StatusTag>Pending</StatusTag>
19
+ ```
20
+
21
+ ## Use cases
22
+
23
+ ### Error
24
+
25
+ The `error` appearance — `errorContainer` fill with an `onErrorContainer` foreground. The rejection / blocked / failed state.
26
+
27
+ ```preview
28
+ status-tag/error
29
+ ---
30
+ import { StatusTag } from '@teamblind-chorus/ui';
31
+
32
+ <StatusTag appearance="error">Rejected</StatusTag>
33
+ ```
34
+
35
+ ### On a list row
36
+
37
+ The canonical pairing — a `list/thumbnail` row whose label carries a trailing StatusTag with a `sys.layout.container.2xs` (4px) inline gap. The gap belongs to the label column; StatusTag carries no outer margin.
38
+
39
+ ```preview
40
+ status-tag/list-row
41
+ ---
42
+ import { List, StatusTag, Thumbnail } from '@teamblind-chorus/ui';
43
+
44
+ <List
45
+ variant="standard"
46
+ items={[
47
+ {
48
+ value: 'ch-2',
49
+ label: (
50
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--sys-layout-container-2xs)' }}>
51
+ User channel 2
52
+ <StatusTag>Pending</StatusTag>
53
+ </span>
54
+ ),
55
+ thumbnail: { alt: 'User channel 2', shape: 'circle' },
56
+ },
57
+ {
58
+ value: 'ch-3',
59
+ label: (
60
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--sys-layout-container-2xs)' }}>
61
+ User channel 3
62
+ <StatusTag appearance="error">Rejected</StatusTag>
63
+ </span>
64
+ ),
65
+ thumbnail: { alt: 'User channel 3', shape: 'circle' },
66
+ },
67
+ ]}
68
+ />
69
+ ```
70
+
71
+ ### Inline in a paragraph
72
+
73
+ A StatusTag tucked inline inside a paragraph or a feed-post header. Same 4px gap rule: the host column owns the whitespace, StatusTag stays a bare pill.
74
+
75
+ ```preview
76
+ status-tag/inline
77
+ ---
78
+ import { StatusTag } from '@teamblind-chorus/ui';
79
+
80
+ <p style={{ font: '14px var(--sys-typo-fontFamily)', color: 'var(--sys-color-onSurface)' }}>
81
+ Shared document <StatusTag>Pending</StatusTag> is awaiting review.
82
+ </p>
83
+ ```
84
+
85
+ ## Slots
86
+
87
+ - **container** — tonal pill. 4px inline / 2px block padding, `sys.radius.xs` corners. Carries `role="status"` so the announcement reads as a state update.
88
+ - **label** — tag text. 10px / Semibold / appearance-bound foreground. Short phrase (≤ 6 chars Latin, ≤ 4 CJK); `white-space: nowrap`.
89
+
90
+ ## Anatomy
91
+
92
+ | Slot | Token bindings |
93
+ |-----------|----------------|
94
+ | container | Fill + foreground per appearance, 4px inline / 2px block padding, `sys.radius.xs` corners |
95
+ | label | `ref.fontSize.125` (10px) / Semibold, 1.2 line-height |
96
+ | gap from host label | `sys.layout.container.2xs` (4px) — paid by the host column, NOT by StatusTag |
97
+
98
+ ## Appearance
99
+
100
+ | Appearance | Container fill | Foreground | When to reach |
101
+ |------------|-----------------------------------------------------------------------------|----------------------------------|-------------------------------------------------------------------------------|
102
+ | `neutral` | `sys.color.scrimSubtle` (translucent inverse-tone scrim — ~8% black light / ~8% white dark) | `sys.color.onSurfaceVariant` | Quiet informational default — visible on every surface tier. In-progress / awaiting states — "pending", "draft", "queued", "in review". |
103
+ | `error` | `sys.color.errorContainer` | `sys.color.onErrorContainer` | Rejection / blocked / failed state. Use sparingly. |
104
+
105
+ ## States
106
+
107
+ StatusTag is decorative and carries **no** lifecycle states (no hover, pressed, focus, or disabled paint). If the state needs to be tappable, the host row is the interactive surface.
108
+
109
+ ## Behavior
110
+
111
+ - **Decorative only.** Never a `<button>`, `<a href>`, or focusable element. The host row owns the click target.
112
+ - **Inline next to a label.** The host label column carries a 4px gap to StatusTag; StatusTag itself has no outer margin.
113
+ - **No wrap.** Container is `inline-block` with `white-space: nowrap`. If the phrase is long, the label is wrong — not the pill.
114
+ - **Accessibility.** Container carries `role="status"` so screen readers announce the tag as a state update rather than as an unlabelled span.