@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.
@@ -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&apos;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
- - Automatically detects system preference on first load
102
- - Saves user preference to localStorage
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
@@ -1,4 +1,4 @@
1
- import React, { createContext, useContext, ReactNode, useState, useEffect } from 'react';
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,
@@ -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: '#ff6b35', // Bright tangerine
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: '#ff6b35', // Tangerine border
1376
+ highlightBorder: '#F36F41', // Tangerine border
1377
1377
 
1378
1378
  // Text on primary background
1379
- textOnPrimary: '#ffffff', // White text on tangerine primary
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: '#0d274d', // Deep navy/midnight blue
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: '#1e3a5f', // Navy 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: '#0a1829', // Extra dark navy
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: '#5a8a9e', // Even darker ice blue
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: '#ffffff', // White text on tangerine primary
1685
+ textOnPrimary: '#0d274d', // Deep navy text on tangerine primary (4.5:1+ WCAG)
1686
1686
  // Text on secondary background
1687
- textOnSecondary: '#ffffff', // White text on lighter tangerine
1687
+ textOnSecondary: '#0d274d', // Deep navy text on lighter tangerine (4.5:1+ WCAG)
1688
1688
  // Text on accent background
1689
- textOnAccent: '#ffffff', // White text on teal accent
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: '#ffffff', // White text on tangerine
1695
+ color: '#0d274d', // Deep navy text on tangerine (4.5:1+ WCAG)
1696
1696
  bg: 'primary',
1697
1697
  borderWidth: 0,
1698
1698
  '&:hover': {