@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,109 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "Button",
4
+ "family": "button",
5
+ "subcomponent": "toolbar",
6
+ "description": "Dense inline toolbar action — 32px (ref.space.400) capsule, label + optional leading / trailing glyph. Visual chrome delegated verbatim to Filter chip — every visual property (min-height, padding, radius, label rung, icon size, container/border/label colors, state overlays, focus ring) mirrors the chip. The semantic divergence is intent: Toolbar Button fires an action; Filter chip toggles a selection; both render with the same DOM and the same CSS classes (`chorus-chip chorus-chip--filter`).",
7
+ "delegatesTo": "chip/filter",
8
+ "element": "button",
9
+ "props": {
10
+ "variant": {
11
+ "type": "literal",
12
+ "value": "toolbar"
13
+ },
14
+ "appearance": {
15
+ "type": "enum",
16
+ "values": [
17
+ "default",
18
+ "accent",
19
+ "inverse"
20
+ ],
21
+ "default": "default"
22
+ },
23
+ "leadingIcon": {
24
+ "type": "node",
25
+ "optional": true
26
+ },
27
+ "trailingIcon": {
28
+ "type": "node",
29
+ "optional": true
30
+ },
31
+ "disabled": {
32
+ "type": "boolean",
33
+ "default": false
34
+ }
35
+ },
36
+ "slots": {
37
+ "label": {
38
+ "required": true,
39
+ "description": "Required, single line.",
40
+ "accepts": [
41
+ "text"
42
+ ]
43
+ },
44
+ "leadingIcon": {
45
+ "required": false,
46
+ "description": "Context glyph before the label.",
47
+ "accepts": [
48
+ "icon"
49
+ ]
50
+ },
51
+ "trailingIcon": {
52
+ "required": false,
53
+ "description": "Directional / destination glyph after the label (chevron-down, ×).",
54
+ "accepts": [
55
+ "icon"
56
+ ]
57
+ }
58
+ },
59
+ "sizing": {
60
+ "$ref": "../chip/filter.spec.json#/sizing"
61
+ },
62
+ "appearances": {
63
+ "default": {
64
+ "background": "sys.color.surfaceContainerHigh",
65
+ "label": "sys.color.onSurface",
66
+ "border": {
67
+ "width": "sys.borderWidth.hairline",
68
+ "color": "sys.color.outlineVariant"
69
+ },
70
+ "note": "The canonical Toolbar Button chrome (mirrors Filter chip's unselected state). The chip-shape signals an inline action that lives within a row of peers (a toolbar, a card footer, a Page bar where the affordance is one of several actions the user might pick)."
71
+ },
72
+ "accent": {
73
+ "background": "sys.color.primary",
74
+ "label": "sys.color.onPrimary",
75
+ "border": null,
76
+ "note": "Brand-blue commit chip — reach for it when the Toolbar Button IS the surface's commit affordance (a Page bar's 'Save', a sheet's 'Confirm', a flow step's 'Done'). The accent fill claims commit-rank attention while keeping the 32px Toolbar Button footprint, so the bar reads at one density even though the action is the screen's most important. Pair sparingly — never two accent Toolbar Buttons in the same row."
77
+ },
78
+ "inverse": {
79
+ "background": "sys.color.inverseSurface",
80
+ "label": "sys.color.inverseOnSurface",
81
+ "border": null,
82
+ "note": "Inverse-toned chip for use inside an inverse host (snackbars, coach-mark surfaces, dark coach overlays). Same chrome geometry as `default`; the colour pair flips to the inverse cluster so the chip reads against the host's `inverseSurface` fill without a per-host tweak."
83
+ }
84
+ },
85
+ "states": {
86
+ "$ref": "../chip/filter.spec.json#/states"
87
+ },
88
+ "focusIndicator": {
89
+ "description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Composes over whichever lifecycle state the button is in. Visual ring is delegated to Filter chip's composition; this block restates the contract for external readers.",
90
+ "composition": "outward",
91
+ "compositionReason": "Action affordance with breathing room around it; the 3px outward extent is reserved by the surrounding toolbar row.",
92
+ "overlay": {
93
+ "color": "label",
94
+ "opacity": "sys.state.focus"
95
+ },
96
+ "ring": {
97
+ "outerWidth": "sys.borderWidth.thin",
98
+ "outerColor": "sys.color.focus",
99
+ "insetWidth": "sys.borderWidth.hairline",
100
+ "insetColor": "sys.color.focusInset"
101
+ },
102
+ "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
103
+ },
104
+ "forbidden": [
105
+ "Toolbar button without aria-label",
106
+ "Toolbar button used as a primary commit — toolbar is for dense secondary actions in a row",
107
+ "Mixing toolbar buttons with non-toolbar variants in the same row — the row geometry is the contract"
108
+ ]
109
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "carousel",
4
+ "name": "Carousel",
5
+ "description": "Page-region wrapper for editorial collections — a labelled block with a leading section heading and an optional trailing `See all` link, hosting a horizontal swipeable rail of cards underneath. Two sub-component shapes ride the same Carousel chrome: **post** — a swipeable rail of compact post cards (curated popular posts, paid / verified-account priority placements); **profile** — a swipeable rail of profile-style cards (channels, user profiles, company channels). Both share the same pager geometry (40px guaranteed trailing peek, leading-padding sticky anchor) so editorial collections of posts and profiles tile cleanly. The Carousel component is exported as `<Carousel>`; the two carousel shapes are accessed via `<Carousel.Post>` / `<Carousel.Profile>` (or legacy aliases `<PostCarousel>` / `<ProfileCarousel>`) and are always composed inside the labelled Carousel host.",
6
+ "useCases": [
7
+ "section heading",
8
+ "labelled editorial collection",
9
+ "see-all link",
10
+ "post carousel",
11
+ "profile carousel",
12
+ "channel carousel",
13
+ "company carousel"
14
+ ],
15
+ "visualReuse": "open",
16
+ "layoutInset": "full-bleed",
17
+ "legacyExports": ["Section"],
18
+ "legacyExportsNote": "The family was renamed from `Section` to `Carousel`. `Section` remains a deprecated runtime alias re-exported from `packages/ui/src/index.js` so existing imports keep compiling; new code should reach for `Carousel`. Consumers may be migrated when convenient — there is no fixed removal date.",
19
+ "wrapperGuidance": "Owns its inline rail internally — the host is full-bleed (no inline padding); its Header pays the 16 rail and the pager re-pays it via padding-left. 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 }} — 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 — see AGENTS.md § Composition rules.",
20
+ "usage": {
21
+ "note": "Carousel is the labelled section *wrapper* (heading + see-all link). The cards are a separate export per sub — there is NO `<CarouselItem>`. Also exported as the deprecated alias `Section`.",
22
+ "subs": {
23
+ "post": { "import": "PostCarousel", "example": "<Carousel label=\"…\"><PostCarousel items={[{ avatar, channel, title, body, views, onClick }]} /></Carousel>" },
24
+ "profile": { "import": "ProfileCarousel", "example": "<Carousel label=\"…\"><ProfileCarousel items={[{ avatar, name, meta, onFollowChange }]} /></Carousel>" }
25
+ }
26
+ },
27
+ "spec": "carousel.md",
28
+ "subcomponents": [
29
+ {
30
+ "slug": "post",
31
+ "spec": "post.spec.json",
32
+ "md": "post.md",
33
+ "default": true
34
+ },
35
+ {
36
+ "slug": "profile",
37
+ "spec": "profile.spec.json",
38
+ "md": "profile.md"
39
+ }
40
+ ]
41
+ }
@@ -0,0 +1,40 @@
1
+ # Carousel
2
+
3
+ Page-region wrapper for editorial collections — a labelled block with a leading heading and an optional trailing `See all` link, hosting a horizontal swipeable rail underneath. Carousel owns the chrome (surface, padding, header anatomy); each sub owns only its pager + cards.
4
+
5
+ - **[Post](./post.md)** — a swipeable rail of compact post cards. Surfaces a curated set of popular posts or gives paid / verified accounts priority placement inside the feed column.
6
+ - **[Profile](./profile.md)** — a swipeable rail of profile-style cards (channels, user profiles, company channels). Surfaces "Hot companies right now", recommended channels, or any profile-shaped collection.
7
+
8
+ Both subs share the same pager geometry — a `ref.space.500` (40px) trailing peek and a leading-padding sticky snap anchor — so editorial collections of posts and profiles tile cleanly side by side.
9
+
10
+ **Reach for this when** a finite, curated set of cards belongs together under a labelled heading and reads as a horizontal swipeable rail — "Popular posts this week", "Recommended channels", "Hot companies right now". **Skip when** the collection is an open-ended scrolling stream of authored items (use [Feed](../feed/feed.md)), a vertical list of same-kind rows (use [List](../list/list.md)), or a channel directory that needs the channel-specific row chrome (use [SuggestionList](../suggestion-list/suggestion-list.md)). Pick the sub by the card shape: post cards → [Post](./post.md); profile / channel / company cards → [Profile](./profile.md).
11
+
12
+ **Layout inset.** `full-bleed` — sits as a direct child of the page shell. The host pays **no** inline padding; its [Header](../header/header.md) owns the 16 inline rail (and its 24/16 block padding), and the pager re-pays the 16 left rail via `padding-left`, reaching the trailing edge of the full-bleed surface. 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).
13
+
14
+ ## Cross-sub contract
15
+
16
+ ### Header anatomy
17
+
18
+ Every Carousel paints a [Header](../header/header.md) (`size="large"`) at the top:
19
+
20
+ - **Label** *(required)* — `sys.typo.heading.md` / Semibold / `sys.color.onSurface`. Leading position.
21
+ - **headerAction** *(optional)* — trailing [Text Button](../button/text.md) (`size={'xsmall'}`, `appearance={'accent'}`) per the link-affordance rule. Extends the header when there's an index page to route to.
22
+
23
+ Carousel forwards `label` and `headerAction` to [Header](../header/header.md) verbatim; the header anatomy lives in Header's spec. Other hosts (in-sheet sub-sections, bounded cards, [SuggestionList](../suggestion-list/suggestion-list.md)) reach for `<Header />` directly — Carousel is the labelled-region host, Header the leading-row primitive it composes.
24
+
25
+ ### Surface + padding
26
+
27
+ Both subs compose inside a single Carousel surface: `sys.color.surface` fill, `sys.layout.container.lg` block padding, **full-bleed inline** (no host inline padding — the Header pays the 16 inline rail and the pager re-pays it via `padding-left`), `sys.layout.stack.lg` between header and pager body. The sub itself paints no surface and no padding.
28
+
29
+ ### Pager geometry
30
+
31
+ Both subs use the same pager: `scroll-snap-type: x mandatory`, `scroll-snap-align: start` on each card, leading `padding-left` = `sys.layout.container.md` (the 16 rail) with a matching `scroll-padding-left` snap anchor so page 1 lands on the rail, and — because the host is full-bleed — the pager reaches the trailing surface edge intrinsically (no negative margin), giving the guaranteed `ref.space.500` (40px) peek at the trailing edge.
32
+
33
+ ### Card count
34
+
35
+ Each sub renders **at most 5 cards** — `items.slice(0, 5)`. Editorial / ops mistakes never blow out the carousel.
36
+
37
+ ## Sub-components
38
+
39
+ - **[Post](./post.md)** — compact post cards for curated / priority placements.
40
+ - **[Profile](./profile.md)** — fixed-shape profile cards for follow-able entities.
@@ -0,0 +1,148 @@
1
+ # Post
2
+
3
+ Sub-component of the [Carousel](./carousel.md) family. Horizontally-scrolling pager of up to 5 compact post cards — surfaces curated popular posts or gives paid / verified accounts priority placement inside the [Post](../feed/post.md) feed column. The section heading and `See all` link live on the [Carousel](./carousel.md) wrapper — PostCarousel is the *content* only.
4
+
5
+ **Reach for this when** spotlighting a small set (≤5) of curated or sponsored posts inside a feed column. **Skip when** the surface is a single feed stream ([Feed · Post](../feed/post.md)) or an entity directory ([List/entry](../list/entry.md)).
6
+
7
+ **Layout inset.** full-bleed — sits as a direct child of the page shell (typically inside a [Carousel](./carousel.md)). The pager re-pays its own 16 left rail via `padding-left` (`sys.layout.container.md`) — the Carousel host is full-bleed — and the trailing card flushes to the page edge so the horizontal-scroll affordance reads cleanly; 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
+ Carousel header (label only) above three cards with verified marks, follow actions, and pagination dots.
12
+
13
+ ```preview
14
+ carousel/post-default
15
+ ---
16
+ import { Carousel, PostCarousel } from '@teamblind-chorus/ui';
17
+
18
+ <Carousel label="Trending right now">
19
+ <PostCarousel
20
+ items={[
21
+ {
22
+ avatar: { src: '/placeholder.png', alt: 'Channel' },
23
+ channel: 'Engineering',
24
+ verified: true,
25
+ followAction: true,
26
+ title: 'Which cushion brand still works on combo skin in summer?',
27
+ body: 'Slightly dehydrated combo skin — coverage is nice but not required. Anything that holds for a workday without sliding off? Pore-blurring a plus.',
28
+ mention: '@beauty, @skincare-talk',
29
+ views: '5K',
30
+ },
31
+ {
32
+ avatar: { src: '/placeholder.png', alt: 'Channel' },
33
+ channel: 'Compensation',
34
+ verified: true,
35
+ followAction: true,
36
+ title: 'L5 → L6 promo packet review — what worked, what nearly killed it',
37
+ body: 'I shipped two cross-team launches in the year, but my packet still came back with "scope of influence unclear" twice before it cleared. Sharing the rewrites.',
38
+ mention: '@career, @big-tech',
39
+ views: '12K',
40
+ },
41
+ {
42
+ avatar: { src: '/placeholder.png', alt: 'Channel' },
43
+ channel: 'Plant People',
44
+ verified: false,
45
+ followAction: true,
46
+ title: 'Monstera dropping aerial roots — repot or train?',
47
+ body: 'Two-year-old monstera, roots crawling out of the drainage holes. Light and watering are dialed in. Looking for the lazy-but-right answer.',
48
+ mention: '@plant-parents',
49
+ views: '3K',
50
+ },
51
+ ]}
52
+ />
53
+ </Carousel>
54
+ ```
55
+
56
+ ## Use cases
57
+
58
+ ### With header action
59
+
60
+ 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.
61
+
62
+ ```preview
63
+ carousel/post-with-header-action
64
+ ---
65
+ import { Carousel, PostCarousel } from '@teamblind-chorus/ui';
66
+
67
+ <Carousel label="Trending right now" headerAction={{ label: 'See all', href: '#' }}>
68
+ <PostCarousel
69
+ items={[
70
+ { avatar: { src: '/placeholder.png', alt: 'Channel' }, channel: 'Engineering', verified: true, followAction: true, title: 'The migration that finally landed after three quarters', body: 'Internal postmortem turned editorial — the scaffolding that held the rewrite together when the timeline did not.', mention: '@infra-talk', views: '14K' },
71
+ { avatar: { src: '/placeholder.png', alt: 'Channel' }, channel: 'Compensation', verified: true, followAction: true, title: 'Equity refresh negotiations — what actually moves', body: 'A read on the conversations that get an actual refresh on the calendar versus the ones that get a polite no.', mention: '@career', views: '9K' },
72
+ { avatar: { src: '/placeholder.png', alt: 'Channel' }, channel: 'Plant People', verified: false, followAction: true, title: 'Monstera dropping aerial roots — repot or train?', body: 'Two-year-old monstera, roots crawling out of the drainage holes. Light and watering are dialed in.', mention: '@plant-parents', views: '3K' },
73
+ ]}
74
+ />
75
+ </Carousel>
76
+ ```
77
+
78
+ ### Editorial cards (no follow, no verified)
79
+
80
+ Cards drop `verified` and `followAction` — each card's header collapses to avatar + channel name. Reach for it on editorial collections where the card is informational only (round-ups, archives, *what we're reading*); the surface should not invite a per-card commit.
81
+
82
+ ```preview
83
+ carousel/post-editorial
84
+ ---
85
+ import { Carousel, PostCarousel } from '@teamblind-chorus/ui';
86
+
87
+ <Carousel label="Editor picks">
88
+ <PostCarousel
89
+ items={[
90
+ { avatar: { src: '/placeholder.png', alt: 'Channel' }, channel: 'Career', title: 'The quiet math of staying versus leaving', body: 'Salary checks, offer evaluations, and the long thread that runs longer than any single conversation can.', views: '18K' },
91
+ { avatar: { src: '/placeholder.png', alt: 'Channel' }, channel: 'Compensation', title: 'Equity refresh negotiations — what actually moves', body: 'A read on the conversations that get an actual refresh on the calendar versus the ones that get a polite no.', views: '9K' },
92
+ { avatar: { src: '/placeholder.png', alt: 'Channel' }, channel: 'Engineering', title: 'The migration that finally landed after three quarters', body: 'Internal postmortem turned editorial — the scaffolding that held the rewrite together when the timeline did not.', views: '14K' },
93
+ ]}
94
+ />
95
+ </Carousel>
96
+ ```
97
+
98
+ ## Slots
99
+
100
+ - **container** — wraps pager + pagination dots. No fill / padding — the surrounding [Carousel](../carousel/carousel.md) provides the chrome.
101
+ - **pager** — horizontal scroll-snap track. `scroll-snap-type: x mandatory`; native scrollbar hidden.
102
+ - **card** — one compact post card per page; outline-only surface.
103
+ - **avatar** — [Thumbnail](../thumbnail/thumbnail.md) `size={40}`, every prop (`src`, `alt`, `updateDot`, `logoBadge`) forwarded verbatim.
104
+ - **verified** *(optional)* — `VerifiedFillIcon` (`sys.icon.md`, `sys.color.primary`) to the LEFT of the channel name so the trust signal reads first.
105
+ - **channel** — channel / author name. `sys.typo.label.md` / Semibold / `sys.color.onSurface`.
106
+ - **followAction** *(optional)* — trailing [Text Button](../button/text.md) (`xsmall`). `accent` inactive (link-affordance) → `default` active (recedes). Same trailing-Text-Button shape as [List/entry](../list/entry.md)'s [attribution-row case](../list/entry.md#with-trailing-text-button-compact-attribution-row) — see the Behavior note below.
107
+ - **title** — `sys.typo.label.md` / Semibold / `sys.color.onSurface`. Single line, truncates.
108
+ - **body** — `sys.typo.body.sm` / `sys.color.onSurfaceVariant`. Three-line clamp.
109
+ - **mention** *(optional)* — `sys.typo.body.sm` / `sys.color.primary` (not italic).
110
+ - **footer** — leading 'See more' [Text Button](../button/text.md) (`xsmall` / `secondary`) + trailing view count (`EyeIcon` + count `<span>`).
111
+ - **pagination** — one dot per card; active `sys.color.onSurface`, rest `sys.color.outlineVariant`. Decorative (`aria-hidden`).
112
+
113
+ ## Anatomy
114
+
115
+ | Slot | Token bindings |
116
+ |-------------------|----------------|
117
+ | container | No fill / padding — surrounding [Carousel](../carousel/carousel.md) provides chrome; `sys.layout.stack.md` pager→dots gap |
118
+ | 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`, `scrollbar-width: none` |
119
+ | card | `flex: 0 0 calc(100% - sys.layout.inline.md - ref.space.500)`; `sys.color.surface` fill, `sys.radius.md`, `sys.borderWidth.hairline sys.color.outlineVariant` outline (inset box-shadow), `sys.layout.container.md` padding, `sys.layout.stack.sm` between blocks, `scroll-snap-align: start` |
120
+ | header | Row: avatar + verified mark + name + spacer + follow action; `align-items: center`; `sys.layout.inline.md` gap |
121
+ | avatar / verified | [Thumbnail](../thumbnail/thumbnail.md) `size={40}` delegated verbatim · `VerifiedFillIcon` at `sys.icon.md` / `sys.color.primary` (resolves to `ref.palette.blue.500`), leading of the channel name |
122
+ | channel / title | `sys.typo.label.md`, `sys.color.onSurface`, single-line truncate |
123
+ | body / mention | `sys.typo.body.sm` / `sys.color.onSurfaceVariant` (three-line clamp) · mention in `sys.color.primary` |
124
+ | followAction | [Text Button](../button/text.md) `size={'xsmall'}`, `appearance={'accent'}` inactive → `appearance={'default'}` active; state tokens delegate to Text Button |
125
+ | footer | Row: leading 'See more' Text Button (`xsmall` / `secondary`) + trailing view count `<span>`; `justify-content: space-between` |
126
+ | pagination dot | 6 × 6, `sys.radius.full`; active `sys.color.onSurface`, inactive `sys.color.outlineVariant`; `sys.layout.inline.sm` row gap |
127
+
128
+ ## Sizes
129
+
130
+ A single rung. The carousel stretches to its column width (`width: 100%`); on web a feed column typically caps at `comp-content-max`. Card basis is computed from the pager's inner width, so the peek width holds across breakpoints.
131
+
132
+ ## States
133
+
134
+ Not itself interactive — commit lives in the header action, each card's follow action / more link, and any card-level `onClick`. Each follows its own spec's state contract. The carousel surface has no hover / pressed / focused treatment.
135
+
136
+ ## Focus indicator
137
+
138
+ Inward — cards tile inside a horizontal scroll viewport with hairline outlines; an outward ring would collide with the next-card peek. Each focusable child paints its own ring per its spec.
139
+
140
+ ## Behavior
141
+
142
+ - **Max 5 cards.** Items beyond index 4 are silently dropped (`items.slice(0, 5)`).
143
+ - **Section header anatomy delegates to SuggestionList.** The *wrapper* header — label + headerAction — retunes at the [SuggestionList](../suggestion-list/suggestion-list.md) spec.
144
+ - **Card header renders the shared `EntryRow`.** The avatar + optional verified mark + channel label + trailing follow [Text Button](../button/text.md) row pinned to the top of each *card* is **the same `EntryRow` atom that [List/entry](../list/entry.md) rows render** — not a look-alike, the literal shared component. The carousel passes `verified` (the inline `VerifiedFillIcon`, left of the label), a 40-rung `thumbnail`, and a `xsmall` follow Text Button (`accent` inactive → `default` active) into `EntryRow`'s trailing slot; the leading→label gap (12), identity column, and trailing-action pin all live on `EntryRow`. Only the surrounding card chrome (`chorus-post-carousel__card-header` wrapper) is carousel-specific. Retune the attribution shape on `EntryRow` / at [List/entry](../list/entry.md), not here.
145
+ - **One card per page, scroll-snap.** `scroll-snap-type: x mandatory`; each card declares `scroll-snap-align: start`. The pager re-pays the 16 left rail via `padding-left` and, as a full-bleed child, reaches the trailing edge intrinsically so the peek isn't clipped.
146
+ - **Guaranteed 40px peek.** Trailing-edge visibility of the next card pins to `ref.space.500` — raw ref step so the floor is fixed-pixel across breakpoints. Card basis is `calc(100% - sys.layout.inline.md - ref.space.500)`. After every swipe, the snapped card aligns flush with the container's left padding; the trailing edge always holds the 40px peek.
147
+ - **Pagination dots are decorative.** Dots reflect scroll position via `IntersectionObserver`; tapping a dot does not scroll. Active dot paints `sys.color.onSurface`, the rest `sys.color.outlineVariant`.
148
+ - **Cards route via `onClick`.** When an item carries `onClick`, the card surface becomes the click target; header (follow action) and footer (more link) intercept the tap so each routes independently.
@@ -0,0 +1,229 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "PostCarousel",
4
+ "family": "carousel",
5
+ "subcomponent": "post",
6
+ "description": "Horizontally-scrolling pager of up to 5 compact post cards — sub-component of the [Carousel](./carousel.md) family. Used to surface a curated set of popular posts, or to give paid / verified accounts priority placement, inside the same scrolling feed column the default Feed card lives in. Editorial / ops control inserts a Carousel + PostCarousel pair between regular Feed cards based on placement policy. PostCarousel is the **content** — the section heading and trailing 'See all' link live on the [Carousel](./carousel.md) wrapper. Compose `<Carousel label='…' headerAction={…}><PostCarousel items={…} /></Carousel>`. The pager is a one-card-per-page horizontal scroll-snap track with a `ref.space.500` (40px) peek at the trailing edge inviting the swipe; pagination dots sit below the pager, one per card.",
7
+ "element": "div",
8
+ "props": {
9
+ "items": {
10
+ "type": "node",
11
+ "required": true,
12
+ "description": "Array of up to 5 compact post cards. Each item: { id?, avatar, channel, verified?, followAction?, followed?, onFollowChange?, title, body, mention?, views, moreLabel?, onClick? }. Items beyond index 4 are dropped — the section enforces the 5-card maximum.",
13
+ "maxItems": 5
14
+ }
15
+ },
16
+ "slots": {
17
+ "container": {
18
+ "required": true,
19
+ "description": "Wraps the pager and pagination dots. `sys.layout.stack.md` pager→dots gap. No surface 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": "Horizontally-scrolling track of cards with `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 compact post card per page. `flex: 0 0 calc(100% - sys.layout.inline.md - ref.space.500)` so a 40px peek of the next card always surfaces at the trailing edge. `scroll-snap-align: start`. Inner padding `sys.layout.container.md`, `sys.color.surface` fill, `sys.radius.md` corner, `sys.borderWidth.hairline` outline in `sys.color.outlineVariant`.",
30
+ "intrinsic": true
31
+ },
32
+ "header": {
33
+ "required": true,
34
+ "description": "Top row of the card: avatar (Thumbnail size 40) + channel name + optional VerifiedIcon (size sys.icon.md, sys.color.primary) + trailing follow affordance. Rendered by the **shared `EntryRow` atom** — the same component [List/entry](../list/entry.md) rows render — wrapped in a carousel-specific `chorus-post-carousel__card-header`. See `behavior.headerReusesEntryCombo`.",
35
+ "intrinsic": true
36
+ },
37
+ "avatar": {
38
+ "required": true,
39
+ "description": "Channel / author avatar — rendered by the [Thumbnail](../thumbnail/thumbnail.md) component at `size={40}`. Every Thumbnail prop (`src`, `alt`, `updateDot`, `logoBadge`) is forwarded verbatim. The carousel does not paint its own circular crop — Thumbnail owns the corner shape and the image-area fallback.",
40
+ "accepts": [
41
+ "thumbnail"
42
+ ],
43
+ "rendersAs": "thumbnail:40"
44
+ },
45
+ "verified": {
46
+ "required": false,
47
+ "description": "Inline `VerifiedFillIcon` at `sys.icon.md`, painted in `sys.color.primary` (resolves to `ref.palette.blue.500`). Sits to the **LEFT** of the channel name so the reader's eye lands on the trust signal first, then the name. Decorative — pair with a textual affordance elsewhere if the verified status itself is meaningful.",
48
+ "accepts": [
49
+ "icon"
50
+ ],
51
+ "defaultIcon": "VerifiedFillIcon",
52
+ "defaultIconColor": "sys.color.primary"
53
+ },
54
+ "channel": {
55
+ "required": true,
56
+ "description": "Channel / author name. `sys.typo.label.md` / Semibold / `sys.color.onSurface`. Single line; truncates. Sits to the right of the optional `verified` mark inside the channel-row sub-container.",
57
+ "accepts": [
58
+ "text"
59
+ ]
60
+ },
61
+ "followAction": {
62
+ "required": false,
63
+ "description": "[Text Button](../button/text.md) (`size={'xsmall'}`) at the trailing edge of the card header — the trailing-Text-Button half of the [List/entry](../list/entry.md) attribution combo (see `behavior.headerReusesEntryCombo`). **Inactive** (`Follow`) uses `appearance={'accent'}` — the follow action is a navigation-shaped commit on the card, and the link-affordance rule applies. **Active** (`Following`) flips to `appearance={'default'}` so the followed state recedes into the body and the unfollowed alternative remains the visually loudest call. The carousel does not paint its own follow chrome — every state binding (rest, hover, pressed, focus) lives on the Text Button family.",
64
+ "accepts": [
65
+ "button"
66
+ ],
67
+ "rendersAs": "button:text:xsmall:accent (inactive) / button:text:xsmall:default (active)"
68
+ },
69
+ "title": {
70
+ "required": true,
71
+ "description": "Post title. `sys.typo.label.md` / Semibold / `sys.color.onSurface`. One line; truncates with ellipsis.",
72
+ "accepts": [
73
+ "text"
74
+ ]
75
+ },
76
+ "body": {
77
+ "required": true,
78
+ "description": "Post excerpt. `sys.typo.body.sm` / Regular / `sys.color.onSurfaceVariant`. Three-line clamp with trailing ellipsis.",
79
+ "accepts": [
80
+ "text"
81
+ ]
82
+ },
83
+ "mention": {
84
+ "required": false,
85
+ "description": "Tap-anywhere mention / tag line below the body. `sys.typo.body.sm` / `sys.color.primary` (no italic — the carousel card reads tags as part of its body block, unlike the Feed · Post card which italicises a single mention).",
86
+ "accepts": [
87
+ "text"
88
+ ]
89
+ },
90
+ "footer": {
91
+ "required": true,
92
+ "description": "Bottom row: leading `more` affordance + trailing view count. The 'See more' label renders as a [Text Button](../button/text.md) (`size={'xsmall'}`, `appearance={'secondary'}`) — same family as the card-header follow action — so both card affordances share one state contract. The view count renders as a non-interactive `<span>` matching the same `xsmall` rhythm (`EyeIcon` at `sys.icon.md` + `sys.typo.label.sm` / `sys.color.onSurfaceVariant`).",
93
+ "intrinsic": true
94
+ },
95
+ "pagination": {
96
+ "required": true,
97
+ "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.",
98
+ "intrinsic": true
99
+ }
100
+ },
101
+ "sizing": {
102
+ "containerFill": "none — provided by surrounding Carousel",
103
+ "containerPadding": "none — provided by surrounding Carousel",
104
+ "pagerToPaginationGap": "sys.layout.stack.md",
105
+ "pageGap": "sys.layout.inline.md",
106
+ "pagePeek": "ref.space.500",
107
+ "pagePeekNote": "Guaranteed minimum visibility of the next card at the trailing edge. Pinned to `ref.space.500` (40px) — a raw ref step rather than a responsive sys-layout rung — because the carousel wants a fixed-pixel visibility floor independent of the responsive sys-layout shift. The card's flex-basis is `calc(100% - sys.layout.inline.md - ref.space.500)`, so the inter-card gap plus the peek subtract from the pager's inline space in lock-step.",
108
+ "pageSnapAnchor": "Cards stick to the leading edge of the pager (the pager's own `padding-left: sys.layout.container.md`, the 16 rail). `scroll-snap-align: start` on each card plus the pager's `scroll-padding-left: sys.layout.container.md` together guarantee that, after every swipe, the snapped card aligns flush with the 16 rail — and the trailing edge holds the 40px peek of the next card.",
109
+ "cardFill": "sys.color.surface",
110
+ "cardOutline": "sys.borderWidth.hairline sys.color.outlineVariant",
111
+ "cardRadius": "sys.radius.md",
112
+ "cardPadding": "sys.layout.container.md",
113
+ "cardStackGap": "sys.layout.stack.sm",
114
+ "cardHeaderGap": "sys.layout.inline.md",
115
+ "cardAvatarSize": 40,
116
+ "cardAvatarRendersAs": "Thumbnail at size 40 — the carousel does not paint its own circular crop or fallback; both come from the Thumbnail family.",
117
+ "cardChannelTypo": "sys.typo.label.md",
118
+ "cardChannelColor": "sys.color.onSurface",
119
+ "cardVerifiedIcon": "VerifiedFillIcon",
120
+ "cardVerifiedSize": "sys.icon.md",
121
+ "cardVerifiedColor": "sys.color.primary",
122
+ "cardVerifiedColorResolved": "ref.palette.blue.500",
123
+ "cardVerifiedPosition": "Leading — sits to the LEFT of the channel name inside the channel-row sub-container.",
124
+ "cardFollowActionRendersAs": "Button variant='text' size='xsmall' appearance='accent' (inactive — link-affordance rule) → appearance='default' (active — followed state recedes). All state tokens delegate to the Text Button family.",
125
+ "cardMoreActionRendersAs": "Button variant='text' size='xsmall' appearance='secondary'. Same Text Button rung as the follow action so the card's two affordances share one state contract.",
126
+ "cardTitleTypo": "sys.typo.label.md",
127
+ "cardTitleColor": "sys.color.onSurface",
128
+ "cardBodyTypo": "sys.typo.body.sm",
129
+ "cardBodyColor": "sys.color.onSurfaceVariant",
130
+ "cardBodyLineClamp": 3,
131
+ "cardMentionTypo": "sys.typo.body.sm",
132
+ "cardMentionColor": "sys.color.primary",
133
+ "cardFooterTypo": "sys.typo.label.sm",
134
+ "cardFooterColor": "sys.color.onSurfaceVariant",
135
+ "cardFooterIcon": "EyeIcon",
136
+ "cardFooterIconSize": "sys.icon.md",
137
+ "paginationDotSize": "ref.space.75",
138
+ "paginationDotGap": "sys.layout.inline.sm",
139
+ "paginationActiveColor": "sys.color.onSurface",
140
+ "paginationInactiveColor": "sys.color.outlineVariant"
141
+ },
142
+ "itemProps": {
143
+ "id": {
144
+ "type": "string",
145
+ "optional": true
146
+ },
147
+ "avatar": {
148
+ "type": "object",
149
+ "required": true,
150
+ "description": "Forwarded to Thumbnail verbatim at size 40."
151
+ },
152
+ "channel": {
153
+ "type": "string",
154
+ "required": true
155
+ },
156
+ "verified": {
157
+ "type": "boolean",
158
+ "default": false
159
+ },
160
+ "followAction": {
161
+ "type": "boolean",
162
+ "default": false
163
+ },
164
+ "followed": {
165
+ "type": "boolean",
166
+ "default": false
167
+ },
168
+ "onFollowChange": {
169
+ "type": "function",
170
+ "optional": true
171
+ },
172
+ "title": {
173
+ "type": "string",
174
+ "required": true
175
+ },
176
+ "body": {
177
+ "type": "string",
178
+ "required": true
179
+ },
180
+ "mention": {
181
+ "type": "string",
182
+ "optional": true
183
+ },
184
+ "views": {
185
+ "type": "string",
186
+ "required": true
187
+ },
188
+ "moreLabel": {
189
+ "type": "string",
190
+ "optional": true,
191
+ "description": "Trailing 'see more' label in the card footer. Defaults to 'See more'."
192
+ },
193
+ "onClick": {
194
+ "type": "function",
195
+ "optional": true
196
+ }
197
+ },
198
+ "states": {
199
+ "note": "Container has no interactive state. Each card's commit affordances (the optional follow action, the 'see more' link) follow their own state contracts. The card body is presentational by default — pass `onClick` on an item to route the entire card."
200
+ },
201
+ "focusIndicator": {
202
+ "description": "PostCarousel itself is not a focus target. Each focusable child (header action, card follow action, card body when `onClick` is wired) paints its own ring per its own spec. Cards tile horizontally in a scroll viewport; the Inward composition is preferred for any card-level ring so the stroke does not collide with the next-card peek.",
203
+ "composition": "inward",
204
+ "trigger": ":focus-visible"
205
+ },
206
+ "behavior": {
207
+ "maxFiveCards": "PostCarousel renders at most five cards. Items beyond index 4 are silently dropped — `items.slice(0, 5)` — so editorial / ops mistakes never blow out the section.",
208
+ "headerDelegatesToSuggestionList": "The *wrapper* (section) header's anatomy (label + optional trailing text-link action) is the same contract as [SuggestionList](../suggestion-list/suggestion-list.md). Bindings stay in lock-step — retune at the SuggestionList spec, not here.",
209
+ "headerReusesEntryCombo": "The *card* header renders the **shared `EntryRow` atom** (`packages/ui/src/EntryRow.jsx`, class `chorus-entry-row`) — the same component each [List/entry](../list/entry.md) row renders, not a look-alike. The carousel passes `verified` (inline VerifiedFillIcon, LEFT of the label), a 40-rung `thumbnail`, and a `xsmall` follow Text Button (`accent` inactive → `default` active) into EntryRow's trailing slot. The leading→label gap (12), identity column, and trailing-action pin live on EntryRow; only the `chorus-post-carousel__card-header` wrapper is carousel-specific. Retune the attribution shape on EntryRow / at List/entry, not here.",
210
+ "horizontalScrollSnap": "scroll-snap-type: x mandatory; each card declares scroll-snap-align: start.",
211
+ "nextCardPeek": "The Carousel host is full-bleed, so the pager spans the full surface width and reaches the trailing edge intrinsically (no negative margin); it re-pays the 16 left rail via `padding-left: sys.layout.container.md`. Card basis composes the inter-card gap (`sys.layout.inline.md`) plus the visible peek (`ref.space.500`, 40px) into one calc — a minimum of 40px of the next card is guaranteed to remain visible at the trailing edge whenever a card is snapped to the leading edge. The peek is pinned to a raw ref step rather than a responsive sys-layout rung so the visibility floor is fixed-pixel and does not shift at the responsive breakpoint.",
212
+ "stickyToLeadingPadding": "After every swipe, the snapped card aligns flush with the pager's left padding (the 16 rail) — it 'sticks' to the leading edge of the pager. `scroll-snap-align: start` plus the pager's `scroll-padding-left: sys.layout.container.md` together produce this behavior. The trailing edge of the same snap state always holds the 40px peek of the next card; the two are one geometry contract, not two independent rules.",
213
+ "paginationDecorative": "Dots are decorative (`aria-hidden`). They reflect the scroll position only; tapping a dot does not scroll. Active state updates via IntersectionObserver on the pager's snap targets.",
214
+ "cardClickRoutes": "When an item carries `onClick`, the card surface becomes the click target. Header affordances (follow action) and footer affordances (more link) intercept the tap so each can route independently.",
215
+ "insertionPolicy": "Editorial / ops control inserts the carousel between regular Feed cards according to placement policy — the component does not own that decision."
216
+ },
217
+ "accessibility": {
218
+ "region": "the pager is a labelled scroll region: it carries role='group' (or 'region') with aria-roledescription='carousel' and aria-label wired to the Carousel wrapper's `label` (aria-labelledby pointing at the wrapper heading) so assistive tech announces it as a named carousel, not an unlabelled scroll box.",
219
+ "keyboardReach": "Because the pagination dots are decorative and non-interactive, the cards on pages 2+ MUST stay reachable another way: each card's focusable contents (card onClick target, follow / more actions) 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 and the carousel is keyboard-inaccessible.",
220
+ "paginationHidden": "The dots are aria-hidden (presentational position indicator only) — see behavior.paginationDecorative.",
221
+ "perCardName": "Each card exposes its own accessible name via its heading / author so the tab stop is announced meaningfully, not as a bare 'link'."
222
+ },
223
+ "forbidden": [
224
+ "card outline painted as a raw `border:` — outline is an inset box-shadow on the card",
225
+ "later cards reachable by swipe only — focusing an off-screen card MUST scroll it into view so a keyboard user can reach every card",
226
+ "Carousel heading composed inside the carousel — header + see-all link live on the Carousel wrapper, never on the carousel itself",
227
+ "card width altered from the spec'd footprint — every post card paints the same width regardless of card count"
228
+ ]
229
+ }