@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,184 @@
1
+ # Profile
2
+
3
+ Sub-component of the [Carousel](./carousel.md) family. Horizontally-scrolling rail of fixed-width (176px) profile cards — channels, user profiles, or company channels grouped under a single editorial heading. Each card carries a cover band, an overlapping 64-rung avatar, entity name + follower count, a metrics row or two-line description, and a trailing follow [Toggle Button](../button/text.md).
4
+
5
+ **Reach for this when** an editorial collection groups follow-able entities under a single heading — hot companies, recommended channels, suggested people. **Skip when** the rail carries content posts ([Post carousel](./post.md)), the surface needs the full list scanned vertically ([SuggestionList](../suggestion-list/suggestion-list.md) / [DirectoryList](../directory-list/directory-list.md)), or the rung is a label-only nav strip ([AvatarRail](../avatar-rail/avatar-rail.md)).
6
+
7
+ **Layout inset.** `full-bleed` — sits as a direct child of the page shell so the rail can bleed off the trailing edge into the swipe zone. The pager re-pays its own `16px` left rail via `padding-left` (`sys.layout.container.md`) — the Carousel host is full-bleed; do **not** wrap it in another `padding-inline` / `px-*` / `style={{ padding: … }}` div. 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
+ Three profile cards under a Carousel heading (label only).
12
+
13
+ ```preview
14
+ carousel/profile-default
15
+ ---
16
+ import { Carousel, ProfileCarousel } from '@teamblind-chorus/ui';
17
+
18
+ <Carousel label="Hot companies right now">
19
+ <ProfileCarousel
20
+ items={[
21
+ {
22
+ avatar: { src: '/placeholder.png', alt: 'Amazon' },
23
+ name: 'Amazon',
24
+ followers: '1,678 followers',
25
+ metrics: [
26
+ { icon: 'star', value: '4.1' },
27
+ { icon: 'pulse', value: '81.1' },
28
+ { icon: 'heart', value: '81%' },
29
+ ],
30
+ },
31
+ {
32
+ avatar: { src: '/placeholder.png', alt: 'Tesla' },
33
+ name: 'Tesla',
34
+ followers: '1.4K followers',
35
+ metrics: [
36
+ { icon: 'star', value: '4.7' },
37
+ { icon: 'pulse', value: '86' },
38
+ { icon: 'heart', value: '85.3%' },
39
+ ],
40
+ followed: true,
41
+ },
42
+ {
43
+ avatar: { src: '/placeholder.png', alt: 'Stripe' },
44
+ name: 'Stripe',
45
+ followers: '2.1K followers',
46
+ metrics: [
47
+ { icon: 'star', value: '4.5' },
48
+ { icon: 'pulse', value: '92.4' },
49
+ { icon: 'heart', value: '88%' },
50
+ ],
51
+ },
52
+ ]}
53
+ />
54
+ </Carousel>
55
+ ```
56
+
57
+ ## Use cases
58
+
59
+ ### With header action
60
+
61
+ Extend the header with a trailing `accent` Text Button when there's an index page to route to. Lifts the `headerAction` prop on the `<Carousel>` wrapper.
62
+
63
+ ```preview
64
+ carousel/profile-with-header-action
65
+ ---
66
+ import { Carousel, ProfileCarousel } from '@teamblind-chorus/ui';
67
+
68
+ <Carousel label="Hot companies right now" headerAction={{ label: 'See all', href: '#' }}>
69
+ <ProfileCarousel
70
+ items={[
71
+ {
72
+ avatar: { src: '/placeholder.png', alt: 'Amazon' },
73
+ name: 'Amazon',
74
+ followers: '1,678 followers',
75
+ metrics: [
76
+ { icon: 'star', value: '4.1' },
77
+ { icon: 'pulse', value: '81.1' },
78
+ { icon: 'heart', value: '81%' },
79
+ ],
80
+ },
81
+ {
82
+ avatar: { src: '/placeholder.png', alt: 'Tesla' },
83
+ name: 'Tesla',
84
+ followers: '1.4K followers',
85
+ metrics: [
86
+ { icon: 'star', value: '4.7' },
87
+ { icon: 'pulse', value: '86' },
88
+ { icon: 'heart', value: '85.3%' },
89
+ ],
90
+ followed: true,
91
+ },
92
+ {
93
+ avatar: { src: '/placeholder.png', alt: 'Stripe' },
94
+ name: 'Stripe',
95
+ followers: '2.1K followers',
96
+ metrics: [
97
+ { icon: 'star', value: '4.5' },
98
+ { icon: 'pulse', value: '92.4' },
99
+ { icon: 'heart', value: '88%' },
100
+ ],
101
+ },
102
+ ]}
103
+ />
104
+ </Carousel>
105
+ ```
106
+
107
+ ### With description
108
+
109
+ The metrics row swaps out for a two-line description. Use for editorial collections where the value of each profile is best explained in copy (channel topic, hot pitch) rather than numeric signals. The description block is fixed to the same two-line height as the metrics row, so cards stay flush across both modes even when description copy clamps with an ellipsis.
110
+
111
+ ```preview
112
+ carousel/profile-with-description
113
+ ---
114
+ import { Carousel, ProfileCarousel } from '@teamblind-chorus/ui';
115
+
116
+ <Carousel label="Recommended channels">
117
+ <ProfileCarousel
118
+ items={[
119
+ {
120
+ avatar: { src: '/placeholder.png', alt: 'Engineering' },
121
+ name: 'Engineering',
122
+ followers: '12.4K followers',
123
+ description: 'Hands-on threads about systems, infra, and the work behind the launch.',
124
+ },
125
+ {
126
+ avatar: { src: '/placeholder.png', alt: 'Compensation' },
127
+ name: 'Compensation',
128
+ followers: '8.1K followers',
129
+ description: 'Salary checks, offer evaluations, and the quiet math of staying versus leaving — the channel that runs longer than any single conversation can.',
130
+ followed: true,
131
+ },
132
+ {
133
+ avatar: { src: '/placeholder.png', alt: 'Career' },
134
+ name: 'Career',
135
+ followers: '5.3K followers',
136
+ description: 'Promotion packets, scope debates, and the rewrites that actually cleared.',
137
+ },
138
+ ]}
139
+ />
140
+ </Carousel>
141
+ ```
142
+
143
+ ## Slots
144
+
145
+ - **container** — wraps the pager and pagination dots. No fill / padding — the surrounding [Carousel](./carousel.md) provides the chrome.
146
+ - **pager** — horizontal scroll-snap track. `scroll-snap-type: x mandatory`; native scrollbar hidden.
147
+ - **card** — one profile card per page; fixed at **176px** wide.
148
+ - **cover** — top band; 88px tall image-area slot. Renders an `<img>` defaulting to `/placeholder.png` (universal Chorus placeholder). `object-fit: cover` crops to fill the band; `sys.color.surfaceContainerHigh` underlies as the no-image fallback. Consumers override via `items[i].cover.src`.
149
+ - **avatar** — [Thumbnail](../thumbnail/thumbnail.md) `size={64}` with [`outlined={true}`](../thumbnail/thumbnail.md#with-surface-outline), centered and overlapping the cover band's bottom edge. The 2-token (`sys.borderWidth.thin`) `surface`-tone outset halo separating the circle from the cover image is owned by Thumbnail's outlined case — the carousel forwards the prop instead of painting a halo on its own wrapper.
150
+ - **name** — entity name; `sys.typo.label.md` / Semibold / `sys.color.onSurface`; centered, single line truncate.
151
+ - **followers** — follower count; `sys.typo.label.sm` / `sys.color.onSurfaceVariant`; centered.
152
+ - **metrics** *(optional)* — row of `icon + value` chips: `star → StarFillIcon (sys.color.icon.yellow)`, `pulse → PulseFillIcon (sys.color.success)`, `heart → HeartFillIcon (sys.color.icon.red)`. Mutually exclusive with `description`.
153
+ - **description** *(optional)* — two-line clamped paragraph that replaces the metrics row when present. Block height fixed to two lines of `sys.typo.label.sm` regardless of copy length, so card height stays consistent across metrics-carrying and copy-carrying cards.
154
+ - **followAction** — full-width [Toggle Button](../button/text.md) (`variant={'toggle'}`); `Follow` (inactive) / `Following` (active).
155
+ - **pagination** — one dot per card. Active dot paints `sys.color.onSurface`; rest paint `sys.color.outlineVariant`. Decorative.
156
+
157
+ ## Anatomy
158
+
159
+ | Slot | Token bindings |
160
+ |----------------|----------------|
161
+ | pager | `gap: sys.layout.inline.md`, `padding-left: sys.layout.container.md` + `scroll-padding-left: sys.layout.container.md` (the 16 rail; full-bleed host → trailing edge reached intrinsically, no negative margin), `scroll-snap-type: x mandatory` |
162
+ | card | Fixed `width: 176px`, `sys.color.surface` fill, `sys.radius.md`, inset hairline outline, `scroll-snap-align: start` |
163
+ | cover | 88px tall image-area slot. Default `src` = `/placeholder.png` (universal image placeholder), `object-fit: cover`, `sys.color.surfaceContainerHigh` underlay |
164
+ | avatar | [Thumbnail](../thumbnail/thumbnail.md) `size={64}` `outlined`, vertical center on cover's bottom edge. The 2-token `surface`-tone halo separating the circle from the cover image is painted by Thumbnail's `outlined` case (outset `box-shadow: 0 0 0 sys.borderWidth.thin sys.color.surface`) — wrapper has no halo of its own |
165
+ | name | `sys.typo.label.md`, `sys.color.onSurface`, centered |
166
+ | followers | `sys.typo.label.sm`, `sys.color.onSurfaceVariant`, centered |
167
+ | metrics row | `sys.layout.inline.md` gap, centered. Fixed-height slot — `calc(sys.typo.label.sm.size * sys.typo.label.sm.line * 2)` so the row always reserves two lines of `label.sm` regardless of content. |
168
+ | metric chip | `sys.icon.md` glyph + `sys.typo.label.sm` value; star → `StarFillIcon` (`sys.color.icon.yellow`), pulse → `PulseFillIcon` (`sys.color.success`), heart → `HeartFillIcon` (`sys.color.icon.red`) |
169
+ | description | `sys.typo.label.sm` / `sys.color.onSurfaceVariant`, centered, two-line clamp with trailing ellipsis. Two-layer DOM — outer container owns the same fixed-height slot as `metrics row` (min/max-height = 2 label.sm lines); inner `<p>` owns the `-webkit-line-clamp: 2` truncation. Split sidesteps a Chrome quirk where `display: -webkit-box` and explicit `height` on one element break the third-line clip. |
170
+ | followAction | [Toggle Button](../button/text.md) (Chip-toggle anatomy), stretched to full card width |
171
+ | pagination dot | 6×6, `sys.radius.full`; active `sys.color.onSurface`, inactive `sys.color.outlineVariant` |
172
+
173
+ ## States
174
+
175
+ ProfileCarousel is not itself interactive — commit lives in each card's Toggle Button follow affordance (and the optional card-level `onClick`). Each follows its own spec's state contract.
176
+
177
+ ## Behavior
178
+
179
+ - **Max 5 cards.** Items beyond index 4 are silently dropped.
180
+ - **Fixed 176px card width.** Cards do not reflow to the viewport — the rail always paints the same footprint and scrolls horizontally.
181
+ - **Header lives on Carousel.** ProfileCarousel does not paint its own section heading or 'See all' link.
182
+ - **Sticks to the pager's left padding on swipe.** Same geometry contract as [Post carousel](./post.md) — `scroll-snap-align: start` + the pager's `scroll-padding-left` (the 16 rail).
183
+ - **Guaranteed 40px peek.** A minimum of `ref.space.500` (40px) of the next card is always visible at the trailing edge.
184
+ - **Follow toggle commits in place.** Tapping `Follow` flips the card's Toggle Button to `Following` and stays there. Consumer owns state via `items[i].followed` + `onFollowChange`.
@@ -0,0 +1,219 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "ProfileCarousel",
4
+ "family": "carousel",
5
+ "subcomponent": "profile",
6
+ "description": "Horizontally-scrolling rail of profile-style cards — sub-component of the [Carousel](./carousel.md) family. Each card is a fixed-shape profile module (cover band + centered overlapping avatar + name + followers + metric row + Toggle-Button follow), used to surface channels, user profiles, or company channels in a curated editorial collection. The 'profile-card' shape names what the carousel actually carries — every card is a follow-able profile, regardless of whether the entity is a community channel, an individual user, or a company. Composed inside a [Carousel](./carousel.md) wrapper that owns the editorial heading and the optional 'See all' link. The pager geometry mirrors [Post carousel](./post.md) — same `ref.space.500` (40px) trailing peek, same leading-padding sticky anchor — so editorial collections of posts and profiles read as one family.",
7
+ "element": "div",
8
+ "props": {
9
+ "items": {
10
+ "type": "node",
11
+ "required": true,
12
+ "description": "Array of profile-card descriptors. Each item: { id?, avatar, cover?, name, followers, metrics?, description?, followed?, onFollowChange?, followLabel?, followingLabel?, onClick? }. `metrics` and `description` are mutually exclusive — when `description` is present, it replaces the metrics row. Items beyond index 4 are dropped — the rail enforces a hard 5-card maximum.",
13
+ "maxItems": 5
14
+ }
15
+ },
16
+ "slots": {
17
+ "container": {
18
+ "required": true,
19
+ "description": "Wraps the pager and pagination dots. No fill / padding of its own — the surrounding [Carousel](./carousel.md) provides the page-region chrome.",
20
+ "intrinsic": true
21
+ },
22
+ "pager": {
23
+ "required": true,
24
+ "description": "Horizontal scroll-snap track. `scroll-snap-type: x mandatory`; native scrollbar hidden. Re-pays the 16 left rail via `padding-left: sys.layout.container.md` (+ `scroll-padding-left`) now that the Carousel host is full-bleed; the pager spans the full surface width so the next-card peek reaches the trailing edge intrinsically (no negative margin).",
25
+ "intrinsic": true
26
+ },
27
+ "card": {
28
+ "required": true,
29
+ "description": "One profile card per page. **Width is fixed at 176px** — every card paints the same footprint regardless of how many entries the rail carries. Vertical stack: cover band → avatar (overlapping) → name → followers → metrics row → follow toggle. `sys.color.surface` fill, `sys.radius.md` corner, `sys.borderWidth.hairline sys.color.outlineVariant` inset outline. `scroll-snap-align: start`.",
30
+ "intrinsic": true
31
+ },
32
+ "cover": {
33
+ "required": true,
34
+ "description": "Cover band at the top of the card — 88px tall, full card width. **Image-area slot, same contract as `.chorus-thumbnail` and `.chorus-feed-ad__media`**: a `<img>` paints the band, falling back to the universal Chorus placeholder PNG (`/placeholder.png`) when no `cover.src` is provided. The placeholder is *also* wired as the runtime CSS `background-image` on the band, so a missing/failed `<img>` still resolves to the placeholder rather than an empty surface tone. The image is `object-fit: cover` — aspect ratio preserved, cropped to fill — so the lowercase 'blind' wordmark centered in the placeholder stays visually centered in the band.",
35
+ "assetType": "image",
36
+ "placeholder": "/placeholder.png",
37
+ "intrinsic": true
38
+ },
39
+ "avatar": {
40
+ "required": true,
41
+ "description": "Centered circular avatar overlapping the cover band's bottom edge. Renders via the [Thumbnail](../thumbnail/thumbnail.md) component at `size={64}` with `outlined={true}` — the 2-token (`sys.borderWidth.thin`) `surface`-tone halo that separates the avatar from the cover image is owned by Thumbnail's outlined case (see [Thumbnail § With surface outline](../thumbnail/thumbnail.md#with-surface-outline)), not by the carousel's own wrapper. Every other Thumbnail prop is forwarded verbatim; the carousel does not paint its own crop or image-area fallback.",
42
+ "accepts": [
43
+ "thumbnail"
44
+ ],
45
+ "rendersAs": "thumbnail:64 (outlined)"
46
+ },
47
+ "name": {
48
+ "required": true,
49
+ "description": "Entity name (channel / profile / company). `sys.typo.label.md` / Semibold / `sys.color.onSurface`. Centered; single line, truncates with ellipsis.",
50
+ "accepts": [
51
+ "text"
52
+ ]
53
+ },
54
+ "followers": {
55
+ "required": true,
56
+ "description": "Follower count line. `sys.typo.label.sm` / Semibold / `sys.color.onSurfaceVariant`. Centered; single line.",
57
+ "accepts": [
58
+ "text"
59
+ ]
60
+ },
61
+ "metrics": {
62
+ "required": false,
63
+ "description": "Row of metric chips below the name / followers stack. Each chip is `icon + value` painted in `sys.typo.label.sm` / `sys.color.onSurface`. Three default kinds: { icon: 'star', … } → `StarFillIcon` in `sys.color.icon.yellow`; { icon: 'pulse', … } → `PulseFillIcon` in `sys.color.success`; { icon: 'heart', … } → `HeartFillIcon` in `sys.color.icon.red`. Consumers may pass custom { icon: <ReactNode>, value, color? } entries for other shapes. Mutually exclusive with `description` — when both are present `description` wins.",
64
+ "intrinsic": true
65
+ },
66
+ "description": {
67
+ "required": false,
68
+ "description": "Two-line clamped descriptive paragraph that replaces the metrics row when present. `sys.typo.label.sm` / `sys.color.onSurfaceVariant`, centered. The slot reserves a fixed height of two `label.sm` lines so card height stays consistent across cards that carry metrics and cards that carry copy — extra content clamps with a trailing ellipsis.",
69
+ "accepts": [
70
+ "text"
71
+ ]
72
+ },
73
+ "followAction": {
74
+ "required": true,
75
+ "description": "Trailing full-width [Toggle Button](../button/text.md) at the foot of the card. `Follow` (inactive) → `Following` (active). The carousel does not paint its own follow chrome — every state binding lives on the Toggle Button (Chip-toggle) contract, which paints `sys.color.primary` while inactive and a `transparent` fill with a hairline outline while active so the followed state recedes against the card's own `surface` tier without re-painting a tone that would clash.",
76
+ "accepts": [
77
+ "button"
78
+ ],
79
+ "rendersAs": "button:toggle (full-width inside card)"
80
+ },
81
+ "pagination": {
82
+ "required": true,
83
+ "description": "Row of dots below the pager — one per card. Active dot paints `sys.color.onSurface`; the rest paint `sys.color.outlineVariant`. Decorative (`aria-hidden`); the active index updates from the pager's scroll position via IntersectionObserver.",
84
+ "intrinsic": true
85
+ }
86
+ },
87
+ "sizing": {
88
+ "pagerToPaginationGap": "sys.layout.stack.md",
89
+ "pageGap": "sys.layout.inline.md",
90
+ "pagePeek": "ref.space.500",
91
+ "pagePeekNote": "Guaranteed minimum visibility of the next card at the trailing edge — pinned to 40px (ref.space.500). Identical contract to PostCarousel.",
92
+ "pageSnapAnchor": "Cards stick to the leading edge of the pager (the pager's own `padding-left`, the 16 rail). `scroll-snap-align: start` plus the pager's `scroll-padding-left: sys.layout.container.md` produce this anchor.",
93
+ "cardFill": "sys.color.surface",
94
+ "cardOutline": "sys.borderWidth.hairline sys.color.outlineVariant",
95
+ "cardOutlineComposition": "overlay",
96
+ "cardOutlineCompositionNote": "Painted on a dedicated `::after` overlay layer (`position: absolute; inset: 0; z-index: 2; pointer-events: none`), not as an inset `box-shadow` on the card box itself. The card hosts an opaque full-bleed cover band at the top; an inset shadow on the card would be masked by that fill at the top edge. Same overlay idiom DESIGN.md prescribes for the focus ring — so the stroke renders above every child regardless of edge-painting content.",
97
+ "cardRadius": "sys.radius.md",
98
+ "cardWidth": "176px",
99
+ "cardWidthNote": "Fixed pixel width — every profile card paints the same 176px footprint regardless of card count or screen width. The pager scrolls horizontally so the 176px footprint never reflows.",
100
+ "cardStackGap": "sys.layout.stack.sm",
101
+ "coverHeight": "88px",
102
+ "coverFill": "sys.color.surfaceContainerHigh (background underlay — the placeholder image paints on top via `object-fit: cover`)",
103
+ "coverImageSource": "Same `/placeholder.png` asset every Chorus image-area slot falls back to. Cropped to fill the 88px band via `object-fit: cover` — the placeholder's centered 'blind' wordmark stays visually centered. Decorative — `aria-hidden`. Consumers can override per-item via `items[i].cover.src` (any image URL preserving the same `object-fit: cover` crop).",
104
+ "avatarSize": 64,
105
+ "avatarOverlap": "Avatar's vertical center sits on the cover band's bottom edge — the bottom half of the avatar bleeds onto the card surface. The 2-token (`sys.borderWidth.thin`) `sys.color.surface` halo separating the avatar from the cover band is owned by Thumbnail's `outlined={true}` case (see [Thumbnail § With surface outline](../thumbnail/thumbnail.md#with-surface-outline)) — outset `box-shadow`, not a wrapper border. Same contract as ProfileHeader's avatar.",
106
+ "nameTypo": "sys.typo.label.md",
107
+ "nameColor": "sys.color.onSurface",
108
+ "followersTypo": "sys.typo.label.sm",
109
+ "followersColor": "sys.color.onSurfaceVariant",
110
+ "metricsRowGap": "sys.layout.inline.md",
111
+ "metricChipTypo": "sys.typo.label.sm",
112
+ "metricChipColor": "sys.color.onSurface",
113
+ "metricIconSize": "sys.icon.md",
114
+ "metricIconStar": "StarFillIcon — sys.color.icon.yellow",
115
+ "metricIconPulse": "PulseFillIcon — sys.color.success",
116
+ "metricIconHeart": "HeartFillIcon — sys.color.icon.red",
117
+ "midSlotHeight": "calc(sys.typo.label.sm.size * sys.typo.label.sm.line * 2)",
118
+ "midSlotHeightNote": "Shared fixed height for the metrics row and the description slot — exactly two `label.sm` lines (12 × 1.5 × 2 ≈ 36px). Cards that carry metrics and cards that carry a description paint the same outer height.",
119
+ "descriptionTypo": "sys.typo.label.sm",
120
+ "descriptionColor": "sys.color.onSurfaceVariant",
121
+ "descriptionLineClamp": 2,
122
+ "descriptionOverflow": "Two-line clamp with trailing ellipsis; copy exceeding two lines truncates so the slot height stays fixed.",
123
+ "followActionRendersAs": "Button variant='toggle' — Chip-toggle anatomy. Stretched to full card width via a wrapper rule. All state tokens delegate to the Toggle Button (Chip) family.",
124
+ "cardPaddingBlock": "sys.layout.stack.sm",
125
+ "cardPaddingInline": "sys.layout.container.md",
126
+ "paginationDotSize": "ref.space.75",
127
+ "paginationDotGap": "sys.layout.inline.sm",
128
+ "paginationActiveColor": "sys.color.onSurface",
129
+ "paginationInactiveColor": "sys.color.outlineVariant"
130
+ },
131
+ "itemProps": {
132
+ "id": {
133
+ "type": "string",
134
+ "optional": true
135
+ },
136
+ "avatar": {
137
+ "type": "object",
138
+ "required": true,
139
+ "description": "Forwarded to Thumbnail verbatim at size 64."
140
+ },
141
+ "cover": {
142
+ "type": "object",
143
+ "optional": true,
144
+ "description": "{ src?, alt? } — image-area override for the cover band. `src` accepts any URL; falls back to `/placeholder.png` (the universal Chorus image-area placeholder) when omitted. The slot is image-typed (same contract as `Thumbnail.src` / `FeedAd.media.src`) — `object-fit: cover` preserves aspect ratio and crops to the band's intrinsic 88px height."
145
+ },
146
+ "name": {
147
+ "type": "string",
148
+ "required": true
149
+ },
150
+ "followers": {
151
+ "type": "string",
152
+ "required": true
153
+ },
154
+ "metrics": {
155
+ "type": "node",
156
+ "optional": true,
157
+ "description": "Array of { icon, value, color? } where `icon` is one of 'star' | 'pulse' | 'heart' or a custom ReactNode. Mutually exclusive with `description` — when both are passed, `description` wins."
158
+ },
159
+ "description": {
160
+ "type": "string",
161
+ "optional": true,
162
+ "description": "Two-line clamped paragraph that replaces the metrics row. Fixed-height slot — extra copy ellipsizes."
163
+ },
164
+ "followed": {
165
+ "type": "boolean",
166
+ "default": false
167
+ },
168
+ "onFollowChange": {
169
+ "type": "function",
170
+ "optional": true
171
+ },
172
+ "followLabel": {
173
+ "type": "string",
174
+ "optional": true,
175
+ "description": "Inactive label. Defaults to 'Follow'."
176
+ },
177
+ "followingLabel": {
178
+ "type": "string",
179
+ "optional": true,
180
+ "description": "Active label. Defaults to 'Following'."
181
+ },
182
+ "onClick": {
183
+ "type": "function",
184
+ "optional": true
185
+ }
186
+ },
187
+ "states": {
188
+ "note": "ProfileCarousel is not itself interactive — commit lives in each card's Toggle Button follow affordance and the optional card-level `onClick`. Each follows its own spec's state contract."
189
+ },
190
+ "focusIndicator": {
191
+ "description": "ProfileCarousel is not a focus target. Each focusable child (Toggle Button follow, card body when `onClick` is wired) paints its own ring per its spec. Cards tile inside a horizontal scroll viewport; the Inward composition is preferred so the stroke does not collide with the next-card peek.",
192
+ "composition": "inward",
193
+ "trigger": ":focus-visible"
194
+ },
195
+ "behavior": {
196
+ "maxFiveCards": "ProfileCarousel renders at most five cards — `items.slice(0, 5)`. Editorial / ops mistakes never blow out the rail.",
197
+ "headerLivesOnCarousel": "ProfileCarousel does not paint its own section heading or 'See all' link — those live on the [Carousel](./carousel.md) wrapper. Compose `<Carousel label='…' headerAction={…}><ProfileCarousel items={…} /></Carousel>`.",
198
+ "fixedCardWidth": "Every profile card paints a fixed 176px footprint — the carousel never reflows the card to fit the available inline width. The trailing peek (`ref.space.500`, 40px) of the next card is always visible regardless of viewport.",
199
+ "horizontalScrollSnap": "scroll-snap-type: x mandatory; each card declares scroll-snap-align: start.",
200
+ "stickyToLeadingPadding": "After every swipe, the snapped card aligns flush with the pager's left padding (the 16 rail) — same geometry contract as PostCarousel.",
201
+ "paginationDecorative": "Dots are decorative; tapping a dot does not scroll. Active state updates via IntersectionObserver on the snap targets.",
202
+ "followToggleCommitsInPlace": "Tapping Follow flips the card's Toggle Button to Following and stays there. State is owned by the consumer via items[i].followed + onFollowChange.",
203
+ "cardClickRoutes": "When an item carries `onClick`, the card surface becomes the click target. The follow toggle intercepts the tap so it routes independently."
204
+ },
205
+ "accessibility": {
206
+ "region": "the pager is a labelled scroll region: role='group' (or 'region') with aria-roledescription='carousel' and aria-label wired to the Carousel wrapper's `label` (aria-labelledby on the wrapper heading), so assistive tech announces a named carousel rather than an unlabelled scroll box.",
207
+ "keyboardReach": "Dots are decorative, so cards on pages 2+ MUST stay reachable: each card's focusable contents (card onClick target, follow Toggle Button) are in the natural tab order, and focusing an off-screen card scrolls it into the viewport (scroll-into-view on :focus). Without this, swipe is the only way to reach later cards.",
208
+ "paginationHidden": "The dots are aria-hidden (presentational position indicator only) — see behavior.paginationDecorative.",
209
+ "perCardName": "Each card exposes its own accessible name via the profile name so the tab stop is announced meaningfully."
210
+ },
211
+ "forbidden": [
212
+ "card outline painted as a raw `border:` — outline is an `::after` overlay layer because the card hosts a full-bleed cover band",
213
+ "later cards reachable by swipe only — focusing an off-screen card MUST scroll it into view so a keyboard user can reach every card",
214
+ "cover band painted as an inline SVG wordmark — cover uses /placeholder.png via the image-area contract, object-fit: cover",
215
+ "Carousel heading composed inside the carousel — header + see-all link live on the Carousel wrapper",
216
+ "metrics row mixed with description in the same card — the spec declares them mutually exclusive (description replaces metrics when present)",
217
+ "fixed 176px card width altered — the carousel never reflows the card"
218
+ ]
219
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "chip",
4
+ "name": "Chip",
5
+ "description": "Compact capsule control family. `filter` is a selectable toggle chip used for facet selection; `tag` is an informational chip used for metadata badges and dismissable opt-outs. The two share the capsule footprint but diverge on radius, tone, and intent.",
6
+ "useCases": [
7
+ "facet selection",
8
+ "filter toggle",
9
+ "tag",
10
+ "metadata pill",
11
+ "dismissable opt-out"
12
+ ],
13
+ "visualReuse": "open",
14
+ "layoutInset": "inline",
15
+ "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.",
16
+ "spec": "chip.md",
17
+ "usage": {
18
+ "note": "Both forms are the single `Chip` export selected by the `variant` prop — there is NO `<FilterChip>` or `<Tag>` export.",
19
+ "subs": {
20
+ "filter": { "variant": "filter", "example": "<Chip variant=\"filter\" selected leadingIcon={<CheckedIcon />}>Selected</Chip>" },
21
+ "tag": { "variant": "tag", "example": "<Chip variant=\"tag\" trailingIcon={<XIcon />}>Newsletter</Chip>" }
22
+ }
23
+ },
24
+ "subcomponents": [
25
+ {
26
+ "slug": "filter",
27
+ "spec": "filter.spec.json",
28
+ "md": "filter.md",
29
+ "default": true
30
+ },
31
+ {
32
+ "slug": "tag",
33
+ "spec": "tag.spec.json",
34
+ "md": "tag.md"
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,10 @@
1
+ # Chip
2
+
3
+ A small, content-shaped control or label — chip-shaped affordance for two anchoring roles. **Filter** is the selectable, capsule-shaped choice the user toggles to refine a set (search filters, taxonomy switches, faceted lists). **Tag** is the static, square-cornered label naming attached metadata (categories on a card, attributes on a profile, opt-in/out tokens on a settings row). Each family carries its own slot vocabulary, geometry, and tone; the cross-family contract is limited to the shared label rung (12px Semibold, 4px block padding, 4px label-slot inset producing the visible icon-to-label rhythm) and the Chorus-wide color / state / focus tokens in [`DESIGN.md`](../../DESIGN.md). The two diverge on min-height and horizontal padding by role — **Filter** sits at 32 / 12 (tappable affordance, shared verbatim with [Toolbar Button](../button/toolbar.md) and [Tabs Segmented](../tabs/segmented.md)); **Tag** tightens to 24 / 8 (passive metadata that should not invite touch).
4
+
5
+ **Layout inset.** `inline` — slot atom. A single Chip has no page-rail responsibility; the surrounding container places it (List row leading, Feed footer, FormField suffix). A Chip *group* — multiple Filter chips arranged as a filter rail — is rail-responsible: place the group as a direct child of the page shell (no `padding-inline` / `px-*` wrapper) and let its outer container pay the gutter once, so the first chip's leading edge aligns with section H2s and list-row leading content. See [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
6
+
7
+ ## Sub-components
8
+
9
+ - **[Filter](./filter.md)** — Selectable filter chip. Capsule (`radius.full`), `surfaceContainerHigh` raised tone with a hairline `outlineVariant` stroke at rest, swaps to an inverse fill when selected. Optional leading and trailing icons.
10
+ - **[Tag](./tag.md)** — Informational tag chip. Square-cornered (`radius.sm`), `secondaryContainer` fill that sits one tonal step *below* the lifted Filter so the label reads as attached metadata rather than a tappable choice. Optional trailing icon only — typically the dismiss "×" for opt-out flows.