@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
@@ -30,7 +30,7 @@
30
30
  "slots": {
31
31
  "container": {
32
32
  "required": true,
33
- "description": "Outer scroll surface. Vertical stack with a transparent fill (inherits parent container tone); rows separated by a 1px outlineVariant divider, not a gap."
33
+ "description": "Outer scroll surface. Vertical stack with a transparent fill (inherits parent container tone); rows separated by a 1px border.default divider, not a gap."
34
34
  },
35
35
  "row": {
36
36
  "required": true,
@@ -114,17 +114,17 @@
114
114
  "trailingActionGap": "sys.layout.inline.md",
115
115
  "trailingActionGapNote": "Fixed 8px between the text group and a trailing nav chevron (the major-category `nav: true` case) — the family-wide trailing gap.",
116
116
  "dividerWidth": "sys.borderWidth.hairline",
117
- "dividerColor": "sys.color.outlineVariant",
117
+ "dividerColor": "sys.color.border.default",
118
118
  "labelTypo": "sys.typo.body.md",
119
- "labelColor": "sys.color.onSurface",
119
+ "labelColor": "sys.color.text.default",
120
120
  "supportingTypo": "sys.typo.body.sm",
121
- "supportingColor": "sys.color.onSurfaceVariant",
121
+ "supportingColor": "sys.color.text.subtle",
122
122
  "supportingOffset": "0",
123
123
  "leadingRadioSize": "24 × 24",
124
- "leadingRadioColorRest": "sys.color.outline",
125
- "leadingRadioColorSelected": "sys.color.primary",
124
+ "leadingRadioColorRest": "sys.color.border.boldest",
125
+ "leadingRadioColorSelected": "sys.color.background.primary",
126
126
  "navChevronSize": "16 × 16",
127
- "navChevronColor": "sys.color.onSurfaceVariant"
127
+ "navChevronColor": "sys.color.text.subtle"
128
128
  },
129
129
  "states": {
130
130
  "default": {
@@ -152,11 +152,11 @@
152
152
  "layer": "::after/::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
153
153
  "innerCounterRing": {
154
154
  "width": "sys.borderWidth.hairline",
155
- "color": "sys.color.focusInset"
155
+ "color": "sys.color.border.focused"
156
156
  },
157
157
  "outerRing": {
158
158
  "width": "sys.borderWidth.thin",
159
- "color": "sys.color.focus"
159
+ "color": "sys.color.border.focused"
160
160
  }
161
161
  },
162
162
  "note": "Keyboard-focus (:focus-visible) visual. Mirrors the `focusIndicator` block (the external-reader contract); kept here so spec-only renderers see focus in the states map. Composes over the lifecycle state the row is in; never via plain mouse click."
@@ -165,27 +165,23 @@
165
165
  "leading": "Filled primary indicator; row foreground stays at onSurface. No fill change on the row itself."
166
166
  },
167
167
  "disabled": {
168
- "containerOpacity": "sys.state.disabled",
169
- "containerOpacityScope": "Dims the row content only — the inter-row divider and the focus overlay keep full opacity, so a disabled row never fades the hairline rule between it and the next row.",
168
+ "text": "sys.color.text.disabled",
169
+ "icon": "sys.color.icon.disabled",
170
170
  "pointerEvents": "none",
171
- "note": "Radio indicator dims with the row."
171
+ "note": "Explicit disabled (no opacity): row text to text.disabled, radio indicator + icons to icon.disabled. Divider/focus overlay unaffected."
172
172
  }
173
173
  },
174
174
  "focusIndicator": {
175
175
  "description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. The ring sits on the row, not on the leading indicator — the row is the keyboard target.",
176
176
  "composition": "inward",
177
- "compositionReason": "Rows tile the column flush with only a hairline `outlineVariant` divider between them; an outward ring would overlap the divider and the neighbouring row.",
177
+ "compositionReason": "Rows tile the column flush with only a hairline `border.default` divider between them; an outward ring would overlap the divider and the neighbouring row.",
178
178
  "overlay": {
179
179
  "color": "label",
180
180
  "opacity": "sys.state.focus"
181
181
  },
182
182
  "ring": {
183
- "outerWidth": "sys.borderWidth.thin",
184
- "outerColor": "sys.color.focus",
185
- "outerLayerPosition": "depth 0..2px from the row edge (the outer stroke)",
186
- "insetWidth": "sys.borderWidth.hairline",
187
- "insetColor": "sys.color.focusInset",
188
- "insetLayerPosition": "depth 2..3px from the row edge (the counter-ring just inside the outer stroke)",
183
+ "width": "sys.borderWidth.hairline",
184
+ "color": "sys.color.border.focused",
189
185
  "implementation": "inset box-shadow on the row's `::before` overlay (the `::after` carries the inter-row divider). Constrained strictly inside the row's footprint and never exceeds it."
190
186
  },
191
187
  "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
@@ -200,6 +196,6 @@
200
196
  "forbidden": [
201
197
  "radio glyph as a separate hit area — the entire row is the click target",
202
198
  "multi-select painted as radio — radio variant is single-select; use checkbox or chip/filter for multi-select",
203
- "selected state painted with sys.color.primaryContainer fill — radio selected paints the inner dot, not the row fill"
199
+ "selected state painted with sys.color.background.selected fill — radio selected paints the inner dot, not the row fill"
204
200
  ]
205
201
  }
@@ -10,7 +10,7 @@ A row opts into an inline `count` Badge to the right of the label (4px / `sys.la
10
10
 
11
11
  ## Default
12
12
 
13
- A plain text listfive menu rows with optional supporting text.
13
+ A single text rowthe atomic List component. A row is a `value` + `label`, with an optional `supportingText` second line. The whole row is the click target.
14
14
 
15
15
  ```preview
16
16
  list/standard
@@ -19,18 +19,12 @@ import { List } from '@teamblind-chorus/ui';
19
19
 
20
20
  <List
21
21
  items={[
22
- { value: 'profile', label: 'Profile', supportingText: 'Name, photo, bio' },
23
- { value: 'notif', label: 'Notifications', supportingText: 'Email, push, in-app' },
24
- { value: 'privacy', label: 'Privacy', supportingText: 'Who can see your activity' },
25
- { value: 'language', label: 'Language' },
26
- { value: 'about', label: 'About' },
22
+ { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio' },
27
23
  ]}
28
24
  />
29
25
  ```
30
26
 
31
- ## Use cases
32
-
33
- ### With trailing action
27
+ ## Trailing action
34
28
 
35
29
  A Text Button in the row's `trailingIcon` slot turns a display row into row + action — the row label stays informational (no `onClick` on the row), the trailing button is the only commit target. Reach for it on settings rows that pair a value with a small "change / edit / view" action.
36
30
 
@@ -52,21 +46,11 @@ import { Button, List } from '@teamblind-chorus/ui';
52
46
  </Button>
53
47
  ),
54
48
  },
55
- {
56
- value: 'sms',
57
- label: 'SMS',
58
- supportingText: '+1 (415) ***-2487',
59
- trailingIcon: (
60
- <Button variant="text" size="small" appearance="accent" onClick={() => {}}>
61
- Edit
62
- </Button>
63
- ),
64
- },
65
49
  ]}
66
50
  />
67
51
  ```
68
52
 
69
- ### With an inline count
53
+ ## Inline count
70
54
 
71
55
  Pass a `count` on a row and a Badge renders to the right of the label on the same line, separated by `4px` (`sys.layout.inline.sm`) — the unread / status-count case. The label shrinks first so a long label truncates against the count, which stays pinned at its intrinsic width. Unlike `trailingIcon`, the count tiles tight to the label inside the text group, so it **composes with the drill-in chevron**: a `nav: true` row carries the count by the label *and* keeps its trailing chevron — one row with both an icon and a badge.
72
56
 
@@ -77,16 +61,12 @@ import { List, Badge } from '@teamblind-chorus/ui';
77
61
 
78
62
  <List
79
63
  items={[
80
- { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio', nav: true },
81
- { value: 'channels', label: 'My channels', supportingText: '12 joined · 3 muted', nav: true },
82
- { value: 'notif', label: 'Notifications', count: <Badge>3</Badge>, nav: true },
83
- { value: 'privacy', label: 'Privacy', nav: true },
84
- { value: 'account', label: 'Account', nav: true },
64
+ { value: 'notif', label: 'Notifications', count: <Badge>3</Badge>, nav: true },
85
65
  ]}
86
66
  />
87
67
  ```
88
68
 
89
- ### Drill-in rows
69
+ ## Drill-in
90
70
 
91
71
  Set `nav: true` on a row to auto-render a trailing right-pointing chevron — the drill-in affordance signalling the row routes to another surface. The whole row is the click target; the chevron is decorative. This is the canonical settings / menu navigation shape (it replaces the former `nav` variant). A per-item `trailingIcon` overrides the chevron for that row.
92
72
 
@@ -97,16 +77,12 @@ import { List } from '@teamblind-chorus/ui';
97
77
 
98
78
  <List
99
79
  items={[
100
- { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio', nav: true },
101
- { value: 'channels', label: 'My channels', supportingText: '12 joined · 3 muted', nav: true },
102
- { value: 'notif', label: 'Notifications', nav: true },
103
- { value: 'privacy', label: 'Privacy', nav: true },
104
- { value: 'account', label: 'Account', nav: true },
80
+ { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio', nav: true },
105
81
  ]}
106
82
  />
107
83
  ```
108
84
 
109
- ### With a leading icon
85
+ ## Leading icon
110
86
 
111
87
  Pass an `icon` on a row and a 24px (`sys.icon.lg`) glyph renders at the leading edge in `onSurfaceVariant`, `8px` (`sys.layout.inline.md`) from the text group — the category-mark shape for settings / menu rows, lighter than a 40px leading image (and mutually exclusive with `thumbnail`). The glyph is decorative (the label carries the meaning) and the slot enforces the 24 rung regardless of the glyph's own `size`. Every other slot stays optional, so the icon composes with `supportingText`, an inline `count`, and the drill-in chevron.
112
88
 
@@ -114,19 +90,16 @@ Pass an `icon` on a row and a 24px (`sys.icon.lg`) glyph renders at the leading
114
90
  list/standard-icon-leading
115
91
  ---
116
92
  import { List, Badge } from '@teamblind-chorus/ui';
117
- import { ProfileIcon, BellIcon, BookmarkIcon, PulseIcon } from '@teamblind-chorus/ui/icons';
93
+ import { BellIcon } from '@teamblind-chorus/ui/icons';
118
94
 
119
95
  <List
120
96
  items={[
121
- { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio', icon: <ProfileIcon />, nav: true },
122
- { value: 'notif', label: 'Notifications', icon: <BellIcon />, count: <Badge>3</Badge>, nav: true },
123
- { value: 'saved', label: 'Saved', icon: <BookmarkIcon />, nav: true },
124
- { value: 'activity', label: 'Activity', icon: <PulseIcon />, nav: true },
97
+ { value: 'notif', label: 'Notifications', icon: <BellIcon />, count: <Badge>3</Badge>, nav: true },
125
98
  ]}
126
99
  />
127
100
  ```
128
101
 
129
- ### Leading image
102
+ ## Leading image
130
103
 
131
104
  Pass a `thumbnail` on a row and a 40px [Thumbnail](../thumbnail/thumbnail.md) renders at the leading edge, vertically centred against the label column. `thumbnail` props (`src`, `alt`, `updateDot`, `logoBadge`) forward verbatim. The gap to the text group steps up to `12px` (`sys.layout.inline.lg`) so the avatar and the label column read as two distinct blocks. This is the channel / source / author row shape — same click semantics as a text row, no selection model.
132
105
 
@@ -137,14 +110,12 @@ import { List } from '@teamblind-chorus/ui';
137
110
 
138
111
  <List
139
112
  items={[
140
- { value: 'design-weekly', label: 'Design Weekly', supportingText: 'Updated 2h ago', thumbnail: { alt: 'Design Weekly' } },
141
- { value: 'frontend', label: 'Frontend Friday', supportingText: 'Updated 1d ago', thumbnail: { alt: 'Frontend Friday' } },
142
- { value: 'changelog', label: 'Changelog', supportingText: 'Updated 3d ago', thumbnail: { alt: 'Changelog' } },
113
+ { value: 'sourdough', label: 'Sourdough Bakers', supportingText: '3 new posts today', thumbnail: { alt: 'Sourdough Bakers' } },
143
114
  ]}
144
115
  />
145
116
  ```
146
117
 
147
- ### Leading image with trailing action
118
+ ## Leading image + trailing action
148
119
 
149
120
  A Text Button in the row's `trailingIcon` slot — the canonical "directory row + small commit" composition. Reach for it on follow / join / invite rows where the leading Thumbnail anchors the entity and the trailing button is the only commit. Row body stays informational.
150
121
 
@@ -167,22 +138,11 @@ import { Button, List } from '@teamblind-chorus/ui';
167
138
  </Button>
168
139
  ),
169
140
  },
170
- {
171
- value: 'frontend',
172
- label: 'Frontend',
173
- supportingText: '892 colleagues following',
174
- thumbnail: { alt: 'Frontend' },
175
- trailingIcon: (
176
- <Button variant="text" size="small" appearance="accent" onClick={() => {}}>
177
- Follow
178
- </Button>
179
- ),
180
- },
181
141
  ]}
182
142
  />
183
143
  ```
184
144
 
185
- ### Leading image drill-in rows
145
+ ## Leading image + drill-in
186
146
 
187
147
  Set `nav: true` on a leading-image row for an avatar-anchored row that routes to another surface (channel → channel detail, person → profile). The whole row is the click target; the chevron is decorative. A per-item `trailingIcon` overrides the chevron.
188
148
 
@@ -193,14 +153,12 @@ import { List } from '@teamblind-chorus/ui';
193
153
 
194
154
  <List
195
155
  items={[
196
- { value: 'design-weekly', label: 'Design Weekly', supportingText: '2.3k members', thumbnail: { alt: 'Design Weekly' }, nav: true },
197
- { value: 'frontend', label: 'Frontend Friday', supportingText: '1.1k members', thumbnail: { alt: 'Frontend Friday' }, nav: true },
198
- { value: 'changelog', label: 'Changelog', supportingText: '840 members', thumbnail: { alt: 'Changelog' }, nav: true },
156
+ { value: 'design-weekly', label: 'Design Weekly', supportingText: '2.3k members', thumbnail: { alt: 'Design Weekly' }, nav: true },
199
157
  ]}
200
158
  />
201
159
  ```
202
160
 
203
- ### Leading image without divider
161
+ ## Leading image, no divider
204
162
 
205
163
  `divider: false` on a row suppresses its bottom hairline rule — useful when a visual group ends mid-stack and the divider would visually fence off the next group from its label. The row's footprint and inline padding stay unchanged.
206
164
 
@@ -218,17 +176,17 @@ import { List } from '@teamblind-chorus/ui';
218
176
  />
219
177
  ```
220
178
 
221
- ### With an embedded Banner
179
+ ## Embedded Banner
222
180
 
223
181
  Pass a `banner` on a row and a [Banner](../banner/banner.md) renders **below** the row's text group, `8px` (`sys.layout.stack.xs`) down, spanning the row's full content width (aligned to the same 16px inline inset as the label above it). The row flips from a single line to a vertical stack — text group on top, Banner underneath. Reach for it when a row needs an in-row call-out tied to *that row's* subject (a follow-up prompt, a capability nudge, a single-line CTA), rather than a separate full-width Banner detached from the row.
224
182
 
225
- The Banner keeps its full prop surface — here `appearance="accent"` + `neutralBody` for the quiet-tint shape, a blue `CheckCircleFillIcon` leading the single-line body, and a `trailingAction` Text Button with a trailing chevron. The Banner is a nested-action region: its button never commits the row, and the row's hover / pressed overlay is suppressed over it. The rows lead with fill-type category glyphs (`BriefcaseFillIcon`, `FlagFillIcon`) that the leading slot tones to `onSurfaceVariant`.
183
+ The Banner keeps its full prop surface — here `appearance="accent"` + `neutralBody` for the quiet-tint shape, a blue `CheckCircleFillIcon` leading the single-line body, and a `trailingAction` Text Button with a trailing chevron. The Banner is a nested-action region: its button never commits the row, and the row's hover / pressed overlay is suppressed over it. The row leads with a fill-type category glyph (`BriefcaseFillIcon`) that the leading slot tones to `onSurfaceVariant`.
226
184
 
227
185
  ```preview
228
186
  list/standard-embedded-banner
229
187
  ---
230
188
  import { Banner, Button, List } from '@teamblind-chorus/ui';
231
- import { BriefcaseFillIcon, CheckCircleFillIcon, ChevronRightIcon, FlagFillIcon } from '@teamblind-chorus/ui/icons';
189
+ import { BriefcaseFillIcon, CheckCircleFillIcon, ChevronRightIcon } from '@teamblind-chorus/ui/icons';
232
190
 
233
191
  <List
234
192
  aria-label="Career"
@@ -242,7 +200,7 @@ import { BriefcaseFillIcon, CheckCircleFillIcon, ChevronRightIcon, FlagFillIcon
242
200
  <Banner
243
201
  appearance="accent"
244
202
  neutralBody
245
- icon={<CheckCircleFillIcon size={16} style={{ color: 'var(--sys-color-primary)' }} />}
203
+ icon={<CheckCircleFillIcon size={16} style={{ color: 'var(--sys-color-background-primary)' }} />}
246
204
  trailingAction={(
247
205
  <Button variant="text" appearance="accent" size="small" trailingIcon={<ChevronRightIcon />}>
248
206
  Expert Q&A
@@ -253,11 +211,35 @@ import { BriefcaseFillIcon, CheckCircleFillIcon, ChevronRightIcon, FlagFillIcon
253
211
  </Banner>
254
212
  ),
255
213
  },
214
+ ]}
215
+ />
216
+ ```
217
+
218
+ ## Group
219
+
220
+ Several rows bundled into one `<List>`. Every row carries a leading icon and reaches for a different per-row variant — a drill-in row, a row with an inline `count` + drill-in chevron, a row with a trailing action — so the group reads as the realistic composition the single-variant sections below each isolate one facet of. Rows tile with a hairline divider between them; the last row drops its divider automatically (no `divider: false` needed).
221
+
222
+ ```preview
223
+ list/standard-group
224
+ ---
225
+ import { Badge, Button, List } from '@teamblind-chorus/ui';
226
+ import { BellIcon, InvitationIcon, ProfileIcon } from '@teamblind-chorus/ui/icons';
227
+
228
+ <List
229
+ aria-label="Account"
230
+ items={[
231
+ { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio', icon: <ProfileIcon />, nav: true },
232
+ { value: 'notif', label: 'Notifications', icon: <BellIcon />, count: <Badge>3</Badge>, nav: true },
256
233
  {
257
- value: 'roadmap',
258
- label: 'View career roadmap',
259
- icon: <FlagFillIcon />,
260
- nav: true,
234
+ value: 'email',
235
+ label: 'Email',
236
+ supportingText: 'work@example.com',
237
+ icon: <InvitationIcon />,
238
+ trailingIcon: (
239
+ <Button variant="text" size="small" appearance="accent" onClick={() => {}}>
240
+ Edit
241
+ </Button>
242
+ ),
261
243
  },
262
244
  ]}
263
245
  />
@@ -284,7 +266,7 @@ No `selected` state — selection belongs to the [Radio sub](./radio.md).
284
266
 
285
267
  ## Focus indicator
286
268
 
287
- Inward 3-layer ring inside the row's bounds — see the family-wide [Focus indicator](./list.md#cross-sub-contract). The preview pins **My channels** to its focused state via `forcedState: 'focused'` for static inspection.
269
+ Inward single ring inside the row's bounds — see the family-wide [Focus indicator](./list.md#cross-sub-contract). The preview pins **My channels** to its focused state via `forcedState: 'focused'` for static inspection.
288
270
 
289
271
  ```preview
290
272
  list/focus-indicator
@@ -293,11 +275,7 @@ import { List } from '@teamblind-chorus/ui';
293
275
 
294
276
  <List
295
277
  items={[
296
- { value: 'profile', label: 'Profile', supportingText: 'Display name, avatar, bio', nav: true },
297
- { value: 'channels', label: 'My channels', supportingText: '12 joined · 3 muted', nav: true, forcedState: 'focused' },
298
- { value: 'notif', label: 'Notifications', nav: true },
299
- { value: 'privacy', label: 'Privacy', nav: true },
300
- { value: 'account', label: 'Account', nav: true },
278
+ { value: 'channels', label: 'My channels', supportingText: '12 joined · 3 muted', nav: true, forcedState: 'focused' },
301
279
  ]}
302
280
  />
303
281
  ```
@@ -20,7 +20,7 @@
20
20
  "slots": {
21
21
  "container": {
22
22
  "required": true,
23
- "description": "Outer scroll surface. Vertical stack with a transparent fill (inherits parent container tone); rows separated by a 1px outlineVariant divider, not a gap."
23
+ "description": "Outer scroll surface. Vertical stack with a transparent fill (inherits parent container tone); rows separated by a 1px border.default divider, not a gap."
24
24
  },
25
25
  "row": {
26
26
  "required": true,
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "leading": {
30
30
  "required": false,
31
- "fallbackOnMissingSrc": "sys.color.surfaceContainerHigh",
31
+ "fallbackOnMissingSrc": "sys.color.surface.sunken",
32
32
  "description": "The leading slot — omitted by default (no reserved leading whitespace), hosting one of two mutually exclusive types. **Image type** (`thumbnail`): a [Thumbnail](../thumbnail/thumbnail.md) at the 40 rung, vertically centred against the label column, with a 12px (`sys.layout.inline.lg`) gap to the text group; `thumbnail` props (`src`, `alt`, `updateDot`, `logoBadge`) forward verbatim, and `fallbackOnMissingSrc` is the dim-tone fill it paints when `src` is empty / fails to load (at scaffold time, agents fill `src` with `/placeholder.png` rather than relying on the fallback). **Icon type** (`icon`): a 24px (`sys.icon.lg`) decorative glyph in `onSurfaceVariant`, vertically centred, with an 8px (`sys.layout.inline.md`) gap to the text group — the category-mark shape for settings / menu rows. The slot enforces the 24 rung regardless of the glyph's own `size`. A row passes `thumbnail` or `icon`, never both.",
33
33
  "accepts": [
34
34
  "thumbnail",
@@ -117,7 +117,7 @@
117
117
  "divider": {
118
118
  "type": "boolean",
119
119
  "default": true,
120
- "description": "Per-row bottom-divider opt-out. Pass `divider: false` to suppress the hairline `outlineVariant` rule beneath the row; the row's footprint and inline padding stay unchanged. Reach for it when a visual group ends mid-stack and the divider would visually fence off the next group from its label. The last row already omits its divider via `:not(:last-child)`."
120
+ "description": "Per-row bottom-divider opt-out. Pass `divider: false` to suppress the hairline `border.default` rule beneath the row; the row's footprint and inline padding stay unchanged. Reach for it when a visual group ends mid-stack and the divider would visually fence off the next group from its label. The last row already omits its divider via `:not(:last-child)`."
121
121
  },
122
122
  "nav": {
123
123
  "type": "boolean",
@@ -164,18 +164,18 @@
164
164
  "labelToCountGap": "sys.layout.inline.sm",
165
165
  "labelToCountGapNote": "4px (`sys.layout.inline.sm`) between the label and an inline `count` badge — they tile flush on the primary line as one label+count block, narrower than the 8px trailing gap. Mirrors list/entry's identity-group spacing.",
166
166
  "dividerWidth": "sys.borderWidth.hairline",
167
- "dividerColor": "sys.color.outlineVariant",
167
+ "dividerColor": "sys.color.border.default",
168
168
  "dividerPerRowOptOut": "Pass `divider: false` on a row to suppress its bottom divider.",
169
169
  "leadingThumbnailSize": 40,
170
170
  "labelTypo": "sys.typo.body.md",
171
- "labelColor": "sys.color.onSurface",
171
+ "labelColor": "sys.color.text.default",
172
172
  "supportingTypo": "sys.typo.body.sm",
173
- "supportingColor": "sys.color.onSurfaceVariant",
173
+ "supportingColor": "sys.color.text.subtle",
174
174
  "supportingOffset": "0",
175
175
  "trailingIconSize": "16 × 16",
176
- "trailingIconColor": "sys.color.onSurfaceVariant",
176
+ "trailingIconColor": "sys.color.text.subtle",
177
177
  "navChevronSize": "16 × 16",
178
- "navChevronColor": "sys.color.onSurfaceVariant"
178
+ "navChevronColor": "sys.color.text.subtle"
179
179
  },
180
180
  "states": {
181
181
  "default": {
@@ -203,37 +203,34 @@
203
203
  "layer": "::after/::before overlay — position:absolute, inset:0, inset box-shadow, no reflow (DESIGN.md Focus ring composition)",
204
204
  "innerCounterRing": {
205
205
  "width": "sys.borderWidth.hairline",
206
- "color": "sys.color.focusInset"
206
+ "color": "sys.color.border.focused"
207
207
  },
208
208
  "outerRing": {
209
209
  "width": "sys.borderWidth.thin",
210
- "color": "sys.color.focus"
210
+ "color": "sys.color.border.focused"
211
211
  }
212
212
  },
213
213
  "note": "Keyboard-focus (:focus-visible) visual. Mirrors the `focusIndicator` block (the external-reader contract); kept here so spec-only renderers see focus in the states map. Composes over the lifecycle state the row is in; never via plain mouse click."
214
214
  },
215
215
  "nestedActionScope": "The hover / pressed overlay is suppressed while the pointer sits on an independent trailing action (a `trailingIcon` button — favorite / mute / Follow). The small control owns the state; the large row does NOT also read as hovered / pressed. The decorative nav chevron is exempt — it is the row's own drill-in affordance, so hovering it still lights the whole row. The visual-state boundary matches the event boundary (the trailing action already stops propagation).",
216
216
  "disabled": {
217
- "containerOpacity": "sys.state.disabled",
218
- "containerOpacityScope": "Dims the row content only — the inter-row divider and the focus overlay keep full opacity, so a disabled row never fades the hairline rule between it and the next row.",
219
- "pointerEvents": "none"
217
+ "text": "sys.color.text.disabled",
218
+ "icon": "sys.color.icon.disabled",
219
+ "pointerEvents": "none",
220
+ "note": "Explicit disabled (no opacity): row text to text.disabled, icons to icon.disabled. The inter-row divider and focus overlay are unaffected (they were never part of the content tone)."
220
221
  }
221
222
  },
222
223
  "focusIndicator": {
223
224
  "description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Composes over whichever lifecycle state the row is in.",
224
225
  "composition": "inward",
225
- "compositionReason": "Rows tile the column flush with only a hairline `outlineVariant` divider between them; an outward ring would overlap the divider and the neighbouring row.",
226
+ "compositionReason": "Rows tile the column flush with only a hairline `border.default` divider between them; an outward ring would overlap the divider and the neighbouring row.",
226
227
  "overlay": {
227
228
  "color": "label",
228
229
  "opacity": "sys.state.focus"
229
230
  },
230
231
  "ring": {
231
- "outerWidth": "sys.borderWidth.thin",
232
- "outerColor": "sys.color.focus",
233
- "outerLayerPosition": "depth 0..2px from the row edge (the outer stroke)",
234
- "insetWidth": "sys.borderWidth.hairline",
235
- "insetColor": "sys.color.focusInset",
236
- "insetLayerPosition": "depth 2..3px from the row edge (the counter-ring just inside the outer stroke)",
232
+ "width": "sys.borderWidth.hairline",
233
+ "color": "sys.color.border.focused",
237
234
  "implementation": "inset box-shadow on the row's `::before` overlay (the `::after` carries the inter-row divider). Constrained strictly inside the row's footprint and never exceeds it."
238
235
  },
239
236
  "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
@@ -250,7 +247,7 @@
250
247
  "nav chevron as a separate hit target — the drill-in chevron is decorative; the whole row is the click target",
251
248
  "leading thumbnail at a size other than the row's intrinsic 40 rung",
252
249
  "compact directory rows (selectable 32/48/56 avatar + stacked secondary line + optional toggle) built as a leading-image Standard row — that directory anatomy is [list/entry](./entry.md); Standard's image type is single-density at the 40 rung. (Standard does carry an inline `count` badge next to the label for unread / status counts, but not the avatar-rung directory shape.)",
253
- "raw `border:` on the row — list seam is the family's bottom divider via outlineVariant",
250
+ "raw `border:` on the row — list seam is the family's bottom divider via border.default",
254
251
  "embedded `banner` painted with a per-child `margin-block` / `padding-block` wrapper to fake the 8px gap — the text-group↔Banner gap is the row stack's `gap: sys.layout.stack.xs`, and the Banner's horizontal inset is the row's own 16px padding; Banner ships no outer margin",
255
252
  "embedded `banner` as a separate `<List>` row beneath the header — the call-out belongs to its row's subject, so it nests inside that row via `item.banner`, not as a sibling row that the divider would fence off"
256
253
  ]
@@ -25,9 +25,7 @@ import { Metadata } from '@teamblind-chorus/ui';
25
25
  />
26
26
  ```
27
27
 
28
- ## Use cases
29
-
30
- ### With role badge
28
+ ## Role badge
31
29
 
32
30
  An object identity item carries `badge` — a single presentational mark rendered after the nickname's link, outside the `<a>`, at the middots' 4px gap. At most one badge rides the nickname, never a stack.
33
31
 
@@ -59,9 +57,9 @@ import { Metadata, Badge } from '@teamblind-chorus/ui';
59
57
  | Slot | Token bindings |
60
58
  |---------------|----------------|
61
59
  | avatar | [Thumbnail](../thumbnail/thumbnail.md) `size={32}` |
62
- | meta | `sys.typo.label.sm` / `sys.color.onSurfaceVariant`, links inherit; underline on hover |
63
- | timestamp | `sys.typo.label.sm` / `sys.color.outline` |
64
- | dot separator | `·` glyph, `color: sys.color.outline`, **`line-height: 1`** so its line-box equals its font-size — never inflates the text line |
60
+ | meta | `sys.typo.label.sm` / `sys.color.text.subtle`, links inherit; underline on hover |
61
+ | timestamp | `sys.typo.label.sm` / `sys.color.border.boldest` |
62
+ | dot separator | `·` glyph, `color: sys.color.border.boldest`, **`line-height: 1`** so its line-box equals its font-size — never inflates the text line |
65
63
 
66
64
  ## States
67
65
 
@@ -3,7 +3,7 @@
3
3
  "name": "Metadata",
4
4
  "family": "metadata",
5
5
  "subcomponent": "compact",
6
- "description": "One-line channel-detail attribution — the slimmed [Standard](standard.md) head that keeps the leading 32-rung [Thumbnail](../thumbnail/thumbnail.md) but drops the primary name line, follow toggle, and subtitle (the info that is unnecessary once the channel context is already established). A leading avatar plus the identity meta-link row, with the posting time relocated to the line's trailing edge: avatar · company name · nickname (bare, no @ prefix, optional single role badge) · timestamp. The identity items keep the standard meta-row grammar (independent `<a>` links, middot separators, badge outside the link); the timestamp is plain text in `sys.color.outline` so the line reads identity-first, time-last. `layoutInset: inline` — atom-shaped, pays no padding of its own; the host row owns the gutter / divider / click target.",
6
+ "description": "One-line channel-detail attribution — the slimmed [Standard](standard.md) head that keeps the leading 32-rung [Thumbnail](../thumbnail/thumbnail.md) but drops the primary name line, follow toggle, and subtitle (the info that is unnecessary once the channel context is already established). A leading avatar plus the identity meta-link row, with the posting time relocated to the line's trailing edge: avatar · company name · nickname (bare, no @ prefix, optional single role badge) · timestamp. The identity items keep the standard meta-row grammar (independent `<a>` links, middot separators, badge outside the link); the timestamp is plain text in `sys.color.border.boldest` so the line reads identity-first, time-last. `layoutInset: inline` — atom-shaped, pays no padding of its own; the host row owns the gutter / divider / click target.",
7
7
  "element": "div",
8
8
  "props": {
9
9
  "variant": {
@@ -23,7 +23,7 @@
23
23
  "timestamp": {
24
24
  "type": "string",
25
25
  "required": true,
26
- "description": "Posting time at the line's trailing edge — plain text (never a link), in `label.sm` / `sys.color.outline` so it recedes behind the identity links. NOT preceded by a middot: the time is separated from the identity cluster by an `inline.md` (8) gap so it reads as a distinct trailing element rather than another identity item (mirrors the Standard head's name↔time treatment). Required: the timestamp is what distinguishes a compact attribution from a bare identity row."
26
+ "description": "Posting time at the line's trailing edge — plain text (never a link), in `label.sm` / `sys.color.border.boldest` so it recedes behind the identity links. NOT preceded by a middot: the time is separated from the identity cluster by an `inline.md` (8) gap so it reads as a distinct trailing element rather than another identity item (mirrors the Standard head's name↔time treatment). Required: the timestamp is what distinguishes a compact attribution from a bare identity row."
27
27
  }
28
28
  },
29
29
  "slots": {
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "timestamp": {
49
49
  "required": true,
50
- "description": "Trailing plain-text posting time after the identity cluster — no leading middot, separated by an `inline.md` (8) gap. `sys.typo.label.sm` / `sys.color.outline` — one tonal step further than the identity links.",
50
+ "description": "Trailing plain-text posting time after the identity cluster — no leading middot, separated by an `inline.md` (8) gap. `sys.typo.label.sm` / `sys.color.border.boldest` — one tonal step further than the identity links.",
51
51
  "accepts": [
52
52
  "text"
53
53
  ]
@@ -55,10 +55,10 @@
55
55
  },
56
56
  "sizing": {
57
57
  "metaTypo": "sys.typo.label.sm",
58
- "metaColor": "sys.color.onSurfaceVariant",
58
+ "metaColor": "sys.color.text.subtle",
59
59
  "timestampTypo": "sys.typo.label.sm",
60
- "timestampColor": "sys.color.outline",
61
- "dotColor": "sys.color.outline",
60
+ "timestampColor": "sys.color.border.boldest",
61
+ "dotColor": "sys.color.border.boldest",
62
62
  "dotLineHeight": "1",
63
63
  "dotLineHeightNote": "Same family-wide rule as the standard sub: the middot separator inherits the surrounding text's font-size but uses `line-height: 1` so its line-box never exceeds the glyph's font-size — the dot never inflates the single text line.",
64
64
  "metaSeparatorInset": "sys.layout.inline.sm"
@@ -12,7 +12,7 @@ Three rules hold across every family member.
12
12
 
13
13
  ### Dot height
14
14
 
15
- Every middot separator inherits the surrounding text's font-size but uses `line-height: 1`, so its line-box never exceeds the glyph's font-size — the U+00B7 glyph's natural vertical extent never inflates the host text line. The dot paints in `sys.color.outline` and is always inline, never a block element.
15
+ Every middot separator inherits the surrounding text's font-size but uses `line-height: 1`, so its line-box never exceeds the glyph's font-size — the U+00B7 glyph's natural vertical extent never inflates the host text line. The dot paints in `sys.color.border.boldest` and is always inline, never a block element.
16
16
 
17
17
  ### Bare nickname, single badge
18
18
 
@@ -26,11 +26,9 @@ import { Metadata } from '@teamblind-chorus/ui';
26
26
  />
27
27
  ```
28
28
 
29
- ## Use cases
29
+ ## Follow action
30
30
 
31
- ### With follow action
32
-
33
- The inline follow toggle. A middot separates it from the timestamp; the toggle paints in `sys.color.primary` at rest and `sys.color.onSurfaceVariant` when active. The dot's line-box stays inside its font-size so the row's text-line stays tight.
31
+ The inline follow toggle. A middot separates it from the timestamp; the toggle paints in `sys.color.background.primary` at rest and `sys.color.text.subtle` when active. The dot's line-box stays inside its font-size so the row's text-line stays tight.
34
32
 
35
33
  ```preview
36
34
  metadata/standard/follow
@@ -47,7 +45,7 @@ import { Metadata } from '@teamblind-chorus/ui';
47
45
  />
48
46
  ```
49
47
 
50
- ### Sponsored (ad)
48
+ ## Sponsored
51
49
 
52
50
  Feed Ad shape — `subtitle="Sponsored"` paints a plain caption-tone line under the brand name (no link affordance, no meta row). Pair with `trailing` to host the dismiss × button.
53
51
 
@@ -69,7 +67,7 @@ import { XIcon } from '@teamblind-chorus/ui/icons';
69
67
  />
70
68
  ```
71
69
 
72
- ### With role badge
70
+ ## Role badge
73
71
 
74
72
  A meta item in object form carries `badge` — a single presentational mark rendered after the item's link, outside the `<a>`, at the middots' 4px gap. Canonical fill: a role [Badge](../badge/badge.md) on the trailing nickname. At most one badge rides the nickname, never a stack.
75
73
 
@@ -96,8 +94,8 @@ import { Metadata, Badge } from '@teamblind-chorus/ui';
96
94
  - **container** — outer flex row. `align-items: center`, `sys.layout.inline.md` (8px) gap between avatar, text column, and trailing slot.
97
95
  - **avatar** *(optional)* — leading [Thumbnail](../thumbnail/thumbnail.md) at `size={32}`. Forwards every Thumbnail prop verbatim.
98
96
  - **text** — two-line text column. Primary line on top, optional secondary line below. `flex: 1 1 auto`, `min-width: 0` so both lines truncate.
99
- - **name** — entity name. `<a>` when `nameHref` is set, `<span>` otherwise. `sys.typo.label.sm` / `sys.color.onSurface`. Single line; truncates.
100
- - **timestamp** *(optional)* — inline timestamp after the name. `sys.typo.label.sm` / `sys.color.outline`.
97
+ - **name** — entity name. `<a>` when `nameHref` is set, `<span>` otherwise. `sys.typo.label.sm` / `sys.color.text.default`. Single line; truncates.
98
+ - **timestamp** *(optional)* — inline timestamp after the name. `sys.typo.label.sm` / `sys.color.border.boldest`.
101
99
  - **followAction** *(optional)* — bare-text follow toggle at the primary line's trailing edge. Preceded by a middot.
102
100
  - **subtitle** *(optional, ad)* — plain caption-tone secondary line. Mutually exclusive with `meta`.
103
101
  - **meta** *(optional, post)* — secondary line meta-link row. Each item is its own `<a>`; siblings separate by middot. The last item is canonically the user's nickname, displayed bare (no @ prefix). An object item may carry `badge` — a single presentational mark node rendered after the link, outside the `<a>` (canonical fill: [badge/role](../badge/badge.md) on the nickname — at most one badge rides the nickname, never a stack).
@@ -109,12 +107,12 @@ import { Metadata, Badge } from '@teamblind-chorus/ui';
109
107
  |---------------|----------------|
110
108
  | container | Flex row, `sys.layout.inline.md` (8) gap, `align-items: center` |
111
109
  | avatar | [Thumbnail](../thumbnail/thumbnail.md) `size={32}` |
112
- | name | `sys.typo.label.sm` / Semibold / `sys.color.onSurface`, single-line ellipsis |
113
- | timestamp | `sys.typo.label.sm` / `sys.color.outline` |
114
- | dot separator | `·` glyph, `color: sys.color.outline`, **`line-height: 1`** so its line-box equals its font-size — never inflates the text line |
115
- | follow | `sys.typo.label.sm` / Semibold / `sys.color.primary` (active → `sys.color.onSurfaceVariant`) |
116
- | subtitle | `sys.typo.label.sm` / `sys.color.onSurfaceVariant` |
117
- | meta | `sys.typo.label.sm` / `sys.color.onSurfaceVariant`, links inherit; underline on hover |
110
+ | name | `sys.typo.label.sm` / Semibold / `sys.color.text.default`, single-line ellipsis |
111
+ | timestamp | `sys.typo.label.sm` / `sys.color.border.boldest` |
112
+ | dot separator | `·` glyph, `color: sys.color.border.boldest`, **`line-height: 1`** so its line-box equals its font-size — never inflates the text line |
113
+ | follow | `sys.typo.label.sm` / Semibold / `sys.color.background.primary` (active → `sys.color.text.subtle`) |
114
+ | subtitle | `sys.typo.label.sm` / `sys.color.text.subtle` |
115
+ | meta | `sys.typo.label.sm` / `sys.color.text.subtle`, links inherit; underline on hover |
118
116
 
119
117
  ## States
120
118