@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/agents/DESIGN.md CHANGED
@@ -42,7 +42,7 @@ Chorus is the design language of a community product with high text volume and m
42
42
  Chorus follows a **three-tier** model — *reference → system → component* — with deliberate discipline about when each tier earns a token.
43
43
 
44
44
  - **Reference tier** ([`reference.json`](schema/tokens/reference.json), namespaced under `ref.*`) — raw palettes and scales with no opinion about usage. `ref.palette.neutral.500`, `ref.fontSize.200`, `ref.space.400`. The material.
45
- - **System tier** ([`system.json`](schema/tokens/system.json), namespaced under `sys.*`) — semantic roles consuming the reference tier via `{ref.palette.*}` / `{ref.space.*}` references. `sys.color.primary`, `sys.color.surfaceContainerHigh`, `sys.layout.page.md`, `sys.elevation.floating`. The vocabulary product surfaces speak in.
45
+ - **System tier** ([`system.json`](schema/tokens/system.json), namespaced under `sys.*`) — semantic roles consuming the reference tier via `{ref.palette.*}` / `{ref.space.*}` references. `sys.color.text.default`, `sys.color.surface.default`, `sys.layout.page.md`, `sys.elevation.floating`. The vocabulary product surfaces speak in.
46
46
  - **Component tier** ([`component.json`](schema/tokens/component.json), namespaced under `comp.*`) — per-component tokens that bind system roles to a component's contract. Currently illustrative-only (a hypothetical `comp.button.primary.container` / `comp.button.primary.label` pair would belong here) and ships empty by design — see [Current state of `comp.*`](#current-state-of-comp). Reserved for components reused widely enough that naming the composition earns its keep.
47
47
 
48
48
  CSS variables emit with the full prefix preserved (`var(--sys-color-primary)`, `var(--ref-space-200)`) so tier identity is explicit at the call site — `var(--ref-…)` in a component is a code-review signal that the component reached past the system tier.
@@ -67,11 +67,19 @@ CSS custom properties follow `--<tier>-<group>-<name>`: `var(--sys-color-primary
67
67
 
68
68
  ### Color
69
69
 
70
- Six solid hues plus two opacity overlays, organized into role clusters that always pair a background with its foreground — components consume system roles, never the raw palette.
70
+ Seven solid hues plus two opacity overlays, organized into role clusters that always pair a background with its foreground — components consume system roles, never the raw palette.
71
+
72
+ **Role-based token names.** `sys.color` uses role names that match the Figma Semantic collection: `text.*`, `icon.*`, `border.*`, `surface.*`, `background.*`, `overlay.*`. The M3→role migration is **complete** — the old Material-3 names (`onSurface`, `primary`, `surfaceContainerHigh`, `outline`…) are fully retired across web, native, and Figma; the historical mapping lives in [`ROLE-MIGRATION.md`](tokens/ROLE-MIGRATION.md). Every role carries its own light and dark value; components consume roles, never the raw palette.
73
+
74
+ **Locked status & accent policies:**
75
+
76
+ - **Status = 3 hues.** `success` is the **blue** family (not green) — info and success share blue, so a success state **must pair a checkmark (✓) icon** to carry the positive meaning colour alone can't. `warning` = yellow, `danger` = red. Green / teal / purple are categorical / decorative accents only.
77
+ - **Accent text = label only.** Accent hues at the 500 step clear only ~3:1 on white, so accent colours may paint **labels** (large ≥18pt, or bold ≥14pt) — **never body / paragraph text**. `subtle` / `subtlest` accents are background / decoration, never text.
78
+ - **Focus = neutral, single ring.** One `border.focused` ring, 1px, neutral-toned (near-black in light, mid-grey in dark) — no coloured stroke and no white inset counter-ring.
71
79
 
72
80
  #### Reference palettes
73
81
 
74
- Six solid palettes share a 0–1000 lightness curve, tuned so the same numeric step lands at a perceptually similar brightness across hues. Pairing a 50–400 background with a 700–900 foreground (or vice versa) clears WCAG AA 4.5:1 for body text across every palette.
82
+ Seven solid palettes share a 0–1000 lightness curve, tuned so the same numeric step lands at a perceptually similar brightness across hues. Pairing a 50–400 background with a 700–900 foreground (or vice versa) clears WCAG AA 4.5:1 for body text across every palette.
75
83
 
76
84
  ##### Lightness ramp
77
85
 
@@ -87,12 +95,13 @@ Step bands across all hues:
87
95
 
88
96
  | Palette | 500 (canonical) | Role |
89
97
  |----------|-----------------|---------------------------------------------------|
90
- | `neutral` | `#737373` | Text, surfaces, borders, dark UI chrome |
91
- | `blue` | `#2563eb` | Primary brand accent |
92
- | `green` | `#008838` | Success / positive confirmation |
93
- | `red` | `#d92626` | Brand / Error / destructive |
94
- | `yellow` | `#a16207` | Reserved (warning / categorical) |
95
- | `purple` | `#9333ea` | Reserved (categorical / decorative) |
98
+ | `neutral` | `#7b8496` | Text, surfaces, borders, dark UI chrome |
99
+ | `blue` | `#007aff` | Primary · info · **success** · links |
100
+ | `red` | `#e83a3a` | Brand · error · destructive |
101
+ | `yellow` | `#ffbc00` | Warning / caution |
102
+ | `green` | `#1fa86b` | Positive accent (categorical) |
103
+ | `teal` | `#04ceae` | Categorical accent / charts |
104
+ | `purple` | `#452cc7` | Categorical / decorative |
96
105
 
97
106
  **Only system tokens may reference these palette steps.** Components never consume `palette.*` directly. Document a new role here in DESIGN.md before adding the JSON entry.
98
107
 
@@ -109,163 +118,148 @@ The ramp partitions into three functional bands:
109
118
  | Band | Steps | Alpha values | Used by |
110
119
  |-----------|--------------|-------------------------|--------------------------------------------------------------------------|
111
120
  | Endpoint | `0` | 0% (transparent) | Reset / fully transparent overlays |
112
- | Veil | `50–600` | 4 / 6 / 8 / 12 / 16 / 20 / 24% | `elevation.*` shadow alphas, `state.*` overlay opacities |
113
- | Scrim | `700–900` | 40 / 64 / 80% | `color.scrim`, heavy modal/drawer dim |
114
- | Endpoint | `1000` | 100% (fully opaque) | `color.focus` outer ring, `color.elevation` ink |
121
+ | Veil | `50–600` | 4 / 6 / 8 / 12 / 16 / 20 / 24% | `elevation.*` shadow alphas, `state.*` overlay opacities, `overlay.hover` / `overlay.pressed` |
122
+ | Scrim | `700–900` | 40 / 64 / 80% | `overlay.scrim`, heavy modal / drawer dim |
123
+ | Endpoint | `1000` | 100% (fully opaque) | Fully-opaque overlays and shadow ink |
115
124
 
116
- **Contrast guidance**: veil-band overlays are low-emphasis — foreground text (`color.onSurface`, near-black) stays readable. Scrim-band and `1000` are strong overlays — use inverse text (`color.inverseOnSurface`, near-white).
125
+ **Contrast guidance**: veil-band overlays are low-emphasis — foreground text (`text.default`, near-black) stays readable. Scrim-band and `1000` are strong overlays — use inverse text (`text.inverse`, near-white).
117
126
 
118
127
  `palette.white.*` mirrors `palette.black.*` for dark-mode use: composite over dark backgrounds so the surface tint shows through.
119
128
 
120
- #### Accent roles
121
-
122
- Five role families: brand emphasis (`primary`), neutral support (`secondary`), brand-identity attention (`brand`), positive confirmation (`success`), destructive signal (`error`). The role decides *what the color means*; the structure below decides *how to compose it*.
123
-
124
- ##### Four-token quartet
125
-
126
- Each accent role (`primary` / `secondary` / `brand` / `success` / `error`) ships as a fixed **four-token quartet**: a high-emphasis pair and a low-emphasis pair, foreground always paired to background. The quartet is the unit of meaning — never use a fill without its `on*` foreground, never read contrast manually. The *role* differs across accents but the *four-slot structure* is identical.
127
-
128
- The four slots:
129
-
130
- - **Main pair** — `X` / `onX` — high-attention fill for CTAs, emphasis badges, status chips. Use sparingly per view.
131
- - **Container pair** — `XContainer` / `onXContainer` — low-chroma tinted surface in the same family for callouts, notification tiles, subtle banners. Lower visual weight, safe on larger areas.
132
-
133
- **The Container pair is the tint.** When a surface needs to read as a soft accent — info callouts, selected list rows, success banners, error tiles, "subtle" highlight blocks — reach for `XContainer` + `onXContainer`, **never** a `color-mix(<accent> N%, <surface>)` overlay. `XContainer` already resolves to the soft tone (`blue.50` light / `blue.900` dark for primary, `red.50` / `green.50` light and `red.900` / `green.900` dark for brand / success, `red.100`/`red.900` for error), tuned to clear AA against its paired `on*` foreground; an alpha mix bypasses that contract and lands on the neutral `surface*` family. If the canonical pair gives a poor visual, retune the token value in [`system.json`](schema/tokens/system.json) — never break the pair at the call site.
134
-
135
- **Contrast refusal contract (for agents and humans).** The quartet exists so contrast is *never* a per-call-site decision the pair already clears AA. When a surface composition strays outside the shipped quartets (a new tinted hero, a custom-coloured banner, a brand glyph on a non-`surface*` host), the call site MUST clear WCAG **AA at 4.5 : 1 for normal text, 3 : 1 for ≥18pt or Semibold ≥14pt text, and 3 : 1 for non-text glyphs and graphic boundaries** against the actual rendered fill in BOTH light and dark mode. If the proposed foreground fails, **change the host fill to a surface that already pairs** — pick the nearest `sys.color` quartet — rather than hand-tuning the foreground. The agent-facing zero-tolerance failure modes (black-on-black, white-on-yellow, translucent `sys.color.icon.*` on a colour-tinted host, `onPrimary` text on a neutral `surface*` fill) are enumerated in [`AGENTS.md` § Hard rules § 11](../AGENTS.md#hard-rules) and trigger a regenerate, not a tweak.
129
+ #### Semantic roles
130
+
131
+ The role layer is the only colour surface components touch. Roles are grouped into six families **Text**, **Icon**, **Border**, **Surface**, **Background**, **Overlay** and within a family into three intents:
132
+
133
+ - **Neutral** — the reading / structure hierarchy, no status meaning. Text and icon fade from strong to weak (`default` → `subtle` → `subtlest`); border boldens with emphasis (`default` → `bold` → `boldest`). The two directions are intentional: type recedes as it de-emphasizes, an enclosure strengthens as it emphasizes.
134
+ - **Function** — encodes meaning: interaction (`link`, `mention`, `primary`, `focused`, `selected`) and the three status hues (`danger` red, `success` blue, `warning` yellow).
135
+ - **Accent** decorative hue for labels, glyphs, and category coding only (see the accent-text-label-only policy above).
136
+
137
+ Every value below lists its light / dark primitive. The pairing rule is the same everywhere: a foreground role (`text.*` / `icon.*`) sits on a surface or background role, and the two must clear WCAG AA against the actual rendered fill in **both** modes.
138
+
139
+ ##### Text
140
+
141
+ | Token | Role | → light / dark |
142
+ |-------|------|----------------|
143
+ | `text.default` | Primary body text — body copy, headings, default icons. | `neutral.1000` / `neutral.100` |
144
+ | `text.subtle` | Secondary textnavigation, supporting labels, captions. | `neutral.600` / `neutral.400` |
145
+ | `text.subtlest` | Quietest text — metadata, placeholders, supplementary notes. | `neutral.400` / `neutral.500` |
146
+ | `text.disabled` | Text in a disabled state. | `neutral.300` / `neutral.600` |
147
+ | `text.inverse` | Light text on dark / coloured backgrounds. | `neutral.0` / `neutral.1000` |
148
+ | `text.onFill` | Text on solid fills (buttons, badges). | `neutral.0` / `neutral.0` |
149
+ | `text.link` | Link text. | `blue.500` / `blue.400` |
150
+ | `text.mention` | Mention (@user) text. | `blue.400` / `blue.400` |
151
+ | `text.brand` | Brand-emphasis text — labels / badges only, never body. | `brand.red` / `brand.red` |
152
+ | `text.danger` | Danger / error text — error messages, input-validation notices. | `red.500` / `red.400` |
153
+ | `text.success` | Success text — save-complete, validation-passed notices. | `blue.500` / `blue.400` |
154
+ | `text.warning` | Warning text — non-blocking caution labels. | `yellow.700` / `yellow.300` |
155
+ | `text.accent.blue` | Blue accent text — labels only. | `blue.500` / `blue.400` |
156
+ | `text.accent.red` | Red accent text — labels only. | `red.400` / `red.400` |
157
+ | `text.accent.teal` | Teal accent text — labels only. | `teal.500` / `teal.400` |
158
+
159
+ ##### Icon
160
+
161
+ | Token | Role | → light / dark |
162
+ |-------|------|----------------|
163
+ | `icon.default` | Default icon — the darkest neutral glyph. | `neutral.1000` / `neutral.200` |
164
+ | `icon.subtle` | Secondary icon — one step quieter. | `neutral.600` / `neutral.400` |
165
+ | `icon.subtlest` | Quietest icon — decorative / supporting glyphs. | `neutral.400` / `neutral.500` |
166
+ | `icon.disabled` | Icon in a disabled state. | `neutral.200` / `neutral.600` |
167
+ | `icon.inverse` | Light icon on dark / coloured backgrounds. | `neutral.0` / `neutral.1000` |
168
+ | `icon.onFill` | Icon on solid fills. | `neutral.0` / `neutral.0` |
169
+ | `icon.brand` | Brand icon — logomark / brand glyphs. | `brand.red` / `brand.red` |
170
+ | `icon.danger` | Danger / error icon. | `red.500` / `red.400` |
171
+ | `icon.success` | Success icon. | `blue.500` / `blue.400` |
172
+ | `icon.warning` | Warning icon. | `yellow.500` / `yellow.400` |
173
+
174
+ Accent icons carry six hues, each in a `default` and a `subtle` step, for standalone decorative glyphs (status marks, category tags, favourite stars). Use an accent icon only when the glyph carries its *own* hue; an icon acting as a control's foreground inherits the surrounding `icon.*` / `text.onFill` role instead.
175
+
176
+ | Hue | `default` (L / D) | `subtle` (L / D) |
177
+ |-----|-------------------|------------------|
178
+ | `icon.accent.blue` | `blue.500` / `blue.400` | `blue.300` / `blue.600` |
179
+ | `icon.accent.red` | `red.400` / `red.400` | `red.300` / `red.600` |
180
+ | `icon.accent.yellow` | `yellow.500` / `yellow.400` | `yellow.200` / `yellow.600` |
181
+ | `icon.accent.teal` | `teal.500` / `teal.400` | `teal.200` / `teal.600` |
182
+ | `icon.accent.green` | `green.500` / `green.400` | `green.200` / `green.600` |
183
+ | `icon.accent.purple` | `purple.500` / `purple.400` | `purple.200` / `purple.600` |
184
+
185
+ ##### Border
186
+
187
+ | Token | Role | → light / dark |
188
+ |-------|------|----------------|
189
+ | `border.default` | Light default border — feed / list separators, card edges. The most common default. | `neutral.100` / `neutral.900` |
190
+ | `border.bold` | One step bolder — encloses cards, sections, comments, inputs. | `neutral.200` / `neutral.800` |
191
+ | `border.boldest` | High-emphasis border — emphasized separators. | `neutral.500` / `neutral.600` |
192
+ | `border.primary` | Primary border — emphasis stroke for primary actions. | `blue.500` / `blue.400` |
193
+ | `border.focused` | Focus ring — keyboard-focus indicator, neutral single ring. | `neutral.1000` / `neutral.500` |
194
+ | `border.selected` | Selected border — outline for selected items. | `neutral.1000` / `neutral.100` |
195
+ | `border.danger` | Danger border — error input-field outline. | `red.400` / `red.400` |
196
+ | `border.success` | Success border — validated-input outline. | `blue.400` / `blue.400` |
197
+ | `border.warning` | Warning border. | `yellow.400` / `yellow.400` |
198
+
199
+ `border.focused` and `border.selected` are **neutral**, not blue — a near-black stroke in light and a mid-grey in dark. Selection and focus read by shape and weight rather than by competing with `border.primary`'s hue.
200
+
201
+ ##### Surface
202
+
203
+ The two base planes everything sits on. Elevation is expressed by `elevation.*` shadows, **not** by an ever-brightening surface ladder — the Chorus brand goal is *calm and trustworthy*, and stacking tonal lifts feels showy. A raised element (modal, sheet) separates via `scrim` + shadow, and its surface tone stays at `surface.default`.
204
+
205
+ | Token | Role | → light / dark |
206
+ |-------|------|----------------|
207
+ | `surface.default` | Base plane for pages and cards — the "ground" of the UI. | `neutral.0` / `neutral.1000` |
208
+ | `surface.sunken` | One step below the page base — gutters, wells, inset regions. | `neutral.100` / `neutral.900` |
209
+
210
+ ##### Background
211
+
212
+ Container and banner fills that tint a region without being the page ground.
213
+
214
+ | Token | Role | → light / dark |
215
+ |-------|------|----------------|
216
+ | `background.neutral` | Neutral container fill — chips, tags, general content cards. No status meaning. | `neutral.100` / `neutral.800` |
217
+ | `background.inverse` | Inverse background — dark tooltips, inverted regions. | `neutral.1000` / `neutral.0` |
218
+ | `background.disabled` | Disabled fill. | `neutral.50` / `neutral.900` |
219
+ | `background.primary` | Primary fill — primary buttons and actions. | `blue.500` / `blue.500` |
220
+ | `background.selected` | Selected background — selected list rows, tabs. | `blue.50` / `blue.900` |
221
+ | `background.information` | Information banner — informational callouts / notices. | `blue.100` / `blue.900` |
222
+ | `background.danger` | Danger banner — soft red error surface. | `red.100` / `red.900` |
223
+ | `background.success` | Success banner — soft surface signalling success (blue, not green). | `blue.50` / `blue.900` |
224
+ | `background.warning` | Warning banner — soft yellow surface. | `yellow.50` / `yellow.900` |
225
+
226
+ ##### Overlay
227
+
228
+ Translucent layers composited over content — the host surface bleeds through. **State** overlays convey interaction feedback; **Scrim** overlays darken or protect.
229
+
230
+ | Token | Role | → light / dark |
231
+ |-------|------|----------------|
232
+ | `overlay.pressed` | Pressed-state layer. | `black.300` (12%) / `black.300` (12%) |
233
+ | `overlay.hover` | Hover-state layer. | `white.300` (12%) / `white.300` (12%) |
234
+ | `overlay.scrim` | Modal / sheet backdrop dim — darkens the screen behind. | `overlay.black-32` / `overlay.black-75` |
235
+ | `overlay.image` | Image protection overlay — guards text legibility over photos. | `overlay.black-3` / `overlay.black-3` |
236
+ | `overlay.illustration` | Illustration dim — sinks illustrations back in dark mode. | `black.0` (transparent) / `black.400` (16%) |
237
+
238
+ ##### Composition & pairing
239
+
240
+ Colour is composed as **foreground role on surface / background role** — never a hand-tuned value at the call site.
241
+
242
+ - **Neutral text on any surface.** `text.default` / `subtle` / `subtlest` clear AA against `surface.default`, `surface.sunken`, and `background.neutral` in both modes. Pick the emphasis tier, not the hex.
243
+ - **Status recipes pair a Function background with its Function foreground.** A danger surface is `background.danger` + `text.danger` (and `border.danger` for the outline); success is `background.success` + `text.success`; information is `background.information` + `text.link`; warning is `background.warning` + `text.warning`. Because `success` resolves to blue, a success state **must** also carry a ✓ glyph.
244
+ - **Solid fills use the on-fill pair.** `background.primary` fill takes `text.onFill` / `icon.onFill` (which stay light in both modes).
245
+ - **Never `color-mix(<accent> N%, <surface>)` to fake a tint.** The `background.*` role already resolves to the tuned soft tone that clears AA against its paired foreground; an alpha mix bypasses that contract. If a canonical pair gives a poor visual, retune the token value in [`system.json`](tokens/system.json) — never break the pair at the call site.
246
+
247
+ **Contrast refusal contract (for agents and humans).** The role pairs exist so contrast is *never* a per-call-site decision. When a composition strays outside the shipped pairs (a custom-tinted hero, a brand glyph on a non-`surface` host), the call site MUST clear WCAG **AA — 4.5:1 for normal text, 3:1 for ≥18pt or Semibold ≥14pt text, and 3:1 for non-text glyphs and graphic boundaries** against the actual rendered fill in BOTH modes. If the proposed foreground fails, **change the host fill to a role that already pairs** rather than hand-tuning the foreground. Zero-tolerance failure modes (black-on-black, white-on-yellow, translucent accent glyph on a colour-tinted host, `text.onFill` on a neutral `surface` fill) are enumerated in [`AGENTS.md` § Hard rules](../AGENTS.md#hard-rules) and trigger a regenerate, not a tweak.
136
248
 
137
249
  **Allowed `color-mix` exceptions** — two and only two:
138
250
 
139
- 1. **State-overlay formula** — `color-mix(<onContainer> 8%, <container>)` for hover/focus/pressed layered over a Container surface, per [State overlays](#state-overlays).
140
- 2. **Decorative gradient atmospherics** — an `<accent>`-toned stop fading to `transparent` inside a `radial-gradient` / `linear-gradient` over a flat `surface*` base where the *underlying base* governs text contrast. The gradient is decoration, not a content surface. The tell-tale: the gradient stops with `transparent`, and the text resolves contrast against the base color *under* the gradient (`onSurface` on `surfaceContainerLowest`, etc.).
141
-
142
- ###### Primary
143
-
144
- | Token | Role |
145
- |----------------------------|-------------------------------------------------------------------------------|
146
- | `color.primary` | The brand color and highest-attention accent. Use sparingly for one dominant action per view (primary CTA, selected tab underline, active toggle fill, progress indicator). Two primary buttons in a view collapse the hierarchy. Resolves to `ref.palette.blue.500` in both modes — the brand hue is saturated enough to clear AA against `surface` in both light (white) and dark (`neutral.900`), so the CTA reads as the same blue across themes without a tonal nudge. |
147
- | `color.onPrimary` | Foreground placed on top of `primary`. Label text, icons, and spinners inside primary-filled surfaces. Always pair with `primary`; never against a neutral surface. Resolves to `ref.palette.neutral.50`. |
148
- | `color.primaryContainer` | Low-chroma tinted surface in the primary family. Selected-state list backgrounds, informational callouts, highlighted message bubbles, brand-flavored section banners. Safe on larger areas where `primary` would overwhelm. Resolves to `ref.palette.blue.50` (light) / `ref.palette.blue.900` (dark). The light value sits one step brighter than the other accent containers (`error` uses `*.100`) because primary is the most-used quartet in the product — a `blue.100` callout next to multiple active list rows on the same page felt heavier than the role asks for. The lighter step keeps the brand identity visible against `surface` while reading as a quiet, decorative tint rather than a filled banner. |
149
- | `color.onPrimaryContainer` | Foreground for content placed on `primaryContainer`. Text, icons, and links inside primary-tinted surfaces. Resolves to `ref.palette.blue.600` (light) / `ref.palette.blue.400` (dark) — both stay in the saturated primary family so the foreground reads as *blue* on both tinted backgrounds, instead of collapsing to near-black on the light tint or muddying into the deep container on the dark tint. The dark step lifts one band higher than light's mirror would suggest because identical luminance gaps read darker on dark surfaces. The pair clears AA at ~9:1 against the lifted light container. |
150
-
151
- ###### Secondary
152
-
153
- | Token | Role |
154
- |------------------------------|-------------------------------------------------------------------------------|
155
- | `color.secondary` | A neutral accent for supporting actions that should feel present but not brand-loud. Secondary CTAs paired beside a primary button, quiet filled controls, selection highlights where a colored brand fill would be distracting. Unlike the chromatic accents, this family inverts between light and dark modes. Resolves to `ref.palette.neutral.700` (light) / `ref.palette.neutral.300` (dark). |
156
- | `color.onSecondary` | Foreground placed on top of `secondary`. Label text and icons inside secondary-filled surfaces. Resolves to `ref.palette.neutral.50` (light) / `ref.palette.neutral.900` (dark). |
157
- | `color.secondaryContainer` | Low-contrast neutral surface in the secondary family. Subtle backgrounds that need to separate from the page without implying brand meaning: tonal chip fills, quiet badges, muted selection backgrounds, segmented-control tracks, secondary button fills. Resolves to `ref.palette.neutral.100` (light) / `ref.palette.neutral.600` (dark). The dark step sits two bands lighter than a strict mirror would land — at `neutral.800` the secondary fill would collide with every `surfaceContainer*` and `surfaceVariant` (all `neutral.800` in dark); at `neutral.700` it would still collide with `surfaceContainerHighest` (the topmost surface band). `neutral.600` lifts the secondary accent one step clear of the entire surface ladder so a secondary fill placed on any host — including the most lifted overlay surfaces — stays distinct, while remaining inside the muted band. |
158
- | `color.onSecondaryContainer` | Foreground for content placed on `secondaryContainer`. Resolves to `ref.palette.neutral.900` (light) / `ref.palette.neutral.100` (dark). |
159
-
160
- ###### Brand
161
-
162
- | Token | Role |
163
- |--------------------------|-------------------------------------------------------------------------------|
164
- | `color.brand` | The product's signature red — a high-attention accent reserved for notification counts, unread badges, eyebrow flags, and brand-identity moments (logomark fills, brand-tagged callouts). One tonal step **brighter** than `error` in both modes (`red.500` brand vs. `red.600` / `red.700` error), so the two reds stay visually distinct on the same surface: brand reads as energetic identity, error reads as a deeper destructive signal. Resolves to `ref.palette.red.500` in **both** light and dark modes — brand identity stays stable across themes, and the 500 step is the brightest red the palette ships that still clears AA against `onBrand` (`neutral.50`) for white-on-brand labels. |
165
- | `color.onBrand` | Foreground placed on top of `brand`. Label text and icons inside brand-filled surfaces (notification counts, brand badges). Resolves to `ref.palette.neutral.50`. White-on-`red.500` lands at ~4.7:1 — clears AA for normal text in both modes. |
166
- | `color.brandContainer` | Low-chroma tinted surface in the brand family. Soft brand callouts, "what's new" banners, promotional tiles, marketing surfaces where the energy of `brand` would overwhelm. Resolves to `ref.palette.red.50` (light) / `ref.palette.red.900` (dark). Light is one step lighter than `errorContainer` (`red.50` vs. `red.100`) so the brand callout reads as a quiet identity touch rather than a warning. |
167
- | `color.onBrandContainer` | Foreground for content placed on `brandContainer`. Resolves to `ref.palette.red.600` (light) / `ref.palette.red.400` (dark) — both stay in the saturated red family so the foreground reads as *red on tinted red*, not as *near-black on tinted red*. The dark step lifts one band higher than light's mirror would suggest because identical luminance gaps read darker on dark surfaces. |
168
-
169
- ###### Success
170
-
171
- | Token | Role |
172
- |-----------------------------|-------------------------------------------------------------------------------|
173
- | `color.success` | The signal color for positive confirmation — completed states, success toasts, "saved" pills, validated form fields, healthy status indicators. Reserved strictly for affirmative outcomes; decorative use erodes its signaling power. Resolves to `ref.palette.green.500` in **both** light and dark modes — mirrors `brand`'s cross-mode stability so the success signal reads as the same green across themes, and the 500 step is the brightest green the palette ships that still clears AA against `onSuccess` (`neutral.50`) for white-on-success labels. |
174
- | `color.onSuccess` | Foreground placed on top of `success`. Label text and icons inside success-filled surfaces. Resolves to `ref.palette.neutral.50`. |
175
- | `color.successContainer` | Low-chroma tinted surface in the success family. Soft success callouts, "you're all set" banners, completed-task tiles where `success` would overwhelm. Resolves to `ref.palette.green.50` (light) / `ref.palette.green.900` (dark) — mirrors `brandContainer`'s shallow-light / deep-dark structure. |
176
- | `color.onSuccessContainer` | Foreground for content placed on `successContainer`. Resolves to `ref.palette.green.600` (light) / `ref.palette.green.400` (dark) — both stay in the saturated green family so the foreground reads as *green on tinted green*, not as *near-black on tinted green*. The dark step lifts one band higher than light's mirror would suggest because identical luminance gaps read darker on dark surfaces. |
177
-
178
- ###### Error
179
-
180
- | Token | Role |
181
- |--------------------------|-------------------------------------------------------------------------------|
182
- | `color.error` | The signal color for destructive and error states. Destructive CTAs (Delete, Remove), form-field error outlines, critical status indicators, alert icons. Reserved strictly for negative or dangerous meaning — decorative use erodes its signaling power. One tonal step **darker** than `brand` (red.500) in both modes so destructive moments sit deeper and graver than brand-identity moments on the same screen. Resolves to `ref.palette.red.600` (light) / `ref.palette.red.700` (dark). |
183
- | `color.onError` | Foreground placed on top of `error`. Label text and icons inside error-filled surfaces. Resolves to `ref.palette.neutral.50`. |
184
- | `color.errorContainer` | Low-chroma tinted surface in the error family. Inline error message backgrounds, warning banners, failed-state tiles. Less alarming than `error`, so appropriate for larger areas. Resolves to `ref.palette.red.100` (light) / `ref.palette.red.900` (dark). |
185
- | `color.onErrorContainer` | Foreground for content placed on `errorContainer`. Resolves to `ref.palette.red.700` (light) / `ref.palette.red.500` (dark). |
186
-
187
- #### Surface stack
188
-
189
- Page background and the elevation-tier container surfaces.
190
-
191
- ##### Three sub-groups
192
-
193
- 1. **Base canvas** (`surface` / `onSurface`) — the foundation everything else sits on.
194
- 2. **Canvas modifiers** (`surfaceVariant` / `onSurfaceVariant` / `surfaceDim` / `surfaceBright`) — alternate base tones for quiet separation, recess, or spotlight. `surfaceVariant` carries its own paired foreground for two-tier text hierarchy; `Dim` / `Bright` keep `onSurface` as foreground.
195
- 3. **Container ladder** (`surfaceContainerLowest` → `Low` → `default` → `High` → `Highest`) — five ordered tiers indicating *spatial role* (sunken → recessed → default → raised → topmost). In light mode the tones collapse onto `#ffffff` by design; lift comes from `elevation.*` shadows.
251
+ 1. **State-overlay formula** — `overlay.hover` / `overlay.pressed` composited over a surface, per [State overlays](#state-overlays).
252
+ 2. **Decorative gradient atmospherics** — an accent-toned stop fading to `transparent` inside a gradient over a flat `surface` base, where the *base under the gradient* governs text contrast. The gradient is decoration, not a content surface.
196
253
 
197
- `onSurface` is the canonical foreground for the entire stack.
198
-
199
- ###### Base & variants
200
-
201
- | Token | Role |
202
- |--------------------------|----------------------------------------------------------------------------------------------|
203
- | `color.surface` | The base page background — the canvas everything else sits on. Root app background, empty regions, any large flat area that should read as the "ground" of the UI. Resolves to `ref.palette.neutral.0` (light) / `ref.palette.neutral.900` (dark). |
204
- | `color.onSurface` | Primary foreground against `surface`. Body copy, headings, primary icons. Clears WCAG AA against every `surface*` token. Resolves to `ref.palette.neutral.900` (light) / `ref.palette.neutral.50` (dark). |
205
- | `color.surfaceVariant` | A quiet alternate surface tone — and the **canonical fill for any "subtle container" region** that should read as separated from the page without being lifted. Input field fills, disabled control backgrounds, zebra-striping, muted section backgrounds, and contained sub-modules inside a card (poll banners, citation bodies, attachment chips). Prefer `surfaceVariant` over `surfaceContainer*` for this role: `surfaceContainer` collapses onto `surface` in light mode by design and produces no visible separation when used as a sub-module fill. Resolves to `ref.palette.neutral.100` (light) / `ref.palette.neutral.800` (dark). |
206
- | `color.onSurfaceVariant` | Secondary foreground for lower-emphasis text on any surface tone. Supporting copy, placeholders, helper text, metadata, inactive icon fills. Deliberately lighter than `onSurface` to establish a two-tier text hierarchy. Resolves to `ref.palette.neutral.700` (light) / `ref.palette.neutral.300` (dark). |
207
- | `color.surfaceDim` | A darker variant of `surface` — the darkest base background. Use when the page behind an elevated element needs to recede (dimmed canvas behind a modal/drawer, "quiet" screens where raised cards carry the focus). Resolves to `ref.palette.neutral.200` (light) / `ref.palette.neutral.900` (dark). |
208
- | `color.surfaceBright` | A brighter variant of `surface` — the brightest base background. Spotlight moments where the base layer itself should feel elevated (hero regions, focus screens, brightened split-view panels). Resolves to `ref.palette.neutral.0` (light) / `ref.palette.neutral.800` (dark). |
209
-
210
- ###### Container ladder
211
-
212
- | Token | Role |
213
- |---------------------------------|----------------------------------------------------------------------------------------------|
214
- | `color.surfaceContainerLowest` | Elevation level 0 — the lowest tier in the container stack. The most recessed tone: a soft notch beneath `surface` in light mode, the true palette floor (pure black) in dark mode. Sunken or inset regions: input wells, disabled control bodies, trough/rail backgrounds, page-header recessed bands. Resolves to `ref.palette.neutral.100` (light) / `ref.palette.neutral.1000` (dark). |
215
- | `color.surfaceContainerLow` | Elevation level 1 — low-prominence containers. Backgrounded secondary panels, sidebar sections, cards that should feel "attached" to the page rather than floating. Less recessed than `surfaceContainerLowest`. Resolves to `ref.palette.neutral.100` (light) / `ref.palette.neutral.900` (dark). |
216
- | `color.surfaceContainer` | Elevation level 2 — the default container tone. Standard cards, list items, feed tiles, most everyday content surfaces. Start here when in doubt. In light mode this matches `surface`; in dark mode it is one tonal step above `surface`. Resolves to `ref.palette.neutral.0` (light) / `ref.palette.neutral.800` (dark). |
217
- | `color.surfaceContainerHigh` | Elevation level 3 — the "raised" tone. Two families share this fill: (a) **scrim-anchored interruptions** — modals and dialogs, search view, bottom sheets, expanded navigation drawers; and (b) **in-page raised chrome** — bottom app bar, FAB surface variant, filter / toolbar button bodies (and the chip-chrome tabs that inherit them), selected cards, nested emphasized sections, neutral placeholder fills. Note: Toggle Button's active state uses `transparent` (not `surfaceContainerHigh`) so it sits cleanly on any host tier; only the inactive state's `primary` fill or the filter/toolbar chrome sit on this token. Tonally identical to `surfaceContainer` in both modes — visible lift comes from `elevation.overlay` (for scrim-anchored surfaces) or `elevation.floating` (for in-page raised containers), not an ever-brightening fill. Resolves to `ref.palette.neutral.0` (light) / `ref.palette.neutral.800` (dark). |
218
- | `color.surfaceContainerHighest` | Elevation level 4 — the topmost container tone, reserved for the *most* lifted surfaces that float over everything else without their own scrim. Menus, tooltips, popovers, filled text-field bodies, search bars. In light mode matches `surfaceContainer`; in dark mode steps up one tier so the topmost layer reads against the stack beneath it, reinforced by `elevation.overlay`. Resolves to `ref.palette.neutral.0` (light) / `ref.palette.neutral.700` (dark). |
219
-
220
- **Tonal elevation is capped, not stacked.** The Chorus brand goal is *calm and trustworthy*; ever-brightening surfaces feel showy and break the calm. Lift is expressed by `elevation.*` shadows; the surface names carry semantic weight ("this is a modal") even when the tone is identical to a card.
221
-
222
- #### Outline · Inverse · Focus · Scrim
223
-
224
- Borders, inverted overlays, focus indicators, backdrop dimming.
225
-
226
- ##### Five role-clusters
227
-
228
- - **Outline cluster** (`outline` / `outlineVariant`) — high vs. low emphasis border pair.
229
- - **Inverse cluster** (`inverseSurface` / `inverseOnSurface`) — mini-stack for elements that must contrast with the page (snackbars, tooltips). Action accents inside inverted components fall back to the regular `primary` family — the inverse canvas is contrast-tuned to clear AA against `primary` without a dedicated step.
230
- - **Focus cluster** (`focus` / `focusInset`) — outer ring + inner counter-ring pair. Always composed together (see [Focus ring composition](#focus-ring-composition)).
231
- - **Scrim** (solo) — translucent black for backdrop dimming.
232
- - **Elevation ink** (solo) — base shadow color, referenced only from `elevation.*` definitions, never as a fill.
233
-
234
- ###### Outline
235
-
236
- | Token | Role |
237
- |------------------------|------------------------------------------------------------------------------------------------------|
238
- | `color.outline` | High-emphasis border. Outlined buttons, form field borders, selected-state strokes, dividers that need to carry visual weight. Resolves to `ref.palette.neutral.400` (light) / `ref.palette.neutral.500` (dark). |
239
- | `color.outlineVariant` | Low-emphasis border. Subtle dividers, table row separators, card edges, decorative hairlines where `outline` would be too loud. Resolves to `ref.palette.neutral.200` (light) / `ref.palette.neutral.700` (dark). The dark step sits one band higher than a strict mirror of the light step (`neutral.200`) would land — at `neutral.800` the divider would collide with `surfaceVariant` (`neutral.800` in dark) and vanish against any sub-module fill. `neutral.700` keeps a low-emphasis divider visible against both `surface` (`neutral.900`) and `surfaceVariant` (`neutral.800`) without escalating to the high-emphasis `outline`. |
240
-
241
- ###### Inverse
242
-
243
- | Token | Role |
244
- |--------------------------|------------------------------------------------------------------------------------------------------|
245
- | `color.inverseSurface` | A surface that deliberately reverses the current mode — dark in light mode, light in dark mode. Components that must visually contrast with the surrounding page to grab attention: snackbars, toast backgrounds, coach-mark tooltips, onboarding highlights. Resolves to `ref.palette.neutral.900` (light) / `ref.palette.neutral.50` (dark). |
246
- | `color.inverseOnSurface` | Foreground on `inverseSurface`. Text and icons inside inverted surfaces. Resolves to `ref.palette.neutral.50` (light) / `ref.palette.neutral.900` (dark). |
247
-
248
- ###### Focus
249
-
250
- | Token | Role |
251
- |--------------------|------------------------------------------------------------------------------------------------------|
252
- | `color.focus` | Outer focus-ring color. Intentionally inverse-toned — dark in light mode, light in dark mode — so the ring reads against any surface in the stack regardless of the control's own fill. See [Focus ring composition](#focus-ring-composition) for the full three-layer rule. Resolves to `ref.palette.black.1000` (light) / `ref.palette.white.1000` (dark). |
253
- | `color.focusInset` | Inner counter-ring paired with `focus`. Mirrors `focus` in the opposite direction so even when the outer ring meets a similarly-toned background, the inset edge keeps the indicator legible. Resolves to `ref.palette.white.1000` (light) / `ref.palette.black.1000` (dark). |
254
-
255
- ###### Overlay
254
+ #### Dark-mode strategy
256
255
 
257
- | Token | Role |
258
- |-------------------|------------------------------------------------------------------------------------------------------|
259
- | `color.scrim` | Translucent black used to dim content behind a raised overlay. The backdrop behind modals, drawers, menus, and bottom sheets — focuses attention on the foreground and blocks interaction with the obscured layer. Resolves to `ref.palette.black.800`. |
260
- | `color.elevation` | Base color used to build elevation shadows (composited with opacity inside `elevation.*` definitions). Not for fills — reference only from elevation definitions. Resolves to `ref.palette.black.1000`. |
261
- | `color.placeholderImage.start` / `color.placeholderImage.end` | Gradient endpoints for a decorative **image-placeholder fallback** — composed as `linear-gradient(135deg, start 0%, end 100%)` on slots that expose user-supplied imagery (Feed post thumbnail, Citation hero) when the image `src` is empty or unloaded. Theme-aware: cool teal in light (`#c8e0e1 → #2d6f72`), cool neutral in dark (`#3a4548 → #1a2226`) so the placeholder reads as "image content here" against either surface ladder without competing with adjacent thumbnails. **Not for non-image fills** — for empty/skeleton states of solid surfaces, use `surfaceContainerHigh` instead. |
256
+ Each role carries its own light and dark value; four inversion rules apply across the families.
262
257
 
263
- #### Dark-mode strategy
258
+ - **Neutral roles invert along the ramp.** Text, icon, border, surface, and neutral backgrounds swap ends of the grey ladder — light surfaces go dark, dark text goes light (`surface.default` `neutral.0` → `neutral.1000`; `text.default` `neutral.1000` → `neutral.100`).
259
+ - **Status & accent hues hold or brighten — they don't invert.** `text.brand` stays `brand.red` in both modes so identity is stable; `text.danger` brightens for a dark page (`red.500` → `red.400`); `text.link` / `text.success` / `border.primary` dim one step (`blue.500` → `blue.400`) so the blue reads without glaring. On-fill roles stay light in both modes.
260
+ - **Function backgrounds flip depth, not hue.** Pale banner tints in light become deep tints in dark (`background.selected` `blue.50` → `blue.900`; `background.danger` `red.100` → `red.900`), keeping the paired status foreground legible on either page.
261
+ - **Overlays composite over whatever is beneath.** The State layers (`overlay.hover` / `overlay.pressed`) hold the same low alpha in both modes; the Scrim layers deepen in dark so the backdrop still separates from the darker page (`overlay.scrim` ~32% → ~75%).
264
262
 
265
- - **Chromatic accents (`primary`, `brand`, `success`, `error`) do NOT invert their on-pair between modes.** `error` nudges one tonal step in dark (`red.600` → `red.700`) so the fill still reads against a dark page; `primary` stays at `blue.500`, `brand` at `red.500`, `success` at `green.500` in both modes because each hue clears AA against both `surface` tones and its `on*` foreground (`neutral.50`) without a nudge. The `on*` foreground stays at `neutral.50` across all four. Keeps brand identity stable across modes.
266
- - **Neutral roles (`secondary`, `surface*`, `onSurface*`, `outline*`) invert as usual.**
267
- - **Container pairs (`primaryContainer` / `onPrimaryContainer`, etc.) flip the *container*, not the foreground family**: light mode container is shallow (e.g. `blue.50` for primary, `red.50` / `green.50` for brand / success, `red.100` for error) with a saturated mid-band foreground (`blue.600`); dark mode container goes deep (`blue.900`) with a brighter mid-band foreground (`blue.400`). Both modes keep the foreground in the saturated primary family so the pair reads as *blue on tinted blue*, not *near-black on tinted blue*. Primary's light container sits one step brighter than the other quartets because it appears most often (active nav rows, brand callouts). The dark foreground lifts one band higher than a strict mirror (`blue.400` instead of `blue.500`) because equal luminance gaps appear shallower on dark surfaces.
268
- - **Focus ring inverts** so the outer ring is always inverse-toned to the page.
269
263
 
270
264
  #### Data visualization palette
271
265
 
@@ -283,13 +277,13 @@ Charts and category-coded surfaces draw from the same six reference hues, organi
283
277
 
284
278
  - **Six maximum for categorical.** Past six categories, group the long tail into "Other" or add a secondary visual channel (texture, position).
285
279
  - **Brand color comes first only when it carries meaning.** Using `blue.500` for the first series implies that series is "primary." If categories are equal, rotate the order or pick a non-brand starting hue.
286
- - **Reuse `color.error` and `color.success` for negative / positive coding.** Don't introduce chart-specific red or green.
280
+ - **Reuse the `danger` and `success` roles for negative / positive coding** — don't introduce a chart-specific red. Note `success` resolves to blue, not green; when a chart needs a true green for positive coding, reach for `ref.palette.green.500` directly (dataviz-scoped exception).
287
281
  - **Dark mode shifts the steps, not the hues.** Light uses `*.500` for categorical; dark uses `*.400`/`*.300` so contrast against the dark canvas holds.
288
282
  - **Pair with a non-color channel** — pattern fills, direct labels, or shape differentiation. ~4% of users cannot distinguish red from green.
289
283
 
290
284
  ### Typography
291
285
 
292
- One typeface (Pretendard) handles both Latin and Hangul, materialized as fifteen roles across five purpose categories × three sizes — weight and line-height carry meaning by purpose, not by size.
286
+ One typeface (Pretendard) handles both Latin and Hangul, materialized as 14 roles across four purpose categories × three sizes (`body` and `label` add a fourth `xs` rung) — weight and line-height carry meaning by purpose, not by size.
293
287
 
294
288
  #### Font family
295
289
 
@@ -297,7 +291,7 @@ The system typeface is **Pretendard**.
297
291
 
298
292
  **Why Pretendard** — Chorus is a community service with high text volume where Hangul (국문) and Latin (영문) routinely appear side by side. Most sans-serif families are tuned for one script and break when scripts mix. Pretendard's Hangul and Latin share compatible x-height, weight, and optical rhythm, so mixed-script lines read as one continuous texture.
299
293
 
300
- **How to apply** — one family for every surface (display, heading, body, label, caption). Do not substitute a different family for Latin-only or Korean-only regions. The stack falls back to platform system fonts only when Pretendard fails to load:
294
+ **How to apply** — one family for every surface (display, heading, body, label). Do not substitute a different family for Latin-only or Korean-only regions. The stack falls back to platform system fonts only when Pretendard fails to load:
301
295
 
302
296
  ```
303
297
  Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif
@@ -305,12 +299,12 @@ Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Ne
305
299
 
306
300
  #### Categories × Sizes
307
301
 
308
- Four purpose categories × three size levels, plus the single rung-less `caption` role = 13 type roles, each composed of four atomic properties.
302
+ Four purpose categories × three size levels, plus two extra `xs` rungs — `body.xs` (12px reading metadata) and `label.xs` (the caption-scale control role, 10px) = 14 type roles, each composed of four atomic properties.
309
303
 
310
- - **Category axis (purpose)** — `display` (hero) → `heading` (structural title) → `body` (reading) → `label` (control) `caption` (metadata). Position determines weight and line-height by purpose, not by size.
311
- - **Size axis (emphasis within a category)** — `lg` / `md` / `sm`. `md` is the default.
304
+ - **Category axis (purpose)** — `display` (hero) → `heading` (structural title) → `body` (reading, incl. `body.xs` for inline metadata) → `label` (control, incl. the caption-scale `label.xs`). Position determines weight and line-height by purpose, not by size.
305
+ - **Size axis (emphasis within a category)** — `lg` / `md` / `sm`; `body` and `label` add `xs`. `md` is the default.
312
306
  - **Composition** — each grid cell composes four reference-tier values: `size` / `weight` / `line` / `tracking`. Never mix-and-match across cells.
313
- - **Responsive scaling grows with hierarchy, above the `md` baseline only.** `display.lg` jumps two scale steps on web (≥800px); `heading.lg` jumps one. `display.md/sm` / `heading.md/sm` stay constant. Body / label / caption are unconditionally flat. **`md` is the baseline, only sizes above it grow on web.**
307
+ - **Responsive scaling grows with hierarchy, above the `md` baseline only.** `display.lg` jumps two scale steps on web (≥800px); `heading.lg` jumps one. `display.md/sm` / `heading.md/sm` stay constant. Body / label are unconditionally flat. **`md` is the baseline, only sizes above it grow on web.**
314
308
 
315
309
  | Role | Size (mobile → Web) | Weight | Line | Tracking | Use |
316
310
  |---------------|---------------------|------------|--------|---------------|----------------------------------------------------|
@@ -318,21 +312,22 @@ Four purpose categories × three size levels, plus the single rung-less `caption
318
312
  | `typo.display.md` | 40 px | 700 Bold | 1.25 tight | -0.02em tight | Section heroes, featured content callouts, high-impact empty states. |
319
313
  | `typo.display.sm` | 32 px | 700 Bold | 1.25 tight | -0.02em tight | Compact heroes and sub-hero banners where vertical space is constrained. |
320
314
  | `typo.heading.lg` | 24 → 32 px | 600 Semibold | 1.25 tight | -0.01em snug | Page-level title — the single top heading of a screen or dialog. |
321
- | `typo.heading.md` | 20 px | 600 Semibold | 1.25 tight | -0.01em snug | Section title — card titles, modal titles, settings groups. |
315
+ | `typo.heading.md` | 18 px | 600 Semibold | 1.25 tight | -0.01em snug | Section title — card titles, modal titles, settings groups. |
322
316
  | `typo.heading.sm` | 16 px | 600 Semibold | 1.25 tight | -0.01em snug | Sub-section title, list-group headers, inline emphasis headings. |
323
- | `typo.body.lg` | 20 px | 400 Regular | 1.5 normal | 0em normal | Long-form bodies — article text, descriptions, main readable content. |
317
+ | `typo.body.lg` | 18 px | 400 Regular | 1.5 normal | 0em normal | Long-form bodies — article text, descriptions, main readable content. |
324
318
  | `typo.body.md` | 16 px | 400 Regular | 1.5 normal | 0em normal | Default body. Single-topic page bodies (article text, one-up content surfaces, descriptions where the reader stays in the block) and form-field input values. See [Composition recipes § Body text size](#body-text-size-14px-in-mixed-group-sections-16px-on-single-topic-pages) for picking between `body.md` and `body.sm`. |
325
- | `typo.body.sm` | 14 px | 400 Regular | 1.5 normal | 0em normal | Compact body. The right pick when a section composes multiple distinct text groups (cards listing several short descriptions, settings rows, feed items, dialog/callout messages); also dense lists, secondary descriptions, inline helper prose. Still a reading size, not a caption. |
319
+ | `typo.body.sm` | 14 px | 400 Regular | 1.35 snug | 0em normal | Compact body. The right pick when a section composes multiple distinct text groups (cards listing several short descriptions, settings rows, feed items, dialog/callout messages); also dense lists, secondary descriptions, inline helper prose. Still a reading size, not a caption. |
320
+ | `typo.body.xs` | 12 px | 400 Regular | 1.35 snug | 0em normal | Inline supplementary metadata that rides alongside reading content — timestamps, company / author attribution, nicknames, counts. The reading ramp's smallest rung; stays 400 to read as content, not a control. Pair with `label.sm` (600) when a metadata cluster needs an identifying anchor. |
326
321
  | `typo.label.lg` | 16 px | 600 Semibold | 1.5 normal | 0em normal | Primary CTA buttons, prominent tab labels, standalone form labels. |
327
322
  | `typo.label.md` | 14 px | 600 Semibold | 1.5 normal | 0em normal | Default control label. Standard buttons, input labels, menu items, chip labels; also form helper text, footnote-style explanations, figure captions. |
328
- | `typo.label.sm` | 12 px | 600 Semibold | 1.5 normal | 0.02em wide | Compact controls — dense toolbars, small badges, inline tag labels, supporting actions; also timestamps, attribution metadata, card metadata, image captions. |
329
- | `typo.caption` | 10 px | 600 Semibold | 1.5 normal | 0.02em wide | Smallest text rung (single rung-less role) — badge counts, legal fine print, dense metadata columns. Use sparingly. |
323
+ | `typo.label.sm` | 12 px | 600 Semibold | 1.5 normal | 0.02em wide | Compact controls — dense toolbars, small badges, inline tag labels, supporting actions; also *identifying* metadata that must stay scannable (channel / author names, follow state). Plain supplementary metadata — timestamps, company, nicknames, counts — uses `body.xs` (400), not this rung. |
324
+ | `typo.label.xs` | 10 px | 600 Semibold | 1.5 normal | 0.02em wide | Smallest label rung (caption-scale) — badge counts, legal fine print, dense metadata columns, tab-bar labels. Use sparingly. |
330
325
 
331
326
  #### Tracking & line-height principles
332
327
 
333
- - **Bold display, semibold heading, semibold label and caption, regular body.** Weight differentiates roles at the same size — `body.md` (400) and `label.lg` (600) share 16px but read differently. Labels and captions borrow heading's 600 to read as actionable / scannable; body stays regular for comfortable reading.
328
+ - **Bold display, semibold heading, semibold label, regular body.** Weight differentiates roles at the same size — `body.md` (400) and `label.lg` (600) share 16px but read differently. The split repeats wherever sizes coincide: at 12px, `label.sm` (600) reads as *identifying* metadata (channel name, follow state) while `body.xs` (400) reads as *supplementary* metadata (timestamp, nickname, count) — weight, not size, says which you're reading. The split tracks *emphasis*, not interactivity: a tappable nickname still sits in `body.xs`, because a feed need not elevate it; `label.sm`'s 600 is spent only where the screen genuinely wants attention drawn (channel name, follow control). Labels borrow heading's 600 to read as scannable; body stays regular for comfortable reading.
334
329
  - **Tracking only diverges at the extremes.** Display compresses (`-0.02em`); small UI text and uppercase widens (`0.02em`). Body and label-md use the typeface's intended spacing (`0em`).
335
- - **Line-height splits by purpose, not size.** Display and heading use `tight` (1.25); body / label / caption use `normal` (1.5).
330
+ - **Line-height splits by purpose and stays a unitless ratio so it scales with the font.** Display and heading use `tight` (1.25); long-form reading (`body.lg` / `body.md`) and all controls (`label.*`) use `normal` (1.5); the small reading rungs `body.sm` (14px) and `body.xs` (12px) use `snug` (1.35) — tighter than long-form because compact metadata sits in short runs. Line-height is never pinned to an absolute px: keeping it unitless is what lets the line grow with the glyph when font-size scales to the user's font-size preference. A ratio may land on a half-pixel at the 16px root (e.g. `heading.md` 18 × 1.25 = 22.5) — that is intended; once text scales, exact pixels are moot.
336
331
 
337
332
  #### Letter-spacing scale
338
333
 
@@ -350,10 +345,24 @@ Mapping into `typo.*`: display → `tight`, heading → `snug`, body / reading
350
345
 
351
346
  #### Font-size scale
352
347
 
353
- Built on the 8px base (`fontSize.100` = 8px = 1×, `fontSize.200` = 16px = 2×), with finer in-between rungs (10 / 14 / 18 / 56 / 72px) added where legibility demands resolution the spacing scale cannot provide. Each rung carries `$rem` alongside `$value` so consumers can emit rem units (which respect the user's browser font-size preference).
348
+ Built on the 8px base (`fontSize.100` = 8px = 1×, `fontSize.200` = 16px = 2×), with finer in-between rungs (10 / 14 / 18 / 56 / 72px) added where legibility demands resolution the spacing scale cannot provide. Each rung carries `$rem` alongside `$value`, and **CSS compiles font-size to `rem`** the accessibility-recommended unit, which respects the user's browser / OS font-size preference so text scales — while spacing, radius, and other dimensions stay `px` (fixed structure: only type scales, the layout grid does not).
349
+
350
+ **Base font size: `1rem = 16px`.** This is the browser default — the user's *100% / default* font size — **not a fixed equation**: when the user raises their font-size preference, `1rem` grows past 16px and every type token scales with it. Two consequences:
351
+
352
+ - **Don't pin the root.** For the scaling to work, a consuming web surface must **not** hard-set the root (`html`) font-size to a px value — `html { font-size: 16px }` pins the root and silently disables the user's preference. Leave it at the browser default (or `font-size: 100%`).
353
+ - **The px column is the design reference, not the shipped unit.** The pixel values in the tables above are the *rendered size at the 16px base* — what Figma shows and what designers spec — while the shipped web unit is `rem`. **Native ports never use `rem`**: they read the px `$value` and emit `sp` (Android) / points + Dynamic Type (iOS), which scale through the OS, so the `1rem = 16px` convention is web-only.
354
354
 
355
355
  The reference ladder is *material*, not vocabulary: `typo.*` categories pick rungs from it; product code never references `fontSize.*` directly.
356
356
 
357
+ #### Dynamic Type & the display/heading cap
358
+
359
+ Type scales to the user's font-size preference on every platform — web `rem`, iOS Dynamic Type, Android `sp` — all anchored to the same `1rem = 16px` base. Two policies keep that scaling consistent and layout-safe across platforms:
360
+
361
+ - **Reading roles scale uncapped; `display` and `heading` cap at 1.6×.** `body` and `label` — what a low-vision reader actually reads — honor the user's setting all the way up. `display` and `heading` are large, short, and mostly decorative, so their growth is bounded at **1.6× the base size**: an uncapped extreme setting would push `display.lg` (48px) past ~120px and break layouts, while 1.6× still gives a strong jump (`heading.lg` 24→38px). The bound is a single cross-platform policy, not a per-port accident.
362
+ - **Line-height stays a unitless ratio under scaling.** The 1.25 / 1.35 / 1.5 ratios grow with the glyph rather than freezing to an absolute px, so line spacing stays proportional as type scales — on every platform.
363
+
364
+ *Implementation status.* The 1.6× cap ships on iOS (`ChorusText.headingMaxScale`) and web (`min(rem, 1.6×px)` on `display` / `heading` in `tokens.css`); the Android per-category cap is the remaining follow-up. Line-height now applies on all three platforms — web (unitless), Android (baked `size × ratio` in `sp`), and iOS (`.lineSpacing` from the token ratio). Chrome with fixed geometry (tab bar, chip) still needs a layout pass so its labels don't clip at the largest accessibility sizes.
365
+
357
366
  #### Casing
358
367
 
359
368
  **Sentence case is the default** for every piece of UI text — navigation items, section titles, button and label text, page titles, dialog and toast bodies. Capitalize the first word and proper nouns; everything else stays lowercase. **Title Case Like This is not used anywhere in product surfaces.**
@@ -362,7 +371,7 @@ The reference ladder is *material*, not vocabulary: `typo.*` categories pick run
362
371
 
363
372
  **UPPERCASE is reserved for in-content category markers** — eyebrows, overlines, table-section headers — pairing with `letterSpacing.wider` (see [Letter-spacing scale](#letter-spacing-scale)). It does **not** apply to navigation structure (side-nav group labels, page-nav section headers); those use sentence case with hierarchy expressed by font size and weight. Apply via CSS `text-transform: uppercase`, never by writing the text in caps in source — the underlying text stays sentence case so it reads correctly when the transform is removed (search, screen readers, diff review, localization).
364
373
 
365
- **Exceptions** — proper nouns (Pretendard, Hangul), product names, code identifiers (`sys.color.primary`), acronyms (CTA, AA, WCAG) keep their natural casing.
374
+ **Exceptions** — proper nouns (Pretendard, Hangul), product names, code identifiers (`sys.color.text.default`), acronyms (CTA, AA, WCAG) keep their natural casing.
366
375
 
367
376
  ##### Segmented sentence case
368
377
 
@@ -409,10 +418,10 @@ The grid is **drawing-area, not bounding-box**. A 16px icon should occupy ~13px
409
418
 
410
419
  Icons inherit the same `on*` foreground tokens as the text they sit with by default. The dedicated `sys.color.icon.*` palette is the only other place a glyph may pick a colour from — components must never reach into `ref.palette.*` to paint an icon.
411
420
 
412
- - **Solo icon (icon-only button)** — color is the parent control's foreground role (`color.onSurface` for a ghost button, `color.onPrimary` inside a primary fill).
421
+ - **Solo icon (icon-only button)** — color is the parent control's foreground role (`icon.default` for a ghost button, `icon.onFill` inside a primary fill).
413
422
  - **Icon + label** — both use the same foreground; never paint the icon a different hue for emphasis. Hierarchy belongs to the label.
414
423
  - **Inactive / disabled** — inherit from the surrounding `state.disabled` opacity; do not pre-darken the icon SVG.
415
- - **Status icons** (success/error checks, alert glyphs) follow the accent role they signal: `color.success`, `color.error`, paired with their `on*` foreground when on a filled accent surface.
424
+ - **Status icons** (success/error checks, alert glyphs) follow the role they signal: `icon.success`, `icon.danger`, or the paired `text.onFill` / `icon.onFill` when on a filled status surface.
416
425
  - **Semantic-glyph paint** — when the glyph itself carries meaning that is *not* a backgrounded status pair (an unpressed vs. starred favourite, a "live" pulse next to text, a premium/AI badge glyph), pick from `sys.color.icon.*` (`muted`, `yellow`, `red`, `blue`, `green`, `purple`). These hues are tuned one step lighter than their `sys.color.*` background analogues for small-glyph optical readability and pair with `icon.muted` (translucent inverse-tone) for the inactive state on the same affordance — flip state by colour, not by shape.
417
426
 
418
427
  | Token | Light | Dark | Typical use |
@@ -468,7 +477,7 @@ The ladder is unitless. Scales bind it to different units:
468
477
 
469
478
  - **Spacing** binds to **pixels** — each rung is a `space.*` step (`0px` … `80px`); see [Reference scale](#reference-scale).
470
479
  - **Type rungs** bind to **pixels** for ladder rungs (8 / 16 / 24 / 32 / 40 / 48 / 64 / 80px); finer typographic resolution adds in-between rungs where legibility demands it ([Font-size scale](#font-size-scale)).
471
- - **Opacity** binds to **percent** — `palette.black.*` / `palette.white.*` alphas are drawn from the ladder read as percent, plus a color-specific endpoint at `100%` for fully-opaque overlays (`color.focus`, `color.elevation`). 100 isn't on the spacing/typography ladder because spacing has no "fully opaque." See [Opacity ramp](#opacity-ramp).
480
+ - **Opacity** binds to **percent** — `palette.black.*` / `palette.white.*` alphas are drawn from the ladder read as percent, plus a color-specific endpoint at `100%` for fully-opaque overlays and shadow ink. 100 isn't on the spacing/typography ladder because spacing has no "fully opaque." See [Opacity ramp](#opacity-ramp).
472
481
 
473
482
  Surfaces needing a value outside the ladder are a code-review signal — either the ladder needs a step, or the surface is reaching past the system tier.
474
483
 
@@ -681,7 +690,7 @@ Hardcoded values (`border: 1px`, `border: 2px`) accumulate inconsistently across
681
690
  | `borderWidth.thin` | 2px | Emphasis borders — focus ring outer, selected-state outlines, error-state field borders. Strong enough to register without competing with the fill. |
682
691
  | `borderWidth.thick` | 4px | Load-bearing strokes — keyboard-focus emphasis on touch surfaces, status indicators (active tab underline at hero scale), decorative rules. Use sparingly. |
683
692
 
684
- The focus ring composition (see [Focus ring composition](#focus-ring-composition)) consumes `borderWidth.thin` for the outer ring and a 1px inner counter-ring.
693
+ The focus ring composition (see [Focus ring composition](#focus-ring-composition)) is a single `borderWidth.hairline` (1px) ring in `border.focused`.
685
694
 
686
695
  **Sub-pixel widths are forbidden.** A 0.5px hairline renders inconsistently across DPR. For a thinner-than-1px effect, lower the stroke color's *opacity*, not the width.
687
696
 
@@ -703,21 +712,20 @@ Shadows are classified by **spatial role**, not by component. A card and a selec
703
712
 
704
713
  Three lift levels plus one direction-special token. Each preset is a self-contained two-layer shadow (tight ambient + wider spread); components consume the role, never assemble shadows themselves.
705
714
 
706
- - **Lift ramp** — `raised` (subtle) → `floating` (free-floating) → `overlay` (page-blocking). Each step deepens the spread layer; meaning is the spatial relationship (sits-on vs hovers-above vs blocks).
715
+ - **Lift ramp** — `floating` (free-floating) → `overlay` (page-blocking). Each step deepens the spread layer; meaning is the spatial relationship (hovers-above vs blocks). Surfaces that merely sit on the page (cards at rest, list rows) stay flat and separate via border, not shadow.
707
716
  - **Direction-special** — `sheet`. Same intensity as `floating`, offset inverted so the shadow projects *away from the anchored edge* (bottom sheets cast upward).
708
717
  - **Two-layer composition.** Tight ambient layer + wider spread layer, mirroring physical light so edges stay crisp while the halo fades.
709
718
  - **Shadow alphas come from the overlay palettes.** `palette.black.*` draws from the [base-unit ladder](#base-unit-ladder) read as percent — every shadow alpha (4 / 6 / 8 / 12 / 16 / 20%) is a ladder step.
710
719
 
711
720
  | Token | Two-layer shadow | Role |
712
721
  |----------------------|-----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
713
- | `elevation.raised` | `0 1px 2px black/4%, 0 2px 6px black/6%` | Subtle lift. Cards at rest, hovered list rows, selected menu items, buttons that should read as gently elevated without demanding attention. |
714
722
  | `elevation.floating` | `0 2px 4px black/6%, 0 8px 20px black/12%` | Free-floating above the page. FABs, floating menus, dropdowns, autocomplete panels — elements that detach from the flow and hover over content. |
715
723
  | `elevation.overlay` | `0 4px 12px black/8%, 0 16px 48px black/20%` | Page-level overlay demanding user focus. Modals, dialogs, popovers, full-screen prompts that sit above a scrim and block interaction below. |
716
724
  | `elevation.sheet` | `0 -2px 6px black/4%, 0 -8px 24px black/16%` | Edge-anchored panel projecting shadow away from its anchored edge (here, anchored bottom — shadow rises). Bottom sheets, side drawers, pinned panels. |
717
725
 
718
726
  ### State layers & Focus
719
727
 
720
- A single rule expresses every interactive state — paint a translucent layer of the element's foreground over its base, at the state's opacity — paired with a three-layer focus ring.
728
+ A single rule expresses every interactive state — paint a translucent layer of the element's foreground over its base, at the state's opacity — paired with a single-ring focus indicator.
721
729
 
722
730
  #### State overlays
723
731
 
@@ -732,10 +740,10 @@ Interactive controls need feedback for hover, focus, pressed, dragged across man
732
740
  **How to apply**:
733
741
 
734
742
  1. **Pick the overlay color** — it is the element's foreground.
735
- - Filled primary button → `color.onPrimary`.
736
- - Tonal button on `primaryContainer` → `color.onPrimaryContainer`.
737
- - Text / ghost button on `surface` → `color.primary` (the ink becomes the overlay when there is no fill).
738
- - Selectable surface (list row, menu item) → `color.onSurface`.
743
+ - Filled primary button → `text.onFill`.
744
+ - Tonal button on `background.selected` → `text.link`.
745
+ - Text / ghost button on `surface.default` → `text.link` (the ink becomes the overlay when there is no fill).
746
+ - Selectable surface (list row, menu item) → `text.default`.
739
747
  2. **Pick the opacity from `state.*`** based on active state.
740
748
  3. **Composite** — render the overlay as a layer (pseudo-element, extra background-image, or `color-mix`) clipped to the element's shape.
741
749
  4. **Stack additively** when states coexist. Focused + hovered → 8% + 12% composited. Pressed during focus → 12% + 16%.
@@ -753,27 +761,26 @@ Interactive controls need feedback for hover, focus, pressed, dragged across man
753
761
 
754
762
  #### Focus ring composition
755
763
 
756
- The state overlay alone doesn't meet keyboard-accessibility contrast requirements. Every interactive control pairs `state.focus` with a visible ring on `:focus-visible` — a **three-layer composition** built outward from the control's edge:
764
+ The state overlay alone doesn't meet keyboard-accessibility contrast requirements. Every interactive control pairs `state.focus` with a visible ring on `:focus-visible` — a **single 1px ring** in `border.focused`:
757
765
 
758
- | Layer | Position relative to control | Width | Token |
759
- |----------------------|---------------------------------------|-------|----------------------|
760
- | Fill layer | painted ON the control's surface | — | `state.focus` (12%) of the variant's foreground composited over the variant's container |
761
- | Inner counter-ring | 0 → 1px outside the control's edge | 1px | `color.focusInset` |
762
- | Outer ring | 1px → 3px outside the control's edge | 2px | `color.focus` |
766
+ | Layer | Position relative to control | Width | Token |
767
+ |------------|---------------------------------|-------|--------------------------|
768
+ | Fill layer | painted ON the control's surface | — | `state.focus` (12%) of the variant's foreground composited over the variant's container |
769
+ | Ring | at the control's edge | 1px | `sys.color.border.focused` |
763
770
 
764
- Reading from the control outward: **fill layer (on the control) 1px `color.focusInset` 2px `color.focus`**. Both rings are always visible `color.focusInset` is a thin interior counter-ring (a single-pixel inverse-toned hairline between control and outer ring); the one-pixel inversion guarantees a visible edge on any surface.
771
+ `border.focused` is **neutral** (near-black in light, mid-grey in dark), so the ring reads against any surface in the stack without competing with `border.primary`'s blue. It is a single ring no coloured stroke and no white inset counter-ring. (The earlier two-layer outer-ring + inset-hairline composition is retired.)
765
772
 
766
773
  The ring sits on a **dedicated overlay layer** — a `position: absolute` pseudo-element (`::after`) — not an `outline` / `box-shadow` on the control. The pseudo draws *on top of* the state-overlay tint and label, and **never affects layout** — focus moving across a row never reflows a sibling.
767
774
 
768
775
  Two named compositions cover every control:
769
776
 
770
- **Outward** — the default. Ring sits *outside* the control's footprint, extending 0..3px beyond the edge. For controls that live inline with breathing room — **action affordances**: Button (every appearance), Chip, Form Field, FAB, Icon Button, Text Button. The 3px outward extent is reserved by surrounding layout.
777
+ **Outward** — the default. Ring sits *outside* the control's footprint. For controls that live inline with breathing room — **action affordances**: Button (every appearance), Chip, Form Field, FAB, Icon Button, Text Button. The outward extent is reserved by surrounding layout.
771
778
 
772
- **Inward** — for **container-shaped components** filling their parent edge-to-edge: Tab Bar (slots flush at `flex: 1 1 0`), Tabs Underline (row in `overflow-x: auto` scroller), List (rows tile the column with a hairline divider). Drawn *inside* the bounding box (depth 0..3px inward), avoiding clipping at scrollers / overlap with neighbours / past dividers. Composition is identical to outward; only the offset flips. Both rings inherit the control's `border-radius`. Suppressed while `disabled`.
779
+ **Inward** — for **container-shaped components** filling their parent edge-to-edge: Tab Bar (slots flush at `flex: 1 1 0`), Tabs Underline (row in `overflow-x: auto` scroller), List (rows tile the column with a hairline divider). Drawn *inside* the bounding box, avoiding clipping at scrollers / overlap with neighbours / past dividers. Composition is identical to outward; only the offset flips. The ring inherits the control's `border-radius`. Suppressed while `disabled`.
773
780
 
774
781
  **Choosing.** Default to **Outward**. Switch to **Inward** when *any* of: (a) the control is flush against a sibling, (b) the parent is an `overflow: hidden` / `overflow-x: auto` scroller, (c) the footprint tiles the available width (`flex: 1 1 0` slot, `width: 100%` list row). The choice is fixed per sub-component — never per-instance.
775
782
 
776
- Canonical CSS recipe — write on a pseudo-element layer, write the multi-shadow inline (do **not** wrap in a `var()`; Chrome resolves stylesheet `box-shadow: var(--multi-shadow)` to zero spreads):
783
+ Canonical CSS recipe — write on a pseudo-element layer:
777
784
 
778
785
  ```css
779
786
  .control { position: relative; isolation: isolate; }
@@ -791,16 +798,12 @@ Canonical CSS recipe — write on a pseudo-element layer, write the multi-shadow
791
798
 
792
799
  .control:focus-visible::after {
793
800
  /* outward — the default */
794
- box-shadow:
795
- 0 0 0 1px var(--sys-color-focusInset), /* inner counter-ring, on top */
796
- 0 0 0 3px var(--sys-color-focus); /* outer ring, visible at 1..3px */
801
+ box-shadow: 0 0 0 var(--sys-borderWidth-hairline) var(--sys-color-border-focused);
797
802
  }
798
803
 
799
- /* inside a scroller, re-anchor inward instead (same two layers): */
804
+ /* inside a scroller, re-anchor inward (same single ring): */
800
805
  .scroller .control:focus-visible::after {
801
- box-shadow:
802
- inset 0 0 0 2px var(--sys-color-focus), /* outer stroke at 0..2px in */
803
- inset 0 0 0 3px var(--sys-color-focusInset); /* counter-ring visible at 2..3px in */
806
+ box-shadow: inset 0 0 0 var(--sys-borderWidth-hairline) var(--sys-color-border-focused);
804
807
  }
805
808
 
806
809
  .control:disabled::after { box-shadow: none; }
@@ -816,19 +819,19 @@ Text links diverge from [State overlays](#state-overlays) because they have no c
816
819
 
817
820
  | State | Treatment |
818
821
  |--------------|--------------------------------------------------------------------------------------------------|
819
- | `default` | No decoration. Color is the link's resting ink (`color.primary` for accent links, `color.onSurface` for navigation labels — whatever the surrounding type role specifies). |
822
+ | `default` | No decoration. Color is the link's resting ink (`text.link` for accent links, `text.default` for navigation labels — whatever the surrounding type role specifies). |
820
823
  | `hovered` | `text-decoration: underline`, `text-decoration-thickness: 1px`, `text-underline-offset: 2px`. **Color does not change** — the underline is the affordance. |
821
824
  | `pressed` | Underline persists; opacity drops to `state.pressed` (16%) overlay on the text via `color-mix` so the link feels tactile without flipping its ink color. |
822
825
  | `disabled` | Element opacity at `state.disabled` (40%); underline suppressed. |
823
- | `focused` | Underline persists; the three-layer focus ring (see [Focus ring composition](#focus-ring-composition)) paints around the link's text box. |
826
+ | `focused` | Underline persists; the focus ring (see [Focus ring composition](#focus-ring-composition)) paints around the link's text box. |
824
827
 
825
828
  Underline appears **only on hover** because resting text already inherits hierarchy from `typo.*` — a permanent underline would over-emphasize navigation chrome.
826
829
 
827
- **Do not change color on hover.** A blue-on-hover (flipping to `color.primary`) competes with the surrounding type's color hierarchy and reads as a category change. Underline owns hover; color owns role.
830
+ **Do not change color on hover.** A blue-on-hover (flipping to `text.link`) competes with the surrounding type's color hierarchy and reads as a category change. Underline owns hover; color owns role.
828
831
 
829
832
  ```css
830
833
  .text-link {
831
- color: inherit; /* or color.primary for accent links */
834
+ color: inherit; /* or text.link for accent links */
832
835
  text-decoration: none;
833
836
  }
834
837
  .text-link:hover {
@@ -837,7 +840,7 @@ Underline appears **only on hover** because resting text already inherits hierar
837
840
  text-underline-offset: 2px;
838
841
  }
839
842
  .text-link:focus-visible {
840
- /* three-layer focus ring per Focus ring composition above */
843
+ /* single-ring focus per Focus ring composition above */
841
844
  }
842
845
  ```
843
846
 
@@ -849,20 +852,20 @@ The blinking insertion mark inside a text-input slot (`<input>`, `<textarea>`, c
849
852
 
850
853
  | Property | Value | Token |
851
854
  |------------------|------------------------------------------------|--------------------------------------|
852
- | Color | High-emphasis foreground of the surface — same ink the user is typing | `sys.color.onSurface` (default), `sys.color.error` (error appearance) |
855
+ | Color | High-emphasis foreground of the surface — same ink the user is typing | `sys.color.text.default` (default), `sys.color.text.danger` (error appearance) |
853
856
  | Intended width | 2px | `sys.borderWidth.thin` |
854
857
  | Intended height | 0.75 × the input's text line-box (computed from `line-height`) | derived |
855
858
  | Intended ends | Rounded | radius equal to half the caret width |
856
859
 
857
860
  **Color is the only part of the recipe browsers honour.** Standard CSS exposes `caret-color` and nothing else — width, height, end-cap shape are painted by the browser's text engine and can't be overridden without forfeiting the native input (and with it: IME composition, RTL, screen-reader cursor, mobile autocorrect). The width / height / rounded-ends columns are **design intent** the system asks browsers to approximate; the enforceable contract is colour.
858
861
 
859
- **Why no Caret component.** A caret isn't compositional — it lives inside a text-input element, has no React tree, can't accept props. Wrapping it would either re-implement the input surface in JS over a `caret-color: transparent` field (breaks platform IME / a11y) or invent a token group with nothing to wire into. Every input-bearing component sets `caret-color: var(--sys-color-onSurface)` (or `var(--sys-color-error)` on error) and references this section.
862
+ **Why no Caret component.** A caret isn't compositional — it lives inside a text-input element, has no React tree, can't accept props. Wrapping it would either re-implement the input surface in JS over a `caret-color: transparent` field (breaks platform IME / a11y) or invent a token group with nothing to wire into. Every input-bearing component sets `caret-color: var(--sys-color-text-default)` (or `var(--sys-color-text-danger)` on error) and references this section.
860
863
 
861
864
  ```css
862
865
  .chorus-input,
863
866
  .chorus-field__input,
864
867
  .chorus-navigation-bar__search-input {
865
- caret-color: var(--sys-color-onSurface);
868
+ caret-color: var(--sys-color-text-default);
866
869
  }
867
870
 
868
871
  .chorus-input.is-error,
@@ -871,7 +874,7 @@ The blinking insertion mark inside a text-input slot (`<input>`, `<textarea>`, c
871
874
  }
872
875
  ```
873
876
 
874
- **Inheritance shortcut** — when the input element's `color` is bound to the right ink (Form Field Input sets `color: var(--field-text)` resolving to `onSurface` / `onErrorContainer`), `caret-color` inherits automatically. Set `caret-color` explicitly only when the input's own `color` differs from the caret's intended colour.
877
+ **Inheritance shortcut** — when the input element's `color` is bound to the right ink (Form Field Input sets `color: var(--field-text)` resolving to `text.default` / `text.danger`), `caret-color` inherits automatically. Set `caret-color` explicitly only when the input's own `color` differs from the caret's intended colour.
875
878
 
876
879
  ### Responsive behavior
877
880
 
@@ -899,7 +902,7 @@ Per-group rules for the mobile→tablet (800px) step-up. **`md` is the responsiv
899
902
  | `typo.display.lg` | +2 scale steps (48 → 80px) |
900
903
  | `typo.heading.lg` | +1 scale step (24 → 32px) |
901
904
  | `typo.display.md/sm`, `typo.heading.md/sm` | unchanged — already small enough to read on any viewport |
902
- | `typo.body.*`, `typo.label.*`, `typo.caption.*` | unchanged — reading and tap targets stay constant |
905
+ | `typo.body.*`, `typo.label.*` (incl. `label.xs`) | unchanged — reading and tap targets stay constant |
903
906
  | `layout.*` at `lg` and above | +1 step (e.g. `layout.container.lg` 24 → 32px) |
904
907
  | `layout.*` at `md` and below | unchanged — flat across the mobile↔web line |
905
908
  | Elevation, radius, color, state | unchanged |
@@ -938,15 +941,15 @@ Accessibility is a property of the token system, not a checklist applied at the
938
941
 
939
942
  #### Conformance targets
940
943
 
941
- Chorus targets **WCAG 2.2 Level AA** as the floor for every product surface and **AAA where the foundations already meet it** (e.g. `onSurface`/`surface` clears AAA at 7:1 in both modes). A surface that fails AA is a bug — fix or document the exception.
944
+ Chorus targets **WCAG 2.2 Level AA** as the floor for every product surface and **AAA where the foundations already meet it** (e.g. `text.default`/`surface.default` clears AAA at 7:1 in both modes). A surface that fails AA is a bug — fix or document the exception.
942
945
 
943
946
  #### Color contrast
944
947
 
945
948
  Enforced by the **paired-token rule**: every fill ships with its `on*` foreground, tuned to clear 4.5:1 for body text and 3:1 for large text and non-text UI.
946
949
 
947
- - **Never read contrast manually.** If two roles aren't paired, they aren't a permitted combination. `onSurface` text on a `primary` background bypasses the contract.
948
- - **Surface stack is single-pair.** All `surfaceContainer*` tones read against `onSurface`. The ladder carries *spatial meaning*, not contrast variation.
949
- - **Lower-emphasis text uses `onSurfaceVariant`** — still ≥ 4.5:1 against every surface tone, one step lighter than `onSurface` for two-tier hierarchy.
950
+ - **Never read contrast manually.** If two roles aren't paired, they aren't a permitted combination. `text.default` on a `background.primary` fill bypasses the contract.
951
+ - **Surface is single-pair.** Both `surface.default` and `surface.sunken` read against `text.default`. Surface names carry *spatial meaning*, not contrast variation.
952
+ - **Lower-emphasis text uses `text.subtle`** — still ≥ 4.5:1 against every surface tone, one step lighter than `text.default` for two-tier hierarchy.
950
953
  - **Disabled is the exception.** `state.disabled` (40% opacity) drops below AA on purpose — WCAG 1.4.3 inactive-component carve-out applies. Never use `disabled` styling to convey live information.
951
954
 
952
955
  #### Touch & pointer targets
@@ -988,7 +991,7 @@ Every interactive control must be reachable, operable, and visible to a keyboard
988
991
  - **Don't convey meaning by color alone.** Required markers, error states, status pills pair color with text or icon.
989
992
  - **Resize support to 200%.** Type scales in rem; layout doesn't break, no horizontal scroll at zoom 200%.
990
993
  - **Reflow at 320 CSS pixels.** Mobile-narrow content reflows without horizontal scroll except for elements needing 2D scrolling (tables, code blocks, maps).
991
- - **`prefers-contrast: more`** — increase border weight from `borderWidth.hairline` to `borderWidth.thin`, switch `outlineVariant` to `outline`, drop tonal elevation in favor of explicit borders.
994
+ - **`prefers-contrast: more`** — increase border weight from `borderWidth.hairline` to `borderWidth.thin`, switch `border.default` to `border.bold`, drop tonal elevation in favor of explicit borders.
992
995
  - **Plain language.** Use [Voice & Content](#voice--content) rules.
993
996
 
994
997
  #### Internationalization
@@ -1016,12 +1019,12 @@ Quick rules, paired 1-to-1: each Do has a matching Don't.
1016
1019
  - **Pair every accent fill with its `on*` foreground.** Pairs are tuned to clear AA.
1017
1020
  - **Reach for `XContainer` + `onXContainer` for tinted surfaces.** Callouts, info banners, success tiles, selected rows — the Container tone is the tint.
1018
1021
  - **Compose state as foreground-over-base.** `state.*` opacity over the element's foreground — works on every variant.
1019
- - **Express lift with `elevation.*` shadows.** `surfaceContainer*` names carry spatial *meaning* even when tones collapse.
1022
+ - **Express lift with `elevation.*` shadows.** Surface names carry spatial *meaning* even when the tone is identical.
1020
1023
  - **Use `layout.*` for layout-participating spacing.** Page gutters, card insets, section rhythm grow on web; raw `space.*` only for fixed-footprint controls.
1021
1024
  - **Apply `layout.page.*` once at the route root.** Nested content uses `layout.container.*` / `layout.stack.*` / `layout.inline.*`.
1022
1025
  - **Use Pretendard for both Hangul and Latin.**
1023
1026
  - **Use `radius.md` for controls and `radius.xl` for surfaces.** Containers visually "hold" the controls inside them.
1024
- - **Build every `:focus-visible` ring from the three-layer composition.** Outer `color.focus`, `state.focus` fill, inner `color.focusInset`.
1027
+ - **Build every `:focus-visible` ring from the standard composition.** A single 1px `border.focused` ring plus the `state.focus` fill overlay.
1025
1028
 
1026
1029
  #### Don't
1027
1030
 
@@ -1030,12 +1033,12 @@ Quick rules, paired 1-to-1: each Do has a matching Don't.
1030
1033
  - **Don't read foreground contrast manually or mix `on*` across roles.** A handpicked text color silently breaks AA as the palette evolves.
1031
1034
  - **Don't compose ad-hoc tinted surfaces with `color-mix(<accent> N%, <surface>)`.** A 5–10% accent over `surface*` for a callout, banner, info block, or "subtle" highlight bypasses the Container quartet's AA contract. The only allowed `color-mix` involving an accent is the [state-overlay formula](#state-overlays) or a [decorative gradient stop fading to `transparent`](#four-token-quartet) where text contrast is governed by the underlying base.
1032
1035
  - **Don't hardcode hover or pressed colors per component.**
1033
- - **Don't add tonal elevation in light mode.** All `surfaceContainer*` tones resolve to `#ffffff` by design.
1036
+ - **Don't add tonal elevation in light mode.** `surface.default` resolves to `#fcfcfd` and lift comes from `elevation.*` shadows, not brighter fills.
1034
1037
  - **Don't reach for raw `space.*` for layout-level rhythm.** Section gaps, card-stack rhythm, page gutters live in `layout.*`.
1035
1038
  - **Don't reapply `layout.page.*` to nested content.** Full-bleed elements opt out by negating the gutter, not by changing the token.
1036
1039
  - **Don't substitute Latin-only or Korean-only fonts per region.**
1037
1040
  - **Don't introduce per-corner radius tokens.** They multiply the token surface and don't survive a global radius change.
1038
- - **Don't use `color.focus` alone.** A single-layer ring fails contrast against same-toned backgrounds.
1041
+ - **Don't recolour the focus ring per control.** `border.focused` is neutral by design so it reads on any surface; a per-control hue (e.g. blue on a blue button) can vanish.
1039
1042
 
1040
1043
  ---
1041
1044
 
@@ -1080,7 +1083,7 @@ Three lines max: **what this surface is for · why it's empty · the one action
1080
1083
 
1081
1084
  - ✅ "No posts yet. Conversations you start or join will appear here. **Start a post.**"
1082
1085
 
1083
- The CTA is often the surface's primary action — make it primary visually too (`color.primary` button).
1086
+ The CTA is often the surface's primary action — make it primary visually too (`background.primary` button).
1084
1087
 
1085
1088
  #### Loading & success
1086
1089
 
@@ -1148,16 +1151,16 @@ The trap is splitting on *how different it looks*. `outlined` looks nothing like
1148
1151
 
1149
1152
  Three lines max — see [Empty states](#empty-states) in Voice & Content for writing rules. Visual composition:
1150
1153
 
1151
- - **Optional illustration** at `icon.xl` or larger, centered, color `color.onSurfaceVariant` (illustrations stay monochrome unless they carry brand-moment intent).
1152
- - **Headline** in `typo.heading.sm` color `color.onSurface`, `layout.stack.sm` below illustration.
1153
- - **Body** in `typo.body.sm` color `color.onSurfaceVariant`, `layout.stack.2xs` below headline.
1154
+ - **Optional illustration** at `icon.xl` or larger, centered, color `text.subtle` (illustrations stay monochrome unless they carry brand-moment intent).
1155
+ - **Headline** in `typo.heading.sm` color `text.default`, `layout.stack.sm` below illustration.
1156
+ - **Body** in `typo.body.sm` color `text.subtle`, `layout.stack.2xs` below headline.
1154
1157
  - **Primary CTA** as a default-size primary button, `layout.stack.md` below body.
1155
1158
  - Whole composition centered inside the surface that would otherwise hold the data.
1156
1159
 
1157
1160
  ### Loading & Skeleton states
1158
1161
 
1159
- - **Spinners** for indeterminate loads under ~1 second of expected wait. Use `color.primary` for foreground motion on neutral surfaces; reserve to a single spinner per view.
1160
- - **Skeleton placeholders** for content shapes that will arrive — feed cards, list rows, profile headers. Skeleton color is `color.surfaceContainerHigh`; the pulse animation runs at 1.5–2 Hz (well below the WCAG flash threshold) and respects `prefers-reduced-motion: reduce` (no animation; show the skeleton statically).
1162
+ - **Spinners** for indeterminate loads under ~1 second of expected wait. Use `background.primary` for foreground motion on neutral surfaces; reserve to a single spinner per view.
1163
+ - **Skeleton placeholders** for content shapes that will arrive — feed cards, list rows, profile headers. Skeleton color is `background.neutral`; the pulse animation runs at 1.5–2 Hz (well below the WCAG flash threshold) and respects `prefers-reduced-motion: reduce` (no animation; show the skeleton statically).
1161
1164
  - **Match the shape**, not the content. A skeleton for a feed card uses the same radius, padding, and inline rhythm as the real card so the layout doesn't jump on resolution.
1162
1165
  - **Don't skeleton tiny surfaces.** A spinner is faster than authoring a skeleton for a 40px button.
1163
1166
  - **Loading copy** lives inside the skeleton or beside the spinner — see [Loading & success](#loading--success) for the writing rule.
@@ -1246,13 +1249,13 @@ Chorus-specific vocabulary. The section introducing each term is canonical.
1246
1249
  - **System tier (`sys.*`)** — Semantic roles that consume the reference tier and form the vocabulary product surfaces speak in. The default tier for any product code.
1247
1250
  - **Component tier (`comp.*`)** — Per-component tokens that bind system roles to a component's contract. Opt-in; currently empty by design.
1248
1251
  - **Quartet** — The fixed four-token unit every accent role ships as: `X` / `onX` / `XContainer` / `onXContainer`. The unit of meaning; never use a fill without its `on*`. See [Four-token quartet](#four-token-quartet).
1249
- - **Container ladder** — The five-step `surfaceContainerLowest` `Lowest` `default` `High` `Highest` stack. Encodes *spatial role*, not five distinct fill tones.
1252
+ - **Surface roles** — `surface.default` (page / card ground) and `surface.sunken` (gutters, wells). Two planes, not a tonal ladder; spatial meaning without ever-brightening fills.
1250
1253
  - **Base-unit ladder** — The single canonical numeric ladder (`0 · 2 · 4 · 6 · 8 · 12 · 16 · 20 · 24 · 40 · 48 · 64 · 80`) that spacing (px), type (px), and opacity (%) all draw from. See [Base-unit ladder](#base-unit-ladder).
1251
1254
  - **Layout axis** — One of four orthogonal spacing roles: `page`, `container`, `stack`, `inline`. Each owns one spatial relationship and is applied by exactly one kind of element.
1252
1255
  - **Veil / Scrim / Endpoint** — The three opacity bands of `palette.black` / `palette.white`: veil (4–24%) for state overlays, scrim (40–80%) for backdrop dimming, endpoint (0% / 100%) for reset and fully-opaque uses.
1253
- - **Tonal elevation** — the pattern of expressing lift via brighter surface tones at higher elevation. Chorus *caps* tonal elevation in light mode (all `surfaceContainer*` collapse onto `#ffffff`); lift comes from `elevation.*` shadows.
1256
+ - **Tonal elevation** — the pattern of expressing lift via brighter surface tones at higher elevation. Chorus *does not* use tonal elevation; lift comes from `elevation.*` shadows and surface tone stays flat.
1254
1257
  - **State overlay** — The single rule that paints a translucent layer of an element's *foreground color* over its *base*, at the opacity defined by `state.*`. One rule, every variant. See [State overlays](#state-overlays).
1255
- - **Focus ring composition** — The fixed three-layer focus indicator: outer ring + fill + inner counter-ring. Every interactive control uses the same composition; never single-layer rings.
1258
+ - **Focus ring composition** — The focus indicator: a single 1px `border.focused` ring plus a `state.focus` fill overlay. Every interactive control uses the same composition.
1256
1259
  - **Slot** — A named region inside a component anatomy (`container`, `label`, `leadingIcon`, …). Tokens bind to slots, not to components as a whole.
1257
1260
  - **Sub-component** ("type") — A distinct anchoring role within a family that forks slot vocabulary or sizing (Icon vs Standard button), shipped as its own `<sub>.spec.json`. Forced by a *contract* change — a required slot, interaction model, or anchoring role differs — never by visual distance alone. See [Sub-component or case](#sub-component-or-case-the-contract-test).
1258
1261
  - **Composition case** — A configuration of one sub-component where *optional* slots are present or absent (`With leading icon`). The contract is unchanged; demonstrated under `## Use cases`, never forked into a sub-component.
@@ -1269,36 +1272,34 @@ Mapping from common UI needs to system tokens.
1269
1272
 
1270
1273
  | Need | Token | Light value |
1271
1274
  |-----------------------|--------------------------------------------------|---------------------|
1272
- | Page background | `color.surface` | `#ffffff` |
1273
- | Primary text | `color.onSurface` | `#121212` |
1274
- | Secondary text | `color.onSurfaceVariant` | `#3d3d3d` |
1275
- | Card surface | `color.surfaceContainer` | `#ffffff` |
1276
- | Card border | `color.outlineVariant` | `#e6e6e6` |
1277
- | Primary CTA fill | `color.primary` | `#2563eb` |
1278
- | Primary CTA text | `color.onPrimary` | `#fafafa` |
1279
- | Link | `color.primary` | `#2563eb` |
1280
- | Error | `color.error` | `#b42222` |
1281
- | Success | `color.success` | `#008838` |
1282
- | Focus ring (outer) | `color.focus` | `#000000` |
1283
- | Focus ring (inner) | `color.focusInset` | `#ffffff` |
1275
+ | Page background | `surface.default` | `#fcfcfd` |
1276
+ | Primary text | `color.text.default` | `#1e2024` |
1277
+ | Secondary text | `color.text.subtle` | `#5e6573` |
1278
+ | Card surface | `surface.default` | `#fcfcfd` |
1279
+ | Card border | `color.border.default` | `#eef0f5` |
1280
+ | Primary CTA fill | `color.background.primary` | `#007aff` |
1281
+ | Primary CTA text | `color.text.onFill` | `#fcfcfd` |
1282
+ | Link | `color.text.link` | `#007aff` |
1283
+ | Error | `color.text.danger` | `#e83a3a` |
1284
+ | Success (+ ✓ icon) | `color.text.success` | `#007aff` |
1285
+ | Focus ring | `color.border.focused` | `#007aff` |
1284
1286
  | Card padding | `layout.container.md` | 16 → 24px |
1285
1287
  | Page gutter | `layout.page.md` | 16 → 32px |
1286
1288
  | Section rhythm | `layout.stack.lg` | 24 → 32px |
1287
1289
  | Control radius | `radius.md` | 8px |
1288
1290
  | Surface radius | `radius.xl` | 16px |
1289
- | Card shadow | `elevation.raised` | two-layer ambient + spread |
1290
1291
 
1291
1292
  #### Example component prompts
1292
1293
 
1293
1294
  Reference prompts resolving through the system tier — copy and adapt.
1294
1295
 
1295
- > "Build a primary button: `color.primary` background, `color.onPrimary` text, `radius.md` corners, `layout.container.sm` vertical padding and `layout.container.md` horizontal padding, `typo.label.md` for the label. On `:hover`, composite an 8% `onPrimary` overlay; on `:focus-visible`, apply the three-layer focus ring (see [Focus ring composition](#focus-ring-composition))."
1296
+ > "Build a primary button: `background.primary` background, `text.onFill` text, `radius.md` corners, `layout.container.sm` vertical padding and `layout.container.md` horizontal padding, `typo.label.md` for the label. On `:hover`, composite an 8% `text.onFill` overlay; on `:focus-visible`, apply the single-ring focus indicator (see [Focus ring composition](#focus-ring-composition))."
1296
1297
 
1297
- > "Design a content card: `color.surfaceContainer` background, `radius.xl` corners, `elevation.raised` shadow, `layout.container.md` padding. Title in `typo.heading.md` `color.onSurface`; body in `typo.body.md` `color.onSurfaceVariant`. Stack title and body with `layout.stack.sm`."
1298
+ > "Design a content card: `surface.default` background, `radius.xl` corners, `1px solid border.default` border (cards stay flat — no shadow), `layout.container.md` padding. Title in `typo.heading.md` `text.default`; body in `typo.body.md` `text.subtle`. Stack title and body with `layout.stack.sm`."
1298
1299
 
1299
- > "Create a form field: `color.surfaceVariant` background, `radius.md` corners, `layout.container.sm` padding. Label above in `typo.label.sm` `color.onSurfaceVariant`. Border `1px solid color.outlineVariant`; on focus, full three-layer focus composition. Error state: border swaps to `color.error`, helper text uses `color.error` at `typo.label.sm`."
1300
+ > "Create a form field: `surface.sunken` background, `radius.md` corners, `layout.container.sm` padding. Label above in `typo.label.sm` `text.subtle`. Border `1px solid border.default`; on focus, border swaps to `border.focused` and the single-ring focus indicator paints. Error state: border swaps to `border.danger`, helper text uses `text.danger` at `typo.label.sm`."
1300
1301
 
1301
- > "Build a notification banner using the primaryContainer pair: `color.primaryContainer` background, `color.onPrimaryContainer` text and icons, `radius.lg` corners, `layout.container.md` padding. Inline with `layout.inline.md` between icon and text. No shadow — containers stay flat."
1302
+ > "Build an information banner: `background.information` background, `text.link` text and icons, `radius.lg` corners, `layout.container.md` padding. Inline with `layout.inline.md` between icon and text. No shadow — banners stay flat."
1302
1303
 
1303
1304
  #### Iteration rules
1304
1305