@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,232 @@
1
+ # Catalog — intent → component
2
+
3
+ Reverse index from natural-language intent to family + sub-component. Read *before* opening any spec to narrow candidates. Authoritative shape lives in `schema/components/<family>/<sub>.spec.json`; this file is a routing layer.
4
+
5
+ > **How to import / use a family → `usage.json`.** This catalog names families as `family / sub`. The React access pattern differs per family: a named import (`profile-header` → `import { ProfileHeader }`), a `variant=` prop (`button / fab` → `<Button variant="fab">` — there is **no** `<Fab>` export), a separate export (`carousel / post` → `PostCarousel`), or a data array (`list / entry` → `<List items={[…]}>` — there is **no** `<ListItem>`). `usage.json` is the authoritative, build-time **parity-checked** map of every family to its exact usage. **A failed `import { X }` for one component never means the catalog is unreliable or a family is "missing at runtime" — look it up in `usage.json` and use the access it specifies. Never fall back to assembling a family from token primitives.**
6
+
7
+ ## How intent binding works
8
+
9
+ Each family declares `visualReuse: "open" | "locked"` in its `<family>.family.json`. The catalog respects that flag.
10
+
11
+ - **Open (default, 27 families)** — `avatar-rail`, `badge`, `banner`, `bubble`, `button`, `carousel`, `chip`, `directory-list`, `divider`, `feed`, `header`, `list`, `metadata`, `nav-card`, `nav-list`, `navigation-bar`, `page-shell`, `profile-header`, `progress`, `side-sheet`, `skeleton`, `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.
12
+ - **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.
13
+
14
+ When in doubt: open families are recipes, locked families are rules.
15
+
16
+ ## Top bars
17
+
18
+ **Layout**: every entry is `layoutInset: full-bleed` — direct child of `<main>`, no wrapper `padding-inline`.
19
+
20
+ | Intent | Family + sub |
21
+ | ------------------------------------- | ---------------------------------- |
22
+ | landing screen header, title bar | `navigation-bar / main` |
23
+ | post / content detail header, back + wordmark + action cluster | `navigation-bar / main` (drill-in: pass `onBack`) |
24
+ | drill-in screen header, back + centred title | `navigation-bar / sub` |
25
+ | search screen header, input fills bar | `navigation-bar / search` |
26
+ | profile / channel detail page identity (cover + avatar + name + follow) | `profile-header` |
27
+
28
+ **Disambiguate**: never stack two NavigationBars. Pick by *screen kind*, not *content kind*. `profile-header` is not a bar — it's the page-level identity block (cover, avatar, name, follow) at the top of a profile detail route; pair it with a transparent overlay `navigation-bar / sub` when the route is a drill-in.
29
+
30
+ ## Action commits
31
+
32
+ **Layout**: every entry is `layoutInset: inline` — an atom that needs grouping (action row, toolbar, button group) to sit at page level. Exception: `button / fab` is viewport-anchored fixed-position chrome and lives outside the page's normal stack.
33
+
34
+ | Intent | Family + sub |
35
+ | ------------------------------------------ | ----------------------------- |
36
+ | primary inline commit (Save, Continue) | `button / standard` |
37
+ | canonical floating commit (Compose, New) | `button / fab` |
38
+ | icon-only inline commit | `button / icon` |
39
+ | link-shaped commit, low emphasis | `button / text` |
40
+ | option toggle next to a primary commit | `button / check` |
41
+ | reversible state (mute/unmute, follow) | `button / toggle` |
42
+ | dense action inside a toolbar | `button / toolbar` |
43
+ | destructive primary commit | `button / standard` with `flavor: "destructive"` (inside a Dialog) |
44
+
45
+ **Disambiguate**: FAB = the **single** canonical commit per screen. Destructive primary commits go in `dialog`/`bottom-sheet`, not on a FAB.
46
+
47
+ ## Structure & layout
48
+
49
+ **Layout**: mixed. `page-shell`, `divider`, and `header` are `full-bleed` — direct children of `<main>` (or the host column), no wrapper `padding-inline`. Both `header` members (`header / main` = `<Header>`, `header / sub` = `<SubHeader>`) own their own `container.md` (16) inline rail and a transparent background, so they align with the rows / feed items they head and paint on whatever surface tier hosts them.
50
+
51
+ | Intent | Family + sub | layoutInset |
52
+ | ------------------------------------------------------- | ------------- | ------------- |
53
+ | app screen scaffold that pins the top + bottom bars | `page-shell` | `full-bleed` |
54
+ | labelled block heading + optional trailing "See all" | `header / main` | `full-bleed` |
55
+ | quiet group label above a stack of rows ("Following") | `header / sub` | `full-bleed` |
56
+ | heavy tonal band splitting two page regions | `divider` | `full-bleed` |
57
+
58
+ **Disambiguate**: `page-shell` is the only screen-level scaffold — it pins `navigation-bar` (top) + `tab-bar` (bottom) so a long scrolling body never pushes the bars off-screen; never re-implement pinning with `position: fixed` + body padding. `header / main` (`<Header>`) = a leading title + a single trailing action ("See all"), composed at the head of a list / carousel / feed / card / sheet section — it's the title atom, not screen chrome (`navigation-bar` is the bar). `header / sub` (`<SubHeader>`) = the same family's quieter member, a 14px muted label that *names* a group of rows beneath it (at most a single Text Button action). `divider` = a heavy `scrimSubtle` region band for when vertical rhythm alone doesn't separate two regions — NOT a hairline between list rows (that's built into `List`), and NOT the thin shadcn `<Separator>` (see name-translation table).
59
+
60
+ ## Vertical content surfaces
61
+
62
+ **Layout**: most entries below are `layoutInset: full-bleed` — direct child of `<main>`, no wrapper `padding-inline`. When nested inside `<Carousel>` / `<Feed>` (another full-bleed host that pays its own chrome), pass `embedded` so background + padding defer to the host — see [`AGENTS.md` § Composition rules](../AGENTS.md#composition-rules). The `nav-card` row is the exception — it's `layoutInset: inline` (an inline card with its own padding + outline + radius that fills the host column; the host owns the surrounding inset).
63
+
64
+ | Intent | Family + sub | layoutInset |
65
+ | -------------------------------------------- | ------------------------ | -------------- |
66
+ | settings / menu / picker rows | `list / standard` | `full-bleed` |
67
+ | single-select option group | `list / radio` | `full-bleed` |
68
+ | avatar-anchored rows (channels, DMs) | `list / entry` | `full-bleed` |
69
+ | drill-in rows with trailing chevron | `list / standard` (or `/ entry`, `/ radio`) with `nav: true` | `full-bleed` |
70
+ | standalone drill-in card (single row) | `nav-card` | **`inline`** |
71
+ | expandable titled sections (FAQ, T&C) | `accordion` | `full-bleed` |
72
+ | authored content stream (posts, comments) | `feed / feed` | `full-bleed` |
73
+ | follow suggestions block (swipeable peek) | `suggestion-list` | `full-bleed` |
74
+ | follow directory (full vertical scroll) | `directory-list` | `full-bleed` |
75
+ | label-only nav list with chevron rows | `nav-list` | `full-bleed` |
76
+
77
+ **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.
78
+
79
+ ### Entity directory rows + author attribution
80
+
81
+ | Intent | Family + sub | Layout |
82
+ | ----------------------------------------------------------------- | ------------------------ | ------------ |
83
+ | generic entity row (channel / person / company / topic + commit) | `list / entry` | `full-bleed` |
84
+ | compact directory entry (channel / topic / playlist + count) | `list / entry` at `size="small"` | `full-bleed` |
85
+ | follow-suggestion swipeable page-of-three | `suggestion-list` | `full-bleed` |
86
+ | author / brand attribution at the head of a Feed card | `metadata / standard` | `inline` |
87
+ | one-line comment / reply author attribution (company · nickname · time) | `metadata / compact` | `inline` |
88
+
89
+ **Disambiguate**: `list/entry` is the single home for every entity-row case — directory entries, follow rows, member rosters, mention / recipient pickers, entity search results. The list ships its own `16px inline / 8px block` row padding and tiles rows full-bleed with hairline dividers; the row itself supports identity group (label + optional inline `count` Badge + optional stacked `secondary` line) + optional single-line `description` (separated by `ref.space.25` (2)) + optional trailing affordance. Pick the leading Thumbnail rung via `size="small|medium|large|xlarge"` (32 / 40 / 48 / 56). `suggestion-list` wraps the same row contract in a swipeable page-of-three for the follow-suggestion block. `metadata` is the only `inline` atom in this group, in two shapes: `standard` — fixed 32-rung Thumbnail, primary line is name + optional inline timestamp + optional follow toggle (middot-separated), secondary line is a `meta` link row OR a `Sponsored` subtitle — composed at the head of `feed / post` and `feed / ad`; `compact` (`variant="compact"`) — the secondary-line grammar standing alone, one text line, no avatar: company name · nickname (optional role badge) · trailing plain timestamp, for comment / reply rows. Reach for `list/entry` for every entity-presentation row; reach for `metadata` only at Feed card heads (standard) or comment / reply attributions (compact).
90
+
91
+ ## Horizontal content surfaces
92
+
93
+ **Layout**: every entry is `layoutInset: full-bleed` — direct child of `<main>`, no wrapper `padding-inline`. When nested inside `<Carousel>` / `<Feed>`, pass `embedded` so chrome defers to the host — see [`AGENTS.md` § Composition rules](../AGENTS.md#composition-rules).
94
+
95
+ | Intent | Family + sub |
96
+ | --------------------------------------- | ------------------------ |
97
+ | compact horizontal entity quick-nav | `avatar-rail` |
98
+ | section tabs with sliding indicator | `tabs / underline` |
99
+ | chip-shaped tab row with leading icons | `tabs / rounded` |
100
+ | in-place mode toggle (List ↔ Grid) | `tabs / segmented` |
101
+
102
+ ## Modal surfaces
103
+
104
+ **Layout**: mixed — column below. `bounded-surface` floats *above* the page (portal-mounted, viewport-anchored, owns its safe area). `banner` is the outlier — it's `inline` (an inline card that fits the host column with no outer margin) and lives **in** the content column, not above it; it's listed here only because it's the in-flow alternative to a modal prompt.
105
+
106
+ | Intent | Family + sub | layoutInset |
107
+ | --------------------------------------------------- | ----------------------------------------------------------- | ----------------- |
108
+ | short focused commit anchored to bottom of viewport | `bottom-sheet` *(locked)* | `bounded-surface` |
109
+ | confirmation prompt, centered | `dialog` *(locked)* | `bounded-surface` |
110
+ | destructive confirmation | `dialog` *(locked)* with destructive primary action | `bounded-surface` |
111
+ | inline notice inside the content column | `banner / accent` or `/ default` (not modal — in-flow aside)| **`inline`** ⚠ |
112
+ | transient post-action confirmation (saved, copied) | `toast` *(locked)* — non-modal, auto-dismiss | `bounded-surface` |
113
+ | trigger-anchored hint over a hovered/focused control | `tooltip` *(locked)* — non-modal, hover/focus-driven | `bounded-surface` |
114
+ | off-canvas navigation / filter drawer (left or right edge) | `side-sheet` (anchor `left` / `right`) | `bounded-surface` |
115
+
116
+ **Disambiguate**: `bottom-sheet`/`dialog` are modal — require a trigger elsewhere. `banner` is not modal; lives in the content column. `toast` is non-modal and self-dismissing — reach for it only when the action has landed and no decision is needed. `tooltip` is non-modal and floats over a specific trigger — only when the message describes a hovered/focused control. `side-sheet` is the off-canvas anchor sibling of `bottom-sheet` (open, not locked) — a drawer for navigation / channel directory / filter rail; compose `Header` (medium) + compact `List` inside `SideSheetGroup`. Top anchor is out of scope.
117
+
118
+ ## Inputs
119
+
120
+ **Layout**: every entry in the table below is `layoutInset: inline` — `<FormField>` wraps inline within a form column. The Search sub-table further down mixes layouts (NavBar vs FormField).
121
+
122
+ | Intent | Family + sub |
123
+ | ------------------------------------- | -------------------------- |
124
+ | labeled single-line text field | `form-field / input` *(locked)* |
125
+ | bare pill search input | `form-field / search` *(locked)* |
126
+
127
+ **Disambiguate**: `form-field` is *(locked)* — only for real text entry. For an Input-shaped read-only display row, reach for a `list / standard` row or a token-faithful primitive rather than borrowing the form-field shell (the `<input>` semantics, label binding, and a11y plumbing would lie).
128
+
129
+ ### Search affordance — three candidates, pick by surface
130
+
131
+ "Search" maps to three different Chorus rungs by where the field sits. Do NOT default to the first that comes to mind — and do NOT fall back to a hand-rolled `<input>`. All three exist and are typed in `dist/index.d.ts`:
132
+
133
+ | Surface (where the field lives) | Component | layoutInset | Why |
134
+ | -------------------------------------------------------------- | ---------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
135
+ | **Top bar takes the whole row** (search mode of a screen) | `<NavigationBar variant="search">` | `full-bleed` | The bar IS the field. Owns back chevron + bare input + clear button as one chrome unit. Use when entering "search mode" of a screen. |
136
+ | **Inline filter / list-top / sheet-top** (chrome, not a form) | `<FormField variant="search">` | `inline` | The bare pill — leading magnifier glyph, focus-only clear button, no label / helper / maxLength. The affordance is the glyph. |
137
+ | **Inside a real form** (search-shaped input that needs a label) | `<FormField variant="input" leadingIcon={<SearchIcon/>} label="…" helper="…">` | `inline` | Use when the field needs a visible label, helper text, error appearance, or character count — `search` has none of those. |
138
+
139
+ Each row resolves to a typed React component — `<FormField variant="search" placeholder="…" onChange={…}>` autocompletes from `dist/index.d.ts`. IDE shows `ComponentType<any>`? You're reading a stale shim — delete it and re-resolve.
140
+
141
+ ## Compact controls
142
+
143
+ **Layout**: every entry is `layoutInset: inline` — sits inside another component's slot (List leading, Banner trailing, Feed footer, Thumbnail corner), never at page level on its own.
144
+
145
+ | Intent | Family + sub |
146
+ | -------------------------------------------- | ----------------- |
147
+ | toggle chip for facet selection | `chip / filter` |
148
+ | informational / dismissable metadata pill | `chip / tag` |
149
+ | numeric unread / update count | `badge / update` |
150
+ | role / title mark beside a user's nickname | `badge / role` |
151
+ | inline image (avatar, list leading, channel) | `thumbnail` |
152
+ | instant-commit on/off toggle | `switch` |
153
+ | inline status mark next to a row label | `status-tag` |
154
+ | always-on annotation pill anchored to a UI element | `bubble` |
155
+
156
+ **Disambiguate**: `switch` = single binary on/off that commits the moment it flips (notifications, privacy). For multi-option pickers use `list / radio`; for actions needing confirmation use `button` + `dialog`; for "selected" facet state in a chip row use `chip / filter`. `status-tag` = SMALL (16-rung, 10px text) decorative status pill inline next to a row label — "pending", "rejected", "draft". For a 32-rung interactive chip use `chip / tag`; for a numeric count attached to an icon/thumbnail use `badge / update`; for a role / title mark beside a user's nickname (Channel owner / Verified / PRO) use `badge / role` — identity, where `status-tag` is workflow state. `bubble` = a labelled pill with a tail that points at a specific element to flag attention *at rest* (new-feature nudge, keyword/campaign promo next to a top-bar icon or search bar) — vs `badge` (numeric count / dot, no tail) and `status-tag` (status mark on a row label).
157
+
158
+ ## Loading & Placeholder
159
+
160
+ **Layout**: every entry is `layoutInset: inline` — placed where the real content will land, taking the slot's footprint.
161
+
162
+ | Intent | Family + sub |
163
+ | ----------------------------------------------------- | --------------------- |
164
+ | in-flight content placeholder (mirrors content shape) | `skeleton` |
165
+ | linear progress for a known long-running task | `progress` |
166
+
167
+ **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). For sub-300ms or opaque waits use neither (a brief blank surface or a spinner reads better).
168
+
169
+ ## Shadcn / Lovable name translation
170
+
171
+ When an AI agent (or designer paging in from shadcn) reaches for a shadcn-named component, this is the Chorus surface to render. Direct React `export` aliases live in `packages/ui/src/index.js` where the name doesn't already collide (`Sheet`, `Drawer`, `Alert`, `Avatar`, `AppBar`, `BottomNav`); the table below covers the rest — including where Chorus splits one shadcn shape into multiple intent-bound surfaces.
172
+
173
+ | Shadcn / Lovable name | Chorus surface to render | Notes |
174
+ |-----------------------------------|------------------------------------------------------------------------------------|-------|
175
+ | `<Alert variant="default">` | `<Banner appearance="default">` | Already aliased — `import { Alert } from '@teamblind-chorus/ui'`. |
176
+ | `<Alert variant="destructive">` | `<Banner appearance="destructive">` | Use the destructive appearance. |
177
+ | `<AlertDialog>` (confirm prompt) | `<Dialog>` | Same locked modal-confirmation contract (focus trap, primary/secondary actions). |
178
+ | `<Badge variant="…">` *(text pill)* | `<StatusTag appearance="neutral \| error">` | Chorus `Badge` defaults to the numeric/dot count marker — same name, different intent. Text-pill `<Badge>` calls translate to `StatusTag` when they carry workflow state, or to `<Badge variant="role">` when they name a user's role / title. |
179
+ | `<Avatar>` (image+fallback compound) | `<Thumbnail src=… alt=… />` | Already aliased. Use Thumbnail's `imageFallbackImage` / `imageFallbackText` props instead of the `<AvatarFallback>` child. |
180
+ | `<Card>` (generic bordered block) | One of: `<Carousel>` (labelled editorial block), `<Feed>` (authored content), `<NavCard>` (drill-in row), `<Banner>` (tonal aside) | Chorus splits "Card" by intent — pick by what the block carries, not by the chrome. |
181
+ | `<Carousel>` (wrapper) | `<Carousel><PostCarousel /></Carousel>` (cards) or `<Carousel><ProfileCarousel /></Carousel>` (avatar-anchored) | Pick the sub by content rung. |
182
+ | `<Checkbox>` (single) | `<Button variant="check">` | Chorus `Button` `check` sub IS the checkbox — leading 24px checkbox glyph + label. List-row multi-select uses `<Chip variant="filter">`. |
183
+ | `<Command>` (command palette) | `<FormField variant="search">` + `<BottomSheet>` + `<List>` pattern | Composition, not a single primitive. Mobile-first translation of the desktop command palette. |
184
+ | `<ContextMenu>` (right-click menu) | `<BottomSheet>` with a `List` | Same as `<DropdownMenu>` — right-click on mobile becomes a one-thumb sheet. |
185
+ | `<DropdownMenu>` (floating menu) | `<BottomSheet>` with a `List` | Mobile-first — floating menus collide with one-thumb reach. |
186
+ | `<HoverCard>` / `<Popover>` | `<Tooltip>` (short text) or `<BottomSheet>` (multi-line / actionable) | Hover-anchored surfaces don't translate to mobile. |
187
+ | `<Label>` (standalone form label) | `<FormField>`'s `label` slot | No standalone `<Label>`. Every input label lives on its host `<FormField>` via the `label` prop — keeps label/field/helper triplet locked. For non-input labels (settings group title, drawer column heading), use `<Header size="medium">`. |
188
+ | `<NavigationMenu>` (top-level nav) | `<NavigationBar>` (page chrome) / `<TabBar>` (bottom app nav) / `<NavCard>` (drill-in row) | Chorus splits "navigation" by surface role. |
189
+ | `<RadioGroup>` + `<RadioGroupItem>` | `<List variant="radio" items={[…]} onChange=… />` | Chorus's only radio surface IS the list row. |
190
+ | `<Separator>` (horizontal rule) | `<Divider>` for a heavy region split; otherwise built into the host (`List` row divider, `Carousel` block gap, `BottomSheet` action rail border) | Hairlines between rows come from the host's anatomy, not a standalone rule — vertical rhythm comes from `sys.layout.stack.*`. For a heavier band between two page-level regions, use `<Divider>` (the `divider` family). Do NOT hand-roll a hairline `<Separator>`. |
191
+ | `<Sheet side="bottom">` | `<BottomSheet>` | Aliased. |
192
+ | `<Sheet side="left">` / `<Sheet side="right">` | `<SideSheet anchor="left">` / `<SideSheet anchor="right">` | Off-canvas navigation drawer / side panel. Compose with `Header` (medium) + `List` (thumbnail, compact) inside `SideSheetGroup`. Aliased — `import { SideDrawer } from '@teamblind-chorus/ui'`. Top is out of scope. |
193
+ | `<Sidebar>` (off-canvas app nav) | `<SideSheet anchor="left">` | Desktop persistent sidebar becomes the off-canvas drawer. Compose with `SideSheetGroup` (Header + List compact) for the channel-directory shape. |
194
+ | `<Sonner>` / `toast()` imperative | `<Toast>` declarative | Chorus toast is declarative — render `<Toast>` from the host; no imperative `toast()` call. |
195
+ | `<ToggleGroup type="single">` | `<Tabs variant="segmented">` | In-place mode toggle. |
196
+ | `<ToggleGroup type="multiple">` | A row of `<Chip variant="filter">` | Multi-select facet selection. |
197
+
198
+ ### Chorus gaps — flag, do not improvise
199
+
200
+ These shadcn primitives have no Chorus equivalent and no on-pattern mobile substitution. When a brief demands one, **stop and flag a "Chorus gap"** — do NOT improvise a wrapper, re-introduce shadcn, or hardcode raw values. Maintainers add the missing family; agents wait or work around.
201
+
202
+ | Shadcn / Lovable name | Mobile use case (when it WOULD be needed) | Status |
203
+ |-----------------------|--------------------------------------------|--------|
204
+ | `<Calendar>` (date picker) | Birthday, schedule, calendar event picker. | **Gap**. Workaround: pair `<FormField variant="select">` with native `<input type="date">` inside a `<BottomSheet>`; flag the gap. |
205
+ | `<Chart>` (data viz) | Analytics, finance, fitness charts. | **Gap**. Workaround: external `recharts`/`chart.js`, but every color/typography MUST resolve through Chorus tokens (`var(--sys-*)`); flag the gap. |
206
+ | `<Slider>` (range) | Price-range filter, volume/brightness. | **Gap**. Workaround: native `<input type="range">` styled via tokens; flag the gap. |
207
+ | `<InputOTP>` (OTP code) | Verification code entry (auth flows). | **Gap**. Workaround: row of `<FormField variant="input">` with `maxLength=1`; flag the gap. |
208
+
209
+ ### Out of mobile scope — substitute the mobile pattern
210
+
211
+ These shadcn primitives are desktop-first or web-OS conventions Chorus deliberately omits because the mobile equivalent is a different shape. Agents MUST substitute the listed pattern instead of re-introducing the shadcn primitive.
212
+
213
+ | Shadcn / Lovable name | Out of scope because | Mobile substitute |
214
+ |-----------------------|----------------------|--------------------|
215
+ | `<Breadcrumb>` | Mobile is deep-link / drill-in; no horizontal trail. | `<NavigationBar variant="sub">` (back + title). |
216
+ | `<Menubar>` | Persistent app menubar is desktop chrome. | `<NavigationBar>` for top chrome + `<BottomSheet>` for action overflow. |
217
+ | `<Pagination>` | Mobile feeds are infinite-scroll, not paged. | Infinite scroll inside `<Feed>` / `<List>` (consumer wires the loader). |
218
+ | `<Resizable>` | Adjustable split panels are desktop. | None — single-pane mobile layout. Flag a "Chorus gap" if a tablet split view is required. |
219
+ | `<Table>` (data grid) | Wide rows don't fit one-thumb column. | `<List variant="standard">` or `<Feed>` rows. For tabular data, render one row per item with `label` + `supportingText`. |
220
+ | `<ScrollArea>` | Custom-styled scrollbars fight native mobile scroll. | Native scroll on the host element. Do NOT introduce a styled track. |
221
+
222
+ ## Disambiguation cheat sheet
223
+
224
+ *First-pass* defaults for open families; *hard rules* for locked families.
225
+
226
+ - **Filter chip vs Toggle button** (both open): `chip/filter` for facet selection in a chip row; `button/toggle` for standalone reversible state. Either visual shape may be reused outside canonical intent.
227
+ - **Tag chip vs Badge vs StatusTag** (open): `chip/tag` = 32-rung selectable text metadata; `badge / update` = numeric count/dot attached to a host icon or thumbnail; `badge / role` = 16-rung tonal role / title pill beside a user's name (identity); `status-tag` = 16-rung decorative inline status pill next to a row label (workflow state).
228
+ - **List vs Feed** (both open): same-kind rows → List, authored content stream → Feed *as default*. Either may be reused for a different content shape — e.g. a `Feed`-style card hosting a non-post summary, or a `List` row hosting an editorial item.
229
+ - **BottomSheet vs Dialog** (both locked): short/actionable/one-thumb → BottomSheet; confirmation or image-led → Dialog. **Never** borrow either for non-modal — dismiss/focus contracts are the point.
230
+ - **Banner vs BottomSheet**: in-flow context → `banner` (open, reusable as a generic tonal block). Demands a commit before continuing → `bottom-sheet` *(locked)*.
231
+ - **Toast vs Tooltip vs Banner**: all three describe "short message", but `toast` *(locked)* is auto-dismissing and `tooltip` *(locked)* is trigger-anchored — visual reuse outside those roles is forbidden. For a static "small message" in the reading flow, reach for `banner` (open).
232
+ - **Skeleton vs Progress**: `skeleton` previews *where content will land* (placeholder that swaps out atomically). `progress` shows *how far a task has advanced* (value-bound or busy indicator). Sub-300ms waits: neither.
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "avatar-rail",
4
+ "name": "AvatarRail",
5
+ "description": "Compact horizontal strip of avatar entry points \u2014 each item routes to an entity (channel, person, brand, topic) with a single tap. The rail sits above a feed or inside a sidebar and gives the visitor a quick switcher across the entities they follow. Single-spec family. Historically shipped as ChannelRail; that name is kept as a deprecated alias.",
6
+ "useCases": [
7
+ "followed entities rail",
8
+ "horizontal avatar switcher",
9
+ "story-style quick nav",
10
+ "one-thumb entity nav"
11
+ ],
12
+ "visualReuse": "open",
13
+ "layoutInset": "full-bleed",
14
+ "wrapperGuidance": "Owns its inline padding internally. Place as a direct child of the page-shell <main> (or any host that pays the gutter once). Do NOT wrap in a padding-inline div, className=\"px-*\", or style={{ padding }} \u2014 the page rail is paid once at the shell, never on the full-bleed child. Inside a bounded surface (Dialog / BottomSheet / SideSheet), apply the negative-margin opt-out \u2014 see AGENTS.md \u00a7 Composition rules.",
15
+ "compositionModes": {
16
+ "standalone": {
17
+ "default": true,
18
+ "chrome": {
19
+ "background": "sys.color.surface",
20
+ "padding": "sys.layout.container.sm sys.layout.container.md"
21
+ },
22
+ "context": "Direct child of the page shell or any host that pays the page-rail gutter once. The rail owns its own row chrome."
23
+ },
24
+ "embedded": {
25
+ "trigger": "prop `embedded={true}` on <AvatarRail /> (renders as `data-embedded=\"true\"`) OR direct child of `.chorus-carousel` / `.chorus-feed` (DOM-ancestry safety net via `:where()` in styles.css).",
26
+ "chrome": {
27
+ "background": "transparent",
28
+ "padding": "0"
29
+ },
30
+ "context": "Composed inside another rail-responsible host (e.g. `<Carousel label=\"Shortcuts\"><AvatarRail embedded /></Carousel>`) that already pays the gutter and background. Row items take over from the host's content-box edge, so the same vertical rail aligns through host header and rail items. Use the `embedded` prop explicitly so the composition is readable in JSX; the ancestry safety net handles the case where the prop is omitted."
31
+ }
32
+ },
33
+ "spec": "avatar-rail.md",
34
+ "usage": {
35
+ "note": "Also exported as the deprecated alias `ChannelRail`. Items route via `href`; the update dot lives on `thumbnail.updateDot`.",
36
+ "example": "<AvatarRail aria-label=\"…\" items={[{ value, label, href, thumbnail }]} trailingAction={{ label, href }} />"
37
+ },
38
+ "subcomponents": [
39
+ {
40
+ "slug": "avatar-rail",
41
+ "spec": "avatar-rail.spec.json",
42
+ "md": "avatar-rail.md",
43
+ "default": true
44
+ }
45
+ ]
46
+ }
@@ -0,0 +1,103 @@
1
+ # Avatar rail
2
+
3
+ A horizontal strip of channel entry points — each item routes to a channel or company page. Composes a [Thumbnail](../thumbnail/thumbnail.md) (optional `updateDot`) and a single-line label; an optional trailing action lives at the end.
4
+
5
+ **Reach for this when** the row is a label-only nav strip of subscribed or saved entities — *Subscribed channels*, *Saved companies*, *My topics*. **Skip when** rows carry follower counts or descriptions (use [DirectoryList](../directory-list/directory-list.md) / [SuggestionList](../suggestion-list/suggestion-list.md)), the surface is fixed-width profile cards ([Profile carousel](../carousel/profile.md)), or rows commit in place rather than route ([Toggle Button](../button/toggle.md) cluster).
6
+
7
+ **Layout inset.** `full-bleed` — sits as a direct child of the page shell (or any surface that pays the gutter) and stretches edge-to-edge. The family owns its own internal row / header padding via `layout.container.*`; do **not** wrap in another `padding-inline` / `px-*` / `style={{ padding: … }}` div, or the page rail double-pays. Inside a bounded surface (Card / Dialog / BottomSheet / Sheet), apply the negative-margin opt-out — see [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
8
+
9
+ ## Default
10
+
11
+ A four-channel rail with a trailing "View all" action.
12
+
13
+ ```preview
14
+ avatar-rail/default
15
+ ---
16
+ import { AvatarRail } from '@teamblind-chorus/ui';
17
+
18
+ <AvatarRail
19
+ aria-label="Subscribed channels"
20
+ items={[
21
+ { value: 'hyundai', label: 'Hyundai Motor', href: '/channels/hyundai', thumbnail: { alt: 'Hyundai', updateDot: true } },
22
+ { value: 'samsung', label: 'Samsung', href: '/channels/samsung', thumbnail: { alt: 'Samsung', updateDot: true } },
23
+ { value: 'naver', label: 'Naver', href: '/channels/naver', thumbnail: { alt: 'Naver' } },
24
+ { value: 'mobis', label: 'Hyundai Mobis', href: '/channels/mobis', thumbnail: { alt: 'Hyundai Mobis', updateDot: true } },
25
+ ]}
26
+ trailingAction={{ label: 'View all', href: '/channels' }}
27
+ />
28
+ ```
29
+
30
+ ## Use cases
31
+
32
+ ### With overflow
33
+
34
+ When the rail carries more items than the container fits, it scrolls horizontally.
35
+
36
+ ```preview
37
+ avatar-rail/overflow
38
+ ---
39
+ import { AvatarRail } from '@teamblind-chorus/ui';
40
+
41
+ <AvatarRail
42
+ aria-label="Subscribed channels"
43
+ items={[
44
+ { value: 'hyundai', label: 'Hyundai Motor', href: '/channels/hyundai', thumbnail: { alt: 'Hyundai', updateDot: true } },
45
+ { value: 'samsung', label: 'Samsung', href: '/channels/samsung', thumbnail: { alt: 'Samsung', updateDot: true } },
46
+ { value: 'naver', label: 'Naver', href: '/channels/naver', thumbnail: { alt: 'Naver' } },
47
+ { value: 'mobis', label: 'Hyundai Mobis', href: '/channels/mobis', thumbnail: { alt: 'Hyundai Mobis', updateDot: true } },
48
+ { value: 'kakao', label: 'Kakao', href: '/channels/kakao', thumbnail: { alt: 'Kakao' } },
49
+ { value: 'lg', label: 'LG Electronics', href: '/channels/lg', thumbnail: { alt: 'LG', updateDot: true } },
50
+ { value: 'sk', label: 'SK Hynix', href: '/channels/sk', thumbnail: { alt: 'SK Hynix' } },
51
+ { value: 'kia', label: 'Kia', href: '/channels/kia', thumbnail: { alt: 'Kia' } },
52
+ ]}
53
+ trailingAction={{ label: 'View all', href: '/channels' }}
54
+ />
55
+ ```
56
+
57
+ ## Slots
58
+
59
+ - **container** — horizontal flex strip over `surface`. Overflows horizontally; scrollbar hidden. Never wraps.
60
+ - **item** — channel entry, rendered as `<a href>`. Stacks Thumbnail above label.
61
+ - **avatar** — [Thumbnail](../thumbnail/thumbnail.md) at the 48 rung. Forwards every other Thumbnail prop verbatim.
62
+ - **label** — channel name. `label.sm` / Regular / `onSurface`. Single line; truncates.
63
+ - **trailingAction** *(optional)* — [`small` Text Button](../button/text.md), `accent` appearance per the link-affordance rule. Renders as `<a>` when `href` is set. Vertically centred against the avatar row.
64
+
65
+ ## Anatomy
66
+
67
+ | Slot | Token bindings |
68
+ |----------------|----------------|
69
+ | container | `surface` fill, `sys.layout.container.sm` / `sys.layout.container.md` (12 / 16px) padding, `sys.layout.inline.xl` (16→24px) gap between track and trailing action, scrollbar hidden |
70
+ | track | Trailing-edge `mask-image` fade over the rightmost `ref.space.600` (48px), painted only when the track overflows |
71
+ | item | Flex column, items centred; 8px avatar↔label; 16px between item columns |
72
+ | avatar | [Thumbnail](../thumbnail/thumbnail.md) `size={48}` |
73
+ | label | `label.sm` / Regular / `onSurface`, max-width 80px (matches avatar) |
74
+ | trailingAction | `small` [Text Button](../button/text.md), `accent` appearance, vertically centred against avatar row |
75
+
76
+ ## Sizes
77
+
78
+ A single rung. Rail width follows its container and overflows horizontally when content exceeds the box.
79
+
80
+ ## States
81
+
82
+ Container has no interactive state. Each item is a text-link affordance obeying the [Text links](../../DESIGN.md#text-links) contract — underline is the affordance, colour does not change on hover or press.
83
+
84
+ | State | Overlay | Additional |
85
+ |------------|----------------------------|------------|
86
+ | `default` | — | Label `onSurface`, no underline. |
87
+ | `hovered` | — | 1px same-colour underline; label colour unchanged. |
88
+ | `pressed` | `sys.state.pressed` | Underline persists; pressed overlay tints. |
89
+ | `disabled` | overlay suppressed | Item at `sys.state.disabled` opacity; underline suppressed. |
90
+
91
+ The trailing action is a `small` Text Button (rendered as `<a>` when `href` is set) — Text Button hover overlay + standard three-layer focus ring. Mirrors the Channel List header action; the only difference is the rung.
92
+
93
+ ## Focus indicator
94
+
95
+ Standard ring painted around the item's outer edge (see [Focus ring composition](../../DESIGN.md#focus-ring-composition)). Trigger: `:focus-visible`.
96
+
97
+ ## Behavior
98
+
99
+ - **Items are anchors.** Each item is `<a href>`; routing belongs to the host framework's link integration.
100
+ - **Horizontal scroll only.** Overflowing items scroll horizontally; the rail never wraps.
101
+ - **Edge fade (conditional).** Trailing 48px fade via `mask-image`, painted only while overflow is present. Same `useScrollOverflow` hook Tabs uses.
102
+ - **No selection state.** Navigation, not a picker — no `value` / `onChange`. "Current channel" highlighting is the host's job via Thumbnail props.
103
+ - **Trailing action floats with the row.** Stays at the end of rail content; scrolling reveals it like the last item.
@@ -0,0 +1,160 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "AvatarRail",
4
+ "family": "avatar-rail",
5
+ "exportAlias": "ChannelRail",
6
+ "description": "Horizontal strip of avatar entry points — each item routes to an entity (channel, person, brand, topic) page. The rail sits above a feed or inside a sidebar and gives the visitor one tap to jump into the entities they follow. Every item composes a Thumbnail (with optional updateDot) and a single-line label; an optional trailing action ('View all', 'Manage') lives at the end of the row.",
7
+ "element": "div",
8
+ "props": {
9
+ "aria-label": {
10
+ "type": "string",
11
+ "required": true,
12
+ "description": "Accessible name for the rail."
13
+ },
14
+ "items": {
15
+ "type": "node",
16
+ "required": true,
17
+ "description": "Array of channel entries: { value, label, href, thumbnail }."
18
+ },
19
+ "trailingAction": {
20
+ "type": "object",
21
+ "optional": true,
22
+ "description": "{ label, href? , onClick? } — trailing [Text Button](../button/text.md) (`size={'small'}`, `appearance={'accent'}`) at the end of the rail (typically 'View all' / 'Manage'). Link-affordance rule: link-shaped Text Buttons take `accent` for chromatic emphasis."
23
+ },
24
+ "embedded": {
25
+ "type": "boolean",
26
+ "default": false,
27
+ "description": "Composition mode flag. When `true` (or when the AvatarRail is a direct child of `.chorus-carousel` / `.chorus-feed`), the rail enters **embedded mode**: zeroes its own `background` + `padding` so chrome defers to the host. Pass explicitly inside `<Carousel>` / `<Feed>` for the contract to be visible in JSX; the DOM-ancestry safety net in styles.css also activates the mode when omitted. See `compositionModes` in `avatar-rail.family.json`."
28
+ }
29
+ },
30
+ "slots": {
31
+ "container": {
32
+ "required": true,
33
+ "description": "Horizontal flex strip over a surface fill; overflows horizontally with native scrollbar hidden. Never wraps to a second row.",
34
+ "intrinsic": true
35
+ },
36
+ "track": {
37
+ "required": true,
38
+ "description": "The horizontally-scrolling track of items. Carries the Edge fade affordance — a trailing-edge mask-image gradient when overflow is present.",
39
+ "intrinsic": true
40
+ },
41
+ "item": {
42
+ "required": true,
43
+ "description": "Single channel entry point. Rendered as <a href> — the entire item is the click target.",
44
+ "intrinsic": true
45
+ },
46
+ "avatar": {
47
+ "required": true,
48
+ "description": "Thumbnail at size 48 — every Thumbnail prop forwarded verbatim.",
49
+ "accepts": [
50
+ "thumbnail"
51
+ ]
52
+ },
53
+ "label": {
54
+ "required": true,
55
+ "description": "Channel name. label.sm / Regular / onSurface. Single line; truncates with ellipsis. max-width matches the avatar (80px).",
56
+ "accepts": [
57
+ "text"
58
+ ]
59
+ },
60
+ "trailingAction": {
61
+ "required": false,
62
+ "description": "Trailing [Text Button](../button/text.md) (`size={'small'}`, `appearance={'accent'}`). Vertically centred against the avatar row, not the full item. Link-affordance rule applies.",
63
+ "accepts": [
64
+ "button"
65
+ ]
66
+ }
67
+ },
68
+ "sizing": {
69
+ "containerFill": "sys.color.surface",
70
+ "containerPaddingBlock": "sys.layout.container.sm",
71
+ "containerPaddingInline": "sys.layout.container.md",
72
+ "containerActionGap": "sys.layout.inline.xl",
73
+ "fadeWidth": "ref.space.600",
74
+ "itemColumnGap": "sys.layout.inline.md",
75
+ "itemAvatarLabelGap": "sys.layout.stack.xs",
76
+ "avatarSize": 48,
77
+ "labelTypo": "sys.typo.label.sm",
78
+ "labelColor": "sys.color.onSurface",
79
+ "labelMaxWidth": "ref.space.1000",
80
+ "trailingActionRendersAs": "Button variant='text' size='small' appearance='accent' — label paints in sys.color.primary via the Text Button accent token.",
81
+ "trailingActionTypo": "sys.typo.label.md",
82
+ "trailingActionColor": "sys.color.primary"
83
+ },
84
+ "itemProps": {
85
+ "value": {
86
+ "type": "string",
87
+ "required": true
88
+ },
89
+ "label": {
90
+ "type": "string",
91
+ "required": true
92
+ },
93
+ "href": {
94
+ "type": "string",
95
+ "required": true
96
+ },
97
+ "thumbnail": {
98
+ "type": "object",
99
+ "required": true,
100
+ "description": "Forwarded to Thumbnail verbatim — src, alt, updateDot, logoBadge."
101
+ }
102
+ },
103
+ "states": {
104
+ "default": {
105
+ "decoration": "none",
106
+ "label": "sys.color.onSurface"
107
+ },
108
+ "hovered": {
109
+ "description": "1px same-color underline under the label. Color does not change — the underline is the affordance.",
110
+ "textDecoration": "underline",
111
+ "textDecorationThickness": "sys.borderWidth.hairline",
112
+ "textUnderlineOffset": "ref.space.25",
113
+ "cursor": "pointer"
114
+ },
115
+ "pressed": {
116
+ "overlay": {
117
+ "color": "label",
118
+ "opacity": "sys.state.pressed"
119
+ },
120
+ "note": "Underline persists."
121
+ },
122
+ "disabled": {
123
+ "containerOpacity": "sys.state.disabled",
124
+ "suppressUnderline": true,
125
+ "pointerEvents": "none"
126
+ }
127
+ },
128
+ "focusIndicator": {
129
+ "description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Composes over whichever lifecycle state the item is in. The `states.focused` block above is kept for JSX runtime consumers; this block is the parallel external-reader contract.",
130
+ "composition": "inward",
131
+ "compositionReason": "Horizontal scroller; an outward ring would clip at the rail's top/bottom edges.",
132
+ "overlay": {
133
+ "color": "label",
134
+ "opacity": "sys.state.focus"
135
+ },
136
+ "ring": {
137
+ "outerWidth": "sys.borderWidth.thin",
138
+ "outerColor": "sys.color.focus",
139
+ "outerLayerPosition": "depth 0..2px from the item edge (the outer stroke)",
140
+ "insetWidth": "sys.borderWidth.hairline",
141
+ "insetColor": "sys.color.focusInset",
142
+ "insetLayerPosition": "depth 2..3px from the item edge (the counter-ring just inside the outer stroke)",
143
+ "implementation": "inset box-shadow on the item's `::after` overlay. Constrained strictly inside the item's footprint and never exceeds it."
144
+ },
145
+ "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
146
+ },
147
+ "behavior": {
148
+ "itemsAreAnchors": "Each item renders as <a href> so open-in-new-tab / drag-to-bookmark works. The component does not intercept clicks.",
149
+ "horizontalScrollOnly": "Overflowing items scroll horizontally; the rail never wraps to a second row. Native scrollbar hidden.",
150
+ "edgeFade": "When the track overflows, its trailing 48px (ref.space.600) fades to transparent via a mask-image linear-gradient. Painted only while overflow is present — useScrollOverflow hook (shared with Tabs) toggles data-overflow-end='true' on the track.",
151
+ "noSelectionState": "The rail is navigation, not a picker. No value / onChange contract. Highlighting 'the current channel' is the host page's job.",
152
+ "trailingActionFloats": "The optional trailingAction stays at the end of the rail content; when the rail overflows, scrolling reveals it."
153
+ },
154
+ "forbidden": [
155
+ "rail item rendered with sys.color.brand as the fill — avatar-rail items inherit the underlying Thumbnail family contract",
156
+ "rail wrapped in a horizontal-padding div — avatar-rail is full-bleed by family declaration",
157
+ "label below 12px — rail labels use sys.typo.label.sm (12px), never finer",
158
+ "rail items reflowing — the rail snap-scrolls horizontally with a fixed item width"
159
+ ]
160
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "badge",
4
+ "name": "Badge",
5
+ "description": "Small non-interactive mark attached to a host label. Two subs share the anchored-to-a-host anatomy and `radius.full` corner but diverge on intent and tone: `update` is the brand-tone activity indicator (numeric count pill or labelless dot) flagging unread / new activity; `role` is the tonal primary-container pill naming a user's role or title ('Channel owner', 'Verified') riding the bare nickname at the end of the user-metadata meta row — one badge per nickname; the 'inverse' appearance is reserved for the paid-expert 'PRO' mark.",
6
+ "useCases": [
7
+ "unread count",
8
+ "update count",
9
+ "numeric indicator",
10
+ "host attachment",
11
+ "user role mark",
12
+ "title / position annotation",
13
+ "paid-expert PRO mark"
14
+ ],
15
+ "visualReuse": "open",
16
+ "layoutInset": "inline",
17
+ "spec": "badge.md",
18
+ "usage": {
19
+ "note": "Both forms are the single `Badge` export selected by the `variant` prop — there is NO `<UpdateBadge>` or `<RoleBadge>` export. Default variant is `update`: count via children or `count`; render a dot (no number) with a `dot-*` size — there is no separate `Dot` element.",
20
+ "example": "<Badge size=\"medium\" count={3} />",
21
+ "subs": {
22
+ "update": {
23
+ "variant": "update",
24
+ "example": "<Badge size=\"medium\" count={3} />"
25
+ },
26
+ "role": {
27
+ "variant": "role",
28
+ "example": "<Badge variant=\"role\">Verified</Badge>"
29
+ }
30
+ }
31
+ },
32
+ "subcomponents": [
33
+ {
34
+ "slug": "update",
35
+ "spec": "update.spec.json",
36
+ "md": "update.md",
37
+ "default": true
38
+ },
39
+ {
40
+ "slug": "role",
41
+ "spec": "role.spec.json",
42
+ "md": "role.md"
43
+ }
44
+ ]
45
+ }
@@ -0,0 +1,10 @@
1
+ # Badge
2
+
3
+ A small, non-interactive mark anchored to a host label — badge-shaped annotation for two roles. **Update** is the brand-tone activity indicator: a numeric count pill or a labelless corner dot flagging unread / new activity (3 unread, `99+`, a dot on a [Thumbnail](../thumbnail/thumbnail.md)). **Role** is the tonal identity pill: a pale `primaryContainer` mark naming the user's role or title — Channel owner (채널장), Verified (현직자) — riding the bare nickname at the end of their metadata's meta row. The two share the anchored anatomy, the `radius.full` corner, and the never-interactive contract; they diverge on tone (brand vs primary-container) and on what they say about the host (*what's new* vs *who this is*). Both are reached through the single `Badge` export — `variant="update"` (default) / `variant="role"`.
4
+
5
+ **Layout inset.** `inline` — slot atom. A Badge has no page-rail responsibility; the host places it (Thumbnail corner, List row label, [Metadata](../metadata/metadata.md) trailing edge). Never a sibling of `full-bleed` page rows.
6
+
7
+ ## Sub-components
8
+
9
+ - **[Update](./update.md)** — Brand-tone activity indicator. **Numeric** count pill (`medium` / `small`) for counts that carry meaning, or labelless **Dot** (`dot-md` / `dot-sm`) when the presence of activity is the whole signal. The default variant.
10
+ - **[Role](./role.md)** — Tonal role / title pill (English labels: Channel owner, Verified). `primaryContainer` fill, `onPrimaryContainer` 10px label, 16-rung; rides the bare nickname at the end of the metadata meta row. Identity, not state — workflow status belongs to [Status tag](../status-tag/status-tag.md).