@teamblind-chorus/ui 1.2.0 → 2.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 (141) hide show
  1. package/README.md +3 -3
  2. package/agents/AGENTS.md +6 -6
  3. package/agents/DESIGN.md +245 -244
  4. package/agents/LOVABLE.md +40 -11
  5. package/agents/catalog.md +4 -4
  6. package/agents/components/avatar-rail/avatar-rail.md +2 -4
  7. package/agents/components/avatar-rail/avatar-rail.spec.json +10 -14
  8. package/agents/components/badge/role.md +7 -9
  9. package/agents/components/badge/role.spec.json +6 -6
  10. package/agents/components/badge/update.md +6 -8
  11. package/agents/components/badge/update.spec.json +5 -5
  12. package/agents/components/banner/banner.md +16 -18
  13. package/agents/components/banner/banner.spec.json +14 -14
  14. package/agents/components/bottom-sheet/bottom-sheet.md +4 -6
  15. package/agents/components/bottom-sheet/bottom-sheet.spec.json +5 -5
  16. package/agents/components/bubble/bubble.md +8 -10
  17. package/agents/components/bubble/bubble.spec.json +11 -11
  18. package/agents/components/button/button.md +1 -1
  19. package/agents/components/button/check.md +9 -11
  20. package/agents/components/button/check.spec.json +8 -10
  21. package/agents/components/button/fab.md +7 -9
  22. package/agents/components/button/fab.spec.json +10 -12
  23. package/agents/components/button/group.spec.json +4 -4
  24. package/agents/components/button/icon.md +21 -23
  25. package/agents/components/button/icon.spec.json +12 -14
  26. package/agents/components/button/standard.md +40 -42
  27. package/agents/components/button/standard.spec.json +20 -22
  28. package/agents/components/button/text.md +21 -23
  29. package/agents/components/button/text.spec.json +13 -15
  30. package/agents/components/button/toggle.md +7 -9
  31. package/agents/components/button/toggle.spec.json +10 -12
  32. package/agents/components/button/toolbar.md +24 -26
  33. package/agents/components/button/toolbar.spec.json +10 -12
  34. package/agents/components/carousel/carousel.md +1 -1
  35. package/agents/components/carousel/post.md +15 -21
  36. package/agents/components/carousel/post.spec.json +17 -17
  37. package/agents/components/carousel/profile.md +9 -45
  38. package/agents/components/carousel/profile.spec.json +17 -17
  39. package/agents/components/chip/chip.md +1 -1
  40. package/agents/components/chip/filter.md +22 -24
  41. package/agents/components/chip/filter.spec.json +17 -13
  42. package/agents/components/chip/tag.md +22 -24
  43. package/agents/components/chip/tag.spec.json +19 -15
  44. package/agents/components/dialog/dialog.md +1 -3
  45. package/agents/components/dialog/dialog.spec.json +3 -3
  46. package/agents/components/directory-list/directory-list.md +1 -3
  47. package/agents/components/directory-list/directory-list.spec.json +2 -2
  48. package/agents/components/divider/divider.family.json +1 -1
  49. package/agents/components/divider/divider.md +12 -14
  50. package/agents/components/divider/divider.spec.json +8 -8
  51. package/agents/components/empty-state/empty-state.md +9 -9
  52. package/agents/components/empty-state/empty-state.spec.json +14 -14
  53. package/agents/components/feed/ad.md +2 -4
  54. package/agents/components/feed/ad.spec.json +10 -10
  55. package/agents/components/feed/post.md +41 -43
  56. package/agents/components/feed/post.spec.json +35 -39
  57. package/agents/components/form-field/form-field.md +1 -1
  58. package/agents/components/form-field/input.md +32 -34
  59. package/agents/components/form-field/input.spec.json +34 -33
  60. package/agents/components/form-field/search.md +2 -4
  61. package/agents/components/form-field/search.spec.json +19 -18
  62. package/agents/components/form-field/select.md +18 -20
  63. package/agents/components/form-field/select.spec.json +30 -29
  64. package/agents/components/form-field/textarea.md +3 -5
  65. package/agents/components/form-field/textarea.spec.json +32 -31
  66. package/agents/components/header/main.md +4 -6
  67. package/agents/components/header/main.spec.json +3 -3
  68. package/agents/components/header/sub.md +6 -8
  69. package/agents/components/header/sub.spec.json +3 -3
  70. package/agents/components/list/accordion.md +34 -45
  71. package/agents/components/list/accordion.spec.json +20 -20
  72. package/agents/components/list/entry.md +59 -81
  73. package/agents/components/list/entry.spec.json +20 -23
  74. package/agents/components/list/list.md +2 -2
  75. package/agents/components/list/radio.md +13 -20
  76. package/agents/components/list/radio.spec.json +16 -20
  77. package/agents/components/list/standard.md +50 -72
  78. package/agents/components/list/standard.spec.json +18 -21
  79. package/agents/components/metadata/compact.md +4 -6
  80. package/agents/components/metadata/compact.spec.json +6 -6
  81. package/agents/components/metadata/metadata.md +1 -1
  82. package/agents/components/metadata/standard.md +12 -14
  83. package/agents/components/metadata/standard.spec.json +10 -10
  84. package/agents/components/nav-card/nav-card.md +25 -27
  85. package/agents/components/nav-card/nav-card.spec.json +19 -19
  86. package/agents/components/nav-list/nav-list.md +2 -8
  87. package/agents/components/nav-list/nav-list.spec.json +3 -3
  88. package/agents/components/navigation-bar/main.md +9 -11
  89. package/agents/components/navigation-bar/main.spec.json +6 -6
  90. package/agents/components/navigation-bar/search.md +6 -8
  91. package/agents/components/navigation-bar/search.spec.json +9 -9
  92. package/agents/components/navigation-bar/sub.md +9 -11
  93. package/agents/components/navigation-bar/sub.spec.json +7 -7
  94. package/agents/components/pagination/pagination.family.json +1 -1
  95. package/agents/components/pagination/pagination.md +3 -3
  96. package/agents/components/pagination/pagination.spec.json +5 -5
  97. package/agents/components/profile-header/profile-header.md +9 -11
  98. package/agents/components/profile-header/profile-header.spec.json +9 -9
  99. package/agents/components/progress/progress.family.json +1 -1
  100. package/agents/components/progress/progress.md +5 -5
  101. package/agents/components/progress/progress.spec.json +8 -8
  102. package/agents/components/side-sheet/side-sheet.md +11 -13
  103. package/agents/components/side-sheet/side-sheet.spec.json +3 -3
  104. package/agents/components/skeleton/skeleton.md +7 -9
  105. package/agents/components/skeleton/skeleton.spec.json +5 -5
  106. package/agents/components/spinner/spinner.family.json +1 -1
  107. package/agents/components/spinner/spinner.md +8 -10
  108. package/agents/components/spinner/spinner.spec.json +9 -9
  109. package/agents/components/status-tag/status-tag.md +7 -9
  110. package/agents/components/status-tag/status-tag.spec.json +5 -5
  111. package/agents/components/suggestion-list/suggestion-list.md +3 -7
  112. package/agents/components/suggestion-list/suggestion-list.spec.json +8 -12
  113. package/agents/components/switch/switch.md +12 -14
  114. package/agents/components/switch/switch.spec.json +17 -18
  115. package/agents/components/tab-bar/tab-bar.md +9 -11
  116. package/agents/components/tab-bar/tab-bar.spec.json +25 -27
  117. package/agents/components/tabs/rounded.md +6 -8
  118. package/agents/components/tabs/rounded.spec.json +17 -15
  119. package/agents/components/tabs/segmented.md +4 -6
  120. package/agents/components/tabs/segmented.spec.json +4 -8
  121. package/agents/components/tabs/underline.md +9 -11
  122. package/agents/components/tabs/underline.spec.json +14 -16
  123. package/agents/components/thumbnail/thumbnail.md +5 -7
  124. package/agents/components/thumbnail/thumbnail.spec.json +8 -8
  125. package/agents/components/toast/toast.md +5 -7
  126. package/agents/components/toast/toast.spec.json +3 -3
  127. package/agents/components/tooltip/tooltip.md +6 -8
  128. package/agents/components/tooltip/tooltip.spec.json +4 -4
  129. package/agents/tokens.usage.json +71 -226
  130. package/dist/index.cjs +212 -223
  131. package/dist/index.cjs.map +1 -1
  132. package/dist/index.d.cts +16 -16
  133. package/dist/index.d.ts +16 -16
  134. package/dist/index.js +212 -223
  135. package/dist/index.js.map +1 -1
  136. package/dist/styles.css +386 -387
  137. package/eslint/rules.js +7 -7
  138. package/package.json +2 -3
  139. package/agents/anti-patterns.md +0 -533
  140. package/agents/compose.md +0 -240
  141. package/agents/images.md +0 -66
package/eslint/rules.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Custom ESLint rules that enforce Chorus design-system invariants in CONSUMER
2
- // code (the app a Lovable/AI agent generates against @teamblind-chorus/ui). These are
2
+ // code (the app an AI agent generates against @teamblind-chorus/ui). These are
3
3
  // the deterministic backstop for the four "Chorus First" failure modes that
4
4
  // prose guardrails in LOVABLE.md cannot guarantee:
5
5
  //
@@ -237,7 +237,7 @@ export const rules = {
237
237
  docs: { description: 'Disallow Tailwind color utilities in className; color comes from Chorus tokens via styles.css, not utility classes.' },
238
238
  schema: [],
239
239
  messages: {
240
- twColor: 'Tailwind color utility "{{ cls }}" — Chorus color comes from tokens, not utilities. Drop it; surfaces/text inherit from @teamblind-chorus/tokens. See anti-patterns.md.',
240
+ twColor: 'Tailwind color utility "{{ cls }}" — Chorus color comes from tokens, not utilities. Drop it; surfaces/text inherit from @teamblind-chorus/tokens.',
241
241
  },
242
242
  },
243
243
  create(context) {
@@ -266,7 +266,7 @@ export const rules = {
266
266
  docs: { description: 'Disallow raw <button>, and <a> used as an action; commits go through the @teamblind-chorus/ui Button family.' },
267
267
  schema: [],
268
268
  messages: {
269
- rawButton: 'Raw <button> — use <Button> from @teamblind-chorus/ui (variant standard/text/icon/fab). See compose.md § CTAs.',
269
+ rawButton: 'Raw <button> — use <Button> from @teamblind-chorus/ui (variant standard/text/icon/fab).',
270
270
  anchorAsButton: '<a> used as an action (onClick / no href) — use <Button variant="text" appearance="accent"> from @teamblind-chorus/ui.',
271
271
  },
272
272
  },
@@ -302,7 +302,7 @@ export const rules = {
302
302
  docs: { description: 'Disallow off-scale numeric/px spacing, typography, and radius in inline style; values must resolve to sys.layout.* / sys.radius.* / sys.typo.* tokens.' },
303
303
  schema: [],
304
304
  messages: {
305
- offScale: 'Off-scale "{{ prop }}: {{ value }}" — resolve to a Chorus token (e.g. var(--sys-layout-*), var(--sys-radius-*)). For typography use className="sys-typo-<role>-<rung>". See compose.md § raw → token map.',
305
+ offScale: 'Off-scale "{{ prop }}: {{ value }}" — resolve to a Chorus token (e.g. var(--sys-layout-*), var(--sys-radius-*)). For typography use className="sys-typo-<role>-<rung>".',
306
306
  },
307
307
  },
308
308
  create(context) {
@@ -347,7 +347,7 @@ export const rules = {
347
347
  schema: [],
348
348
  messages: {
349
349
  directPadding: '<{{ name }}> is full-bleed and owns its page-rail padding internally — drop the inline padding (paddingInline / padding / px-* / pl-* / pr-*) on it. A full-bleed child reaches its container edges; the gutter is paid once at the shell. See LOVABLE.md §★ Layout-Type & Padding Contract.',
350
- wrappedInPadding: '<{{ name }}> (full-bleed) wrapped in a padded <{{ wrapper }}> — the page rail is double-paid, so section headings, list-row leading content, and feed author blocks land at different rails. Remove the wrapper\'s inline padding (the shell pays the gutter once), or — inside a bounded surface — apply the negative-margin opt-out on <{{ name }}>. See anti-patterns.md § Rail self-diagnostic.',
350
+ wrappedInPadding: '<{{ name }}> (full-bleed) wrapped in a padded <{{ wrapper }}> — the page rail is double-paid, so section headings, list-row leading content, and feed author blocks land at different rails. Remove the wrapper\'s inline padding (the shell pays the gutter once), or — inside a bounded surface — apply the negative-margin opt-out on <{{ name }}>.',
351
351
  },
352
352
  },
353
353
  create(context) {
@@ -426,8 +426,8 @@ export const rules = {
426
426
  docs: { description: 'Flag identity headers / meta rows hand-assembled from primitives (Thumbnail + heading, or a manual middot separator) — use the ProfileHeader / Metadata / List families instead of reinventing them.' },
427
427
  schema: [],
428
428
  messages: {
429
- reinventedIdentity: 'Identity block hand-assembled from primitives (Thumbnail/Avatar + heading) — use a Chorus family: <ProfileHeader> for a profile/channel header, <Metadata> for a card-head author row, or <List variant="entry"> for an entity row. Resolve the exact usage in usage.json; never reinvent a family from token primitives. See anti-patterns.md §18.',
430
- manualMetaSeparator: 'Hand-rolled "{{ sep }}" separator between meta spans — the <Metadata> family owns this row (name · subhandle · timestamp, middot-separated). Use <Metadata> instead of assembling it from spans. See anti-patterns.md §18.',
429
+ reinventedIdentity: 'Identity block hand-assembled from primitives (Thumbnail/Avatar + heading) — use a Chorus family: <ProfileHeader> for a profile/channel header, <Metadata> for a card-head author row, or <List variant="entry"> for an entity row. Resolve the exact usage in usage.json; never reinvent a family from token primitives.',
430
+ manualMetaSeparator: 'Hand-rolled "{{ sep }}" separator between meta spans — the <Metadata> family owns this row (name · subhandle · timestamp, middot-separated). Use <Metadata> instead of assembling it from spans.',
431
431
  },
432
432
  },
433
433
  create(context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamblind-chorus/ui",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Chorus React components. Ships prebuilt ESM + CJS bundles (`dist/`) and a single `styles.css`; import `@teamblind-chorus/tokens/tokens.css` + `@teamblind-chorus/ui/styles.css` once at the app entry. The contract every component honors lives in schema/components/<family>/<sub>.spec.json; see schema/manifest.json for the inventory.",
5
5
  "license": "MIT",
6
6
  "author": "Teamblind, Inc.",
@@ -54,7 +54,6 @@
54
54
  "./agents/usage.json": "./agents/usage.json",
55
55
  "./agents/DESIGN.md": "./agents/DESIGN.md",
56
56
  "./agents/LOVABLE.md": "./agents/LOVABLE.md",
57
- "./agents/images.md": "./agents/images.md",
58
57
  "./agents/patterns/": "./agents/patterns/",
59
58
  "./agents/components/": "./agents/components/",
60
59
  "./placeholder.png": "./placeholder.png",
@@ -82,7 +81,7 @@
82
81
  "react-dom": ">=18"
83
82
  },
84
83
  "dependencies": {
85
- "@teamblind-chorus/tokens": "^1.0.1"
84
+ "@teamblind-chorus/tokens": "^2.0.0"
86
85
  },
87
86
  "publishConfig": {
88
87
  "access": "public"
@@ -1,533 +0,0 @@
1
- # anti-patterns.md — common Lovable / agent failure modes
2
-
3
- A catalogue of the wrong-shaped output Chorus consumers most often produce, paired with the right-shaped fix. Each entry names the rule, the broken snippet, and the corrected one. **Read at least once before composing a new screen** — most agent violations come from these 18 recurring shapes. Pair with [`compose.md`](compose.md) and [`tokens.usage.json`](tokens.usage.json).
4
-
5
- When in doubt: if your output matches a "❌ wrong" snippet below, discard and regenerate.
6
-
7
- ---
8
-
9
- ## 1. Brand red painted on the header chrome
10
-
11
- **Rule violated**: `tokens.usage.json#sys.color.brand.forbiddenComponents` — `navigation-bar/*`. Brand is an accent marker, never the title surface.
12
-
13
- ```jsx
14
- // ❌ Wrong — header logotype/title painted with brand color
15
- <NavigationBar
16
- variant="main"
17
- title={<img src="/logo.svg" style={{ color: 'var(--sys-color-brand)' }} />}
18
- />
19
-
20
- // ✅ Right — header chrome stays on `surface`; the wordmark paints `onSurface`
21
- <NavigationBar
22
- variant="main"
23
- title={<img src="/blind_logotype_black.svg" alt="Chorus" />}
24
- />
25
- ```
26
-
27
- Brand red is allowed on `button/fab`, `tab-bar/item--primary`, `badge`, `feed/post.like-active`, and promotional `banner` accents. **Nowhere else.** See [`tokens.usage.json#sys.color.brand`](tokens.usage.json).
28
-
29
- ---
30
-
31
- ## 2. `border:` painted on a card / list-row / feed-item / banner
32
-
33
- **Rule violated**: DESIGN.md § Border & Stroke — *no-layout strokes*. Edge strokes are inset `box-shadow` (or `::after` overlay when the surface hosts a full-bleed child). A real `border:` reflows the box on state changes.
34
-
35
- ```css
36
- /* ❌ Wrong — border reflows the card on hover / active state */
37
- .my-card {
38
- border: 1px solid var(--sys-color-outlineVariant);
39
- border-radius: var(--sys-radius-md);
40
- padding: var(--sys-layout-container-md);
41
- }
42
-
43
- /* ✅ Right — inset box-shadow paints the stroke without contributing to layout */
44
- .my-card {
45
- box-shadow: inset 0 0 0 var(--sys-borderWidth-hairline) var(--sys-color-outlineVariant);
46
- border-radius: var(--sys-radius-md);
47
- padding: var(--sys-layout-container-md);
48
- }
49
-
50
- /* ✅ Right (alt) — when the card hosts a full-bleed cover child, promote the
51
- outline to a ::after overlay so it paints above the cover image */
52
- .my-card { position: relative; border-radius: var(--sys-radius-md); }
53
- .my-card::after {
54
- content: ''; position: absolute; inset: 0; border-radius: inherit;
55
- pointer-events: none; z-index: 2;
56
- box-shadow: inset 0 0 0 var(--sys-borderWidth-hairline) var(--sys-color-outlineVariant);
57
- }
58
- ```
59
-
60
- ---
61
-
62
- ## 3. Page padding paid by the page shell *and* the child component
63
-
64
- **Rule violated**: `family.json#layoutInset: "full-bleed"` + LOVABLE.md §A.4 — *page inset is paid once at the shell*. The **full-bleed** families own their own gutter and must NEVER be wrapped in a `padding-inline` div, `className="px-*"`, or `style={{ padding }}`:
65
-
66
- `navigation-bar`, `tab-bar`, **`tabs`**, `carousel` (a.k.a. `Section`), `feed`, `list`, `nav-list`, `directory-list`, `suggestion-list`, `avatar-rail`, `profile-header`, `header` (both `<Header>` and `<SubHeader>`), `divider`.
67
-
68
- > **`Banner` and `NavCard` are `inline`, not full-bleed.** They're `width: 100%` cards whose *host* owns the horizontal inset (they don't pay their own gutter). Still never wrap them in a padded div — but place them as a direct child of the same parent the full-bleed siblings use, so all left edges share one rail.
69
-
70
- Tabs in particular trips up agents because its inner track LOOKS like it might need centering — it doesn't. Tabs pays its own inline padding internally; wrapping adds a second gutter and the underline indicator no longer aligns with the page rail.
71
-
72
- **A self-padded container insets its children — don't split content across levels.** A `Carousel` / `Section` adds its *own interior* `container.*` padding to whatever you nest inside it. So a row placed **inside** a Section sits further in than a sibling placed **directly in `<main>`** — their left edges won't line up. Pick one level for content-bearing rows: either nest them all under the same Section, or place them all direct in `<main>`. (This is the most common cause of "the card is inset but the list below it isn't.")
73
-
74
- ```jsx
75
- // ❌ Wrong — page padding paid twice (shell px-4 + child wrapper px-4)
76
- <main className="px-4">
77
- <div className="px-4">
78
- <Tabs variant="underline" value={tab} onChange={setTab}>…</Tabs>
79
- </div>
80
- <div className="px-4">
81
- <Section label="Top channels">…</Section>
82
- </div>
83
- <div style={{ padding: 16 }}>
84
- <Feed items={…} />
85
- </div>
86
- </main>
87
-
88
- // ✅ Right — shell pays padding-inline once, full-bleed children stretch
89
- <main style={{ paddingInline: 'var(--sys-layout-page-md)', display: 'flex', flexDirection: 'column', gap: 'var(--sys-layout-stack-lg)' }}>
90
- <Tabs variant="underline" value={tab} onChange={setTab}>…</Tabs>
91
- <Section label="Top channels">…</Section>
92
- <Feed items={…} />
93
- </main>
94
- ```
95
-
96
- **Mental check before JSX**: if the component is in the twelve-family list, the call is a *direct child* of `<main>`. No wrapper. If you feel the urge to add `padding` for "alignment", the page shell's `padding-inline` is what needs adjusting.
97
-
98
- After rendering, run the [§E rail self-diagnostic snippet](LOVABLE.md) — every full-bleed child should share the same `left` / `right` rail.
99
-
100
- ---
101
-
102
- ## 4. List rendered as `<div>`s with raw borders
103
-
104
- **Rule violated**: AGENTS.md hard rule #5 + List family contract — list seam is the family's own `outlineVariant` divider; rows are the click target.
105
-
106
- ```jsx
107
- // ❌ Wrong — DIY list with per-row border
108
- <div className="rounded-lg overflow-hidden border border-gray-200">
109
- <div className="flex items-center gap-3 p-4 border-b border-gray-200">
110
- <img src={c.logo} className="w-10 h-10 rounded-full" />
111
- <span className="font-medium">{c.name}</span>
112
- </div>
113
- {/* ...more rows... */}
114
- </div>
115
-
116
- // ✅ Right — List with thumbnail variant
117
- <List
118
- variant="thumbnail"
119
- items={companies.map(c => ({
120
- value: c.id,
121
- label: c.name,
122
- leading: <Thumbnail src={c.logo} alt={c.name} size={40} />,
123
- }))}
124
- />
125
- ```
126
-
127
- The List family paints its own row dividers (`outlineVariant` hairline on the inset shadow), enforces row click semantics, and respects nesting / focus / state contracts.
128
-
129
- ---
130
-
131
- ## 5. Chip / pill rendered with `radius < radius.full`
132
-
133
- **Rule violated**: `chip/*.spec.json#forbidden` — chip is always a fully-rounded pill.
134
-
135
- ```jsx
136
- // ❌ Wrong — "chip" with 8px corner — visually a card
137
- <button className="rounded-lg bg-gray-100 px-3 py-1">Remote Only</button>
138
-
139
- // ✅ Right — Chip from @teamblind-chorus/ui
140
- <Chip variant="filter" selected={false}>Remote Only</Chip>
141
- ```
142
-
143
- A 4-8px-rounded "chip" is a card with the wrong content type. Pick one component. Likewise a 999-rounded "card" reads as a chip.
144
-
145
- ---
146
-
147
- ## 6. Brand red painted on more than three slots per screen
148
-
149
- **Rule violated**: `tokens.usage.json#sys.color.brand.maxInstancesPerScreen: 3`. Brand is a marker; too many instances dilute meaning.
150
-
151
- Canonical instances on a single screen:
152
-
153
- 1. **Create entry on `tab-bar/item--primary`** — exactly 1.
154
- 2. **Active-like state on Feed items** — ≤ 2.
155
- 3. **Promotional banner accent** — 0–1 (optional).
156
-
157
- ```jsx
158
- // ❌ Wrong — brand red painted on every CTA, HOT badge, save icon, header
159
- <NavigationBar title={<span style={{ color: 'var(--sys-color-brand)' }}>Blind</span>} />
160
- <button style={{ background: 'var(--sys-color-brand)' }}>Sign up</button>
161
- <Badge style={{ background: 'var(--sys-color-brand)' }}>HOT</Badge>
162
- <button style={{ background: 'var(--sys-color-brand)' }}>Save</button>
163
- <TabBar items={[{ label: 'Create', icon: <PlusIcon />, primary: true }, …]} />
164
- // 5 brand instances — caps at 3
165
-
166
- // ✅ Right — brand reserved for editorial markers; commits use primary
167
- <NavigationBar title={<img src="/blind_logotype_black.svg" alt="Chorus" />} />
168
- <Button>Sign up</Button> {/* primary commit */}
169
- <Badge appearance="hot">HOT</Badge> {/* HOT marker — brand */}
170
- <Button variant="text" appearance="accent">Save</Button>
171
- <TabBar items={[{ label: 'Create', icon: <PlusIcon />, primary: true }, …]}/>
172
- // brand instances: 1 (HOT badge) + 1 (Create tab) = 2 ≤ 3
173
- ```
174
-
175
- ---
176
-
177
- ## 7. Typography sized below 12px for visible copy
178
-
179
- **Rule violated**: [`compose.md`](compose.md) § Type ramp picker — the smallest rung for visible copy is `sys.typo.label.sm` (12px). Below breaks Korean / CJK hierarchy and accessibility minima.
180
-
181
- ```jsx
182
- // ❌ Wrong — 11px meta line, 10px engagement count
183
- <span style={{ fontSize: 11 }}>익명 · 2시간 전</span>
184
- <span style={{ fontSize: 10 }}>2.1k views</span>
185
-
186
- // ✅ Right — meta on label.sm (12px), engagement on label.sm (12px)
187
- <span className="sys-typo-label-sm">익명 · 2시간 전</span>
188
- <span className="sys-typo-label-sm">2.1k views</span>
189
- ```
190
-
191
- Agents commonly under-size compact text to "look denser." The 12px floor is the contract — take the next-larger rung when in doubt.
192
-
193
- ---
194
-
195
- ## 8. Feed item missing the `thumbnail` slot
196
-
197
- **Rule violated**: `feed/post.spec.json#forbidden` — every feed post carries the thumbnail slot.
198
-
199
- ```jsx
200
- // ❌ Wrong — feed item with no thumbnail
201
- <Feed items={[
202
- { id: 1, title: '연봉협상 어떻게 하셨나요?', body: '...', meta: '익명 · 2시간 전' }
203
- ]} />
204
-
205
- // ✅ Right — thumbnail slot filled (real subject if inferable, else placeholder)
206
- <Feed items={[
207
- {
208
- id: 1,
209
- title: '연봉협상 어떻게 하셨나요?',
210
- body: '...',
211
- meta: '익명 · 2시간 전',
212
- thumbnail: { src: '/placeholder.png', alt: '' }, // fallback when no subject
213
- }
214
- ]} />
215
- ```
216
-
217
- Same rule for FeedAd media, ProfileCarousel cover, Thumbnail itself. When no context-appropriate image can be inferred, use `/placeholder.png` — never an icon-in-a-tinted-circle, empty `src`, or inline SVG wordmark.
218
-
219
- ---
220
-
221
- ## 9. Banner painted with `brandContainer` as the default background
222
-
223
- **Rule violated**: [`compose.md`](compose.md) § Composition guard rails #6 — banner role decides fill. Informational → `primaryContainer`; promotional → `surfaceContainerLow`; error notice → `errorContainer`. `brandContainer` is reserved for explicit *promotional* tinted strips.
224
-
225
- ```jsx
226
- // ❌ Wrong — informational banner painted with brand tint
227
- <Banner
228
- style={{ background: 'var(--sys-color-brandContainer)' }}
229
- title="이직, 더 똑똑하게"
230
- body="블라인드 하이어"
231
- />
232
-
233
- // ✅ Right — informational banner uses primaryContainer
234
- <Banner
235
- variant="informational"
236
- title="이직, 더 똑똑하게"
237
- body="블라인드 하이어"
238
- />
239
- ```
240
-
241
- The Banner variant prop picks the fill. Don't inline-style the background.
242
-
243
- ---
244
-
245
- ## 10. Surface stacked more than two tiers deep
246
-
247
- **Rule violated**: [`compose.md`](compose.md) § Composition guard rails #4 — surface tier cap is 2 per screen (`surface` + one `surface*Container` rung). A third nested tone reads as muddy.
248
-
249
- ```jsx
250
- // ❌ Wrong — surface → surfaceContainerLow → surfaceContainerHigh → surfaceContainerHighest
251
- <main style={{ background: 'var(--sys-color-surface)' }}>
252
- <section style={{ background: 'var(--sys-color-surfaceContainerLow)' }}>
253
- <div style={{ background: 'var(--sys-color-surfaceContainerHigh)' }}>
254
- <div style={{ background: 'var(--sys-color-surfaceContainerHighest)' }}>
255
- {/* four nested surface tones — visually muddy */}
256
- </div>
257
- </div>
258
- </section>
259
- </main>
260
-
261
- // ✅ Right — page surface + one container tier max; for additional grouping,
262
- // promote the inner block to a different component family (Banner, Card, Section)
263
- <main style={{ background: 'var(--sys-color-surface)' }}>
264
- <Section label="Today's top">
265
- <Banner variant="informational">…</Banner> {/* primaryContainer fill — different family */}
266
- <Feed items={…} /> {/* surface — same tier as page */}
267
- </Section>
268
- </main>
269
- ```
270
-
271
- ---
272
-
273
- ## 11. CTA rendered as raw `<button>` / `<a>` with Tailwind
274
-
275
- **Rule violated**: AGENTS.md hard rule — all interactive commits go through `@teamblind-chorus/ui` button family.
276
-
277
- ```jsx
278
- // ❌ Wrong — DIY button with Tailwind
279
- <button className="rounded-full bg-blue-500 text-white px-4 py-2">
280
- See all
281
- </button>
282
-
283
- // ✅ Right — Button family by intent
284
- <Button variant="text" appearance="accent">See all</Button>
285
- ```
286
-
287
- Link-affordance text (See all, Follow, View details) uses `Button variant="text" appearance="accent"`. Primary commits use `Button variant="standard"`. Icon-only commits use `Button variant="icon"`.
288
-
289
- ---
290
-
291
- ## 12. FormField rendered as raw `<input>` + `<label>`
292
-
293
- **Rule violated**: `form-field/*.spec.json#forbidden` — FormField is `visualReuse: "locked"`. The contract is the focus / error / helper-text state machine.
294
-
295
- ```jsx
296
- // ❌ Wrong — DIY input chrome
297
- <label>
298
- Email
299
- <input
300
- type="email"
301
- className="border rounded-lg px-3 py-2 focus:border-blue-500"
302
- />
303
- </label>
304
-
305
- // ✅ Right — FormField with the search / input / select variant
306
- <FormField
307
- variant="input"
308
- label="Email"
309
- type="email"
310
- helperText="We never share your email."
311
- />
312
- ```
313
-
314
- Same rule for search input (`variant="search"`) and select (`variant="select"`).
315
-
316
- ---
317
-
318
- ## 13. Image-area `<img src="/placeholder.png">` 404 in the consumer's public root
319
-
320
- **Rule violated**: [`LOVABLE.md`](LOVABLE.md) § A.0 — the bundled `placeholder.png` MUST be copied into the consumer app's `public/` once at setup.
321
-
322
- Every image-area scaffold (`<Thumbnail src="/placeholder.png">`, `<FeedAd media={{ src: '/placeholder.png' }}>`, `<ProfileCarousel items={[{ cover: { src: '/placeholder.png' } }]} />`, …) addresses a **root-relative URL**. Vite / Next / Remix serve from `public/` root; if the file is missing, the browser fetches `/placeholder.png` → 404 → paints a broken-image glyph **on top of** the CSS layer's data-URL background. The CSS fallback alone is not enough — the inline `<img>` is opaque over the background.
323
-
324
- **Diagnostic (run before composing):** DevTools → Network → refresh → look for 404 on `/placeholder.png`. Missing request entirely = scaffold didn't address an image slot (see #8); 404 = you skipped the §A.0 copy step.
325
-
326
- ```bash
327
- # ❌ Wrong — package installed, scaffolds reference /placeholder.png, but consumer's public/ is empty.
328
- # Result: every image-area slot paints a broken-image glyph over the CSS fallback.
329
- npm install @teamblind-chorus/ui @teamblind-chorus/tokens
330
- # (no copy step)
331
-
332
- # ✅ Right — one command after install, then every scaffold below resolves.
333
- npm install @teamblind-chorus/ui @teamblind-chorus/tokens
334
- cp node_modules/@teamblind-chorus/ui/placeholder.png public/
335
- ```
336
-
337
- Do NOT work around with an invented stock URL, inline SVG wordmark, `display: none` on the `<img>`, or pointing every `src` at the CDN-hosted PNG — those break the §A.0 contract and the AGENTS.md "one universal placeholder" rule. The fix is the one-line `cp`; scaffolds stay as-shipped.
338
-
339
- **Placeholder is the honest default, not the finish line.** When the composition implies a clear, brand-safe subject (an anonymous avatar, a generic cover, a topic thumbnail), the right move is to *swap* the placeholder for a real subject image — see [`images.md`](images.md) for the generate-vs-placeholder decision tree. That sanctioned path (generate with your image tool → upload via your asset pipeline → store the URL in the data field) is a **self-hosted asset the app owns** and is explicitly NOT the forbidden "invented stock URL" above (an unowned, breakable link to someone else's image). Never synthesize a *real* company's logo or a real person's face — that's a placeholder + TODO, not a generation. And whatever stays on placeholder must be **reported** in the compose summary (`images.md` § handoff), never left silent so it reads as finished.
340
-
341
- ---
342
-
343
- ## 14. Custom primitive built with raw numeric literals
344
-
345
- **Rule violated**: [LOVABLE.md](LOVABLE.md) §B.3 *New surfaces stay token-true* + §C *Token strictness*. This is the **most common token-axis drift** and the only one where the component decision is correct — going off-Chorus is allowed, going off-tokens is not.
346
-
347
- The cognitive trap: *"No Chorus family fits a hint card / inline annotation / small aside → I'll build a custom div. Since I'm off-Chorus anyway, a few raw values (`fontSize: 13`, `padding: '10px 12px'`, `gap: 6`) are fine."* They are not. Composition went custom; values stayed bound. **Component flexible, tokens never** — with no Chorus spec denying you, every literal is a deliberate choice that's either a token resolution or a violation.
348
-
349
- ```jsx
350
- // ❌ Wrong — correct call to go custom, but every value is a raw literal
351
- <div
352
- style={{
353
- display: "flex",
354
- flexDirection: "column",
355
- gap: 6,
356
- padding: "10px 12px",
357
- borderRadius: 6,
358
- background: "var(--sys-color-surfaceContainerLow)", // color happens to be a token…
359
- }}
360
- >
361
- <span style={{ fontSize: 13, lineHeight: 1.4 }}>익명 힌트</span>
362
- <span style={{ fontSize: 14, fontWeight: 600 }}>제목</span>
363
- </div>
364
- // gap 6, padding 10/12, radius 6, fontSize 13/14, lineHeight 1.4 — five literals,
365
- // five violations. "I used the color token" is not a partial pass.
366
-
367
- // ✅ Right — composition still custom; every axis resolves through a token
368
- <div
369
- style={{
370
- display: "flex",
371
- flexDirection: "column",
372
- gap: "var(--sys-layout-inline-xs)", // 4 — was `gap: 6`
373
- padding: "var(--sys-layout-container-xs) var(--sys-layout-container-sm)", // 8 12 — was `10px 12px`
374
- borderRadius: "var(--sys-radius-sm)", // 4 — was `borderRadius: 6`
375
- background: "var(--sys-color-surfaceContainerLow)",
376
- }}
377
- >
378
- <span className="sys-typo-body-md">익명 힌트</span>
379
- {/* the utility class bundles family + size + weight + line + tracking — never set lineHeight separately */}
380
- <span className="sys-typo-label-lg">제목</span>
381
- </div>
382
- ```
383
-
384
- **Off-scale = pick the next ladder rung, NOT halfway.** 13px is forbidden; 12 or 14 are tokens. 6px gap is forbidden; 4 or 8 are tokens. If no token feels right, that's a Chorus gap report ("`spacing.inline.xs` reads too tight, `inline.sm` too loose for this slot — proposing a new rung") — not a license to invent. See the full raw → token map in [compose.md § When you go custom](compose.md).
385
-
386
- Side note: this entry catches the values. Whether you should have gone custom at all is a separate question — the visual-reuse table in [LOVABLE.md §C](LOVABLE.md) covers the `"open"` families that you can borrow on visual-fit grounds (`<Feed>` as a generic article-card surface, `<Section>` as any labelled block). Reach for that first; #14 only fires after you've genuinely exhausted the LEGO ladder.
387
-
388
- ---
389
-
390
- ## 15. Tabs (or any compound family) given bare text children
391
-
392
- **Rule violated**: `tabs/*.spec.json` — Tabs is a compound component. Every label MUST be a `<Tab value=…>` child. Bare text / string children render as one run-together, unstyled text line: no tab buttons, no selected state, no sliding indicator (the `.chorus-tab` / `.chorus-tab__label` rules have nothing to match).
393
-
394
- ```jsx
395
- // ❌ Wrong — labels as bare text; the whole row collapses to run-together text
396
- <Tabs variant="underline" value={tab} onChange={setTab}>
397
- 직무 탐색
398
- 준비 시작
399
- 면접 대비
400
- 회사 선택
401
- </Tabs>
402
-
403
- // ✅ Right — each label wrapped in its <Tab value=…> element
404
- <Tabs variant="underline" value={tab} onChange={setTab}>
405
- <Tab value="explore">직무 탐색</Tab>
406
- <Tab value="prepare">준비 시작</Tab>
407
- <Tab value="interview">면접 대비</Tab>
408
- <Tab value="company">회사 선택</Tab>
409
- </Tabs>
410
- ```
411
-
412
- `<Tabs>` only lays out the track and animates the indicator; the per-tab `<button>`, label span, selected state, and click target all live in `<Tab>`. The same compound-child contract holds for every family whose `spec.json` names a required item element — **read the spec; if it lists a child element or an `items` array, bare text is a violation.** (`List`/`SuggestionList`/`AvatarRail` take an `items` descriptor array, not text children.)
413
-
414
- ---
415
-
416
- ## 16. Floating action built as a positioned `standard` Button
417
-
418
- **Rule violated**: [`catalog.md`](catalog.md) "canonical floating commit → `button / fab`" + LOVABLE.md §C. A floating primary action is `<Button variant="fab">`, **never** a `standard` / `text` Button bolted into place with `position: fixed` / `absolute`.
419
-
420
- ```jsx
421
- // ❌ Wrong — standard Button manually floated bottom-right
422
- <Button
423
- variant="standard"
424
- appearance="primary"
425
- style={{ position: 'fixed', right: 16, bottom: 16 }}
426
- leadingIcon={<EditIcon />}
427
- >
428
- 글쓰기
429
- </Button>
430
-
431
- // ✅ Right — FAB owns the floating geometry, elevation, brand fill, and safe-area offset
432
- <Button variant="fab" appearance="primary" icon={<EditIcon />}>
433
- 글쓰기
434
- </Button>
435
- ```
436
-
437
- "Floating" is a component **role** in Chorus, not a CSS position you attach to a standard Button. The FAB carries the pill geometry, floating elevation, the brand-allowlisted fill, and the bottom / safe-area offset — none of which a positioned `standard` Button gets. The **extended** form (icon + label, as shown) is supported: pass both `icon` and children. Icon-only drops the children. ≤ 1 FAB per screen.
438
-
439
- ---
440
-
441
- ## 17. Full-bleed component wrapped in a padded `<div>` or an external `<Link>`
442
-
443
- **Rule violated**: family `layoutInset="full-bleed"` + `feed/post.spec.json` § forbidden. `Feed`, `Section` / `Carousel`, `NavigationBar`, `TabBar`, `Tabs`, the `*List` families — they **own their page-rail inline padding internally** and reach their container's edges. Two habits break this:
444
-
445
- ```jsx
446
- // ❌ Wrong — extra padding wrapper double-pays the page rail
447
- <div style={{ paddingInline: 'var(--sys-layout-page-md)' }}>
448
- <Feed {...post} />
449
- </div>
450
- // ❌ Wrong — external <Link> to make the card navigable adds a gutter AND
451
- // nests the card's own anchors (channel link / citation / mention)
452
- <Link href={`/post/${post.id}`}>
453
- <Feed {...post} />
454
- </Link>
455
-
456
- // ✅ Right — full-bleed sits directly in the zero-padding shell…
457
- <main>
458
- <Feed {...post} onClick={() => router.push(`/post/${post.id}`)} />
459
- </main>
460
- ```
461
-
462
- The rail is paid **once** — at the page shell for inline content, and internally by each full-bleed component. A wrapper that re-adds `padding-inline` (a `<div>`, `className="px-*"`, or `style={{ padding }}`) means section headings, list-row leading content, and feed author blocks land at **different left rails** — the misalignment the runtime `useFullBleedGuard` warns about. To navigate, use the component's **own** `onClick` (Feed and the Carousel cards expose it) — never an outer `<Link>`/`<a>`, which both re-pays the gutter and nests interactive elements. Inside a bounded surface (Dialog / BottomSheet / Card) where the host legitimately pads, opt the child out with the negative-margin idiom (`marginInline: calc(-1 * var(--sys-layout-container-md))`), don't strip the host.
463
-
464
- ---
465
-
466
- ## 18. Reinventing a family by assembling token primitives
467
-
468
- **Rule violated**: LOVABLE.md "compose from families, never reinvent" + DESIGN.md "never reinvent a family with primitives". The drift: hand-building an identity header, an author meta row, or an action row out of `<Thumbnail>` + `<h1>` + `<p>` + `<Button>` in a flex, when a family already owns that exact anatomy. **Tokens being correct does not make it Chorus** — a primitive cluster that matches a family's shape IS that family, and must be the family.
469
-
470
- ```jsx
471
- // ❌ Wrong — a channel/profile identity header reinvented from primitives
472
- <header style={{ display: 'flex', gap: '...' }}>
473
- <Thumbnail src={ch.avatar} />
474
- <div>
475
- <h1 className="sys-typo-title-md">{ch.name}</h1>
476
- <p className="sys-typo-label-sm">공개 · 팔로우 {ch.followers}</p>
477
- </div>
478
- <Button variant="standard">언팔로우</Button>
479
- </header>
480
- // ❌ Wrong — author line with a hand-rolled middot separator
481
- <div className="flex">
482
- <span>{handle}</span> · <span>{sub}</span> · <span>{time}</span>
483
- </div>
484
-
485
- // ✅ Right — the families that own these anatomies
486
- <ProfileHeader name={ch.name} avatar={ch.avatar} meta="공개 · 팔로우 4,218"
487
- trailing={<Button variant="text">언팔로우</Button>} />
488
- <Metadata name={handle} subhandle={sub} timestamp={time} />
489
- ```
490
-
491
- The catalog maps these by intent: profile/channel identity header → `profile-header`; author attribution at a card head → `metadata`; an entity/action row → `list / entry` (an `items` array, **not** a `<ListItem>`); a labelled chip row → `chip` (or `tabs` with `<Tab>` children). **The trigger for this drift is a translation miss, not a missing component**: an `import { Fab }` (or `import { ListItem }`) fails because that access pattern is wrong — Fab is `<Button variant="fab">`, list rows are an `items` array — and the agent over-generalizes "the family is missing at runtime" into "the catalog can't be trusted," then escapes to primitives. **One import miss never licenses primitive assembly for any other component.** Resolve the family in [`usage.json`](usage.json) (the parity-checked family→usage map — it confirms the export exists and spells out named-import vs `variant=` vs `items=` access), then use the family. If a family genuinely looks unexported, that's a build-failing parity bug to report — not a cue to reinvent.
492
-
493
- ---
494
-
495
- ## When you spot one of these
496
-
497
- 1. Stop composing.
498
- 2. Re-read the rule cited in the entry.
499
- 3. Discard the offending block and regenerate from the right-side snippet.
500
- 4. Run the LOVABLE.md §E pre-flight checklist + §E rail self-diagnostic.
501
-
502
- A violation surviving both passes is a Chorus gap report, not a permission slip — flag in one line and stop.
503
-
504
- ---
505
-
506
- ## Rail self-diagnostic
507
-
508
- Visual alignment contracts are checkable. After rendering, paste this into the dev-preview browser console — it measures every full-bleed child's actual left/right edge and fails loudly if they disagree by >1px. Run it with `<Dialog>` / `<BottomSheet>` open *and* closed (full-bleed children inside a surface must share the surface's inner rail). **Misalignment → discard + regenerate.** Cited by [LOVABLE.md](LOVABLE.md) § Layout-Type & Padding Contract and § E pre-flight.
509
-
510
- ```js
511
- (() => {
512
- const sels = [
513
- '.chorus-navigation-bar', '.chorus-tab-bar', '.chorus-tabs',
514
- '.chorus-carousel', '.chorus-feed', '.chorus-feed-ad',
515
- '.chorus-list', '.chorus-suggestion-list', '.chorus-directory-list',
516
- '.chorus-nav-list', '.chorus-avatar-rail',
517
- ];
518
- const rows = sels.flatMap(sel =>
519
- [...document.querySelectorAll(sel)].map(el => {
520
- const r = el.getBoundingClientRect();
521
- return { sel, left: Math.round(r.left), right: Math.round(window.innerWidth - r.right) };
522
- })
523
- );
524
- if (!rows.length) { console.log('No full-bleed children on this page.'); return; }
525
- const L = new Set(rows.map(r => r.left)), R = new Set(rows.map(r => r.right));
526
- console.table(rows);
527
- if (L.size > 1 || R.size > 1) {
528
- console.error(`❌ Rail misalignment — left: [${[...L].join(', ')}], right: [${[...R].join(', ')}]. Every full-bleed child should share one rail. Fix per LOVABLE.md § Layout-Type & Padding Contract.`);
529
- } else {
530
- console.log(`✅ Rail aligned — left=${[...L][0]}px, right=${[...R][0]}px.`);
531
- }
532
- })();
533
- ```