@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,240 @@
1
+ # Post
2
+
3
+ Sub-component of the [Feed](./feed.md) family. The authored-content card — the unit of a scrolling feed. Composes a flag, author row, body block (title + clamped excerpt + optional thumbnail), optional inline modules (poll, offer, citation, mention), and an engagement footer.
4
+
5
+ **Reach for this when** rendering a single user-authored entry — text post, poll, offer evaluation, link share. **Skip when** the placement is sponsored ([Feed · Ad](./ad.md)) or the row is a metric summary rather than authored content.
6
+
7
+ **Layout inset.** full-bleed — Post contributes its own surface padding (`container.lg` block × `container.md` inline) and a hairline bottom divider, so a stream of Posts tiles edge-to-edge inside the feed column. Drop `Feed` as a direct child of the page-shell `<main>` (or any host that pays the gutter once); do **not** wrap it in a `padding-inline` / `px-*` / `style={{ padding }}` div and do **not** pass it inline padding — Post already pays its own `container.md` inline inset, so an outer pad double-pays the page rail and the author block lands at a different inset than sibling full-bleed rows. See [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
8
+
9
+ ## Default
10
+
11
+ Channel header, title, two-line body, thumbnail, mention, engagement footer. No `flag`.
12
+
13
+ ```preview
14
+ feed/post-default
15
+ ---
16
+ import { Feed } from '@teamblind-chorus/ui';
17
+
18
+ <Feed
19
+ avatar={{ alt: 'Channel' }}
20
+ channel="Channel"
21
+ timestamp="Now"
22
+ followAction
23
+ meta={['Company', 'Job Function', 'Username']}
24
+ title="Title"
25
+ body="Body textBody textBody textBody textBody textBody textBody…"
26
+ thumbnail={{ alt: 'Cover', stacked: true }}
27
+ mention="@Mention"
28
+ engagement={{ likes: 999, comments: 999, views: 999 }}
29
+ />
30
+ ```
31
+
32
+ ## Use cases
33
+
34
+ ### With flag
35
+
36
+ Optional single-word editorial label (`HOT`, `NEW`, `PINNED`). Use sparingly.
37
+
38
+ ```preview
39
+ feed/post-with-flag
40
+ ---
41
+ import { Feed } from '@teamblind-chorus/ui';
42
+
43
+ <Feed
44
+ flag="HOT"
45
+ avatar={{ alt: 'Channel' }}
46
+ channel="Channel"
47
+ timestamp="Now"
48
+ followAction
49
+ meta={['Company', 'Job Function', 'Username']}
50
+ title="Title"
51
+ body="Body textBody textBody textBody textBody textBody textBody…"
52
+ thumbnail={{ alt: 'Cover', stacked: true }}
53
+ mention="@Mention"
54
+ engagement={{ likes: 999, comments: 999, views: 999 }}
55
+ />
56
+ ```
57
+
58
+ ### With poll
59
+
60
+ Inline poll module between body and mention/footer. Leading `PollFillIcon` + label paint in `sys.color.brand`; label constrained to the literal `Poll`.
61
+
62
+ ```preview
63
+ feed/post-with-poll
64
+ ---
65
+ import { Feed } from '@teamblind-chorus/ui';
66
+
67
+ <Feed
68
+ flag="HOT"
69
+ channel="Channel"
70
+ timestamp="Now"
71
+ followAction
72
+ meta={['Company', 'Job Function', 'Username']}
73
+ title="Title"
74
+ body="Body textBody textBody textBody textBody textBody textBody…"
75
+ thumbnail={{ alt: 'Cover' }}
76
+ poll={{ label: 'Poll', participants: 'Number' }}
77
+ mention="@Mention"
78
+ engagement={{ likes: 999, comments: 999, views: 999 }}
79
+ />
80
+ ```
81
+
82
+ ### With offer evaluation
83
+
84
+ Same chrome as `poll`; leading glyph swaps to `CompensationFillIcon` and both glyph and label paint in `sys.color.success`. Label constrained to the literal `Offer`.
85
+
86
+ ```preview
87
+ feed/post-with-offer
88
+ ---
89
+ import { Feed } from '@teamblind-chorus/ui';
90
+
91
+ <Feed
92
+ flag="HOT"
93
+ channel="Channel"
94
+ timestamp="Now"
95
+ followAction
96
+ meta={['Company', 'Job Function', 'Username']}
97
+ title="Title"
98
+ body="Body textBody textBody textBody textBody textBody textBody…"
99
+ thumbnail={{ alt: 'Cover' }}
100
+ offer={{ label: 'Offer', participants: 'Number' }}
101
+ mention="@Mention"
102
+ engagement={{ likes: 999, comments: 999, views: 999 }}
103
+ />
104
+ ```
105
+
106
+ ### With citation
107
+
108
+ Citation module naming an external source. Hero image flush-left at 120px wide; title clamps to two lines.
109
+
110
+ ```preview
111
+ feed/post-with-citation
112
+ ---
113
+ import { Feed } from '@teamblind-chorus/ui';
114
+
115
+ <Feed
116
+ channel="Channel"
117
+ timestamp="Now"
118
+ meta={['Company', 'Job Function', 'Username']}
119
+ title="Title"
120
+ body="Body textBody textBody textBody textBody textBody textBody…"
121
+ citation={{
122
+ title: 'Keep subject area text on two lines or less.',
123
+ source: 'Source',
124
+ }}
125
+ engagement={{ likes: 999, comments: 999, views: 999 }}
126
+ />
127
+ ```
128
+
129
+ ### Group
130
+
131
+ Three (or more) Post cards bundled inside `<FeedGroup>` for thread-grouped or topic-bundled feeds. The wrapper adds no chrome — inner Posts keep their padding and divider; the wrapper carries intent (`role="region"` + optional `aria-label`).
132
+
133
+ ```preview
134
+ feed/post-group
135
+ ---
136
+ import { Feed, FeedGroup } from '@teamblind-chorus/ui';
137
+
138
+ <FeedGroup label="Today's top discussions">
139
+ <Feed
140
+ channel="Channel"
141
+ timestamp="2h"
142
+ title="First post in the bundle"
143
+ body="Short excerpt for the first post. Two lines max before the clamp kicks in."
144
+ engagement={{ likes: 240, comments: 12, views: 1840 }}
145
+ />
146
+ <Feed
147
+ channel="Channel"
148
+ timestamp="3h"
149
+ title="Second post — same topic, different angle"
150
+ body="Another excerpt. The wrapper carries the bundle intent so the posts read as one continuous slice."
151
+ engagement={{ likes: 96, comments: 4, views: 720 }}
152
+ />
153
+ <Feed
154
+ channel="Channel"
155
+ timestamp="5h"
156
+ title="Third post wraps the bundle"
157
+ body="Final excerpt. The last post's bottom divider closes the bundle the same way a standalone Post would."
158
+ engagement={{ likes: 58, comments: 2, views: 410 }}
159
+ />
160
+ </FeedGroup>
161
+ ```
162
+
163
+ ### Full composition
164
+
165
+ Every optional slot present.
166
+
167
+ ```preview
168
+ feed/post-full
169
+ ---
170
+ import { Feed } from '@teamblind-chorus/ui';
171
+
172
+ <Feed
173
+ flag="HOT"
174
+ channel="Channel"
175
+ timestamp="Now"
176
+ followAction
177
+ meta={['Company', 'Job Function', 'Username']}
178
+ title="Title"
179
+ body="Body textBody textBody textBody textBody textBody textBody…"
180
+ thumbnail={{ alt: 'Cover', stacked: true }}
181
+ poll={{ label: 'Poll', participants: 'Number' }}
182
+ citation={{
183
+ title: 'Keep subject area text on two lines or less.',
184
+ source: 'Source',
185
+ }}
186
+ mention="@Mention"
187
+ engagement={{ likes: 999, comments: 999, views: 999 }}
188
+ />
189
+ ```
190
+
191
+ ## Slots
192
+
193
+ - **flag** *(optional)* — single-word editorial label.
194
+ - **avatar** — channel thumbnail at the 32 rung.
195
+ - **channel** + **timestamp** + **followAction** *(optional)* — header row.
196
+ - **meta** — middot-separated author metadata links; single line, truncates.
197
+ - **title** + **body** — title (single line, truncates) over a two-line clamped excerpt.
198
+ - **thumbnail** *(optional at runtime, agent-required at scaffold time)* — 80 × 80 trailing image; overlays `SquareStackIcon` when `stacked`. Agents MUST always pass this slot — fill `src` with a real subject photo when implied, `/placeholder.png` otherwise. Runtime omission-collapse is a safety net, not a license to skip the slot at generation time.
199
+ - **poll** / **offer** *(optional)* — inline banners sharing chrome. Label constrained to `Poll` or `Offer`.
200
+ - **citation** *(optional)* — inline link-share card with leading hero and source mark.
201
+ - **mention** *(optional)* — tap-anywhere `@Mention` line under the body.
202
+ - **engagement** — footer row of `xsmall` [Text Buttons](../button/text.md) — Likes / Comments commit, Views non-interactive.
203
+ - **bottom divider** *(intrinsic)* — hairline `outlineVariant` seam at the card's bottom edge.
204
+
205
+ ## Anatomy
206
+
207
+ | Slot | Token bindings |
208
+ |--------------|----------------|
209
+ | container | `surface` fill, `sys.layout.container.lg` (24/32) block × `sys.layout.container.md` (16) inline padding, `sys.layout.stack.md` between blocks |
210
+ | flag | `label.sm` / Semibold, `brand` foreground |
211
+ | avatar | [Thumbnail](../thumbnail/thumbnail.md) `size={32}` — delegated verbatim |
212
+ | channel | 12 / Semibold, `onSurface`. `<a>`; hover underline; focus = hairline `sys.color.focus` at `sys.radius.xs` |
213
+ | timestamp | 12 / Semibold, `outline` |
214
+ | followAction | 12 / Semibold, `primary` (inactive) → `onSurfaceVariant` (active) |
215
+ | meta | 12 / Semibold, `onSurfaceVariant`. Each `<a>`; middot at `sys.layout.inline.sm` (4px), decorative |
216
+ | title | `heading.sm` (16 / Semibold), `onSurface`, single-line truncate. 8px to body |
217
+ | body | 14 / Regular, `onSurfaceVariant`, two-line clamp |
218
+ | thumbnail | 80 × 80, `radius.sm`, `surfaceContainerHigh` fallback. `stacked` overlays `SquareStackIcon` at `sys.icon.md`, `ref.palette.white.1000`, 4px top-right inset |
219
+ | poll / offer | `surfaceVariant` fill, `radius.md`, 12 × 16 padding, 48px min-height, 14px body. Leading icon + label at 4px gap, 12px to divider, 12px to count. `poll` paints `brand`; `offer` paints `success` (`ref.palette.green.500`) |
220
+ | citation | Text-column `surfaceVariant`, `radius.md`, 120px-wide hero. 12px padding, 8px gap title↔source. All text 12px. Source mark 16 × 16 at 4px radius, 4px to source name |
221
+ | mention | `body.sm`, `primary`, italic |
222
+ | engagement | `xsmall` [Text Buttons](../button/text.md) + static `<span>` (Views). 16px glyph + 12 / Semibold label, 4 × 8 padding, 4px gap. Row gap 12px. Active Like retones label to `sys.color.brand` with `HeartFillIcon` |
223
+ | bottom divider | `sys.borderWidth.hairline` × `sys.color.outlineVariant` — `border-bottom` on the card |
224
+
225
+ ## States
226
+
227
+ Feed is not itself interactive — interaction lives in the controls it carries. The card surface has no hover/pressed/focused treatment.
228
+
229
+ ## Focus indicator
230
+
231
+ Feed itself is not a focus target; each focusable control paints its own ring per its spec. Trigger: `:focus-visible`.
232
+
233
+ ## Behavior
234
+
235
+ - **Slot omission collapses without a gap.** Optional blocks drop out entirely.
236
+ - **Truncation, not wrap.** `meta` / `title` truncate; `body` clamps to two lines; thumbnail is a flex sibling so the clamp computes against reduced inline width.
237
+ - **Like is a toggle.** Tapping swaps `HeartIcon` → `HeartFillIcon` in `sys.color.brand` via `--button-text-label` and increments the count. Controlled (`liked` + `onLikeChange`) or uncontrolled. Aligns via Text Button's [optical alignment](../button/text.md#optical-alignment).
238
+ - **Comments commits; Views does not.** Views is a non-interactive `<span>`.
239
+ - **Channel and meta are independent links.** Middot separators decorative (`aria-hidden`), outside link hit areas.
240
+ - **`<FeedGroup>` bundles consecutive Posts.** Semantic wrapper only — inner Posts keep their own padding and bottom divider.
@@ -0,0 +1,361 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "Feed",
4
+ "family": "feed",
5
+ "description": "Vertically-stacked content card — the unit of a scrolling feed. One Feed entry composes a flag, an author row, a body block (title + clamped excerpt + optional thumbnail), optional inline modules (poll banner, offer-evaluation banner, source citation, mention line), and an engagement footer (reactions and view count). Slots are independent — every block beyond the author row is optional, so the same component renders a plain text post, a poll, an offer evaluation, or a link share without forking the variant axis.",
6
+ "element": "article",
7
+ "props": {
8
+ "flag": {
9
+ "type": "string",
10
+ "optional": true,
11
+ "description": "Editorial label — 'HOT', 'NEW', 'PINNED'. Opt-in; most posts render without one."
12
+ },
13
+ "avatar": {
14
+ "type": "object",
15
+ "optional": true,
16
+ "description": "Forwarded to Thumbnail verbatim at size 32."
17
+ },
18
+ "channel": {
19
+ "type": "string",
20
+ "required": true
21
+ },
22
+ "channelHref": {
23
+ "type": "string",
24
+ "optional": true,
25
+ "description": "Destination URL for the channel link. Defaults to '#' (preview-stub)."
26
+ },
27
+ "timestamp": {
28
+ "type": "string",
29
+ "required": true
30
+ },
31
+ "followAction": {
32
+ "type": "boolean",
33
+ "optional": true,
34
+ "description": "When present, renders an inline text affordance after the timestamp."
35
+ },
36
+ "meta": {
37
+ "type": "node",
38
+ "optional": true,
39
+ "description": "Array of independently-linked metadata items. Pass plain strings for stub links, or `{ label, href }` objects to route."
40
+ },
41
+ "liked": {
42
+ "type": "boolean",
43
+ "optional": true,
44
+ "description": "Controlled active state for the Like toggle. Omit (uncontrolled) and the component manages its own active state."
45
+ },
46
+ "onLikeChange": {
47
+ "type": "function",
48
+ "optional": true,
49
+ "description": "Fires with the next `liked` value when the Like Text Button is tapped."
50
+ },
51
+ "title": {
52
+ "type": "string",
53
+ "optional": true
54
+ },
55
+ "body": {
56
+ "type": "string",
57
+ "optional": true
58
+ },
59
+ "thumbnail": {
60
+ "type": "object",
61
+ "optional": true,
62
+ "agentRequired": true,
63
+ "description": "80×80 trailing image; { src, alt, stacked? }. **Agents must always pass this slot at scaffold / generation time** — fill `src` with a real subject photo when implied, the bundled `/placeholder.png` otherwise. The `optional` flag and the `slotOmissionCollapses` rule are RUNTIME contracts (a Feed still reflows cleanly when a downstream consumer drops thumbnail for a verified text-only post); they are NOT a license for agents to omit the slot. See `forbidden` below — omission at scaffold time is forbidden. When `stacked === true` (2+ images attached), the thumbnail overlays a `SquareStackIcon` glyph at its top-right corner — see `sizing.thumbnailMultipleBadge` for the full anatomy."
64
+ },
65
+ "poll": {
66
+ "type": "object",
67
+ "optional": true,
68
+ "description": "{ label, participants } — inline poll banner. `label` is constrained to the literal `\"Poll\"` (the banner is editorial — see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider.",
69
+ "fields": {
70
+ "label": {
71
+ "type": "enum",
72
+ "values": [
73
+ "Poll"
74
+ ],
75
+ "default": "Poll",
76
+ "description": "Constrained — the poll banner only ever shows the literal `Poll`. Omit to use the default; do not pass a free-form string."
77
+ },
78
+ "participants": {
79
+ "type": "string",
80
+ "required": true
81
+ }
82
+ }
83
+ },
84
+ "offer": {
85
+ "type": "object",
86
+ "optional": true,
87
+ "description": "{ label, participants } — inline offer-evaluation banner. Same chrome as `poll` (surfaceVariant slab, leading glyph + label + divider + participant count), but the leading icon is `CompensationFillIcon` and the glyph + label paint in `sys.color.success` (resolves to `ref.palette.green.500`). Surfaces a 'compensation / offer evaluation' post — the author publishes their current salary or a competing offer and asks the community for better-option signals. `label` is constrained to the literal `\"Offer\"` (see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider.",
88
+ "fields": {
89
+ "label": {
90
+ "type": "enum",
91
+ "values": [
92
+ "Offer"
93
+ ],
94
+ "default": "Offer",
95
+ "description": "Constrained — the offer banner only ever shows the literal `Offer`. Omit to use the default; do not pass a free-form string."
96
+ },
97
+ "participants": {
98
+ "type": "string",
99
+ "required": true
100
+ }
101
+ }
102
+ },
103
+ "citation": {
104
+ "type": "object",
105
+ "optional": true,
106
+ "description": "{ title, source, src? } — inline link-share card."
107
+ },
108
+ "mention": {
109
+ "type": "string",
110
+ "optional": true
111
+ },
112
+ "engagement": {
113
+ "type": "object",
114
+ "optional": true,
115
+ "description": "{ likes, comments, views } — footer counters."
116
+ },
117
+ "onClick": {
118
+ "type": "function",
119
+ "optional": true,
120
+ "description": "Whole-card navigation. When present the card surface becomes the click target (an <article role=\"button\">), and interior affordances (channel link, Follow, Like, Comments, citation, mention) route independently — a click originating on any inner <a>/<button> is ignored. This is the in-contract way to make a post navigable; do NOT wrap Feed in an external <Link>/<a> — that re-pays the page rail as a gutter and misaligns the card with NavigationBar / TabBar."
121
+ }
122
+ },
123
+ "slots": {
124
+ "flag": {
125
+ "required": false,
126
+ "description": "Single-word editorial label at the top of the card. brand foreground, no fill. Opt-in.",
127
+ "accepts": [
128
+ "text"
129
+ ]
130
+ },
131
+ "avatar": {
132
+ "required": false,
133
+ "description": "Thumbnail at size 32 — delegated verbatim. Painted by the shared [Metadata](../metadata/metadata.md) cluster (avatar + channel name + timestamp + meta row).",
134
+ "accepts": [
135
+ "thumbnail"
136
+ ]
137
+ },
138
+ "channel": {
139
+ "required": true,
140
+ "description": "Channel / author name, rendered by the [Metadata](../metadata/metadata.md) cluster as an `<a>` link to the channel page. 12px / Semibold / onSurface. No underline at rest; hover underlines the link alone; focus paints a hairline `sys.color.focus` outline at `sys.radius.xs`.",
141
+ "accepts": [
142
+ "text"
143
+ ]
144
+ },
145
+ "timestamp": {
146
+ "required": true,
147
+ "description": "Relative time since the post. 12px / Regular / outline.",
148
+ "accepts": [
149
+ "text"
150
+ ]
151
+ },
152
+ "followAction": {
153
+ "required": false,
154
+ "description": "Inline plain text affordance after the timestamp (separated by middot). 12 / Semibold / primary (inactive) → onSurfaceVariant ('Following'). No fill, no border.",
155
+ "accepts": [
156
+ "text",
157
+ "button"
158
+ ]
159
+ },
160
+ "meta": {
161
+ "required": false,
162
+ "description": "Middot-separated author metadata. Each item renders as its own `<a>` link inheriting the row's tone (12 / Semibold / onSurfaceVariant). Middot separator decorative (`aria-hidden`) and sits outside link hit areas. Single line; truncates.",
163
+ "accepts": [
164
+ "text"
165
+ ]
166
+ },
167
+ "title": {
168
+ "required": false,
169
+ "description": "Post title. heading.sm (16 / Semibold) / onSurface. One line; truncates with ellipsis.",
170
+ "accepts": [
171
+ "text"
172
+ ]
173
+ },
174
+ "body": {
175
+ "required": false,
176
+ "description": "Post excerpt. 14 / Regular / onSurfaceVariant. Two-line clamp with trailing ellipsis.",
177
+ "accepts": [
178
+ "text"
179
+ ]
180
+ },
181
+ "thumbnail": {
182
+ "required": false,
183
+ "agentRequired": true,
184
+ "omittedBehavior": "collapse",
185
+ "fallbackOnMissingSrc": "sys.color.surfaceContainerHigh",
186
+ "description": "80×80 square image at the trailing edge of the title/body block. radius.sm. `omittedBehavior: collapse` is a runtime safety net — when the prop is undefined the slot drops out of the layout (no reserved whitespace). `agentRequired: true` overrides this for scaffold/generation time: agents MUST always pass a `thumbnail` prop, falling back to `src: \"/placeholder.png\"` when no real subject is implied. `fallbackOnMissingSrc` is the dim-tone fill the component paints when `thumbnail` is present but `src` fails to load (network error or empty string). When the post carries 2+ images, overlays a `SquareStackIcon` badge at the top-right corner (16px / white / 4 inset on top + right).",
187
+ "accepts": [
188
+ "thumbnail"
189
+ ]
190
+ },
191
+ "poll": {
192
+ "required": false,
193
+ "description": "Inline banner naming an attached poll. surfaceVariant fill, 48px min-height. Leading glyph `PollFillIcon` + label, both painted in `sys.color.brand`; label is constrained to the literal `Poll`."
194
+ },
195
+ "offer": {
196
+ "required": false,
197
+ "description": "Inline banner naming an attached offer-evaluation post. Identical chrome to `poll` — surfaceVariant fill, 48px min-height — with the leading glyph swapped to `CompensationFillIcon` and the glyph + label painted in `sys.color.success` (resolves to `ref.palette.green.500`). Label is constrained to the literal `Offer`."
198
+ },
199
+ "citation": {
200
+ "required": false,
201
+ "description": "Inline link-share card naming an external source."
202
+ },
203
+ "mention": {
204
+ "required": false,
205
+ "description": "Tap-anywhere line under the body naming the parent thread. Italic, primary, body-sm.",
206
+ "accepts": [
207
+ "text"
208
+ ]
209
+ },
210
+ "engagement": {
211
+ "required": false,
212
+ "description": "Footer row of three counters laid out as an `xsmall` Text Button group. Likes + Comments are real Text Buttons (`text` / `xsmall` / `secondary`); Views renders as a non-interactive `<span>` matching the same 16-glyph / 12-label rhythm. Row gap follows the xsmall-rung group rule (`sys.layout.inline.sm` = 4px). Like is a toggle: active state retones the label to `sys.color.brand` (via `--button-text-label` override) and swaps `HeartIcon` → `HeartFillIcon`, with the count incrementing in lockstep. Never wraps."
213
+ }
214
+ },
215
+ "sizing": {
216
+ "containerFill": "sys.color.surface",
217
+ "containerPaddingBlock": "sys.layout.container.lg",
218
+ "containerPaddingInline": "sys.layout.container.md",
219
+ "interBlockGap": "sys.layout.stack.md",
220
+ "flagTypo": "sys.typo.label.sm",
221
+ "flagColor": "sys.color.brand",
222
+ "avatarSize": 32,
223
+ "channelTypo": "sys.typo.label.sm",
224
+ "channelColor": "sys.color.onSurface",
225
+ "timestampTypo": "sys.typo.label.sm",
226
+ "timestampColor": "sys.color.outline",
227
+ "followActionTypo": "sys.typo.label.sm",
228
+ "followActionColorInactive": "sys.color.primary",
229
+ "followActionColorActive": "sys.color.onSurfaceVariant",
230
+ "channelRowGap": "sys.layout.inline.md",
231
+ "metaTypo": "sys.typo.label.sm",
232
+ "metaColor": "sys.color.onSurfaceVariant",
233
+ "metaSeparatorGap": "sys.layout.inline.sm",
234
+ "titleBodyGap": "ref.space.100",
235
+ "titleTypo": "sys.typo.heading.sm",
236
+ "titleColor": "sys.color.onSurface",
237
+ "containerBottomDividerWidth": "sys.borderWidth.hairline",
238
+ "containerBottomDividerColor": "sys.color.outlineVariant",
239
+ "bodyTypo": "sys.typo.body.sm",
240
+ "bodyColor": "sys.color.onSurfaceVariant",
241
+ "bodyLineClamp": 2,
242
+ "thumbnailSize": "80 × 80",
243
+ "thumbnailRadius": "sys.radius.sm",
244
+ "thumbnailFallbackFill": "sys.color.surfaceContainerHigh",
245
+ "thumbnailMultipleBadge": {
246
+ "trigger": "thumbnail.stacked === true (2+ images attached to the post)",
247
+ "icon": "SquareStackIcon",
248
+ "size": "sys.icon.md",
249
+ "color": "ref.palette.white.1000",
250
+ "colorReason": "Fixed white so the badge stays legible against any user-supplied imagery in any theme; a surface-toned tint would invert in dark mode and disappear on bright photos.",
251
+ "anchor": "top-right of the thumbnail",
252
+ "insetTop": "sys.layout.container.2xs",
253
+ "insetRight": "sys.layout.container.2xs"
254
+ },
255
+ "engagementCounterShape": "xsmall Text Button (Likes / Comments) or matching static span (Views)",
256
+ "engagementLabelTypo": "sys.typo.label.sm",
257
+ "engagementLabelColor": "sys.color.onSurfaceVariant",
258
+ "engagementIconSize": "sys.icon.md",
259
+ "engagementSlotGap": "sys.layout.inline.sm",
260
+ "engagementRowGap": "sys.layout.inline.lg",
261
+ "likeActiveColor": "sys.color.brand",
262
+ "likeActiveIcon": "HeartFillIcon",
263
+ "likeRestIcon": "HeartIcon",
264
+ "likeLeadingOffset": "Inherited from Text Button's optical-alignment default — no per-call offset; the heart glyph sits at the card's content rail automatically."
265
+ },
266
+ "poll": {
267
+ "fill": "sys.color.surfaceVariant",
268
+ "radius": "sys.radius.md",
269
+ "paddingBlock": "ref.space.150",
270
+ "paddingInline": "ref.space.200",
271
+ "minHeight": "ref.space.600",
272
+ "bodyTypo": "14",
273
+ "glyph": "PollFillIcon",
274
+ "glyphColor": "sys.color.brand",
275
+ "labelColor": "sys.color.brand",
276
+ "labelLiteral": "Poll",
277
+ "anatomy": "Leading 'poll-title' sub-container groups glyph (brand tone) + label at 4px gap; 12px flex gap to vertical outlineVariant divider; 12px gap to participant count."
278
+ },
279
+ "offer": {
280
+ "fill": "sys.color.surfaceVariant",
281
+ "radius": "sys.radius.md",
282
+ "paddingBlock": "ref.space.150",
283
+ "paddingInline": "ref.space.200",
284
+ "minHeight": "ref.space.600",
285
+ "bodyTypo": "14",
286
+ "glyph": "CompensationFillIcon",
287
+ "glyphColor": "sys.color.success",
288
+ "glyphColorResolved": "ref.palette.green.500",
289
+ "labelColor": "sys.color.success",
290
+ "labelLiteral": "Offer",
291
+ "anatomy": "Identical to `poll` — same surfaceVariant slab, same 48 min-height, same leading sub-container + divider + participant count. Differs only in the glyph (CompensationFillIcon) and the editorial tone (success / green-500 instead of brand)."
292
+ },
293
+ "tagBanner": {
294
+ "shared": "`poll` and `offer` are siblings of the same banner family — same chrome, same anatomy. They differ only on the glyph + editorial tone.",
295
+ "labelEnum": "The leading-icon label is constrained to exactly two literals across both modules combined: `\"Poll\"` (for the poll banner) and `\"Offer\"` (for the offer-evaluation banner). Consumers (including Lovable) MUST pass one of these two values, or omit `label` to use the default for the chosen banner kind. No other label string is valid.",
296
+ "renderOrder": "When both modules are present on the same post, `poll` renders above `offer`. Either may stand alone."
297
+ },
298
+ "citation": {
299
+ "textColumnFill": "sys.color.surfaceVariant",
300
+ "outerRadius": "sys.radius.md",
301
+ "heroWidth": "120px",
302
+ "heroBleed": "Flush against the card's leading edge — no container padding, no inter-column gap.",
303
+ "textColumnPadding": "ref.space.150",
304
+ "titleSourceGap": "ref.space.100",
305
+ "allTextSize": "ref.space.150",
306
+ "titleStyle": "Semibold / onSurface",
307
+ "sourceStyle": "Regular / onSurfaceVariant",
308
+ "sourceMarkSize": "16 × 16",
309
+ "sourceMarkRadius": "ref.space.50",
310
+ "sourceMarkGap": "ref.space.50"
311
+ },
312
+ "mention": {
313
+ "typo": "sys.typo.body.sm",
314
+ "color": "sys.color.primary",
315
+ "fontStyle": "italic"
316
+ },
317
+ "states": {
318
+ "note": "Feed container is not itself an interactive primitive — interaction lives in the controls it carries (Follow toggle, engagement counters, citation link, mention link, thumbnail/poll affordances). Each follows its own spec's state contract. The card surface has no hover/pressed/focused treatment."
319
+ },
320
+ "focusIndicator": {
321
+ "description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Feed itself is not a focus target; each nested control (Like / Comments Text Buttons, channel / meta links, follow action) paints its own ring per its spec. This contract documents the composition any future row-level focus target would inherit.",
322
+ "composition": "inward",
323
+ "compositionReason": "Rows tile the column with a hairline divider; an outward ring would overlap divider and neighbour row.",
324
+ "overlay": {
325
+ "color": "label",
326
+ "opacity": "sys.state.focus"
327
+ },
328
+ "ring": {
329
+ "outerWidth": "sys.borderWidth.thin",
330
+ "outerColor": "sys.color.focus",
331
+ "outerLayerPosition": "depth 0..2px from the card edge (the outer stroke)",
332
+ "insetWidth": "sys.borderWidth.hairline",
333
+ "insetColor": "sys.color.focusInset",
334
+ "insetLayerPosition": "depth 2..3px from the card edge (the counter-ring just inside the outer stroke)",
335
+ "implementation": "inset box-shadow constrained strictly inside the card's footprint."
336
+ },
337
+ "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
338
+ },
339
+ "behavior": {
340
+ "slotOmissionCollapses": "Runtime safety net — when these optional blocks (flag, followAction, thumbnail, poll, offer, citation, mention) are absent the layout reflows cleanly with no reserved whitespace. This is a graceful-degradation contract for downstream consumers, NOT a license for agents to omit slots at scaffold time. `thumbnail` in particular is `agentRequired` (see props.thumbnail) — agents must always pass it with `/placeholder.png` when no subject photo is implied.",
341
+ "containerBottomDivider": "Each Post card carries a hairline bottom divider (`sys.borderWidth.hairline` × `sys.color.outlineVariant`) so consecutive cards in a stream read with a deliberate inter-card seam. The divider is `border-bottom` on the card itself — layout-safe under `box-sizing: border-box`, no shadow tricks needed. Inside a `<FeedGroup>` (the 3-up bundle case), the inner posts keep their dividers and the group's last card carries the same outer-bottom divider as a standalone Post — the rhythm is identical, the wrapper is purely semantic.",
342
+ "groupCase": "Three (or more) Post cards bundled vertically inside a `<FeedGroup>` semantic wrapper. Used for thread-grouped or topic-bundled feeds where consecutive posts belong together. The group adds no extra surface chrome — each Post keeps its own `surface` fill, `container.lg/md` padding, and bottom divider; the wrapper only contributes a `role=\"region\"` + optional `aria-label` for the bundle's intent. There is no fixed item count contract; 3 is the canonical demo case.",
343
+ "tagBannerLabelConstrained": "The poll and offer banners share one closed enum for their leading label: `\"Poll\"` or `\"Offer\"`. Each module defaults to its own literal when `label` is omitted; passing any other string is a contract violation.",
344
+ "truncationNotWrap": "meta and title truncate; body clamps to two lines. The card never grows to fit a long title.",
345
+ "thumbnailFlexSibling": "Title and body share their inline space with the thumbnail when both are present; thumbnail is a flex sibling, not floated, so the body's two-line clamp computes against the reduced inline width.",
346
+ "engagementNoReflow": "Footer row stays single-line; tiny screens scroll the row rather than wrapping.",
347
+ "likeToggle": "Tapping Likes increments the count and swaps the rest-state HeartIcon / secondary appearance for the active-state HeartFillIcon with the label re-toned to `sys.color.brand` (via a `--button-text-label` override — not the Text Button `primary` appearance — because Feed's active-like colour is the editorial brand tone, not the interactive primary). Glyph re-colours via `currentColor`. Tapping again decrements and reverts. Controlled via `liked` + `onLikeChange`, or left uncontrolled for in-demo behaviour.",
348
+ "likeLeadingNudge": "The Like glyph aligns flush with the card's content rail via Text Button's optical-alignment default — the chrome bleeds outward by its own padding on every side, so the visible heart sits at the rail without any per-call offset. The 4px row gap to Comments stays unaffected.",
349
+ "viewsNonInteractive": "Views renders as a non-interactive `<span>` matching the xsmall Text Button visual rhythm — no hover / pressed / focus, no `cursor: pointer`. It is a metric display, not an action that has been turned off, so `aria-disabled` is the wrong shape.",
350
+ "channelAndMetaAreLinks": "Channel name and every `meta` item are independent links. The middot separator between meta items is decorative (`aria-hidden`) and sits outside each link's hit area, so the hover underline never spans the separator."
351
+ },
352
+ "forbidden": [
353
+ "thumbnail slot omitted at scaffold / agent time — every feed post MUST pass a `thumbnail` prop. Fill `src` with a real subject photo when implied, the bundled `/placeholder.png` otherwise. The runtime `slotOmissionCollapses` reflow is a safety net for downstream consumers, NOT permission for agents to skip the slot",
354
+ "author meta collapsed into a single line — the two meta rows (channel · time / workplace · role · username) are anatomy invariants",
355
+ "engagement footer rendered with raw <button> — like / comments / views are button/text + non-interactive span (views) per the spec",
356
+ "active-like color painted with sys.color.primary — active-like uses sys.color.brand via the --button-text-label plumbing var",
357
+ "raw <a> for hashtag / mention — inline link tone uses sys.color.primary via the body markup contract, not raw anchor styling",
358
+ "Feed wrapped in a padding-inline div / className=\"px-*\" / style={{ padding }} — Feed is layoutInset=\"full-bleed\" and pays its own page rail once; an outer padding wrapper double-pays the gutter and misaligns the card with NavigationBar / TabBar (the runtime useFullBleedGuard warns on this)",
359
+ "Feed wrapped in an external <Link>/<a> to make the card navigable — pass the `onClick` prop instead (the card surface becomes the target, interior affordances route independently). An outer anchor re-pays the page rail as a gutter AND nests the card's own anchors (channel link, citation, mention), which breaks DOM parsing"
360
+ ]
361
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "form-field",
4
+ "name": "FormField",
5
+ "description": "Text-entry family. `input` is the default labeled single-line text field with optional helper / count; `textarea` is the multi-line cousin with a `rows` floor and vertical-only resize; `search` is a bare pill-shaped variant with a leading search glyph; `select` is the Input-shaped picker that opens a sheet.",
6
+ "useCases": [
7
+ "single-line text input",
8
+ "multi-line text input (bio, comment, compose body)",
9
+ "labeled text field",
10
+ "search input",
11
+ "bare search pill",
12
+ "sheet-driven option picker",
13
+ "labelled select+input pairing (e.g. country code + number)"
14
+ ],
15
+ "visualReuse": "locked",
16
+ "layoutInset": "inline",
17
+ "usage": {
18
+ "note": "Each variant is also exposed as a convenience named export (Input / Textarea / SearchBar / Select), all backed by `FormField`.",
19
+ "subs": {
20
+ "input": { "import": "Input", "example": "<Input label=\"…\" value={v} onChange={setV} />" },
21
+ "textarea": { "import": "Textarea", "example": "<Textarea label=\"…\" value={v} onChange={setV} />" },
22
+ "search": { "import": "SearchBar", "example": "<SearchBar value={q} onChange={setQ} />" },
23
+ "select": { "import": "Select", "example": "<Select label=\"…\" value={v} onChange={setV} options={[…]} />" }
24
+ }
25
+ },
26
+ "spec": "form-field.md",
27
+ "subcomponents": [
28
+ {
29
+ "slug": "input",
30
+ "spec": "input.spec.json",
31
+ "md": "input.md",
32
+ "default": true
33
+ },
34
+ {
35
+ "slug": "textarea",
36
+ "spec": "textarea.spec.json",
37
+ "md": "textarea.md"
38
+ },
39
+ {
40
+ "slug": "search",
41
+ "spec": "search.spec.json",
42
+ "md": "search.md"
43
+ },
44
+ {
45
+ "slug": "select",
46
+ "spec": "select.spec.json",
47
+ "md": "select.md"
48
+ }
49
+ ]
50
+ }