@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.
- package/AGENTS.md +23 -1
- package/README.md +6 -0
- package/dist/client.cjs +52 -52
- package/dist/client.css +600 -601
- package/dist/client.js +52 -52
- package/dist/index.cjs +52 -52
- package/dist/index.css +600 -601
- package/dist/index.js +52 -52
- package/dist/web-components/{chunk-SFZ6Q4BP.js → chunk-5HUAFCU6.js} +1 -1
- package/dist/web-components/{chunk-YFAXGRPR.js → chunk-6ZNQPSQJ.js} +1 -1
- package/dist/web-components/{chunk-JMCXAAFK.js → chunk-HBLJ4SLY.js} +2 -2
- package/dist/web-components/{chunk-FUQEFHRC.js → chunk-SOYAOLKR.js} +1 -1
- package/dist/web-components/{chunk-F3ZDNHZZ.js → chunk-Z3BPJNEL.js} +1 -1
- package/dist/web-components/index.js +5 -5
- package/dist/web-components/interaction/bds-accordion-item.js +1 -1
- package/dist/web-components/interaction/bds-drawer.js +1 -1
- package/dist/web-components/interaction/bds-tabs.js +1 -1
- package/dist/web-components/ui/bds-separator.js +1 -1
- package/dist/web-components/ui/bds-table.js +1 -1
- package/package.json +2 -2
- package/src/components/interaction/Drawer/Drawer.mdx +1 -0
- package/src/components/interaction/Drawer/Drawer.module.css +1 -1
- package/src/components/interaction/DropdownMenu/DropdownMenu.mdx +1 -0
- package/src/components/interaction/DropdownMenu/DropdownMenu.module.css +1 -2
- package/src/components/ui/Accordion/Accordion.mdx +1 -0
- package/src/components/ui/Accordion/Accordion.module.css +1 -1
- package/src/components/ui/Separator/Separator.mdx +1 -1
- package/src/components/ui/Separator/Separator.module.css +1 -1
- package/src/components/ui/Table/Table.mdx +1 -0
- package/src/components/ui/Table/Table.module.css +2 -2
- package/src/components/ui/Tabs/Tabs.mdx +1 -0
- package/src/components/ui/Tabs/Tabs.module.css +1 -1
- package/src/stories/DesignSystem/Colors.mdx +14 -0
- package/src/stories/DesignSystem/Overview.mdx +3 -0
- package/src/stories/DesignSystem/TokenSets.mdx +291 -0
- package/src/test/token-contract.spec.ts +71 -0
- package/src/web-components/interaction/BdsAccordion.mdx +1 -0
- package/src/web-components/interaction/BdsDrawer.mdx +10 -0
- package/src/web-components/interaction/BdsTabs.mdx +1 -0
- package/src/web-components/interaction/bds-accordion-item.ts +1 -1
- package/src/web-components/interaction/bds-drawer.spec.ts +7 -0
- package/src/web-components/interaction/bds-drawer.ts +1 -1
- package/src/web-components/interaction/bds-tabs.ts +1 -1
- package/src/web-components/ui/BdsSeparator.mdx +1 -1
- package/src/web-components/ui/BdsTable.mdx +1 -0
- package/src/web-components/ui/bds-separator.spec.ts +7 -0
- package/src/web-components/ui/bds-separator.ts +1 -1
- 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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
121
|
+
} from "./chunk-HBLJ4SLY.js";
|
|
122
122
|
import {
|
|
123
123
|
BdsCalendar,
|
|
124
124
|
buildMonthGrid,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boostdev/design-system-components",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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) |
|