@stigmer/theme 3.1.4 → 3.1.6

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 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
- #### Layout
26
-
27
- | Token | Purpose |
28
- |-------|---------|
29
- | `--stgm-radius` | Base border radius |
30
-
31
- #### Colors (Core)
32
-
33
- | Token | Purpose |
34
- |-------|---------|
35
- | `--stgm-background` | Page / app background |
36
- | `--stgm-foreground` | Default text color |
37
- | `--stgm-primary` | Primary brand / action color |
38
- | `--stgm-primary-foreground` | Text on primary |
39
- | `--stgm-secondary` | Secondary surfaces |
40
- | `--stgm-secondary-foreground` | Text on secondary |
41
- | `--stgm-muted` | Muted / disabled surfaces |
42
- | `--stgm-muted-foreground` | Text on muted |
43
- | `--stgm-accent` | Accent highlights |
44
- | `--stgm-accent-foreground` | Text on accent |
45
-
46
- #### Colors (Semantic)
47
-
48
- | Token | Purpose |
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 5 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.
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 full token surface — radius, all surface colors, borders, sidebar, and accent palette — for both light and dark modes.
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 entire token surface:
131
+ A complete design language preset overrides most of the token surface:
176
132
 
177
- - **Shape**: `--stgm-radius` (sharp `0.25rem` to very rounded `0.875rem`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/theme",
3
- "version": "3.1.4",
3
+ "version": "3.1.6",
4
4
  "description": "Design tokens, CSS custom properties, and utility functions for Stigmer UI components",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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.68 0.02 250);
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.48 0.03 250);
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);
@@ -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.70 0.02 280);
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.58 0.04 280);
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.42 0.03 280);
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.52 0.04 280);
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);
@@ -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.62 0.20 40);
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.62 0.15 40);
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.57 0.18 40);
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.70 0.02 50);
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.62 0.20 40);
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.62 0.03 50);
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.22 0.02 50);
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.45 0.02 50);
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);
@@ -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.68 0 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.18 0 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.45 0 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.55 0 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);
@@ -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.72 0 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.55 0 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.12 0 0);
92
- --stgm-muted-foreground-subtle: oklch(0.45 0 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.38 0 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.50 0 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
+ });