@principal-ade/industry-theme 0.1.20 → 0.1.22
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/dist/cjs/ContrastReport.d.ts +20 -0
- package/dist/cjs/ContrastReport.d.ts.map +1 -0
- package/dist/cjs/contrast.d.ts +75 -0
- package/dist/cjs/contrast.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +286 -12
- package/dist/esm/ContrastReport.d.ts +20 -0
- package/dist/esm/ContrastReport.d.ts.map +1 -0
- package/dist/esm/contrast.d.ts +75 -0
- package/dist/esm/contrast.d.ts.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +286 -12
- package/package.json +8 -7
- package/src/ContrastReport.stories.tsx +77 -0
- package/src/ContrastReport.tsx +226 -0
- package/src/README.md +2 -3
- package/src/ThemeProvider.tsx +1 -20
- package/src/contrast.ts +172 -0
- package/src/index.ts +22 -0
- package/src/themes.ts +11 -11
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { ContrastLevel, ContrastResult, evaluateThemeContrast, WCAG_THRESHOLDS } from './contrast';
|
|
4
|
+
|
|
5
|
+
import { Theme } from './index';
|
|
6
|
+
|
|
7
|
+
export interface ContrastReportProps {
|
|
8
|
+
/** A single theme to audit. */
|
|
9
|
+
theme?: Theme;
|
|
10
|
+
/** Multiple named themes to audit side by side. */
|
|
11
|
+
themes?: { name: string; theme: Theme }[];
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LEVEL_STYLE: Record<ContrastLevel, { bg: string; fg: string; label: string }> = {
|
|
16
|
+
AAA: { bg: '#0f7b3f', fg: '#ffffff', label: 'AAA' },
|
|
17
|
+
AA: { bg: '#1f6feb', fg: '#ffffff', label: 'AA' },
|
|
18
|
+
fail: { bg: '#cf222e', fg: '#ffffff', label: 'FAIL' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ui = {
|
|
22
|
+
fontFamily: "ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
23
|
+
text: '#1c2128',
|
|
24
|
+
subtle: '#57606a',
|
|
25
|
+
border: '#d0d7de',
|
|
26
|
+
surface: '#ffffff',
|
|
27
|
+
panel: '#f6f8fa',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const Badge: React.FC<{ level: ContrastLevel }> = ({ level }) => {
|
|
31
|
+
const s = LEVEL_STYLE[level];
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
style={{
|
|
35
|
+
display: 'inline-block',
|
|
36
|
+
minWidth: 44,
|
|
37
|
+
textAlign: 'center',
|
|
38
|
+
padding: '2px 8px',
|
|
39
|
+
borderRadius: 999,
|
|
40
|
+
fontSize: 11,
|
|
41
|
+
fontWeight: 700,
|
|
42
|
+
letterSpacing: 0.4,
|
|
43
|
+
backgroundColor: s.bg,
|
|
44
|
+
color: s.fg,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{s.label}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const Swatch: React.FC<{ fg: string; bg: string }> = ({ fg, bg }) => (
|
|
53
|
+
<span
|
|
54
|
+
title={`${fg} on ${bg}`}
|
|
55
|
+
style={{
|
|
56
|
+
display: 'inline-flex',
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
justifyContent: 'center',
|
|
59
|
+
width: 40,
|
|
60
|
+
height: 24,
|
|
61
|
+
borderRadius: 4,
|
|
62
|
+
border: `1px solid ${ui.border}`,
|
|
63
|
+
backgroundColor: bg,
|
|
64
|
+
color: fg,
|
|
65
|
+
fontSize: 13,
|
|
66
|
+
fontWeight: 700,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
Aa
|
|
70
|
+
</span>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const Row: React.FC<{ r: ContrastResult }> = ({ r }) => {
|
|
74
|
+
const failed = r.level === 'fail';
|
|
75
|
+
return (
|
|
76
|
+
<tr style={{ backgroundColor: failed ? '#fff5f5' : 'transparent' }}>
|
|
77
|
+
<td style={cell}>
|
|
78
|
+
<Swatch fg={r.fgColor} bg={r.bgColor} />
|
|
79
|
+
</td>
|
|
80
|
+
<td style={{ ...cell, fontWeight: 500 }}>
|
|
81
|
+
{r.pair.label}
|
|
82
|
+
<div style={{ fontSize: 11, color: ui.subtle, fontFamily: 'monospace' }}>
|
|
83
|
+
{r.fgColor} / {r.bgColor}
|
|
84
|
+
</div>
|
|
85
|
+
</td>
|
|
86
|
+
<td style={{ ...cell, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
|
87
|
+
<span style={{ fontWeight: 700, color: failed ? '#cf222e' : ui.text }}>
|
|
88
|
+
{r.ratio === null ? '—' : `${r.ratio.toFixed(2)}:1`}
|
|
89
|
+
</span>
|
|
90
|
+
</td>
|
|
91
|
+
<td
|
|
92
|
+
style={{
|
|
93
|
+
...cell,
|
|
94
|
+
textAlign: 'right',
|
|
95
|
+
color: ui.subtle,
|
|
96
|
+
fontVariantNumeric: 'tabular-nums',
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{r.required}:1
|
|
100
|
+
</td>
|
|
101
|
+
<td style={{ ...cell, textAlign: 'center', textTransform: 'capitalize', color: ui.subtle }}>
|
|
102
|
+
{r.pair.use}
|
|
103
|
+
</td>
|
|
104
|
+
<td style={{ ...cell, textAlign: 'center' }}>
|
|
105
|
+
<Badge level={r.level} />
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const cell: React.CSSProperties = {
|
|
112
|
+
padding: '8px 12px',
|
|
113
|
+
borderBottom: `1px solid ${ui.border}`,
|
|
114
|
+
fontSize: 13,
|
|
115
|
+
verticalAlign: 'middle',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const headCell: React.CSSProperties = {
|
|
119
|
+
padding: '8px 12px',
|
|
120
|
+
textAlign: 'left',
|
|
121
|
+
fontSize: 11,
|
|
122
|
+
fontWeight: 700,
|
|
123
|
+
textTransform: 'uppercase',
|
|
124
|
+
letterSpacing: 0.5,
|
|
125
|
+
color: ui.subtle,
|
|
126
|
+
borderBottom: `2px solid ${ui.border}`,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const ThemeTable: React.FC<{ name: string; theme: Theme }> = ({ name, theme }) => {
|
|
130
|
+
const report = evaluateThemeContrast(theme);
|
|
131
|
+
const fails = report.failures.length;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<section
|
|
135
|
+
style={{
|
|
136
|
+
marginBottom: 28,
|
|
137
|
+
border: `1px solid ${ui.border}`,
|
|
138
|
+
borderRadius: 8,
|
|
139
|
+
overflow: 'hidden',
|
|
140
|
+
backgroundColor: ui.surface,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<header
|
|
144
|
+
style={{
|
|
145
|
+
display: 'flex',
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
justifyContent: 'space-between',
|
|
148
|
+
gap: 12,
|
|
149
|
+
padding: '12px 16px',
|
|
150
|
+
backgroundColor: ui.panel,
|
|
151
|
+
borderBottom: `1px solid ${ui.border}`,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<h3 style={{ margin: 0, fontSize: 16, color: ui.text }}>{name}</h3>
|
|
155
|
+
<span
|
|
156
|
+
style={{
|
|
157
|
+
fontSize: 12,
|
|
158
|
+
fontWeight: 700,
|
|
159
|
+
padding: '4px 10px',
|
|
160
|
+
borderRadius: 999,
|
|
161
|
+
backgroundColor: report.passesAA ? '#dafbe1' : '#ffebe9',
|
|
162
|
+
color: report.passesAA ? '#0f7b3f' : '#cf222e',
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
{report.passesAA ? '✓ Passes AA' : `${fails} AA failure${fails === 1 ? '' : 's'}`}
|
|
166
|
+
</span>
|
|
167
|
+
</header>
|
|
168
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
169
|
+
<thead>
|
|
170
|
+
<tr>
|
|
171
|
+
<th style={headCell}>Sample</th>
|
|
172
|
+
<th style={headCell}>Pair</th>
|
|
173
|
+
<th style={{ ...headCell, textAlign: 'right' }}>Ratio</th>
|
|
174
|
+
<th style={{ ...headCell, textAlign: 'right' }}>Req. (AA)</th>
|
|
175
|
+
<th style={{ ...headCell, textAlign: 'center' }}>Use</th>
|
|
176
|
+
<th style={{ ...headCell, textAlign: 'center' }}>Grade</th>
|
|
177
|
+
</tr>
|
|
178
|
+
</thead>
|
|
179
|
+
<tbody>
|
|
180
|
+
{report.results.map((r, i) => (
|
|
181
|
+
<Row key={i} r={r} />
|
|
182
|
+
))}
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</section>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Renders a WCAG contrast audit for one or more themes — a deterministic,
|
|
191
|
+
* exhaustive view of every declared foreground/background pairing with its
|
|
192
|
+
* ratio and AA/AAA grade. Complements `@storybook/addon-a11y`, which only
|
|
193
|
+
* checks contrast on actually-rendered text.
|
|
194
|
+
*/
|
|
195
|
+
export const ContrastReport: React.FC<ContrastReportProps> = ({
|
|
196
|
+
theme,
|
|
197
|
+
themes,
|
|
198
|
+
title = 'WCAG Contrast Report',
|
|
199
|
+
}) => {
|
|
200
|
+
const list = themes ?? (theme ? [{ name: title, theme }] : []);
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div
|
|
204
|
+
style={{
|
|
205
|
+
fontFamily: ui.fontFamily,
|
|
206
|
+
color: ui.text,
|
|
207
|
+
backgroundColor: ui.panel,
|
|
208
|
+
padding: 24,
|
|
209
|
+
minHeight: '100vh',
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<header style={{ marginBottom: 20 }}>
|
|
213
|
+
<h1 style={{ margin: '0 0 6px', fontSize: 22 }}>{title}</h1>
|
|
214
|
+
<p style={{ margin: 0, fontSize: 13, color: ui.subtle, maxWidth: 720 }}>
|
|
215
|
+
WCAG 2.x contrast ratios for each theme's declared color roles. Text pairs require AA{' '}
|
|
216
|
+
{WCAG_THRESHOLDS.text.AA}:1 (AAA {WCAG_THRESHOLDS.text.AAA}:1); large text requires{' '}
|
|
217
|
+
{WCAG_THRESHOLDS.large.AA}:1; borders and other non-text UI require{' '}
|
|
218
|
+
{WCAG_THRESHOLDS.ui.AA}:1.
|
|
219
|
+
</p>
|
|
220
|
+
</header>
|
|
221
|
+
{list.map((t) => (
|
|
222
|
+
<ThemeTable key={t.name} name={t.name} theme={t.theme} />
|
|
223
|
+
))}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
};
|
package/src/README.md
CHANGED
|
@@ -98,9 +98,8 @@ const borderRadius = getRadius(theme, 1); // theme.radii[1]
|
|
|
98
98
|
|
|
99
99
|
The theme system supports automatic light/dark mode switching:
|
|
100
100
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
- Provides `toggleColorMode()` function
|
|
101
|
+
- Mode is held in React state for the lifetime of the provider (not persisted)
|
|
102
|
+
- Seed the initial mode via the `initialMode` prop
|
|
104
103
|
- Dark mode colors are defined in `theme.colors.modes.dark`
|
|
105
104
|
|
|
106
105
|
## Migration from old theme
|
package/src/ThemeProvider.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, useContext, ReactNode, useState
|
|
1
|
+
import React, { createContext, useContext, ReactNode, useState } from 'react';
|
|
2
2
|
|
|
3
3
|
import { getMode } from './themeHelpers';
|
|
4
4
|
|
|
@@ -76,25 +76,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|
|
76
76
|
};
|
|
77
77
|
}, [customTheme, mode]);
|
|
78
78
|
|
|
79
|
-
// Load saved mode from localStorage on mount
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (!initialMode) {
|
|
82
|
-
const savedMode = localStorage.getItem('principlemd-theme-mode');
|
|
83
|
-
if (savedMode) {
|
|
84
|
-
setMode(savedMode);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}, [initialMode]);
|
|
88
|
-
|
|
89
|
-
// Save mode to localStorage when it changes
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
if (mode) {
|
|
92
|
-
localStorage.setItem('principlemd-theme-mode', mode);
|
|
93
|
-
} else {
|
|
94
|
-
localStorage.removeItem('principlemd-theme-mode');
|
|
95
|
-
}
|
|
96
|
-
}, [mode]);
|
|
97
|
-
|
|
98
79
|
const value: ThemeContextValue = {
|
|
99
80
|
theme: activeTheme,
|
|
100
81
|
mode,
|
package/src/contrast.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG 2.x contrast evaluation for themes.
|
|
3
|
+
*
|
|
4
|
+
* Implements the relative-luminance / contrast-ratio formulas from
|
|
5
|
+
* https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio and provides a
|
|
6
|
+
* declarative audit of the meaningful foreground/background pairings in a
|
|
7
|
+
* {@link Theme}. This is the deterministic, exhaustive counterpart to the
|
|
8
|
+
* axe-core checks surfaced by `@storybook/addon-a11y`: axe only inspects text
|
|
9
|
+
* that is actually rendered, whereas this audits every declared color role.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Theme } from './index';
|
|
13
|
+
|
|
14
|
+
/** WCAG 2.x contrast thresholds. */
|
|
15
|
+
export const WCAG_THRESHOLDS = {
|
|
16
|
+
/** Normal-size text (< 18pt, or < 14pt bold). */
|
|
17
|
+
text: { AA: 4.5, AAA: 7 },
|
|
18
|
+
/** Large text (>= 18pt, or >= 14pt bold). */
|
|
19
|
+
large: { AA: 3, AAA: 4.5 },
|
|
20
|
+
/** Non-text UI components & graphical objects (borders, icons, focus rings). */
|
|
21
|
+
ui: { AA: 3, AAA: 3 },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type ContrastUse = keyof typeof WCAG_THRESHOLDS;
|
|
25
|
+
export type ContrastLevel = 'AAA' | 'AA' | 'fail';
|
|
26
|
+
|
|
27
|
+
/** Parse a CSS color string into an [r, g, b] triple in the 0–255 range. */
|
|
28
|
+
export function parseColor(input: string): [number, number, number] | null {
|
|
29
|
+
if (!input) return null;
|
|
30
|
+
const color = input.trim().toLowerCase();
|
|
31
|
+
|
|
32
|
+
// #rgb / #rgba / #rrggbb / #rrggbbaa
|
|
33
|
+
if (color.startsWith('#')) {
|
|
34
|
+
let hex = color.slice(1);
|
|
35
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
36
|
+
hex = hex
|
|
37
|
+
.split('')
|
|
38
|
+
.map((c) => c + c)
|
|
39
|
+
.join('');
|
|
40
|
+
}
|
|
41
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
42
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
43
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
44
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
45
|
+
if ([r, g, b].some((n) => Number.isNaN(n))) return null;
|
|
46
|
+
return [r, g, b];
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// rgb(...) / rgba(...)
|
|
52
|
+
const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/);
|
|
53
|
+
if (rgbMatch) {
|
|
54
|
+
const parts = rgbMatch[1]
|
|
55
|
+
.split(/[,/\s]+/)
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.slice(0, 3);
|
|
58
|
+
if (parts.length < 3) return null;
|
|
59
|
+
const channels = parts.map((p) =>
|
|
60
|
+
p.endsWith('%') ? Math.round((parseFloat(p) / 100) * 255) : parseFloat(p),
|
|
61
|
+
);
|
|
62
|
+
if (channels.some((n) => Number.isNaN(n))) return null;
|
|
63
|
+
return [channels[0], channels[1], channels[2]];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Relative luminance per WCAG, from an [r, g, b] triple (0–255). */
|
|
70
|
+
export function relativeLuminance([r, g, b]: [number, number, number]): number {
|
|
71
|
+
const toLinear = (c: number) => {
|
|
72
|
+
const s = c / 255;
|
|
73
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
74
|
+
};
|
|
75
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* WCAG contrast ratio between two colors (1–21). Returns `null` if either
|
|
80
|
+
* color cannot be parsed.
|
|
81
|
+
*/
|
|
82
|
+
export function contrastRatio(fg: string, bg: string): number | null {
|
|
83
|
+
const a = parseColor(fg);
|
|
84
|
+
const b = parseColor(bg);
|
|
85
|
+
if (!a || !b) return null;
|
|
86
|
+
const l1 = relativeLuminance(a);
|
|
87
|
+
const l2 = relativeLuminance(b);
|
|
88
|
+
const lighter = Math.max(l1, l2);
|
|
89
|
+
const darker = Math.min(l1, l2);
|
|
90
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Classify a ratio against the thresholds for a given use. */
|
|
94
|
+
export function gradeContrast(ratio: number, use: ContrastUse): ContrastLevel {
|
|
95
|
+
const t = WCAG_THRESHOLDS[use];
|
|
96
|
+
if (ratio >= t.AAA) return 'AAA';
|
|
97
|
+
if (ratio >= t.AA) return 'AA';
|
|
98
|
+
return 'fail';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type ColorKey = keyof Theme['colors'];
|
|
102
|
+
|
|
103
|
+
/** A foreground/background pairing to audit. */
|
|
104
|
+
export interface ContrastPair {
|
|
105
|
+
label: string;
|
|
106
|
+
fg: ColorKey;
|
|
107
|
+
bg: ColorKey;
|
|
108
|
+
use: ContrastUse;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The meaningful color pairings every theme should satisfy. Text-on-surface
|
|
113
|
+
* pairs require AA 4.5:1; borders/graphical roles require AA 3:1.
|
|
114
|
+
*/
|
|
115
|
+
export const CONTRAST_PAIRS: ContrastPair[] = [
|
|
116
|
+
// Body & secondary text on the main surfaces
|
|
117
|
+
{ label: 'text on background', fg: 'text', bg: 'background', use: 'text' },
|
|
118
|
+
{ label: 'text on surface', fg: 'text', bg: 'surface', use: 'text' },
|
|
119
|
+
{ label: 'text on backgroundSecondary', fg: 'text', bg: 'backgroundSecondary', use: 'text' },
|
|
120
|
+
{ label: 'textSecondary on background', fg: 'textSecondary', bg: 'background', use: 'text' },
|
|
121
|
+
{ label: 'textTertiary on background', fg: 'textTertiary', bg: 'background', use: 'text' },
|
|
122
|
+
{ label: 'textMuted on background', fg: 'textMuted', bg: 'background', use: 'text' },
|
|
123
|
+
|
|
124
|
+
// Text drawn on top of brand colors (e.g. button labels)
|
|
125
|
+
{ label: 'textOnPrimary on primary', fg: 'textOnPrimary', bg: 'primary', use: 'text' },
|
|
126
|
+
{ label: 'textOnSecondary on secondary', fg: 'textOnSecondary', bg: 'secondary', use: 'text' },
|
|
127
|
+
{ label: 'textOnAccent on accent', fg: 'textOnAccent', bg: 'accent', use: 'text' },
|
|
128
|
+
|
|
129
|
+
// Brand color used as text/links/headings on the main background
|
|
130
|
+
{ label: 'primary on background', fg: 'primary', bg: 'background', use: 'large' },
|
|
131
|
+
|
|
132
|
+
// Non-text UI: status colors and borders must clear 3:1
|
|
133
|
+
{ label: 'success on background', fg: 'success', bg: 'background', use: 'ui' },
|
|
134
|
+
{ label: 'warning on background', fg: 'warning', bg: 'background', use: 'ui' },
|
|
135
|
+
{ label: 'error on background', fg: 'error', bg: 'background', use: 'ui' },
|
|
136
|
+
{ label: 'info on background', fg: 'info', bg: 'background', use: 'ui' },
|
|
137
|
+
{ label: 'border on background', fg: 'border', bg: 'background', use: 'ui' },
|
|
138
|
+
{ label: 'border on surface', fg: 'border', bg: 'surface', use: 'ui' },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
export interface ContrastResult {
|
|
142
|
+
pair: ContrastPair;
|
|
143
|
+
fgColor: string;
|
|
144
|
+
bgColor: string;
|
|
145
|
+
ratio: number | null;
|
|
146
|
+
/** Threshold required for AA at this pair's use. */
|
|
147
|
+
required: number;
|
|
148
|
+
level: ContrastLevel;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ThemeContrastReport {
|
|
152
|
+
results: ContrastResult[];
|
|
153
|
+
/** Pairs that fail AA. */
|
|
154
|
+
failures: ContrastResult[];
|
|
155
|
+
/** True when every parseable pair clears AA. */
|
|
156
|
+
passesAA: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Run the full contrast audit for a single theme. */
|
|
160
|
+
export function evaluateThemeContrast(theme: Theme): ThemeContrastReport {
|
|
161
|
+
const results: ContrastResult[] = CONTRAST_PAIRS.map((pair) => {
|
|
162
|
+
const fgColor = (theme.colors[pair.fg] as string) ?? '';
|
|
163
|
+
const bgColor = (theme.colors[pair.bg] as string) ?? '';
|
|
164
|
+
const ratio = contrastRatio(fgColor, bgColor);
|
|
165
|
+
const required = WCAG_THRESHOLDS[pair.use].AA;
|
|
166
|
+
const level = ratio === null ? 'fail' : gradeContrast(ratio, pair.use);
|
|
167
|
+
return { pair, fgColor, bgColor, ratio, required, level };
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const failures = results.filter((r) => r.level === 'fail');
|
|
171
|
+
return { results, failures, passesAA: failures.length === 0 };
|
|
172
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -234,4 +234,26 @@ export { overrideColors, makeTheme, addMode, getMode } from './themeHelpers';
|
|
|
234
234
|
export { ThemeShowcase } from './ThemeShowcase';
|
|
235
235
|
export type { ThemeShowcaseProps } from './ThemeShowcase';
|
|
236
236
|
|
|
237
|
+
// Export contrast evaluation utilities
|
|
238
|
+
export {
|
|
239
|
+
WCAG_THRESHOLDS,
|
|
240
|
+
CONTRAST_PAIRS,
|
|
241
|
+
parseColor,
|
|
242
|
+
relativeLuminance,
|
|
243
|
+
contrastRatio,
|
|
244
|
+
gradeContrast,
|
|
245
|
+
evaluateThemeContrast,
|
|
246
|
+
} from './contrast';
|
|
247
|
+
export type {
|
|
248
|
+
ContrastUse,
|
|
249
|
+
ContrastLevel,
|
|
250
|
+
ContrastPair,
|
|
251
|
+
ContrastResult,
|
|
252
|
+
ThemeContrastReport,
|
|
253
|
+
} from './contrast';
|
|
254
|
+
|
|
255
|
+
// Export ContrastReport component
|
|
256
|
+
export { ContrastReport } from './ContrastReport';
|
|
257
|
+
export type { ContrastReportProps } from './ContrastReport';
|
|
258
|
+
|
|
237
259
|
export default theme;
|
package/src/themes.ts
CHANGED
|
@@ -1348,7 +1348,7 @@ export const slateNeonTheme: Theme = {
|
|
|
1348
1348
|
// Base colors - Slate greys with tangerine primary
|
|
1349
1349
|
text: '#d0d6e0', // Near-white grey for most text
|
|
1350
1350
|
background: '#1a1c1e', // Very dark charcoal
|
|
1351
|
-
primary: '#
|
|
1351
|
+
primary: '#F36F41', // Bright tangerine
|
|
1352
1352
|
secondary: '#ff8257', // Lighter tangerine for hover
|
|
1353
1353
|
accent: '#00ff00', // Neon green accent
|
|
1354
1354
|
highlight: '#2a1f18', // Tangerine-tinted highlight
|
|
@@ -1373,10 +1373,10 @@ export const slateNeonTheme: Theme = {
|
|
|
1373
1373
|
|
|
1374
1374
|
// Search highlight colors
|
|
1375
1375
|
highlightBg: '#2a1f18', // Tangerine highlight
|
|
1376
|
-
highlightBorder: '#
|
|
1376
|
+
highlightBorder: '#F36F41', // Tangerine border
|
|
1377
1377
|
|
|
1378
1378
|
// Text on primary background
|
|
1379
|
-
textOnPrimary: '#
|
|
1379
|
+
textOnPrimary: '#1a1c1e', // Dark text on tangerine primary (AA contrast)
|
|
1380
1380
|
// Text on secondary background
|
|
1381
1381
|
textOnSecondary: '#ffffff', // White text on lighter tangerine secondary
|
|
1382
1382
|
// Text on accent background
|
|
@@ -1651,7 +1651,7 @@ export const iceTangerineDarkTheme: Theme = {
|
|
|
1651
1651
|
colors: {
|
|
1652
1652
|
// Base colors
|
|
1653
1653
|
text: '#d0e5ea', // Light ice blue for primary text
|
|
1654
|
-
background: '#
|
|
1654
|
+
background: '#0a1829', // Deep midnight navy (promoted from backgroundDark)
|
|
1655
1655
|
primary: '#ff6b35', // Bright tangerine - primary action color
|
|
1656
1656
|
secondary: '#ff8257', // Lighter tangerine for hover
|
|
1657
1657
|
accent: '#0893d2', // Bright teal accent
|
|
@@ -1665,34 +1665,34 @@ export const iceTangerineDarkTheme: Theme = {
|
|
|
1665
1665
|
info: '#0893d2', // Bright teal (matches accent)
|
|
1666
1666
|
|
|
1667
1667
|
// Additional semantic colors
|
|
1668
|
-
border: '#
|
|
1668
|
+
border: '#5a82aa', // Navy border (brightened for 3:1 WCAG contrast)
|
|
1669
1669
|
backgroundSecondary: '#0f2e58', // Slightly lighter navy for cards/sections
|
|
1670
1670
|
backgroundTertiary: '#123461', // Even lighter navy
|
|
1671
1671
|
backgroundLight: '#0b1f3f', // Darker navy
|
|
1672
|
-
backgroundDark: '#
|
|
1672
|
+
backgroundDark: '#040b15', // Deepest navy backdrop
|
|
1673
1673
|
backgroundHover: '#2a1f18', // Dark tangerine tint hover
|
|
1674
1674
|
primaryBlade: '#0e2b53', // Primary blade/panel color
|
|
1675
1675
|
surface: '#0f2e58', // Navy surface
|
|
1676
1676
|
textSecondary: '#9fc4d4', // Muted ice blue for secondary text
|
|
1677
1677
|
textTertiary: '#7ba8bc', // Darker ice blue
|
|
1678
|
-
textMuted: '#
|
|
1678
|
+
textMuted: '#73a0b3', // Even darker ice blue (lightened for 4.5:1 WCAG contrast)
|
|
1679
1679
|
|
|
1680
1680
|
// Search highlight colors
|
|
1681
1681
|
highlightBg: '#2a1f18', // Dark tangerine highlight
|
|
1682
1682
|
highlightBorder: '#ff6b35', // Tangerine border
|
|
1683
1683
|
|
|
1684
1684
|
// Text on primary background
|
|
1685
|
-
textOnPrimary: '#
|
|
1685
|
+
textOnPrimary: '#0d274d', // Deep navy text on tangerine primary (4.5:1+ WCAG)
|
|
1686
1686
|
// Text on secondary background
|
|
1687
|
-
textOnSecondary: '#
|
|
1687
|
+
textOnSecondary: '#0d274d', // Deep navy text on lighter tangerine (4.5:1+ WCAG)
|
|
1688
1688
|
// Text on accent background
|
|
1689
|
-
textOnAccent: '#
|
|
1689
|
+
textOnAccent: '#0a1829', // Dark navy text on teal accent (4.5:1+ WCAG)
|
|
1690
1690
|
},
|
|
1691
1691
|
|
|
1692
1692
|
// Component variants
|
|
1693
1693
|
buttons: {
|
|
1694
1694
|
primary: {
|
|
1695
|
-
color: '#
|
|
1695
|
+
color: '#0d274d', // Deep navy text on tangerine (4.5:1+ WCAG)
|
|
1696
1696
|
bg: 'primary',
|
|
1697
1697
|
borderWidth: 0,
|
|
1698
1698
|
'&:hover': {
|