@boostdev/design-system-components 2.12.1 → 2.14.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 (48) hide show
  1. package/AGENTS.md +23 -1
  2. package/README.md +6 -0
  3. package/dist/client.cjs +52 -52
  4. package/dist/client.css +600 -601
  5. package/dist/client.js +52 -52
  6. package/dist/index.cjs +52 -52
  7. package/dist/index.css +600 -601
  8. package/dist/index.js +52 -52
  9. package/dist/web-components/{chunk-SFZ6Q4BP.js → chunk-5HUAFCU6.js} +1 -1
  10. package/dist/web-components/{chunk-YFAXGRPR.js → chunk-6ZNQPSQJ.js} +1 -1
  11. package/dist/web-components/{chunk-JMCXAAFK.js → chunk-HBLJ4SLY.js} +2 -2
  12. package/dist/web-components/{chunk-FUQEFHRC.js → chunk-SOYAOLKR.js} +1 -1
  13. package/dist/web-components/{chunk-F3ZDNHZZ.js → chunk-Z3BPJNEL.js} +1 -1
  14. package/dist/web-components/index.js +5 -5
  15. package/dist/web-components/interaction/bds-accordion-item.js +1 -1
  16. package/dist/web-components/interaction/bds-drawer.js +1 -1
  17. package/dist/web-components/interaction/bds-tabs.js +1 -1
  18. package/dist/web-components/ui/bds-separator.js +1 -1
  19. package/dist/web-components/ui/bds-table.js +1 -1
  20. package/package.json +2 -2
  21. package/src/components/interaction/Drawer/Drawer.mdx +1 -0
  22. package/src/components/interaction/Drawer/Drawer.module.css +1 -1
  23. package/src/components/interaction/DropdownMenu/DropdownMenu.mdx +1 -0
  24. package/src/components/interaction/DropdownMenu/DropdownMenu.module.css +1 -2
  25. package/src/components/ui/Accordion/Accordion.mdx +1 -0
  26. package/src/components/ui/Accordion/Accordion.module.css +1 -1
  27. package/src/components/ui/Separator/Separator.mdx +1 -1
  28. package/src/components/ui/Separator/Separator.module.css +1 -1
  29. package/src/components/ui/Table/Table.mdx +1 -0
  30. package/src/components/ui/Table/Table.module.css +2 -2
  31. package/src/components/ui/Tabs/Tabs.mdx +1 -0
  32. package/src/components/ui/Tabs/Tabs.module.css +1 -1
  33. package/src/stories/DesignSystem/Colors.mdx +14 -0
  34. package/src/stories/DesignSystem/Overview.mdx +3 -0
  35. package/src/stories/DesignSystem/TokenSets.mdx +291 -0
  36. package/src/test/token-contract.spec.ts +71 -0
  37. package/src/web-components/interaction/BdsAccordion.mdx +1 -0
  38. package/src/web-components/interaction/BdsDrawer.mdx +10 -0
  39. package/src/web-components/interaction/BdsTabs.mdx +1 -0
  40. package/src/web-components/interaction/bds-accordion-item.ts +1 -1
  41. package/src/web-components/interaction/bds-drawer.spec.ts +7 -0
  42. package/src/web-components/interaction/bds-drawer.ts +1 -1
  43. package/src/web-components/interaction/bds-tabs.ts +1 -1
  44. package/src/web-components/ui/BdsSeparator.mdx +1 -1
  45. package/src/web-components/ui/BdsTable.mdx +1 -0
  46. package/src/web-components/ui/bds-separator.spec.ts +7 -0
  47. package/src/web-components/ui/bds-separator.ts +1 -1
  48. package/src/web-components/ui/bds-table.ts +2 -2
@@ -63,7 +63,7 @@ import {
63
63
  } from "./chunk-WIBAN7KP.js";
64
64
  import {
65
65
  BdsTabs
66
- } from "./chunk-F3ZDNHZZ.js";
66
+ } from "./chunk-Z3BPJNEL.js";
67
67
  import {
68
68
  BdsToastProvider
69
69
  } from "./chunk-HRD3NOA3.js";
@@ -78,7 +78,7 @@ import {
78
78
  } from "./chunk-PXPPKCLT.js";
79
79
  import {
80
80
  BdsAccordionItem
81
- } from "./chunk-YFAXGRPR.js";
81
+ } from "./chunk-6ZNQPSQJ.js";
82
82
  import {
83
83
  BdsButton
84
84
  } from "./chunk-XIZWAWMJ.js";
@@ -90,7 +90,7 @@ import {
90
90
  } from "./chunk-Z7XTADXX.js";
91
91
  import {
92
92
  BdsDrawer
93
- } from "./chunk-FUQEFHRC.js";
93
+ } from "./chunk-SOYAOLKR.js";
94
94
  import {
95
95
  BdsDropdownMenu
96
96
  } from "./chunk-IDSM74LM.js";
@@ -112,13 +112,13 @@ import {
112
112
  import "./chunk-QPOKHLMQ.js";
113
113
  import {
114
114
  BdsSeparator
115
- } from "./chunk-SFZ6Q4BP.js";
115
+ } from "./chunk-5HUAFCU6.js";
116
116
  import {
117
117
  BdsSkeleton
118
118
  } from "./chunk-EF7IVYTY.js";
119
119
  import {
120
120
  BdsTable
121
- } from "./chunk-JMCXAAFK.js";
121
+ } from "./chunk-HBLJ4SLY.js";
122
122
  import {
123
123
  BdsCalendar,
124
124
  buildMonthGrid,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  BdsAccordionItem
3
- } from "../chunk-YFAXGRPR.js";
3
+ } from "../chunk-6ZNQPSQJ.js";
4
4
  import "../chunk-LGEZYFUU.js";
5
5
  export {
6
6
  BdsAccordionItem
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  BdsDrawer
3
- } from "../chunk-FUQEFHRC.js";
3
+ } from "../chunk-SOYAOLKR.js";
4
4
  import "../chunk-LGEZYFUU.js";
5
5
  export {
6
6
  BdsDrawer
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  BdsTabs
3
- } from "../chunk-F3ZDNHZZ.js";
3
+ } from "../chunk-Z3BPJNEL.js";
4
4
  import "../chunk-LGEZYFUU.js";
5
5
  export {
6
6
  BdsTabs
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  BdsSeparator
3
- } from "../chunk-SFZ6Q4BP.js";
3
+ } from "../chunk-5HUAFCU6.js";
4
4
  import "../chunk-LGEZYFUU.js";
5
5
  export {
6
6
  BdsSeparator
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  BdsTable
3
- } from "../chunk-JMCXAAFK.js";
3
+ } from "../chunk-HBLJ4SLY.js";
4
4
  import "../chunk-LGEZYFUU.js";
5
5
  export {
6
6
  BdsTable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "2.12.1",
3
+ "version": "2.14.0",
4
4
  "description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "React",
@@ -338,7 +338,7 @@
338
338
  "changelog:unreleased": "pnpm exec git-cliff --unreleased"
339
339
  },
340
340
  "devDependencies": {
341
- "@boostdev/design-system-foundation": "^2.2.0",
341
+ "@boostdev/design-system-foundation": "^2.3.0",
342
342
  "@boostdev/design-system-react-native": "0.2.0",
343
343
  "@modelcontextprotocol/sdk": "^1.27.0",
344
344
  "@storybook/addon-docs": "8.6.14",
@@ -38,6 +38,7 @@ Slide-in panel from the left or right edge of the viewport. Use for navigation,
38
38
  <tbody>
39
39
  <tr><td>`--drawer_color`</td><td>`var(--bds-color_bg)`</td><td>Panel background colour</td></tr>
40
40
  <tr><td>`--drawer_on-color`</td><td>`var(--bds-color_on-bg)`</td><td>Text colour</td></tr>
41
+ <tr><td>`--drawer_border-color`</td><td>`var(--bds-color_on-bg--as-surface)`</td><td>Header rule colour — the non-text member of the colour set (≥ 3:1)</td></tr>
41
42
  <tr><td>`--drawer_shadow`</td><td>`var(--bds-shadow_xl)`</td><td>Panel drop shadow</td></tr>
42
43
  <tr><td>`--drawer_close-radius`</td><td>`var(--bds-border_radius--circle)`</td><td>Close button border radius</td></tr>
43
44
  <tr><td>`--drawer_close-bg--hover`</td><td>`var(--bds-color_bg--subtle)`</td><td>Close button background on hover</td></tr>
@@ -98,7 +98,7 @@
98
98
  align-items: center;
99
99
  justify-content: space-between;
100
100
  padding: var(--bds-space_m) var(--bds-space_l);
101
- border-block-end: 1px solid currentcolor;
101
+ border-block-end: 1px solid var(--drawer_border-color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_on-bg))));
102
102
  flex-shrink: 0;
103
103
  }
104
104
 
@@ -46,6 +46,7 @@ Floating menu with a list of selectable actions, triggered by a button. Supports
46
46
  <tr><td>`--dropdown_color`</td><td>`var(--bds-color_bg)`</td><td>Menu background colour</td></tr>
47
47
  <tr><td>`--dropdown_on-color`</td><td>`var(--bds-color_on-bg)`</td><td>Menu text colour</td></tr>
48
48
  <tr><td>`--dropdown_shadow`</td><td>`var(--bds-shadow_m)`</td><td>Menu box shadow</td></tr>
49
+ <tr><td>`--dropdown_separator-color`</td><td>`var(--bds-color_on-bg--as-surface)`</td><td>Separator line colour — the non-text member of the colour set (≥ 3:1)</td></tr>
49
50
  <tr><td>`--dropdown_item-bg--hover`</td><td>`var(--bds-color_bg--subtle)`</td><td>Menu item background on hover</td></tr>
50
51
  <tr><td>`--dropdown_item-color--hover`</td><td>`var(--bds-color_interactive_on-bg)`</td><td>Menu item text colour on hover</td></tr>
51
52
  </tbody>
@@ -31,9 +31,8 @@
31
31
 
32
32
  .separator {
33
33
  border: none;
34
- border-block-start: 1px solid currentcolor;
34
+ border-block-start: 1px solid var(--dropdown_separator-color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_on-bg))));
35
35
  margin: var(--bds-space_xxs) 0;
36
- opacity: 0.15;
37
36
  }
38
37
 
39
38
  .item {
@@ -43,6 +43,7 @@ Vertically stacked sections that can be expanded and collapsed independently.
43
43
  <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
44
44
  </thead>
45
45
  <tbody>
46
+ <tr><td>`--accordion_divider-color`</td><td>`var(--bds-color_on-bg--as-surface)`</td><td>Colour of the rule between items — the non-text member of the colour set (≥ 3:1), not a ground colour</td></tr>
46
47
  <tr><td>`--accordion_border`</td><td>`none`</td><td>Container border</td></tr>
47
48
  <tr><td>`--accordion_radius`</td><td>`var(--bdc-outline_radius)`</td><td>Container corner radius</td></tr>
48
49
  <tr><td>`--accordion_shadow`</td><td>`var(--bdc-outline_shadow)`</td><td>Container box-shadow tail (layered after the frame border)</td></tr>
@@ -13,7 +13,7 @@
13
13
  }
14
14
 
15
15
  .item {
16
- border-block-end: 1px solid var(--bds-color_bg--subtle);
16
+ border-block-end: 1px solid var(--accordion_divider-color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_bg--subtle))));
17
17
  }
18
18
 
19
19
  .item:last-child {
@@ -33,7 +33,7 @@ Visual divider between content sections. Renders a semantic `<hr>` (horizontal)
33
33
  <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
34
34
  </thead>
35
35
  <tbody>
36
- <tr><td>`--separator_color`</td><td>`var(--bds-color_on-bg)`</td><td>Line colour</td></tr>
36
+ <tr><td>`--separator_color`</td><td>`var(--bds-color_on-bg--as-surface)`</td><td>Line colour. A separator is not text, so it takes the non-text member of the colour set (≥ 3:1) rather than the text colour. Falls back to `--bds-color_on-bg--border` and then `--bds-color_on-bg` on foundations older than 2.3.0.</td></tr>
37
37
  <tr><td>`--separator_thickness`</td><td>`1px`</td><td>Line thickness (height for horizontal, width for vertical)</td></tr>
38
38
  <tr><td>`--separator_length`</td><td>`1em`</td><td>Minimum height of the vertical separator when rendered outside a flex/grid container. Inside a flex or grid container the separator still stretches to fill the cross axis (`align-self: stretch`) — this token only sets a floor for standalone usage.</td></tr>
39
39
  </tbody>
@@ -1,7 +1,7 @@
1
1
  @layer boostdev.component {
2
2
  .separator {
3
3
  border: none;
4
- background-color: var(--separator_color, var(--bds-color_on-bg));
4
+ background-color: var(--separator_color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_on-bg))));
5
5
  }
6
6
 
7
7
  .separator.--horizontal {
@@ -40,6 +40,7 @@ Data table with optional sortable columns and caption.
40
40
  <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
41
41
  </thead>
42
42
  <tbody>
43
+ <tr><td>`--table_divider-color`</td><td>`var(--bds-color_on-bg--as-surface)`</td><td>Colour of the header underline and the row rules — the non-text member of the colour set (≥ 3:1), not a ground colour</td></tr>
43
44
  <tr><td>`--table_border`</td><td>`none`</td><td>Container border</td></tr>
44
45
  <tr><td>`--table_radius`</td><td>`var(--bdc-outline_radius)`</td><td>Container corner radius</td></tr>
45
46
  <tr><td>`--table_shadow`</td><td>`var(--bdc-outline_shadow)`</td><td>Container frame shadow (appended to the inset border box-shadow)</td></tr>
@@ -41,7 +41,7 @@
41
41
  font-size: var(--table_header-font-size, var(--bds-font_size--body--s));
42
42
  color: var(--bds-color_on-bg--subtle);
43
43
  white-space: nowrap;
44
- border-block-end: 1px solid var(--bds-color_bg--subtle);
44
+ border-block-end: 1px solid var(--table_divider-color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_bg--subtle))));
45
45
  }
46
46
 
47
47
  .th.--sortable {
@@ -92,7 +92,7 @@
92
92
  }
93
93
 
94
94
  .tbody .tr {
95
- border-block-end: 1px solid var(--bds-color_bg--subtle);
95
+ border-block-end: 1px solid var(--table_divider-color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_bg--subtle))));
96
96
  transition: var(--bds-animation_transition);
97
97
  }
98
98
 
@@ -74,6 +74,7 @@ Defaults: hover/active text resolves to `--bds-color_interactive_on-bg`; active
74
74
  </thead>
75
75
  <tbody>
76
76
  <tr><td>`--tab_padding`</td><td>`var(--bds-space_s) var(--bds-space_m)`</td><td>Padding of each tab button.</td></tr>
77
+ <tr><td>`--tabs_divider-color`</td><td>`var(--bds-color_on-bg--as-surface)`</td><td>Colour of the rail under the tablist — the non-text member of the colour set (≥ 3:1), not a ground colour</td></tr>
77
78
  <tr><td>`--tabs_gap`</td><td>`var(--bds-space_xxs)`</td><td>Gap between tab buttons in the tablist.</td></tr>
78
79
  <tr><td>`--tabs_panel-padding`</td><td>`var(--bds-space_m)`</td><td>Block-start padding of the active panel.</td></tr>
79
80
  </tbody>
@@ -6,7 +6,7 @@
6
6
 
7
7
  .tabList {
8
8
  display: flex;
9
- border-block-end: 1px solid var(--bds-color_bg--subtle);
9
+ border-block-end: 1px solid var(--tabs_divider-color, var(--bds-color_on-bg--as-surface, var(--bds-color_on-bg--border, var(--bds-color_bg--subtle))));
10
10
  gap: var(--tabs_gap, var(--bds-space_xxs));
11
11
  }
12
12
 
@@ -56,6 +56,20 @@ Every colour token comes in surface–content pairs: for every `--bds-color_*` t
56
56
  }
57
57
  ```
58
58
 
59
+ A set has a third member for anything you place on the surface that is **not** text — a divider, a
60
+ hairline, a decorative fill. Those need 3:1, not 4.5:1, and a text-grade colour on a one-pixel rule
61
+ shouts:
62
+
63
+ ```css
64
+ .card { background-color: var(--bds-color_primary); }
65
+ .card h3 { color: var(--bds-color_on-primary); } /* text */
66
+ .card hr { background-color: var(--bds-color_on-primary--as-surface); } /* not text */
67
+ ```
68
+
69
+ `--as-surface` ships on `bg`, `primary`, `secondary` and `accent-1..3`. See
70
+ [Token sets](?path=/docs/design-system-token-sets--docs) for why this decision belongs in the token
71
+ layer rather than at the call site.
72
+
59
73
  ## Semantic colours
60
74
 
61
75
  These are the tokens you should use in component CSS. They adapt automatically to light and dark mode.
@@ -8,6 +8,9 @@ import { Meta } from '@storybook/blocks';
8
8
  It provides a complete set of CSS custom properties for colour, spacing, typography, grid, elevation, and motion —
9
9
  all responsive and theme-aware out of the box.
10
10
 
11
+ New to the token layer? [Token sets](?path=/docs/design-system-token-sets--docs) explains the one
12
+ rule the rest depends on: colours ship as sets, never as single values.
13
+
11
14
  ## Installation
12
15
 
13
16
  ```bash
@@ -0,0 +1,291 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Design System/Token sets" />
4
+
5
+ export const Panel = ({ children, tone = 'neutral' }) => (
6
+ <div style={{
7
+ border: '1px solid var(--bds-color_on-bg--as-surface)',
8
+ borderRadius: 'var(--bds-border_radius--m)',
9
+ padding: '1rem',
10
+ background: tone === 'bad' ? 'var(--bds-color_error--subtle)' : tone === 'good' ? 'var(--bds-color_success--subtle)' : 'var(--bds-color_bg--subtle)',
11
+ color: tone === 'bad' ? 'var(--bds-color_on-error--subtle)' : tone === 'good' ? 'var(--bds-color_on-success--subtle)' : 'var(--bds-color_on-bg)',
12
+ }}>
13
+ {children}
14
+ </div>
15
+ );
16
+
17
+ export const Split = ({ children }) => (
18
+ <div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))', marginBlock: '1.5rem' }}>
19
+ {children}
20
+ </div>
21
+ );
22
+
23
+ export const Caption = ({ children }) => (
24
+ <div style={{ fontSize: '0.75rem', fontFamily: 'monospace', opacity: 0.7, marginBlockStart: '0.5rem' }}>
25
+ {children}
26
+ </div>
27
+ );
28
+
29
+ export const Notice = ({ surface, content, label }) => (
30
+ <div>
31
+ <div style={{ background: `var(${surface})`, color: `var(${content})`, padding: '1rem', borderRadius: 'var(--bds-border_radius--m)' }}>
32
+ <strong style={{ display: 'block', marginBlockEnd: '0.25rem' }}>Delivery confirmed</strong>
33
+ <span style={{ fontSize: '0.875rem' }}>Your parcel is on its way to Amersfoort.</span>
34
+ </div>
35
+ <Caption>{label}</Caption>
36
+ </div>
37
+ );
38
+
39
+ export const OnSurfaceDemo = ({ mark, label }) => (
40
+ <div>
41
+ <div style={{ background: 'var(--bds-color_primary)', color: 'var(--bds-color_on-primary)', padding: '1rem', borderRadius: 'var(--bds-border_radius--m)' }}>
42
+ <strong style={{ display: 'block' }}>Included in your plan</strong>
43
+ <hr style={{ border: 'none', blockSize: '1px', background: `var(${mark})`, marginBlock: '0.75rem' }} />
44
+ <span style={{ fontSize: '0.875rem' }}>Unlimited storage · Priority support</span>
45
+ </div>
46
+ <Caption>{label}</Caption>
47
+ </div>
48
+ );
49
+
50
+ # Token sets
51
+
52
+ Every colour in this library was chosen by someone. The only question is when, and by whom.
53
+
54
+ The designer can choose once, in the system: which roles exist, what they are called, and which
55
+ content colour belongs on which surface. Or the developer chooses again on every call site, inside
56
+ a component, under time pressure. That mostly goes well — and "mostly" is exactly the problem.
57
+
58
+ This page is about the first option: how to record colour decisions so they do not have to be
59
+ made again.
60
+
61
+ ## Using a token is not enough
62
+
63
+ Both colours below come from the system. On the token level there is nothing to object to:
64
+
65
+ ```css
66
+ .button {
67
+ /* from the system */
68
+ background: var(--bds-color_secondary);
69
+
70
+ /* also a token, but chosen here on the spot */
71
+ color: var(--bds-color_white);
72
+ }
73
+ ```
74
+
75
+ Nothing about the surface says the text should be white. Somebody decided that at the call site,
76
+ and it holds until the brand colour moves one shade. Then every place where someone picked their
77
+ own text colour has to be visited again.
78
+
79
+ <Split>
80
+ <Notice surface="--bds-color_secondary" content="--bds-color_white" label="--bds-color_white — 2.1:1, fails AA" />
81
+ <Notice surface="--bds-color_secondary" content="--bds-color_on-secondary" label="--bds-color_on-secondary — 10:1, passes AA" />
82
+ </Split>
83
+
84
+ Both panels use tokens. Only the right one takes its content colour from the set, and only the
85
+ right one is legible. So the rule is not "use tokens" — it is **who picks the combination: the
86
+ system, or the developer on that line**.
87
+
88
+ ## Think in sets, not in single values
89
+
90
+ You never add a single colour to this system. You add a set: a surface, the colour that is legible
91
+ on it, and the variant for anything you place on top that is not text.
92
+
93
+ | Member | Naming | Requirement |
94
+ |---|---|---|
95
+ | Surface | `--bds-color_primary` | — |
96
+ | Content on it | `--bds-color_on-primary` | ≥ 4.5:1 (text, icons) |
97
+ | Non-text mark on it | `--bds-color_on-primary--as-surface` | ≥ 3:1, and quieter than the content colour |
98
+
99
+ `on-` is a position, not a hue: `on-primary` is what lies on `primary`. Four things follow from that:
100
+
101
+ - **There are no exceptions.** If you take a surface from the system, you take its content colour
102
+ from the same set. One exception and the guarantee is worth nothing.
103
+ - **The set switches together.** In dark mode the surface changes along with its content, so the
104
+ contrast cannot break.
105
+ - **It is not only about text.** Icons and borders sit on that same surface and belong to the same
106
+ set.
107
+ - **Primitives have no partner.** `--bds-color_white` says which colour something is, not what it
108
+ is for — so there is nothing for it to pair with.
109
+
110
+ ## A name is a design decision
111
+
112
+ The name travels further than the colour. What is called `primary` in Figma is called `primary` in
113
+ the CSS; that name is the contract between design and code.
114
+
115
+ | Name | What goes wrong |
116
+ |---|---|
117
+ | `blue-500` | Says which colour it is, not what it is for. After a restyle the name is a lie. |
118
+ | `button-color` | Too specific. The moment the same colour is needed on a label, somebody invents a second token. |
119
+ | `color-2` | Says nothing. Everyone has to look it up, so everyone guesses. |
120
+ | `primary` | Says which role the colour plays. Works on a button, a label and a border, and survives a restyle. |
121
+
122
+ Renaming is expensive — a token name lives in Figma, in the code and in the documentation — so the
123
+ naming happens early, and it is the designer's work, not the developer's.
124
+
125
+ ## `--as-surface`: the requirement follows from what you place
126
+
127
+ `on-primary` is text-grade: high contrast, because letters may sit on it. But a divider, a badge or
128
+ a decorative block is not text. WCAG asks 3:1 for non-text contrast, not 4.5:1 — and a text-grade
129
+ colour on a one-pixel rule shouts.
130
+
131
+ That is the third member of the set. Same set, different requirement, because you are placing
132
+ something else:
133
+
134
+ ```css
135
+ /* Text on primary: 4.5:1 required */
136
+ .card { background: var(--bds-color_primary); }
137
+ .card h3 { color: var(--bds-color_on-primary); }
138
+
139
+ /* A mark on primary: 3:1 is enough */
140
+ .card hr,
141
+ .card .badge {
142
+ background: var(--bds-color_on-primary--as-surface);
143
+ }
144
+ ```
145
+
146
+ <Split>
147
+ <OnSurfaceDemo mark="--bds-color_on-primary" label="--bds-color_on-primary — text-grade, loud on a 1px rule" />
148
+ <OnSurfaceDemo mark="--bds-color_on-primary--as-surface" label="--bds-color_on-primary--as-surface — calmer, still ≥ 3:1" />
149
+ </Split>
150
+
151
+ The requirement follows from **what** you place, not from **who** places it. That saves another
152
+ decision at the call site — which is why `Separator`, the `DropdownMenu` divider and the `Drawer`
153
+ header rule all take `--as-surface` rather than a hand-rolled opacity over the text colour.
154
+
155
+ `--as-surface` exists on the base rung of `bg`, `primary`, `secondary` and `accent-1..3`. It is a
156
+ partner of the content side, so it is named `on-<x>--as-surface` and never on the surface side.
157
+ One level deep, no further: beyond that it is design work, and the system covers the ordinary case.
158
+
159
+ ## Primitives do not leak into the CSS
160
+
161
+ The source is JSON, not CSS — one machine-readable file in the W3C DTCG format, which Figma can
162
+ import and export as well, so both sides can work from one source. Agree on which side holds the
163
+ truth: Figma-first or code-first, both are fine, just not both at once.
164
+
165
+ The generator is not a source but a translator. JSON in, CSS out, with the value already resolved:
166
+
167
+ ```jsonc
168
+ // tokens/color/semantic.light.dtcg.json — the source
169
+ {
170
+ "on-primary": {
171
+ "DEFAULT": { "$type": "color", "$value": "{color.black}" },
172
+ "as-surface": { "$type": "color", "$value": "{color.blue.950}" }
173
+ }
174
+ }
175
+ ```
176
+
177
+ ```css
178
+ /* color.semantic.css — generated, this is what ships */
179
+ :root {
180
+ --bds-color_on-primary: #000;
181
+ --bds-color_on-primary--as-surface: #162456;
182
+ }
183
+
184
+ /* The palette step does not exist here.
185
+ The reference was resolved at build time. */
186
+ ```
187
+
188
+ What is not there cannot be used. No one has to agree on it or review it — reaching for a raw
189
+ palette value is simply not possible at runtime.
190
+
191
+ ## The build guards the contrast
192
+
193
+ Because the coupling sits in the naming, the pairs can be derived mechanically. Your token list
194
+ *is* your test list — there is nothing separate to maintain:
195
+
196
+ ```ts
197
+ // every on-<x> pairs with <x> → ≥ 4.5:1
198
+ // every on-<x>--as-surface pairs with <x> → ≥ 3:1
199
+ const pairs = deriveContrastPairs(tokens);
200
+
201
+ test.each(pairs)('$name meets its band', ({ a, b, band }) => {
202
+ expect(contrastRatio(map[a], map[b])).toBeGreaterThanOrEqual(band);
203
+ });
204
+ ```
205
+
206
+ Accessibility then stops being a finding in a report and becomes something the system checks about
207
+ itself. Two notes on that:
208
+
209
+ - **A rule like this is only enforceable once the coupling is in the naming.** "Every new
210
+ `--bds-color_X` needs a `--bds-color_on-X` that passes AA" is a test you can write; "please
211
+ remember to check contrast" is not.
212
+ - **Your contrast checker can lie to you.** The common Figma plugins compute WCAG 2, and that
213
+ formula underrates saturated colours. Black on bright blue gets a green tick at 5.6:1 and still
214
+ reads poorly. For vivid colours, verify with APCA.
215
+
216
+ ## A different theme touches one layer only
217
+
218
+ Components refer to semantic names. Those names do not change — only their values do:
219
+
220
+ ```css
221
+ /* 1. Default: light */
222
+ :root {
223
+ --bds-color_bg: var(--neutral-white);
224
+ --bds-color_on-bg: var(--neutral-black);
225
+ }
226
+
227
+ /* 2. OS is dark and nobody explicitly chose light */
228
+ @media (prefers-color-scheme: dark) {
229
+ :root:not([data-theme="light"]) { … }
230
+ }
231
+
232
+ /* 3. Explicit choice. Beats the OS. */
233
+ :root[data-theme="dark"] { … }
234
+ ```
235
+
236
+ Mind the order: the media query comes **before** the manual choice, otherwise the OS overrules what
237
+ the user set themselves. Surface and content come from the same theme block, so the guarantee
238
+ travels into dark mode with them. None of this appears in your component.
239
+
240
+ ## The pattern is wider than colour
241
+
242
+ A token set is not a naming trick. It is the acknowledgement that some values only make sense
243
+ together — one without the other produces a wrong result. Typography is the clearest second case:
244
+
245
+ ```css
246
+ /* Loose: three knobs, three chances to get it wrong */
247
+ .title {
248
+ font-size: var(--bds-font_size--heading-1);
249
+ line-height: 1.2; /* invented here */
250
+ letter-spacing: -0.02em; /* invented here */
251
+ }
252
+
253
+ /* As a set: one decision with a name */
254
+ .title {
255
+ font-size: var(--bds-font_size--heading-1);
256
+ line-height: var(--bds-font_line-height--heading);
257
+ letter-spacing: var(--bds-font_tracking--tight);
258
+ }
259
+ ```
260
+
261
+ A font size without its line height gives a broken rhythm; a display size at default tracking looks
262
+ sloppy. Other candidates: a shadow that belongs to an elevation level, an icon size that belongs to
263
+ a touch target. Same rule of thumb — ship them as a unit, because whatever can be set separately
264
+ will eventually be set separately.
265
+
266
+ ## Who decides what
267
+
268
+ <Split>
269
+ <Panel>
270
+ <strong>The designer</strong>
271
+ <p style={{ margin: '0.5rem 0 0' }}>Which roles exist, what they are called, and which content colour belongs on which surface. That last one is a design decision, not an implementation detail.</p>
272
+ </Panel>
273
+ <Panel>
274
+ <strong>The system</strong>
275
+ <p style={{ margin: '0.5rem 0 0' }}>That it cannot go wrong. The set is fixed, the contrast is tested, and the primitives are out of reach. What cannot be chosen cannot be chosen wrongly.</p>
276
+ </Panel>
277
+ <Panel>
278
+ <strong>The developer</strong>
279
+ <p style={{ margin: '0.5rem 0 0' }}>Which token fits here. Is this a warning or a confirmation? That is the question that remains — not which shade, and not which contrast.</p>
280
+ </Panel>
281
+ </Split>
282
+
283
+ A component in such a system picks no colour of its own. It asks for a semantic token and takes the
284
+ content colour from the set, both from one source that design and code share. A new brand colour or
285
+ a dark mode therefore costs not a single line inside the component.
286
+
287
+ ## Further reading
288
+
289
+ - [Colours](?path=/docs/design-system-colors--docs) — the full token reference and palette
290
+ - [Dark mode](?path=/docs/design-system-dark-mode--docs) — the three-state theming setup
291
+ - [Typography](?path=/docs/design-system-typography--docs) — the type scale as sets
@@ -84,3 +84,74 @@ describe('token contract', () => {
84
84
  }
85
85
  });
86
86
  });
87
+
88
+ // Non-text marks — hairlines, dividers, decorative fills — take the `--as-surface`
89
+ // member of the set (>= 3:1), never the text member (4.5:1) and never a
90
+ // hand-rolled alpha over it. Foundations older than 2.3.0 fall back to
91
+ // `--bds-color_on-bg--border`, which carries the same value under its old name.
92
+ const NON_TEXT_MARKS: { file: string; rule: string }[] = [
93
+ { file: 'components/ui/Separator/Separator.module.css', rule: '.separator' },
94
+ { file: 'components/interaction/DropdownMenu/DropdownMenu.module.css', rule: '.separator' },
95
+ { file: 'components/interaction/Drawer/Drawer.module.css', rule: '.header' },
96
+ { file: 'components/ui/Accordion/Accordion.module.css', rule: '.item' },
97
+ { file: 'components/ui/Tabs/Tabs.module.css', rule: '.tabList' },
98
+ { file: 'components/ui/Table/Table.module.css', rule: '.th' },
99
+ ];
100
+
101
+ describe('non-text marks use the as-surface member of the colour set', () => {
102
+ const srcRoot = join(__dirname, '../');
103
+
104
+ for (const { file, rule } of NON_TEXT_MARKS) {
105
+ it(`${file} ${rule}`, () => {
106
+ const css = readFileSync(join(srcRoot, file), 'utf-8');
107
+ const declaration = new RegExp(
108
+ `\\${rule}[^{]*{[^}]*var\\(--bds-color_on-bg--as-surface,\\s*var\\(--bds-color_on-bg--border,`,
109
+ );
110
+
111
+ expect(css, `${file} paints ${rule} without the as-surface fallback chain`).toMatch(
112
+ declaration,
113
+ );
114
+ expect(css, `${file} still softens ${rule} with a hand-rolled opacity`).not.toMatch(
115
+ new RegExp(`\\${rule}[^{]*{[^}]*opacity:`),
116
+ );
117
+ });
118
+ }
119
+ });
120
+
121
+ // A surface token on a hairline is the mirror of the same mistake: `bg--subtle`
122
+ // on `bg` measures 1.10:1 in light and 1.72:1 in dark, well under the 3:1 a
123
+ // non-text mark needs. Dividers must not be painted with a ground colour.
124
+ // A ground colour is correct where the border is not separating anything: the
125
+ // Loading spinner's ring is the track its coloured arc rotates against, and is
126
+ // meant to recede. The contrast that matters there is arc-vs-track, not
127
+ // track-vs-page.
128
+ const GROUND_BORDER_EXEMPTIONS = ['Loading/Loading.module.css', 'bds-loading.ts'];
129
+
130
+ describe('no component paints a hairline with a background token', () => {
131
+ const srcRoot = join(__dirname, '../');
132
+
133
+ it('component CSS and web component styles', () => {
134
+ const offenders: string[] = [];
135
+ const files = [
136
+ ...collectFiles(join(srcRoot, 'components'), '.module.css'),
137
+ ...collectFiles(join(srcRoot, 'web-components'), '.ts').filter((f) => !f.endsWith('.spec.ts')),
138
+ ];
139
+
140
+ for (const file of files) {
141
+ if (GROUND_BORDER_EXEMPTIONS.some((e) => file.endsWith(e))) continue;
142
+
143
+ const css = readFileSync(file, 'utf-8');
144
+ for (const [, side] of css.matchAll(
145
+ /border[\w-]*:\s*\d+px\s+solid\s+var\(--bds-color_(bg[\w-]*)\)/g,
146
+ )) {
147
+ offenders.push(` --bds-color_${side} in ${relative(srcRoot, file)}`);
148
+ }
149
+ }
150
+
151
+ if (offenders.length > 0) {
152
+ expect.fail(
153
+ `Hairlines painted with a ground colour (needs --bds-color_on-X--as-surface):\n${offenders.join('\n')}`,
154
+ );
155
+ }
156
+ });
157
+ });
@@ -79,6 +79,7 @@ Or in HTML without a bundler:
79
79
 
80
80
  | Property | Default | Description |
81
81
  |----------|---------|-------------|
82
+ | `--accordion_divider-color` | `var(--bds-color_on-bg--as-surface)` | Colour of the rule between items — the non-text member of the colour set (≥ 3:1), not a ground colour |
82
83
  | `--accordion_border` | `none` | Container border |
83
84
  | `--accordion_radius` | `var(--bdc-outline_radius)` | Container corner radius |
84
85
  | `--accordion_shadow` | `var(--bdc-outline_shadow)` | Container box-shadow tail (layered after the frame border) |