@stigmer/theme 3.1.4 → 3.1.5
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/README.md +35 -73
- package/package.json +1 -1
- package/presets/corporate.css +2 -2
- package/presets/fintech.css +4 -4
- package/presets/friendly.css +8 -8
- package/presets/monochrome.css +4 -4
- package/presets/startup.css +6 -6
- package/src/contract/__tests__/color.test.ts +59 -0
- package/src/contract/__tests__/contrast.test.ts +81 -0
- package/src/contract/__tests__/parse.test.ts +106 -0
- package/src/contract/__tests__/resolve.test.ts +85 -0
- package/src/contract/audit.ts +138 -0
- package/src/contract/color.ts +79 -0
- package/src/contract/pairs.ts +169 -0
- package/src/contract/parse.ts +131 -0
- package/src/contract/resolve.ts +92 -0
- package/src/presets/corporate.css +2 -2
- package/src/presets/fintech.css +4 -4
- package/src/presets/friendly.css +8 -8
- package/src/presets/monochrome.css +4 -4
- package/src/presets/startup.css +6 -6
- package/src/tokens.css +171 -28
- package/tokens.css +171 -28
package/README.md
CHANGED
|
@@ -22,79 +22,34 @@ This defines CSS custom properties on `:root` (light) and `[data-stgm-color-mode
|
|
|
22
22
|
|
|
23
23
|
### Token Reference
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|-------|---------|
|
|
50
|
-
| `--stgm-destructive` | Destructive / error actions |
|
|
51
|
-
| `--stgm-destructive-foreground` | Text on destructive |
|
|
52
|
-
| `--stgm-success` | Success states |
|
|
53
|
-
| `--stgm-success-foreground` | Text on success |
|
|
54
|
-
| `--stgm-warning` | Warning states |
|
|
55
|
-
| `--stgm-warning-foreground` | Text on warning |
|
|
56
|
-
| `--stgm-info` | Informational states |
|
|
57
|
-
| `--stgm-info-foreground` | Text on info |
|
|
58
|
-
|
|
59
|
-
#### Colors (Surfaces)
|
|
60
|
-
|
|
61
|
-
| Token | Purpose |
|
|
62
|
-
|-------|---------|
|
|
63
|
-
| `--stgm-card` | Card background |
|
|
64
|
-
| `--stgm-card-foreground` | Card text |
|
|
65
|
-
| `--stgm-popover` | Popover / dropdown background |
|
|
66
|
-
| `--stgm-popover-foreground` | Popover text |
|
|
67
|
-
|
|
68
|
-
#### Colors (Form)
|
|
69
|
-
|
|
70
|
-
| Token | Purpose |
|
|
71
|
-
|-------|---------|
|
|
72
|
-
| `--stgm-border` | Default border color |
|
|
73
|
-
| `--stgm-input` | Input border color |
|
|
74
|
-
| `--stgm-ring` | Focus ring color |
|
|
75
|
-
|
|
76
|
-
#### Colors (Chart)
|
|
77
|
-
|
|
78
|
-
| Token | Purpose |
|
|
79
|
-
|-------|---------|
|
|
80
|
-
| `--stgm-chart-1` through `--stgm-chart-5` | Data visualization palette |
|
|
81
|
-
|
|
82
|
-
#### Colors (Sidebar)
|
|
83
|
-
|
|
84
|
-
| Token | Purpose |
|
|
85
|
-
|-------|---------|
|
|
86
|
-
| `--stgm-sidebar` | Sidebar background |
|
|
87
|
-
| `--stgm-sidebar-foreground` | Sidebar text |
|
|
88
|
-
| `--stgm-sidebar-primary` | Sidebar active item |
|
|
89
|
-
| `--stgm-sidebar-primary-foreground` | Text on sidebar active |
|
|
90
|
-
| `--stgm-sidebar-accent` | Sidebar hover / accent |
|
|
91
|
-
| `--stgm-sidebar-accent-foreground` | Text on sidebar accent |
|
|
92
|
-
| `--stgm-sidebar-border` | Sidebar border |
|
|
93
|
-
| `--stgm-sidebar-ring` | Sidebar focus ring |
|
|
25
|
+
The complete token contract — every token with its purpose, light/dark
|
|
26
|
+
defaults, and swatches — is documented at
|
|
27
|
+
[stigmer.ai/docs/sdk/theme/tokens](https://stigmer.ai/docs/sdk/theme/tokens).
|
|
28
|
+
That reference is generated directly from `src/tokens.css`, so it can never
|
|
29
|
+
drift from the shipped values. The token groups:
|
|
30
|
+
|
|
31
|
+
- **Typography** — `--stgm-font-sans`, `--stgm-font-mono`
|
|
32
|
+
- **Shape** — `--stgm-radius` (the whole radius scale derives from it)
|
|
33
|
+
- **Core surfaces & text** — background/foreground, card, popover, primary, secondary, muted, accent (each with its `-foreground` pair)
|
|
34
|
+
- **Semantic colors** — destructive, success, warning, info
|
|
35
|
+
- **Forms & focus** — border, input, input-bg, ring
|
|
36
|
+
- **Charts** — 5 categorical series + named chart colors
|
|
37
|
+
- **Emphasis & elevation** — prominent borders, shadow scale
|
|
38
|
+
- **Motion & layering** — transition duration/easing, popover z-index
|
|
39
|
+
- **State shades** — hover/subtle/muted/faint variants of the core colors
|
|
40
|
+
- **Sidebar** — a self-contained token context for contrasting sidebars
|
|
41
|
+
- **Status** — ready/running/pending/degraded/failed/disabled/draft, each with `-foreground` and `-subtle`
|
|
42
|
+
- **Diff viewer** and **Syntax highlighting** token sets
|
|
43
|
+
|
|
44
|
+
Default token values are contrast-audited in CI: `src/contract/` measures
|
|
45
|
+
every declared text/surface pairing (WCAG 2.1 AA for text, a documented
|
|
46
|
+
lightness-delta floor for borderless surfaces) across all presets and both
|
|
47
|
+
color modes. Run `npx tsx scripts/contrast-report.ts` in this package to see
|
|
48
|
+
the full matrix.
|
|
94
49
|
|
|
95
50
|
## Design Language Presets
|
|
96
51
|
|
|
97
|
-
Stigmer ships with
|
|
52
|
+
Stigmer ships with 6 design language presets. Each represents a real-world product category's visual DNA — not just a color swap, but a complete design language with different border radius, surface treatments, border styles, sidebar appearance, and color palette.
|
|
98
53
|
|
|
99
54
|
| Preset | Archetype | Import | CSS Class |
|
|
100
55
|
|--------|-----------|--------|-----------|
|
|
@@ -103,8 +58,9 @@ Stigmer ships with 5 design language presets. Each represents a real-world produ
|
|
|
103
58
|
| Startup | Dev tools (Linear, Vercel) | `@stigmer/theme/presets/startup.css` | `stgm-theme-startup` |
|
|
104
59
|
| Friendly | Consumer SaaS (Notion, Intercom) | `@stigmer/theme/presets/friendly.css` | `stgm-theme-friendly` |
|
|
105
60
|
| Fintech | Premium financial (Stripe, Mercury) | `@stigmer/theme/presets/fintech.css` | `stgm-theme-fintech` |
|
|
61
|
+
| Monochrome | Editorial black-and-white (Linear, Notion) | `@stigmer/theme/presets/monochrome.css` | `stgm-theme-monochrome` |
|
|
106
62
|
|
|
107
|
-
Each preset overrides the
|
|
63
|
+
Each preset overrides most of the token surface — radius, surface colors, borders, sidebar, and accent palette — for both light and dark modes, and falls through to the defaults for anything it leaves alone (Monochrome deliberately keeps the semantic status colors). The full per-preset override tables are at [stigmer.ai/docs/sdk/theme/presets](https://stigmer.ai/docs/sdk/theme/presets).
|
|
108
64
|
|
|
109
65
|
### Using a Preset
|
|
110
66
|
|
|
@@ -172,9 +128,9 @@ Apply via `className` on `StigmerProvider`:
|
|
|
172
128
|
|
|
173
129
|
### What a Full Preset Overrides
|
|
174
130
|
|
|
175
|
-
A complete design language preset overrides the
|
|
131
|
+
A complete design language preset overrides most of the token surface:
|
|
176
132
|
|
|
177
|
-
- **Shape**: `--stgm-radius` (sharp `0.
|
|
133
|
+
- **Shape**: `--stgm-radius` (sharp `0.125rem` in Fintech to very rounded `1.25rem` in Friendly)
|
|
178
134
|
- **Surfaces**: `--stgm-background`, `--stgm-card`, `--stgm-popover`, `--stgm-muted`, `--stgm-secondary`, `--stgm-accent`
|
|
179
135
|
- **Text**: `--stgm-foreground`, `--stgm-card-foreground`, `--stgm-muted-foreground`, etc.
|
|
180
136
|
- **Accent**: `--stgm-primary` / `--stgm-primary-foreground`, `--stgm-ring`
|
|
@@ -184,6 +140,11 @@ A complete design language preset overrides the entire token surface:
|
|
|
184
140
|
|
|
185
141
|
You can also create minimal presets that override only a subset of tokens.
|
|
186
142
|
|
|
143
|
+
If your preset defines a token in its light block, define its dark value
|
|
144
|
+
too: preset-light declarations outrank the default dark block in the
|
|
145
|
+
cascade, so a light-only override would leak into dark mode. The theme
|
|
146
|
+
package's contract tests flag this automatically for built-in presets.
|
|
147
|
+
|
|
187
148
|
### Color Format
|
|
188
149
|
|
|
189
150
|
All built-in tokens use [OKLCH](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch) for perceptually uniform color manipulation. You can use any CSS color format in your overrides.
|
|
@@ -211,6 +172,7 @@ import { cn } from "@stigmer/theme";
|
|
|
211
172
|
| `@stigmer/theme/presets/startup.css` | Startup (Modern dev tools) design language |
|
|
212
173
|
| `@stigmer/theme/presets/friendly.css` | Friendly (Consumer SaaS) design language |
|
|
213
174
|
| `@stigmer/theme/presets/fintech.css` | Fintech (Premium financial) design language |
|
|
175
|
+
| `@stigmer/theme/presets/monochrome.css` | Monochrome (Editorial black-and-white) design language |
|
|
214
176
|
|
|
215
177
|
## License
|
|
216
178
|
|
package/package.json
CHANGED
package/presets/corporate.css
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
--stgm-muted-faint: oklch(0.96 0.01 250);
|
|
45
45
|
--stgm-primary-muted: oklch(0.58 0.10 250);
|
|
46
46
|
--stgm-destructive-muted: oklch(0.65 0.12 27);
|
|
47
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
47
|
+
--stgm-muted-foreground-faint: oklch(0.62 0.02 250);
|
|
48
48
|
--stgm-backdrop: oklch(0.97 0.01 250 / 80%);
|
|
49
49
|
--stgm-sidebar: oklch(0.20 0.06 250);
|
|
50
50
|
--stgm-sidebar-foreground: oklch(0.93 0 0);
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
--stgm-muted-faint: oklch(0.19 0.03 250);
|
|
99
99
|
--stgm-primary-muted: oklch(0.60 0.10 250);
|
|
100
100
|
--stgm-destructive-muted: oklch(0.60 0.10 22);
|
|
101
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
101
|
+
--stgm-muted-foreground-faint: oklch(0.5 0.03 250);
|
|
102
102
|
--stgm-backdrop: oklch(0.16 0.04 250 / 80%);
|
|
103
103
|
--stgm-sidebar: oklch(0.13 0.05 250);
|
|
104
104
|
--stgm-sidebar-foreground: oklch(0.93 0 0);
|
package/presets/fintech.css
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
--stgm-muted-faint: oklch(0.975 0.003 280);
|
|
46
46
|
--stgm-primary-muted: oklch(0.58 0.14 280);
|
|
47
47
|
--stgm-destructive-muted: oklch(0.65 0.12 27);
|
|
48
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
48
|
+
--stgm-muted-foreground-faint: oklch(0.62 0.02 280);
|
|
49
49
|
--stgm-backdrop: oklch(0.98 0.005 280 / 80%);
|
|
50
50
|
--stgm-sidebar: oklch(0.12 0.06 280);
|
|
51
51
|
--stgm-sidebar-foreground: oklch(0.90 0.01 280);
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
--stgm-secondary: oklch(0.23 0.05 280);
|
|
73
73
|
--stgm-secondary-foreground: oklch(0.92 0.01 280);
|
|
74
74
|
--stgm-muted: oklch(0.23 0.05 280);
|
|
75
|
-
--stgm-muted-foreground: oklch(0.
|
|
75
|
+
--stgm-muted-foreground: oklch(0.65 0.04 280);
|
|
76
76
|
--stgm-accent: oklch(0.28 0.06 280);
|
|
77
77
|
--stgm-accent-foreground: oklch(0.92 0.01 280);
|
|
78
78
|
--stgm-border: oklch(0.35 0.06 280);
|
|
@@ -99,14 +99,14 @@
|
|
|
99
99
|
--stgm-muted-faint: oklch(0.15 0.04 280);
|
|
100
100
|
--stgm-primary-muted: oklch(0.60 0.14 280);
|
|
101
101
|
--stgm-destructive-muted: oklch(0.60 0.10 22);
|
|
102
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
102
|
+
--stgm-muted-foreground-faint: oklch(0.5 0.03 280);
|
|
103
103
|
--stgm-backdrop: oklch(0.12 0.05 280 / 80%);
|
|
104
104
|
--stgm-sidebar: oklch(0.08 0.06 280);
|
|
105
105
|
--stgm-sidebar-foreground: oklch(0.88 0.01 280);
|
|
106
106
|
--stgm-sidebar-primary: oklch(0.70 0.22 280);
|
|
107
107
|
--stgm-sidebar-primary-foreground: oklch(0.10 0 0);
|
|
108
108
|
--stgm-sidebar-muted: oklch(0.14 0.06 280);
|
|
109
|
-
--stgm-sidebar-muted-foreground: oklch(0.
|
|
109
|
+
--stgm-sidebar-muted-foreground: oklch(0.58 0.04 280);
|
|
110
110
|
--stgm-sidebar-accent: oklch(0.16 0.07 280);
|
|
111
111
|
--stgm-sidebar-accent-foreground: oklch(0.88 0.01 280);
|
|
112
112
|
--stgm-sidebar-border: oklch(0.22 0.06 280);
|
package/presets/friendly.css
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
--stgm-card-foreground: oklch(0.22 0.03 50);
|
|
11
11
|
--stgm-popover: oklch(0.99 0.01 65);
|
|
12
12
|
--stgm-popover-foreground: oklch(0.22 0.03 50);
|
|
13
|
-
--stgm-primary: oklch(0.
|
|
13
|
+
--stgm-primary: oklch(0.55 0.20 40);
|
|
14
14
|
--stgm-primary-foreground: oklch(0.98 0 0);
|
|
15
15
|
--stgm-secondary: oklch(0.94 0.02 65);
|
|
16
16
|
--stgm-secondary-foreground: oklch(0.30 0.03 50);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
--stgm-border: oklch(0.88 0.02 65);
|
|
22
22
|
--stgm-input: oklch(0.88 0.02 65);
|
|
23
23
|
--stgm-input-bg: oklch(0.99 0.01 65);
|
|
24
|
-
--stgm-ring: oklch(0.
|
|
24
|
+
--stgm-ring: oklch(0.55 0.15 40);
|
|
25
25
|
--stgm-chart-1: oklch(0.62 0.20 40);
|
|
26
26
|
--stgm-chart-2: oklch(0.58 0.16 150);
|
|
27
27
|
--stgm-chart-3: oklch(0.55 0.18 280);
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
--stgm-shadow-lg: 0 10px 20px -3px rgb(0 0 0 / 0.08), 0 4px 8px -4px rgb(0 0 0 / 0.06);
|
|
33
33
|
--stgm-transition-duration: 200ms;
|
|
34
34
|
--stgm-transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
|
|
35
|
-
--stgm-primary-hover: oklch(0.
|
|
35
|
+
--stgm-primary-hover: oklch(0.50 0.18 40);
|
|
36
36
|
--stgm-primary-subtle: oklch(0.95 0.03 40);
|
|
37
37
|
--stgm-destructive-subtle: oklch(0.95 0.03 27);
|
|
38
38
|
--stgm-muted-subtle: oklch(0.95 0.02 65);
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
--stgm-muted-faint: oklch(0.96 0.015 65);
|
|
45
45
|
--stgm-primary-muted: oklch(0.68 0.12 40);
|
|
46
46
|
--stgm-destructive-muted: oklch(0.65 0.12 27);
|
|
47
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
47
|
+
--stgm-muted-foreground-faint: oklch(0.62 0.02 50);
|
|
48
48
|
--stgm-backdrop: oklch(0.97 0.015 65 / 80%);
|
|
49
49
|
--stgm-sidebar: oklch(0.95 0.02 65);
|
|
50
50
|
--stgm-sidebar-foreground: oklch(0.22 0.03 50);
|
|
51
|
-
--stgm-sidebar-primary: oklch(0.
|
|
51
|
+
--stgm-sidebar-primary: oklch(0.55 0.20 40);
|
|
52
52
|
--stgm-sidebar-primary-foreground: oklch(0.98 0 0);
|
|
53
53
|
--stgm-sidebar-muted: oklch(0.92 0.02 65);
|
|
54
54
|
--stgm-sidebar-muted-foreground: oklch(0.50 0.03 50);
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
--stgm-secondary: oklch(0.27 0.03 50);
|
|
72
72
|
--stgm-secondary-foreground: oklch(0.93 0.01 65);
|
|
73
73
|
--stgm-muted: oklch(0.27 0.03 50);
|
|
74
|
-
--stgm-muted-foreground: oklch(0.
|
|
74
|
+
--stgm-muted-foreground: oklch(0.67 0.03 50);
|
|
75
75
|
--stgm-accent: oklch(0.33 0.04 50);
|
|
76
76
|
--stgm-accent-foreground: oklch(0.93 0.01 65);
|
|
77
77
|
--stgm-border: oklch(0.38 0.04 50);
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
--stgm-primary-hover: oklch(0.66 0.16 40);
|
|
90
90
|
--stgm-primary-subtle: oklch(0.26 0.04 40);
|
|
91
91
|
--stgm-destructive-subtle: oklch(0.26 0.04 22);
|
|
92
|
-
--stgm-muted-subtle: oklch(0.
|
|
92
|
+
--stgm-muted-subtle: oklch(0.24 0.02 50);
|
|
93
93
|
--stgm-muted-foreground-subtle: oklch(0.52 0.03 50);
|
|
94
94
|
--stgm-accent-hover: oklch(0.26 0.03 50);
|
|
95
95
|
--stgm-destructive-hover: oklch(0.64 0.16 22);
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
--stgm-muted-faint: oklch(0.20 0.02 50);
|
|
99
99
|
--stgm-primary-muted: oklch(0.62 0.10 40);
|
|
100
100
|
--stgm-destructive-muted: oklch(0.60 0.10 22);
|
|
101
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
101
|
+
--stgm-muted-foreground-faint: oklch(0.52 0.02 50);
|
|
102
102
|
--stgm-backdrop: oklch(0.18 0.03 50 / 80%);
|
|
103
103
|
--stgm-sidebar: oklch(0.15 0.03 50);
|
|
104
104
|
--stgm-sidebar-foreground: oklch(0.90 0.01 65);
|
package/presets/monochrome.css
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
--stgm-muted-faint: oklch(0.98 0 0);
|
|
47
47
|
--stgm-primary-muted: oklch(0.40 0 0);
|
|
48
48
|
--stgm-destructive-muted: oklch(0.65 0.12 27.325);
|
|
49
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
49
|
+
--stgm-muted-foreground-faint: oklch(0.63 0 0);
|
|
50
50
|
--stgm-backdrop: oklch(0.99 0 0 / 80%);
|
|
51
51
|
--stgm-sidebar: oklch(0.97 0 0);
|
|
52
52
|
--stgm-sidebar-foreground: oklch(0.15 0 0);
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
--stgm-primary-hover: oklch(0.88 0 0);
|
|
92
92
|
--stgm-primary-subtle: oklch(0.21 0 0);
|
|
93
93
|
--stgm-destructive-subtle: oklch(0.21 0.03 25);
|
|
94
|
-
--stgm-muted-subtle: oklch(0.
|
|
94
|
+
--stgm-muted-subtle: oklch(0.20 0 0);
|
|
95
95
|
--stgm-muted-foreground-subtle: oklch(0.55 0 0);
|
|
96
96
|
--stgm-accent-hover: oklch(0.22 0 0);
|
|
97
97
|
--stgm-destructive-hover: oklch(0.64 0.17 22);
|
|
@@ -100,14 +100,14 @@
|
|
|
100
100
|
--stgm-muted-faint: oklch(0.17 0 0);
|
|
101
101
|
--stgm-primary-muted: oklch(0.80 0 0);
|
|
102
102
|
--stgm-destructive-muted: oklch(0.60 0.10 22);
|
|
103
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
103
|
+
--stgm-muted-foreground-faint: oklch(0.5 0 0);
|
|
104
104
|
--stgm-backdrop: oklch(0.145 0 0 / 80%);
|
|
105
105
|
--stgm-sidebar: oklch(0.11 0 0);
|
|
106
106
|
--stgm-sidebar-foreground: oklch(0.96 0 0);
|
|
107
107
|
--stgm-sidebar-primary: oklch(0.96 0 0);
|
|
108
108
|
--stgm-sidebar-primary-foreground: oklch(0.145 0 0);
|
|
109
109
|
--stgm-sidebar-muted: oklch(0.18 0 0);
|
|
110
|
-
--stgm-sidebar-muted-foreground: oklch(0.
|
|
110
|
+
--stgm-sidebar-muted-foreground: oklch(0.6 0 0);
|
|
111
111
|
--stgm-sidebar-accent: oklch(0.20 0 0);
|
|
112
112
|
--stgm-sidebar-accent-foreground: oklch(0.96 0 0);
|
|
113
113
|
--stgm-sidebar-border: oklch(0.24 0 0);
|
package/presets/startup.css
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
--stgm-muted-faint: oklch(0.98 0 0);
|
|
44
44
|
--stgm-primary-muted: oklch(0.58 0.14 285);
|
|
45
45
|
--stgm-destructive-muted: oklch(0.65 0.12 27);
|
|
46
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
46
|
+
--stgm-muted-foreground-faint: oklch(0.62 0 0);
|
|
47
47
|
--stgm-backdrop: oklch(0.99 0 0 / 80%);
|
|
48
48
|
--stgm-sidebar: oklch(0.97 0 0);
|
|
49
49
|
--stgm-sidebar-foreground: oklch(0.13 0 0);
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
--stgm-secondary: oklch(0.16 0 0);
|
|
71
71
|
--stgm-secondary-foreground: oklch(0.85 0 0);
|
|
72
72
|
--stgm-muted: oklch(0.16 0 0);
|
|
73
|
-
--stgm-muted-foreground: oklch(0.
|
|
73
|
+
--stgm-muted-foreground: oklch(0.62 0 0);
|
|
74
74
|
--stgm-accent: oklch(0.20 0 0);
|
|
75
75
|
--stgm-accent-foreground: oklch(0.90 0 0);
|
|
76
76
|
--stgm-border: oklch(0.22 0 0);
|
|
@@ -88,8 +88,8 @@
|
|
|
88
88
|
--stgm-primary-hover: oklch(0.66 0.18 285);
|
|
89
89
|
--stgm-primary-subtle: oklch(0.16 0.04 285);
|
|
90
90
|
--stgm-destructive-subtle: oklch(0.16 0.04 22);
|
|
91
|
-
--stgm-muted-subtle: oklch(0.
|
|
92
|
-
--stgm-muted-foreground-subtle: oklch(0.
|
|
91
|
+
--stgm-muted-subtle: oklch(0.15 0 0);
|
|
92
|
+
--stgm-muted-foreground-subtle: oklch(0.52 0 0);
|
|
93
93
|
--stgm-accent-hover: oklch(0.15 0 0);
|
|
94
94
|
--stgm-destructive-hover: oklch(0.64 0.17 22);
|
|
95
95
|
--stgm-foreground-hover: oklch(0.82 0 0);
|
|
@@ -97,14 +97,14 @@
|
|
|
97
97
|
--stgm-muted-faint: oklch(0.11 0 0);
|
|
98
98
|
--stgm-primary-muted: oklch(0.62 0.12 285);
|
|
99
99
|
--stgm-destructive-muted: oklch(0.60 0.10 22);
|
|
100
|
-
--stgm-muted-foreground-faint: oklch(0.
|
|
100
|
+
--stgm-muted-foreground-faint: oklch(0.48 0 0);
|
|
101
101
|
--stgm-backdrop: oklch(0.09 0 0 / 80%);
|
|
102
102
|
--stgm-sidebar: oklch(0.07 0 0);
|
|
103
103
|
--stgm-sidebar-foreground: oklch(0.85 0 0);
|
|
104
104
|
--stgm-sidebar-primary: oklch(0.72 0.20 285);
|
|
105
105
|
--stgm-sidebar-primary-foreground: oklch(0.10 0 0);
|
|
106
106
|
--stgm-sidebar-muted: oklch(0.13 0 0);
|
|
107
|
-
--stgm-sidebar-muted-foreground: oklch(0.
|
|
107
|
+
--stgm-sidebar-muted-foreground: oklch(0.58 0 0);
|
|
108
108
|
--stgm-sidebar-accent: oklch(0.14 0 0);
|
|
109
109
|
--stgm-sidebar-accent-foreground: oklch(0.85 0 0);
|
|
110
110
|
--stgm-sidebar-border: oklch(0.18 0 0);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { compositeOver, contrastRatio, isColorValue, lightnessDelta, parseColor } from "../color.js";
|
|
3
|
+
|
|
4
|
+
describe("contrastRatio", () => {
|
|
5
|
+
it("computes the canonical black-on-white ratio", () => {
|
|
6
|
+
expect(contrastRatio("black", "white")).toBeCloseTo(21, 5);
|
|
7
|
+
expect(contrastRatio("white", "white")).toBeCloseTo(1, 5);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses OKLCH token values", () => {
|
|
11
|
+
// Dark-mode foreground on background from tokens.css — a high-contrast pair.
|
|
12
|
+
const ratio = contrastRatio("oklch(0.985 0 0)", "oklch(0.145 0 0)");
|
|
13
|
+
expect(ratio).toBeGreaterThan(15);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("composites a translucent foreground over the background first", () => {
|
|
17
|
+
// 50% white over black is a grey; contrast must be far below solid white's.
|
|
18
|
+
const solid = contrastRatio("white", "black");
|
|
19
|
+
const translucent = contrastRatio("rgb(255 255 255 / 50%)", "black");
|
|
20
|
+
expect(translucent).toBeLessThan(solid);
|
|
21
|
+
expect(translucent).toBeGreaterThan(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("requires a backdrop for a translucent background", () => {
|
|
25
|
+
expect(() => contrastRatio("white", "oklch(1 0 0 / 14%)")).toThrow(/backdropValue/);
|
|
26
|
+
const ratio = contrastRatio("white", "oklch(1 0 0 / 14%)", "oklch(0.145 0 0)");
|
|
27
|
+
expect(ratio).toBeGreaterThan(1);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("compositeOver", () => {
|
|
32
|
+
it("returns the foreground unchanged when opaque", () => {
|
|
33
|
+
const fg = parseColor("rgb(10 20 30)");
|
|
34
|
+
expect(compositeOver(fg, parseColor("white"))).toBe(fg);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("blends channels linearly by alpha in sRGB", () => {
|
|
38
|
+
const result = compositeOver(parseColor("rgb(255 255 255 / 50%)"), parseColor("black"));
|
|
39
|
+
expect(result.r).toBeCloseTo(0.5, 5);
|
|
40
|
+
expect(result.g).toBeCloseTo(0.5, 5);
|
|
41
|
+
expect(result.b).toBeCloseTo(0.5, 5);
|
|
42
|
+
expect(result.alpha).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("lightnessDelta", () => {
|
|
47
|
+
it("measures OKLCH lightness separation", () => {
|
|
48
|
+
expect(lightnessDelta("oklch(0.21 0 0)", "oklch(0.145 0 0)")).toBeCloseTo(0.065, 3);
|
|
49
|
+
expect(lightnessDelta("white", "white")).toBeCloseTo(0, 5);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isColorValue", () => {
|
|
54
|
+
it("distinguishes colors from other token values", () => {
|
|
55
|
+
expect(isColorValue("oklch(0.5 0.1 190)")).toBe(true);
|
|
56
|
+
expect(isColorValue("0.625rem")).toBe(false);
|
|
57
|
+
expect(isColorValue("var(--font-geist-sans, system-ui, sans-serif)")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { runContrastAudit, resultId } from "../audit.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The permanent contrast gate for the token contract (stigmer/stigmer#187).
|
|
6
|
+
*
|
|
7
|
+
* Every pair declared in `pairs.ts` must meet its threshold in every preset
|
|
8
|
+
* × color mode, resolved through the real cascade. A regression here means
|
|
9
|
+
* shipped chrome text or surfaces became unreadable in someone's theme.
|
|
10
|
+
*
|
|
11
|
+
* EXEMPTIONS is the explicit, reviewed list of accepted misses. Adding to
|
|
12
|
+
* it is a design decision, not a fix — every entry needs a rationale, and
|
|
13
|
+
* the exemption is pinned to a measured floor so an exempted pair can still
|
|
14
|
+
* never silently get *worse*.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface Exemption {
|
|
18
|
+
/** `resultId()` of the accepted miss. */
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** Why this miss is acceptable. */
|
|
21
|
+
readonly rationale: string;
|
|
22
|
+
/** The measured value at the time of exemption; regressions below fail. */
|
|
23
|
+
readonly measuredFloor: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXEMPTIONS: readonly Exemption[] = [];
|
|
27
|
+
|
|
28
|
+
const exemptionById = new Map(EXEMPTIONS.map((e) => [e.id, e]));
|
|
29
|
+
|
|
30
|
+
describe("token contract contrast audit", () => {
|
|
31
|
+
const { results, leaks } = runContrastAudit();
|
|
32
|
+
|
|
33
|
+
it("declared pairs resolve for every preset and mode", () => {
|
|
34
|
+
// 6 presets × 2 modes × (text+supporting+surface pairs); exact count is
|
|
35
|
+
// asserted loosely so adding a pair does not require touching this test.
|
|
36
|
+
expect(results.length).toBeGreaterThan(400);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("no preset leaks a light-only token value into dark mode", () => {
|
|
40
|
+
const formatted = leaks.map(
|
|
41
|
+
(leak) => `${leak.preset}: ${leak.tokens.join(", ")}`,
|
|
42
|
+
);
|
|
43
|
+
expect(formatted, "preset tokens defined light-only override the default dark value (see resolve.ts)").toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const failures = results.filter(
|
|
47
|
+
(r) => r.enforced && !r.passes && !exemptionById.has(resultId(r)),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it("every non-exempt pair meets its threshold", () => {
|
|
51
|
+
const formatted = failures.map(
|
|
52
|
+
(r) =>
|
|
53
|
+
`${resultId(r)} — ${r.pair.kind} ${r.measured.toFixed(2)} < ${r.threshold} ` +
|
|
54
|
+
`(fg ${r.foregroundValue} on bg ${r.backgroundValue}; used by: ${r.pair.usage})`,
|
|
55
|
+
);
|
|
56
|
+
expect(formatted).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("exempted pairs never regress below their recorded floor", () => {
|
|
60
|
+
const regressions: string[] = [];
|
|
61
|
+
const staleExemptions: string[] = [];
|
|
62
|
+
for (const exemption of EXEMPTIONS) {
|
|
63
|
+
const result = results.find((r) => resultId(r) === exemption.id);
|
|
64
|
+
if (!result) {
|
|
65
|
+
staleExemptions.push(exemption.id);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (result.passes) {
|
|
69
|
+
// The pair now meets the real threshold — the exemption must be
|
|
70
|
+
// removed so the full gate applies again.
|
|
71
|
+
staleExemptions.push(exemption.id);
|
|
72
|
+
} else if (result.measured < exemption.measuredFloor - 1e-6) {
|
|
73
|
+
regressions.push(
|
|
74
|
+
`${exemption.id}: ${result.measured.toFixed(3)} fell below exempted floor ${exemption.measuredFloor}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
expect(regressions).toEqual([]);
|
|
79
|
+
expect(staleExemptions, "exemption no longer needed or references a removed pair — delete it").toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseThemeCss } from "../parse.js";
|
|
3
|
+
|
|
4
|
+
describe("parseThemeCss", () => {
|
|
5
|
+
it("splits :root and dark-attribute blocks into light and dark maps", () => {
|
|
6
|
+
const css = `
|
|
7
|
+
:root {
|
|
8
|
+
--stgm-background: oklch(0.98 0 0);
|
|
9
|
+
--stgm-foreground: oklch(0.145 0 0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
[data-stgm-color-mode="dark"] {
|
|
13
|
+
--stgm-background: oklch(0.145 0 0);
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
const { light, dark } = parseThemeCss(css);
|
|
17
|
+
expect(light.get("--stgm-background")?.value).toBe("oklch(0.98 0 0)");
|
|
18
|
+
expect(light.get("--stgm-foreground")?.value).toBe("oklch(0.145 0 0)");
|
|
19
|
+
expect(dark.get("--stgm-background")?.value).toBe("oklch(0.145 0 0)");
|
|
20
|
+
expect(dark.has("--stgm-foreground")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("classifies the preset dual dark selector spanning multiple lines", () => {
|
|
24
|
+
const css = `
|
|
25
|
+
.stgm-theme-x {
|
|
26
|
+
--stgm-primary: oklch(0.5 0.2 250);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[data-stgm-color-mode="dark"] .stgm-theme-x,
|
|
30
|
+
.stgm-theme-x[data-stgm-color-mode="dark"] {
|
|
31
|
+
--stgm-primary: oklch(0.7 0.2 250);
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
const { light, dark } = parseThemeCss(css);
|
|
35
|
+
expect(light.get("--stgm-primary")?.value).toBe("oklch(0.5 0.2 250)");
|
|
36
|
+
expect(dark.get("--stgm-primary")?.value).toBe("oklch(0.7 0.2 250)");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps values containing semicolons-free complex expressions intact", () => {
|
|
40
|
+
const css = `
|
|
41
|
+
:root {
|
|
42
|
+
--stgm-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
43
|
+
--stgm-font-sans: var(--font-geist-sans, system-ui, sans-serif);
|
|
44
|
+
--stgm-backdrop: oklch(0.98 0 0 / 80%);
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
const { light } = parseThemeCss(css);
|
|
48
|
+
expect(light.get("--stgm-shadow-sm")?.value).toBe(
|
|
49
|
+
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
|
50
|
+
);
|
|
51
|
+
expect(light.get("--stgm-font-sans")?.value).toBe(
|
|
52
|
+
"var(--font-geist-sans, system-ui, sans-serif)",
|
|
53
|
+
);
|
|
54
|
+
expect(light.get("--stgm-backdrop")?.value).toBe("oklch(0.98 0 0 / 80%)");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("attaches @group headers and per-token descriptions", () => {
|
|
58
|
+
const css = `
|
|
59
|
+
:root {
|
|
60
|
+
/* @group Core colors */
|
|
61
|
+
/* Page background behind all content. */
|
|
62
|
+
--stgm-background: oklch(0.98 0 0);
|
|
63
|
+
--stgm-foreground: oklch(0.145 0 0);
|
|
64
|
+
|
|
65
|
+
/* @group Shape */
|
|
66
|
+
--stgm-radius: 0.625rem;
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
const { light } = parseThemeCss(css);
|
|
70
|
+
expect(light.get("--stgm-background")).toMatchObject({
|
|
71
|
+
group: "Core colors",
|
|
72
|
+
description: "Page background behind all content.",
|
|
73
|
+
});
|
|
74
|
+
// The description belongs only to the token directly beneath it.
|
|
75
|
+
expect(light.get("--stgm-foreground")).toMatchObject({ group: "Core colors" });
|
|
76
|
+
expect(light.get("--stgm-foreground")?.description).toBeUndefined();
|
|
77
|
+
expect(light.get("--stgm-radius")).toMatchObject({ group: "Shape" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("ignores multi-line file header comments", () => {
|
|
81
|
+
const css = `
|
|
82
|
+
/* Corporate — Enterprise SaaS design language
|
|
83
|
+
Tight radius, corporate blue */
|
|
84
|
+
|
|
85
|
+
.stgm-theme-corporate {
|
|
86
|
+
--stgm-radius: 0.375rem;
|
|
87
|
+
}
|
|
88
|
+
`;
|
|
89
|
+
const { light } = parseThemeCss(css);
|
|
90
|
+
expect(light.get("--stgm-radius")?.value).toBe("0.375rem");
|
|
91
|
+
expect(light.get("--stgm-radius")?.description).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores non-stgm declarations", () => {
|
|
95
|
+
const css = `
|
|
96
|
+
:root {
|
|
97
|
+
--other-token: red;
|
|
98
|
+
color: blue;
|
|
99
|
+
--stgm-primary: oklch(0.5 0.1 190);
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
const { light } = parseThemeCss(css);
|
|
103
|
+
expect(light.size).toBe(1);
|
|
104
|
+
expect(light.has("--stgm-primary")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|