@runtypelabs/persona 3.21.3 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Input coercion for the WebMCP theme tools.
3
+ *
4
+ * Agent inputs are flexible by design (arcade.dev "parameter coercion"): colors
5
+ * accept hex with/without `#`, 3-digit hex, rgb(), and common CSS color names;
6
+ * enums accept friendly synonyms. Each coercer throws an Error whose message
7
+ * lists the valid options (arcade.dev "error-guided recovery").
8
+ */
9
+
10
+ import {
11
+ normalizeColorValue,
12
+ isValidHex,
13
+ parseCssValue,
14
+ formatCssValue,
15
+ } from '../color-utils';
16
+ import { ROLE_FAMILIES } from '../role-mappings';
17
+ import { STYLE_SECTIONS } from '../sections';
18
+
19
+ // ─── CSS named colors (common subset) ───────────────────────────
20
+
21
+ export const CSS_NAMED_COLORS: Record<string, string> = {
22
+ black: '#000000',
23
+ white: '#ffffff',
24
+ red: '#ff0000',
25
+ green: '#008000',
26
+ lime: '#00ff00',
27
+ blue: '#0000ff',
28
+ yellow: '#ffff00',
29
+ cyan: '#00ffff',
30
+ aqua: '#00ffff',
31
+ magenta: '#ff00ff',
32
+ fuchsia: '#ff00ff',
33
+ silver: '#c0c0c0',
34
+ gray: '#808080',
35
+ grey: '#808080',
36
+ maroon: '#800000',
37
+ olive: '#808000',
38
+ purple: '#800080',
39
+ teal: '#008080',
40
+ navy: '#000080',
41
+ orange: '#ffa500',
42
+ pink: '#ffc0cb',
43
+ hotpink: '#ff69b4',
44
+ gold: '#ffd700',
45
+ indigo: '#4b0082',
46
+ violet: '#ee82ee',
47
+ brown: '#a52a2a',
48
+ beige: '#f5f5dc',
49
+ ivory: '#fffff0',
50
+ khaki: '#f0e68c',
51
+ coral: '#ff7f50',
52
+ salmon: '#fa8072',
53
+ tomato: '#ff6347',
54
+ crimson: '#dc143c',
55
+ turquoise: '#40e0d0',
56
+ lavender: '#e6e6fa',
57
+ plum: '#dda0dd',
58
+ orchid: '#da70d6',
59
+ tan: '#d2b48c',
60
+ chocolate: '#d2691e',
61
+ sienna: '#a0522d',
62
+ slategray: '#708090',
63
+ slategrey: '#708090',
64
+ steelblue: '#4682b4',
65
+ royalblue: '#4169e1',
66
+ dodgerblue: '#1e90ff',
67
+ skyblue: '#87ceeb',
68
+ lightblue: '#add8e6',
69
+ midnightblue: '#191970',
70
+ forestgreen: '#228b22',
71
+ seagreen: '#2e8b57',
72
+ limegreen: '#32cd32',
73
+ olivedrab: '#6b8e23',
74
+ darkgreen: '#006400',
75
+ emerald: '#50c878',
76
+ mint: '#3eb489',
77
+ goldenrod: '#daa520',
78
+ firebrick: '#b22222',
79
+ darkred: '#8b0000',
80
+ indianred: '#cd5c5c',
81
+ deeppink: '#ff1493',
82
+ mediumpurple: '#9370db',
83
+ rebeccapurple: '#663399',
84
+ darkviolet: '#9400d3',
85
+ slateblue: '#6a5acd',
86
+ cornflowerblue: '#6495ed',
87
+ teal2: '#008080',
88
+ charcoal: '#36454f',
89
+ graphite: '#3b3b3b',
90
+ transparent: 'transparent',
91
+ };
92
+
93
+ // ─── Colors ─────────────────────────────────────────────────────
94
+
95
+ /** Structural validation for rgb()/rgba() so malformed "rgb…" input is rejected. */
96
+ const RGB_RE =
97
+ /^rgba?\(\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*(,\s*(0|1|0?\.\d+|\d{1,3}%)\s*)?\)$/;
98
+
99
+ function isValidRgb(value: string): boolean {
100
+ return RGB_RE.test(value);
101
+ }
102
+
103
+ /**
104
+ * Coerce a flexible color input into a canonical CSS color string.
105
+ * Accepts: `#1d4ed8`, `1d4ed8`, `#18f`, `rgb(...)`, `transparent`, and common
106
+ * CSS color names. Throws with guidance when the value can't be understood.
107
+ */
108
+ export function coerceColor(input: unknown): string {
109
+ if (typeof input !== 'string' || input.trim() === '') {
110
+ throw new Error('Color must be a non-empty string (e.g. "#2563eb" or "blue").');
111
+ }
112
+ const trimmed = input.trim().toLowerCase();
113
+
114
+ const named = CSS_NAMED_COLORS[trimmed];
115
+ if (named) return named;
116
+
117
+ const normalized = normalizeColorValue(trimmed);
118
+ if (isValidHex(normalized) || normalized === 'transparent' || isValidRgb(normalized)) {
119
+ return normalized;
120
+ }
121
+
122
+ throw new Error(
123
+ `"${input}" is not a recognized color. Pass a hex value like "#ef4444" or a CSS color name (e.g. ${Object.keys(
124
+ CSS_NAMED_COLORS
125
+ )
126
+ .slice(0, 6)
127
+ .join(', ')}).`
128
+ );
129
+ }
130
+
131
+ // ─── Enums ──────────────────────────────────────────────────────
132
+
133
+ export type BrandFamily = 'primary' | 'secondary' | 'accent';
134
+ export type RoleFamilyInput = BrandFamily | 'neutral';
135
+
136
+ /**
137
+ * Tool-facing family names, derived from the role layer's `ROLE_FAMILIES`. The
138
+ * role layer's canonical neutral family is `gray`; tools surface it as
139
+ * `neutral`. Deriving here keeps the tool vocabulary in sync if a family is
140
+ * added to `ROLE_FAMILIES`.
141
+ */
142
+ export const ROLE_FAMILY_NAMES: RoleFamilyInput[] = ROLE_FAMILIES.map((f) =>
143
+ f === 'gray' ? 'neutral' : (f as RoleFamilyInput)
144
+ );
145
+
146
+ const FAMILY_SYNONYMS: Record<string, RoleFamilyInput> = {
147
+ ...Object.fromEntries(ROLE_FAMILY_NAMES.map((f) => [f, f])),
148
+ gray: 'neutral',
149
+ grey: 'neutral',
150
+ };
151
+
152
+ /** Coerce a palette family. Set `allowNeutral` for role assignments. */
153
+ export function coerceFamily(input: unknown, allowNeutral = true): RoleFamilyInput {
154
+ const key = String(input ?? '').trim().toLowerCase();
155
+ const family = FAMILY_SYNONYMS[key];
156
+ if (!family || (!allowNeutral && family === 'neutral')) {
157
+ const valid = allowNeutral
158
+ ? 'primary, secondary, accent, neutral'
159
+ : 'primary, secondary, accent';
160
+ throw new Error(`Unknown color family "${input}". Valid families: ${valid}.`);
161
+ }
162
+ return family;
163
+ }
164
+
165
+ export function coerceIntensity(input: unknown): 'solid' | 'soft' {
166
+ const key = String(input ?? 'solid').trim().toLowerCase();
167
+ if (key === 'solid' || key === 'soft') return key;
168
+ throw new Error(`Unknown intensity "${input}". Valid intensities: solid, soft.`);
169
+ }
170
+
171
+ export function coerceScheme(input: unknown): 'light' | 'dark' | 'auto' {
172
+ const key = String(input ?? '').trim().toLowerCase();
173
+ if (key === 'light' || key === 'dark' || key === 'auto') return key;
174
+ if (key === 'system') return 'auto';
175
+ throw new Error(`Unknown color scheme "${input}". Valid: light, dark, auto.`);
176
+ }
177
+
178
+ export type RoundnessStyle = 'sharp' | 'default' | 'rounded' | 'pill';
179
+
180
+ const ROUNDNESS_SYNONYMS: Record<string, RoundnessStyle> = {
181
+ sharp: 'sharp',
182
+ square: 'sharp',
183
+ none: 'sharp',
184
+ default: 'default',
185
+ normal: 'default',
186
+ rounded: 'rounded',
187
+ round: 'rounded',
188
+ soft: 'rounded',
189
+ pill: 'pill',
190
+ circle: 'pill',
191
+ full: 'pill',
192
+ };
193
+
194
+ export function coerceRoundnessStyle(input: unknown): RoundnessStyle {
195
+ const key = String(input ?? '').trim().toLowerCase();
196
+ const style = ROUNDNESS_SYNONYMS[key];
197
+ if (!style) {
198
+ throw new Error(`Unknown roundness "${input}". Valid: sharp, default, rounded, pill.`);
199
+ }
200
+ return style;
201
+ }
202
+
203
+ // ─── Sizes ──────────────────────────────────────────────────────
204
+
205
+ /** Coerce a radius value: numbers become `${n}px`; CSS strings are normalized. */
206
+ export function coerceRadius(input: unknown): string {
207
+ if (typeof input === 'number' && Number.isFinite(input)) {
208
+ return `${input}px`;
209
+ }
210
+ if (typeof input === 'string' && input.trim() !== '') {
211
+ const trimmed = input.trim();
212
+ if (trimmed === '9999px' || /^(100%|9999px)$/.test(trimmed)) return '9999px';
213
+ const parsed = parseCssValue(trimmed);
214
+ return formatCssValue(parsed.value, parsed.unit);
215
+ }
216
+ throw new Error('Radius must be a number (px) or a CSS length string like "0.5rem".');
217
+ }
218
+
219
+ // ─── Typography keyword → token-ref maps ────────────────────────
220
+ // Base maps are derived from the editor's typography <select> options in
221
+ // sections.ts (keyed by each option value's last path segment), so the
222
+ // high-level tool's accepted values track the editor. Only the friendly
223
+ // synonyms (monospace, small/medium/large, numeric weights/line-heights) are
224
+ // hand-maintained on top.
225
+
226
+ function refsFromTypographyField(fieldId: string): Record<string, string> {
227
+ for (const section of STYLE_SECTIONS) {
228
+ const field = section.fields.find((f) => f.id === fieldId);
229
+ if (field?.options) {
230
+ const map: Record<string, string> = {};
231
+ for (const opt of field.options) {
232
+ const key = opt.value.split('.').pop();
233
+ if (key) map[key] = opt.value;
234
+ }
235
+ return map;
236
+ }
237
+ }
238
+ return {};
239
+ }
240
+
241
+ const FAMILY_BASE = refsFromTypographyField('typo-font-family');
242
+ const SIZE_BASE = refsFromTypographyField('typo-font-size');
243
+ const WEIGHT_BASE = refsFromTypographyField('typo-font-weight');
244
+ const LINE_BASE = refsFromTypographyField('typo-line-height');
245
+
246
+ export const FONT_FAMILY_REFS: Record<string, string> = {
247
+ ...FAMILY_BASE,
248
+ monospace: FAMILY_BASE.mono,
249
+ };
250
+
251
+ export const FONT_SIZE_REFS: Record<string, string> = {
252
+ ...SIZE_BASE,
253
+ small: SIZE_BASE.sm,
254
+ md: SIZE_BASE.base,
255
+ medium: SIZE_BASE.base,
256
+ large: SIZE_BASE.lg,
257
+ };
258
+
259
+ export const FONT_WEIGHT_REFS: Record<string, string> = {
260
+ ...WEIGHT_BASE,
261
+ '400': WEIGHT_BASE.normal,
262
+ '500': WEIGHT_BASE.medium,
263
+ '600': WEIGHT_BASE.semibold,
264
+ '700': WEIGHT_BASE.bold,
265
+ };
266
+
267
+ export const LINE_HEIGHT_REFS: Record<string, string> = {
268
+ ...LINE_BASE,
269
+ '1.25': LINE_BASE.tight,
270
+ '1.5': LINE_BASE.normal,
271
+ '1.625': LINE_BASE.relaxed,
272
+ };
273
+
274
+ export function coerceTypographyRef(
275
+ input: unknown,
276
+ refs: Record<string, string>,
277
+ label: string
278
+ ): string {
279
+ const key = String(input ?? '').trim().toLowerCase();
280
+ const ref = refs[key];
281
+ if (!ref) {
282
+ const valid = [...new Set(Object.values(refs).map((r) => r.split('.').pop()))].join(', ');
283
+ throw new Error(`Unknown ${label} "${input}". Valid: ${valid}.`);
284
+ }
285
+ return ref;
286
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * WebMCP tools for the Persona theme editor.
3
+ *
4
+ * Transport-agnostic: `createThemeEditorTools(state)` returns plain tool
5
+ * definitions. Host code (e.g. an example app or a self-styling widget) is
6
+ * responsible for obtaining a `document.modelContext` and calling
7
+ * `registerTool` for each — this module has no polyfill dependency.
8
+ */
9
+
10
+ export { createThemeEditorTools } from './tools';
11
+ export { toolResult } from './types';
12
+ export type {
13
+ WebMcpTool,
14
+ ToolResult,
15
+ ToolAnnotations,
16
+ ToolTextContent,
17
+ ToolExecute,
18
+ ThemeEditorLike,
19
+ EditTarget,
20
+ CreateThemeEditorToolsOptions,
21
+ } from './types';
22
+ export {
23
+ buildSummary,
24
+ runContrastChecks,
25
+ quickContrastWarnings,
26
+ CONTRAST_PAIRS,
27
+ RADIUS_PRESETS,
28
+ } from './summary';
29
+ export type {
30
+ ThemeSummary,
31
+ RoleState,
32
+ ContrastReport,
33
+ ContrastCheck,
34
+ ContrastWarning,
35
+ ContrastLevel,
36
+ } from './summary';
37
+ export {
38
+ coerceColor,
39
+ coerceFamily,
40
+ coerceIntensity,
41
+ coerceScheme,
42
+ coerceRoundnessStyle,
43
+ coerceRadius,
44
+ CSS_NAMED_COLORS,
45
+ } from './coerce';
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Theme state summarization + contrast feedback for the WebMCP tools.
3
+ *
4
+ * Every mutation tool returns a compact `ThemeSummary` plus any contrast
5
+ * warnings, so an agent gets actionable feedback without a follow-up read
6
+ * (arcade.dev "proactive state-returning").
7
+ */
8
+
9
+ import { wcagContrastRatio, SHADE_KEYS } from '../color-utils';
10
+ import { ALL_ROLES, detectRoleAssignment } from '../role-mappings';
11
+ import { STYLE_SECTIONS } from '../sections';
12
+ import type { RoleAssignmentOptions } from '../types';
13
+ import type { ThemeEditorLike } from './types';
14
+ import type { RoundnessStyle } from './coerce';
15
+
16
+ // ─── Radius presets (derived from the editor's shape section) ───
17
+ // The editor's shape section in sections.ts owns the canonical radius preset
18
+ // values (default/sharp/rounded); we derive them here so the tool and the GUI
19
+ // can't drift, and add the "pill" preset the editor doesn't expose.
20
+
21
+ const RADIUS_PATH_PREFIX = 'theme.palette.radius.';
22
+
23
+ function buildRadiusPresets(): Record<string, Record<string, string>> {
24
+ const presets: Record<string, Record<string, string>> = {
25
+ pill: { sm: '9999px', md: '9999px', lg: '9999px', xl: '9999px', full: '9999px' },
26
+ };
27
+ for (const section of STYLE_SECTIONS) {
28
+ for (const preset of section.presets ?? []) {
29
+ const match = preset.id.match(/^radius-(\w+)$/);
30
+ if (!match) continue;
31
+ const radius: Record<string, string> = {};
32
+ for (const [path, value] of Object.entries(preset.values)) {
33
+ if (path.startsWith(RADIUS_PATH_PREFIX) && typeof value === 'string') {
34
+ radius[path.slice(RADIUS_PATH_PREFIX.length)] = value;
35
+ }
36
+ }
37
+ presets[match[1]] = radius;
38
+ }
39
+ }
40
+ return presets;
41
+ }
42
+
43
+ export const RADIUS_PRESETS: Record<string, Record<string, string>> = buildRadiusPresets();
44
+
45
+ const RADIUS_KEYS = ['sm', 'md', 'lg', 'xl', 'full'] as const;
46
+
47
+ // ─── Variant-aware color resolution ─────────────────────────────
48
+
49
+ /**
50
+ * Resolve a theme token path to a concrete color, following `palette.*`,
51
+ * `semantic.*`, and `components.*` references. When resolving the `darkTheme`
52
+ * variant, tokens the dark theme doesn't define fall back to the light theme —
53
+ * mirroring the widget's runtime merge behavior.
54
+ */
55
+ export function resolveColor(
56
+ state: ThemeEditorLike,
57
+ path: string,
58
+ prefix: 'theme' | 'darkTheme' = 'theme',
59
+ depth = 0
60
+ ): string | null {
61
+ if (depth > 6) return null;
62
+ const raw = state.get(`${prefix}.${path}`);
63
+ if (typeof raw !== 'string' || raw === '') {
64
+ return prefix === 'darkTheme' ? resolveColor(state, path, 'theme', depth) : null;
65
+ }
66
+ if (raw.startsWith('#') || raw.startsWith('rgb') || raw === 'transparent') return raw;
67
+ if (
68
+ raw.startsWith('palette.') ||
69
+ raw.startsWith('semantic.') ||
70
+ raw.startsWith('components.')
71
+ ) {
72
+ return resolveColor(state, raw, prefix, depth + 1);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // ─── Summary ────────────────────────────────────────────────────
78
+
79
+ export interface RoleState {
80
+ family: string;
81
+ intensity: string;
82
+ }
83
+
84
+ export interface ThemeSummary {
85
+ brand: { primary: string | null; secondary: string | null; accent: string | null };
86
+ roles: Record<string, RoleState | null>;
87
+ typography: {
88
+ fontFamily: string;
89
+ fontSize: string;
90
+ fontWeight: string;
91
+ lineHeight: string;
92
+ };
93
+ roundness: { style: RoundnessStyle | 'custom'; radius: Record<string, string> };
94
+ colorScheme: string;
95
+ history: { index: number; canUndo: boolean; canRedo: boolean };
96
+ }
97
+
98
+ function refSuffix(state: ThemeEditorLike, path: string): string {
99
+ const raw = state.get(path);
100
+ if (typeof raw !== 'string') return 'unknown';
101
+ const parts = raw.split('.');
102
+ return parts[parts.length - 1] || String(raw);
103
+ }
104
+
105
+ /** Friendly role key, e.g. `role-user-messages` → `user-messages`. */
106
+ export function roleKey(roleId: string): string {
107
+ return roleId.replace(/^role-/, '');
108
+ }
109
+
110
+ function detectRoundness(radius: Record<string, string>): RoundnessStyle | 'custom' {
111
+ for (const [style, preset] of Object.entries(RADIUS_PRESETS) as [
112
+ RoundnessStyle,
113
+ Record<string, string>,
114
+ ][]) {
115
+ if (RADIUS_KEYS.every((k) => radius[k] === preset[k])) return style;
116
+ }
117
+ return 'custom';
118
+ }
119
+
120
+ export function buildSummary(state: ThemeEditorLike): ThemeSummary {
121
+ const radius: Record<string, string> = {};
122
+ for (const k of RADIUS_KEYS) {
123
+ radius[k] = String(state.get(`theme.palette.radius.${k}`) ?? '');
124
+ }
125
+
126
+ const roles: Record<string, RoleState | null> = {};
127
+ for (const role of ALL_ROLES) {
128
+ roles[roleKey(role.roleId)] = detectRoleAssignment(
129
+ (p) => state.get(`theme.${p}`),
130
+ role
131
+ );
132
+ }
133
+
134
+ return {
135
+ brand: {
136
+ primary: asColor(state.get('theme.palette.colors.primary.500')),
137
+ secondary: asColor(state.get('theme.palette.colors.secondary.500')),
138
+ accent: asColor(state.get('theme.palette.colors.accent.500')),
139
+ },
140
+ roles,
141
+ typography: {
142
+ fontFamily: refSuffix(state, 'theme.semantic.typography.fontFamily'),
143
+ fontSize: refSuffix(state, 'theme.semantic.typography.fontSize'),
144
+ fontWeight: refSuffix(state, 'theme.semantic.typography.fontWeight'),
145
+ lineHeight: refSuffix(state, 'theme.semantic.typography.lineHeight'),
146
+ },
147
+ roundness: { style: detectRoundness(radius), radius },
148
+ colorScheme: String(state.get('colorScheme') ?? 'light'),
149
+ history: {
150
+ index: state.getHistoryIndex(),
151
+ canUndo: state.canUndo(),
152
+ canRedo: state.canRedo(),
153
+ },
154
+ };
155
+ }
156
+
157
+ function asColor(value: unknown): string | null {
158
+ return typeof value === 'string' && value !== '' ? value : null;
159
+ }
160
+
161
+ // ─── Contrast ───────────────────────────────────────────────────
162
+
163
+ export interface ContrastPair {
164
+ key: string;
165
+ label: string;
166
+ fg: string;
167
+ bg: string;
168
+ }
169
+
170
+ export const CONTRAST_PAIRS: ContrastPair[] = [
171
+ { key: 'user-message', label: 'User message text', fg: 'components.message.user.text', bg: 'components.message.user.background' },
172
+ { key: 'assistant-message', label: 'Assistant message text', fg: 'components.message.assistant.text', bg: 'components.message.assistant.background' },
173
+ { key: 'header', label: 'Header title', fg: 'components.header.titleForeground', bg: 'components.header.background' },
174
+ { key: 'primary-button', label: 'Primary button label', fg: 'components.button.primary.foreground', bg: 'components.button.primary.background' },
175
+ { key: 'input', label: 'Input placeholder', fg: 'components.input.placeholder', bg: 'components.input.background' },
176
+ { key: 'link', label: 'Link text', fg: 'components.markdown.link.foreground', bg: 'semantic.colors.background' },
177
+ { key: 'scroll', label: 'Scroll-to-bottom icon', fg: 'components.scrollToBottom.foreground', bg: 'components.scrollToBottom.background' },
178
+ { key: 'body', label: 'Body text on background', fg: 'semantic.colors.text', bg: 'semantic.colors.background' },
179
+ { key: 'surface', label: 'Body text on surface', fg: 'semantic.colors.text', bg: 'semantic.colors.surface' },
180
+ ];
181
+
182
+ /**
183
+ * The contrast-pair keys relevant to a role, derived by intersecting the role's
184
+ * target token paths with the contrast pairs. This replaces a hand-maintained
185
+ * map so a new role or contrast pair is covered automatically. Roles with no
186
+ * text pair (e.g. borders) correctly yield `[]`.
187
+ */
188
+ export function roleContrastPairKeys(role: RoleAssignmentOptions): string[] {
189
+ const targets = new Set(role.targets.map((t) => t.path));
190
+ return CONTRAST_PAIRS.filter((p) => targets.has(p.fg) || targets.has(p.bg)).map((p) => p.key);
191
+ }
192
+
193
+ export interface ContrastWarning {
194
+ code: 'contrast';
195
+ pair: string;
196
+ variant: 'light' | 'dark';
197
+ ratio: number;
198
+ threshold: number;
199
+ message: string;
200
+ }
201
+
202
+ export const CONTRAST_THRESHOLDS = { AA: 4.5, AAA: 7 } as const;
203
+ export type ContrastLevel = keyof typeof CONTRAST_THRESHOLDS;
204
+
205
+ function round2(n: number): number {
206
+ return Math.round(n * 100) / 100;
207
+ }
208
+
209
+ /**
210
+ * Suggest a same-family shade for `fgPath` that meets `threshold` against
211
+ * `bgHex`, preferring the passing shade closest to the current one. Returns a
212
+ * token-ref string (e.g. `palette.colors.primary.700`) or null.
213
+ */
214
+ function suggestShade(
215
+ state: ThemeEditorLike,
216
+ fgPath: string,
217
+ bgHex: string,
218
+ threshold: number,
219
+ prefix: 'theme' | 'darkTheme'
220
+ ): string | null {
221
+ const raw = state.get(`${prefix}.${fgPath}`);
222
+ if (typeof raw !== 'string') return null;
223
+ const m = raw.match(/^palette\.colors\.(\w+)\.(\d+)$/);
224
+ if (!m) return null;
225
+ const family = m[1];
226
+ const currentIdx = SHADE_KEYS.indexOf(m[2] as (typeof SHADE_KEYS)[number]);
227
+
228
+ let best: string | null = null;
229
+ let bestDistance = Infinity;
230
+ SHADE_KEYS.forEach((shade, idx) => {
231
+ const hex = state.get(`${prefix}.palette.colors.${family}.${shade}`);
232
+ if (typeof hex !== 'string' || !(hex.startsWith('#') || hex.startsWith('rgb'))) return;
233
+ if (wcagContrastRatio(hex, bgHex) >= threshold) {
234
+ const distance = currentIdx >= 0 ? Math.abs(idx - currentIdx) : idx;
235
+ if (distance < bestDistance) {
236
+ bestDistance = distance;
237
+ best = `palette.colors.${family}.${shade}`;
238
+ }
239
+ }
240
+ });
241
+ return best;
242
+ }
243
+
244
+ export interface ContrastCheck {
245
+ pair: string;
246
+ label: string;
247
+ variant: 'light' | 'dark';
248
+ fg: string;
249
+ bg: string;
250
+ ratio: number;
251
+ threshold: number;
252
+ passes: boolean;
253
+ suggestion?: string;
254
+ }
255
+
256
+ export interface ContrastReport {
257
+ level: ContrastLevel;
258
+ checks: ContrastCheck[];
259
+ failures: ContrastCheck[];
260
+ }
261
+
262
+ /** Run contrast over the named pairs (default: all) for the given variant(s). */
263
+ export function runContrastChecks(
264
+ state: ThemeEditorLike,
265
+ level: ContrastLevel = 'AA',
266
+ variant: 'light' | 'dark' | 'both' = 'both',
267
+ pairKeys?: string[]
268
+ ): ContrastReport {
269
+ const threshold = CONTRAST_THRESHOLDS[level];
270
+ const variants: ('light' | 'dark')[] = variant === 'both' ? ['light', 'dark'] : [variant];
271
+ const pairs = pairKeys
272
+ ? CONTRAST_PAIRS.filter((p) => pairKeys.includes(p.key))
273
+ : CONTRAST_PAIRS;
274
+
275
+ const checks: ContrastCheck[] = [];
276
+ for (const v of variants) {
277
+ const prefix = v === 'light' ? 'theme' : 'darkTheme';
278
+ for (const pair of pairs) {
279
+ const fg = resolveColor(state, pair.fg, prefix);
280
+ const bg = resolveColor(state, pair.bg, prefix);
281
+ if (!fg || !bg) continue;
282
+ const ratio = round2(wcagContrastRatio(fg, bg));
283
+ const passes = ratio >= threshold;
284
+ const check: ContrastCheck = {
285
+ pair: pair.key,
286
+ label: pair.label,
287
+ variant: v,
288
+ fg,
289
+ bg,
290
+ ratio,
291
+ threshold,
292
+ passes,
293
+ };
294
+ if (!passes) {
295
+ const suggestion = suggestShade(state, pair.fg, bg, threshold, prefix);
296
+ if (suggestion) check.suggestion = suggestion;
297
+ }
298
+ checks.push(check);
299
+ }
300
+ }
301
+
302
+ return { level, checks, failures: checks.filter((c) => !c.passes) };
303
+ }
304
+
305
+ /** Compact contrast warnings for the regions a mutation touched. */
306
+ export function quickContrastWarnings(
307
+ state: ThemeEditorLike,
308
+ pairKeys: string[],
309
+ variant: 'light' | 'dark' | 'both' = 'light',
310
+ level: ContrastLevel = 'AA'
311
+ ): ContrastWarning[] {
312
+ if (pairKeys.length === 0) return [];
313
+ const report = runContrastChecks(state, level, variant, pairKeys);
314
+ return report.failures.map((f) => ({
315
+ code: 'contrast' as const,
316
+ pair: f.pair,
317
+ variant: f.variant,
318
+ ratio: f.ratio,
319
+ threshold: f.threshold,
320
+ message: `${f.label} (${f.variant}) has a contrast ratio of ${f.ratio}:1, below the ${level} threshold of ${f.threshold}:1${
321
+ f.suggestion ? `. Try ${f.suggestion} for the foreground.` : '.'
322
+ }`,
323
+ }));
324
+ }