@optilogic/core 1.0.0-beta.17 → 1.0.0-beta.18

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.
@@ -40,6 +40,9 @@ export const GREEN_THEME: Theme = {
40
40
  border: "#243630",
41
41
  input: "#243630",
42
42
  ring: "#6FCF97",
43
+ toggleTrack: "#354840",
44
+ toggleTrackForeground: "#E6F5EC",
45
+ inputHover: "#6FCF97",
43
46
  chart1: "#6FCF97",
44
47
  chart2: "#8FE3B0",
45
48
  chart3: "#9DB8A8",
@@ -79,8 +82,11 @@ export const OPTILOGIC_LEGACY_THEME: Theme = {
79
82
  chip: "#B8C5F9",
80
83
  chipForeground: "#0C0A5A",
81
84
  border: "#D0D0D0",
82
- input: "#FFFFFF",
85
+ input: "#D0D0D0",
83
86
  ring: "#5766F2",
87
+ toggleTrack: "#D0D0D0",
88
+ toggleTrackForeground: "#FFFFFF",
89
+ inputHover: "#5766F2",
84
90
  chart1: "#78D237",
85
91
  chart2: "#F5CF47",
86
92
  chart3: "#5766F2",
@@ -122,6 +128,9 @@ export const FUTURISTIC_THEME: Theme = {
122
128
  border: "#1e293b",
123
129
  input: "#1e293b",
124
130
  ring: "#6366f1",
131
+ toggleTrack: "#334155",
132
+ toggleTrackForeground: "#e0e7ff",
133
+ inputHover: "#6366f1",
125
134
  chart1: "#6366f1",
126
135
  chart2: "#8b5cf6",
127
136
  chart3: "#a855f7",
@@ -163,6 +172,9 @@ export const NATURE_THEME: Theme = {
163
172
  border: "#243824",
164
173
  input: "#243824",
165
174
  ring: "#4caf50",
175
+ toggleTrack: "#3d5a3d",
176
+ toggleTrackForeground: "#e8f5e9",
177
+ inputHover: "#4caf50",
166
178
  chart1: "#4caf50",
167
179
  chart2: "#66bb6a",
168
180
  chart3: "#81c784",
@@ -204,6 +216,9 @@ export const SCIFI_THEME: Theme = {
204
216
  border: "#30363d",
205
217
  input: "#21262d",
206
218
  ring: "#00d9ff",
219
+ toggleTrack: "#30363d",
220
+ toggleTrackForeground: "#c9d1d9",
221
+ inputHover: "#00d9ff",
207
222
  chart1: "#00d9ff",
208
223
  chart2: "#ff00ff",
209
224
  chart3: "#00ff88",
@@ -245,6 +260,9 @@ export const OCEAN_THEME: Theme = {
245
260
  border: "#1e3a5f",
246
261
  input: "#1e3a5f",
247
262
  ring: "#2196f3",
263
+ toggleTrack: "#264a6e",
264
+ toggleTrackForeground: "#e3f2fd",
265
+ inputHover: "#2196f3",
248
266
  chart1: "#2196f3",
249
267
  chart2: "#00bcd4",
250
268
  chart3: "#03a9f4",
@@ -286,6 +304,9 @@ export const SUNSET_THEME: Theme = {
286
304
  border: "#241424",
287
305
  input: "#241424",
288
306
  ring: "#ff6b35",
307
+ toggleTrack: "#3d2a3d",
308
+ toggleTrackForeground: "#ffe0e6",
309
+ inputHover: "#ff6b35",
289
310
  chart1: "#ff6b35",
290
311
  chart2: "#c44569",
291
312
  chart3: "#f7931e",
@@ -327,6 +348,9 @@ export const FOREST_THEME: Theme = {
327
348
  border: "#1b5e20",
328
349
  input: "#1a2e1a",
329
350
  ring: "#2e7d32",
351
+ toggleTrack: "#2d4a2d",
352
+ toggleTrackForeground: "#e8f5e9",
353
+ inputHover: "#2e7d32",
330
354
  chart1: "#2e7d32",
331
355
  chart2: "#388e3c",
332
356
  chart3: "#43a047",
@@ -368,6 +392,9 @@ export const CYBERPUNK_THEME: Theme = {
368
392
  border: "#1a1a2e",
369
393
  input: "#16213e",
370
394
  ring: "#ff00ff",
395
+ toggleTrack: "#2a2a4e",
396
+ toggleTrackForeground: "#f0f0f0",
397
+ inputHover: "#ff00ff",
371
398
  chart1: "#ff00ff",
372
399
  chart2: "#00ffff",
373
400
  chart3: "#00ff88",
@@ -407,8 +434,11 @@ export const MINIMALIST_LIGHT_THEME: Theme = {
407
434
  chip: "#e9ecef",
408
435
  chipForeground: "#495057",
409
436
  border: "#dee2e6",
410
- input: "#ffffff",
437
+ input: "#dee2e6",
411
438
  ring: "#000000",
439
+ toggleTrack: "#ced4da",
440
+ toggleTrackForeground: "#ffffff",
441
+ inputHover: "#6c757d",
412
442
  chart1: "#000000",
413
443
  chart2: "#6c757d",
414
444
  chart3: "#adb5bd",
@@ -450,6 +480,9 @@ export const DARK_ELEGANT_THEME: Theme = {
450
480
  border: "#2d2d2d",
451
481
  input: "#1e1e1e",
452
482
  ring: "#bb86fc",
483
+ toggleTrack: "#3d3d3d",
484
+ toggleTrackForeground: "#e0e0e0",
485
+ inputHover: "#bb86fc",
453
486
  chart1: "#bb86fc",
454
487
  chart2: "#03dac6",
455
488
  chart3: "#4caf50",
@@ -54,6 +54,11 @@ export interface Theme {
54
54
  input: string; // --input
55
55
  ring: string; // --ring (focus ring)
56
56
 
57
+ /** Interactive control colors (optional, with fallbacks) */
58
+ toggleTrack?: string; // --toggle-track (switch track bg when off; fallback: muted)
59
+ toggleTrackForeground?: string; // --toggle-track-foreground (switch thumb when off; fallback: background)
60
+ inputHover?: string; // --input-hover (form control border on hover; fallback: derived)
61
+
57
62
  /** Chart colors */
58
63
  chart1: string; // --chart-1
59
64
  chart2: string; // --chart-2
@@ -61,6 +66,9 @@ export interface Theme {
61
66
  chart4: string; // --chart-4
62
67
  chart5: string; // --chart-5
63
68
 
69
+ /** Disabled state */
70
+ disabledOpacity?: string; // --disabled-opacity (default: 0.5)
71
+
64
72
  /** Border radius */
65
73
  radius?: string; // --radius (default: 0.5rem)
66
74
  }
@@ -105,6 +113,9 @@ export interface ThemeHSL extends Omit<Theme, keyof ThemeColorFields> {
105
113
  border: string;
106
114
  input: string;
107
115
  ring: string;
116
+ toggleTrack?: string;
117
+ toggleTrackForeground?: string;
118
+ inputHover?: string;
108
119
  chart1: string;
109
120
  chart2: string;
110
121
  chart3: string;
@@ -138,6 +149,9 @@ type ThemeColorFields = {
138
149
  border: string;
139
150
  input: string;
140
151
  ring: string;
152
+ toggleTrack?: string;
153
+ toggleTrackForeground?: string;
154
+ inputHover?: string;
141
155
  chart1: string;
142
156
  chart2: string;
143
157
  chart3: string;
@@ -85,6 +85,11 @@ export function themeToHsl(theme: Theme): ThemeHSL {
85
85
  border: hexToHsl(theme.border),
86
86
  input: hexToHsl(theme.input),
87
87
  ring: hexToHsl(theme.ring),
88
+ toggleTrack: theme.toggleTrack ? hexToHsl(theme.toggleTrack) : undefined,
89
+ toggleTrackForeground: theme.toggleTrackForeground
90
+ ? hexToHsl(theme.toggleTrackForeground)
91
+ : undefined,
92
+ inputHover: theme.inputHover ? hexToHsl(theme.inputHover) : undefined,
88
93
  chart1: hexToHsl(theme.chart1),
89
94
  chart2: hexToHsl(theme.chart2),
90
95
  chart3: hexToHsl(theme.chart3),
@@ -93,6 +98,20 @@ export function themeToHsl(theme: Theme): ThemeHSL {
93
98
  };
94
99
  }
95
100
 
101
+ /**
102
+ * Derive an input hover border color from the theme's HSL values.
103
+ * Blends toward the foreground for a subtle but visible hover effect.
104
+ */
105
+ function deriveInputHoverHsl(hslTheme: ThemeHSL): string {
106
+ const parts = hslTheme.foreground.split(/\s+/);
107
+ if (parts.length >= 3) {
108
+ const h = parts[0];
109
+ const s = parts[1];
110
+ return `${h} ${s} 50%`;
111
+ }
112
+ return hslTheme.foreground;
113
+ }
114
+
96
115
  /**
97
116
  * Apply a theme to the DOM
98
117
  *
@@ -135,12 +154,31 @@ export function applyTheme(theme: Theme, targetElement?: HTMLElement): void {
135
154
  element.style.setProperty("--border", hslTheme.border);
136
155
  element.style.setProperty("--input", hslTheme.input);
137
156
  element.style.setProperty("--ring", hslTheme.ring);
157
+
158
+ // Interactive control tokens (with fallbacks)
159
+ element.style.setProperty(
160
+ "--toggle-track",
161
+ hslTheme.toggleTrack ?? hslTheme.muted
162
+ );
163
+ element.style.setProperty(
164
+ "--toggle-track-foreground",
165
+ hslTheme.toggleTrackForeground ?? hslTheme.background
166
+ );
167
+ element.style.setProperty(
168
+ "--input-hover",
169
+ hslTheme.inputHover ?? deriveInputHoverHsl(hslTheme)
170
+ );
171
+
138
172
  element.style.setProperty("--chart-1", hslTheme.chart1);
139
173
  element.style.setProperty("--chart-2", hslTheme.chart2);
140
174
  element.style.setProperty("--chart-3", hslTheme.chart3);
141
175
  element.style.setProperty("--chart-4", hslTheme.chart4);
142
176
  element.style.setProperty("--chart-5", hslTheme.chart5);
143
177
 
178
+ if (theme.disabledOpacity) {
179
+ element.style.setProperty("--disabled-opacity", theme.disabledOpacity);
180
+ }
181
+
144
182
  if (theme.radius) {
145
183
  element.style.setProperty("--radius", theme.radius);
146
184
  }
@@ -254,6 +292,9 @@ export function areThemesEqual(theme1: Theme, theme2: Theme): boolean {
254
292
  "border",
255
293
  "input",
256
294
  "ring",
295
+ "toggleTrack",
296
+ "toggleTrackForeground",
297
+ "inputHover",
257
298
  "chart1",
258
299
  "chart2",
259
300
  "chart3",
@@ -307,3 +348,165 @@ export function importTheme(jsonString: string): {
307
348
  };
308
349
  }
309
350
  }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Contrast validation utilities
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /**
357
+ * Parse a hex color string into linear-light sRGB components (0-1).
358
+ */
359
+ function hexToLinearRgb(hex: string): [number, number, number] {
360
+ hex = hex.replace(/^#/, "");
361
+ const r = parseInt(hex.substring(0, 2), 16) / 255;
362
+ const g = parseInt(hex.substring(2, 4), 16) / 255;
363
+ const b = parseInt(hex.substring(4, 6), 16) / 255;
364
+
365
+ const toLinear = (c: number) =>
366
+ c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
367
+
368
+ return [toLinear(r), toLinear(g), toLinear(b)];
369
+ }
370
+
371
+ /**
372
+ * Calculate WCAG 2.x relative luminance for a hex color.
373
+ * @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
374
+ */
375
+ export function getRelativeLuminance(hex: string): number {
376
+ const [r, g, b] = hexToLinearRgb(hex);
377
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
378
+ }
379
+
380
+ /**
381
+ * Calculate the WCAG contrast ratio between two hex colors.
382
+ * Returns a value >= 1, where 1 means no contrast and 21 is maximum.
383
+ * @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
384
+ */
385
+ export function getContrastRatio(hex1: string, hex2: string): number {
386
+ const l1 = getRelativeLuminance(hex1);
387
+ const l2 = getRelativeLuminance(hex2);
388
+ const lighter = Math.max(l1, l2);
389
+ const darker = Math.min(l1, l2);
390
+ return (lighter + 0.05) / (darker + 0.05);
391
+ }
392
+
393
+ export interface ContrastWarning {
394
+ pair: [string, string];
395
+ pairLabels: [string, string];
396
+ ratio: number;
397
+ required: number;
398
+ level: "fail" | "AA-large" | "AA";
399
+ }
400
+
401
+ /**
402
+ * Validate contrast ratios for critical color pairs in a theme.
403
+ *
404
+ * WCAG 2.1 requirements:
405
+ * - **3:1** minimum for UI components and large text (AA)
406
+ * - **4.5:1** minimum for normal text (AA)
407
+ *
408
+ * Returns an array of warnings for pairs that fall below the recommended
409
+ * minimums. An empty array means all checked pairs pass.
410
+ */
411
+ export function validateThemeContrast(theme: Theme): ContrastWarning[] {
412
+ const warnings: ContrastWarning[] = [];
413
+
414
+ const uiPairs: {
415
+ a: string;
416
+ b: string;
417
+ labelA: string;
418
+ labelB: string;
419
+ minRatio: number;
420
+ }[] = [
421
+ // Text on background
422
+ {
423
+ a: theme.foreground,
424
+ b: theme.background,
425
+ labelA: "foreground",
426
+ labelB: "background",
427
+ minRatio: 4.5,
428
+ },
429
+ // Primary button text on primary bg
430
+ {
431
+ a: theme.primaryForeground,
432
+ b: theme.primary,
433
+ labelA: "primaryForeground",
434
+ labelB: "primary",
435
+ minRatio: 4.5,
436
+ },
437
+ // Accent foreground on accent
438
+ {
439
+ a: theme.accentForeground,
440
+ b: theme.accent,
441
+ labelA: "accentForeground",
442
+ labelB: "accent",
443
+ minRatio: 4.5,
444
+ },
445
+ // Input border vs background (UI component boundary — 3:1)
446
+ {
447
+ a: theme.input,
448
+ b: theme.background,
449
+ labelA: "input",
450
+ labelB: "background",
451
+ minRatio: 3,
452
+ },
453
+ // Toggle track vs background (UI component — 3:1)
454
+ {
455
+ a: theme.toggleTrack ?? theme.muted,
456
+ b: theme.background,
457
+ labelA: "toggleTrack",
458
+ labelB: "background",
459
+ minRatio: 3,
460
+ },
461
+ // Toggle thumb vs track (UI component — 3:1)
462
+ {
463
+ a: theme.toggleTrackForeground ?? theme.background,
464
+ b: theme.toggleTrack ?? theme.muted,
465
+ labelA: "toggleTrackForeground",
466
+ labelB: "toggleTrack",
467
+ minRatio: 3,
468
+ },
469
+ // Border vs background (UI component boundary — 3:1)
470
+ {
471
+ a: theme.border,
472
+ b: theme.background,
473
+ labelA: "border",
474
+ labelB: "background",
475
+ minRatio: 3,
476
+ },
477
+ // Muted foreground on muted bg
478
+ {
479
+ a: theme.mutedForeground,
480
+ b: theme.muted,
481
+ labelA: "mutedForeground",
482
+ labelB: "muted",
483
+ minRatio: 4.5,
484
+ },
485
+ // Destructive foreground on destructive bg
486
+ {
487
+ a: theme.destructiveForeground,
488
+ b: theme.destructive,
489
+ labelA: "destructiveForeground",
490
+ labelB: "destructive",
491
+ minRatio: 4.5,
492
+ },
493
+ ];
494
+
495
+ for (const { a, b, labelA, labelB, minRatio } of uiPairs) {
496
+ const ratio = getContrastRatio(a, b);
497
+ if (ratio < minRatio) {
498
+ let level: ContrastWarning["level"] = "fail";
499
+ if (ratio >= 3) level = "AA-large";
500
+
501
+ warnings.push({
502
+ pair: [a, b],
503
+ pairLabels: [labelA, labelB],
504
+ ratio: Math.round(ratio * 100) / 100,
505
+ required: minRatio,
506
+ level,
507
+ });
508
+ }
509
+ }
510
+
511
+ return warnings;
512
+ }