@rlabs-inc/tui 0.3.2 → 0.5.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.
- package/index.ts +9 -2
- package/package.json +2 -2
- package/src/engine/arrays/interaction.ts +54 -0
- package/src/engine/lifecycle.ts +186 -0
- package/src/engine/registry.ts +5 -0
- package/src/pipeline/frameBuffer.ts +34 -8
- package/src/pipeline/layout/titan-engine.ts +37 -2
- package/src/primitives/box.ts +12 -0
- package/src/primitives/each.ts +8 -0
- package/src/primitives/index.ts +2 -1
- package/src/primitives/input.ts +360 -0
- package/src/primitives/show.ts +6 -5
- package/src/primitives/text.ts +13 -1
- package/src/primitives/types.ts +50 -1
- package/src/primitives/when.ts +5 -2
- package/src/state/context.ts +212 -0
- package/src/state/drawnCursor.ts +321 -0
- package/src/state/focus.ts +84 -13
- package/src/state/index.ts +9 -0
- package/src/state/keyboard.ts +6 -0
- package/src/state/theme.ts +260 -10
- package/src/types/color.ts +115 -0
package/src/state/index.ts
CHANGED
|
@@ -72,6 +72,15 @@ export {
|
|
|
72
72
|
export * from './scroll'
|
|
73
73
|
export * from './cursor'
|
|
74
74
|
|
|
75
|
+
// Drawn cursor - for input components (style, blink, colors)
|
|
76
|
+
export {
|
|
77
|
+
createCursor,
|
|
78
|
+
disposeCursor,
|
|
79
|
+
getCursorCharCode,
|
|
80
|
+
hasCursor,
|
|
81
|
+
} from './drawnCursor'
|
|
82
|
+
export type { DrawnCursorStyle, DrawnCursorConfig, DrawnCursor } from './drawnCursor'
|
|
83
|
+
|
|
75
84
|
// Global keys - all shortcuts wired together
|
|
76
85
|
export { globalKeys } from './global-keys'
|
|
77
86
|
|
package/src/state/keyboard.ts
CHANGED
|
@@ -66,8 +66,13 @@ const focusedHandlers = new Map<number, Set<KeyHandler>>()
|
|
|
66
66
|
* Returns true if any handler consumed the event.
|
|
67
67
|
*/
|
|
68
68
|
export function dispatch(event: KeyboardEvent): boolean {
|
|
69
|
+
// Always update reactive state (for monitoring)
|
|
69
70
|
lastEvent.value = event
|
|
70
71
|
|
|
72
|
+
// Only dispatch press events to handlers
|
|
73
|
+
// (Kitty protocol sends press/repeat/release - we only want press)
|
|
74
|
+
if (event.state !== 'press') return false
|
|
75
|
+
|
|
71
76
|
// Dispatch to key-specific handlers
|
|
72
77
|
const handlers = keyHandlers.get(event.key)
|
|
73
78
|
if (handlers) {
|
|
@@ -90,6 +95,7 @@ export function dispatch(event: KeyboardEvent): boolean {
|
|
|
90
95
|
*/
|
|
91
96
|
export function dispatchFocused(focusedIndex: number, event: KeyboardEvent): boolean {
|
|
92
97
|
if (focusedIndex < 0) return false
|
|
98
|
+
if (event.state !== 'press') return false
|
|
93
99
|
|
|
94
100
|
const handlers = focusedHandlers.get(focusedIndex)
|
|
95
101
|
if (!handlers) return false
|
package/src/state/theme.ts
CHANGED
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import { state, derived } from '@rlabs-inc/signals'
|
|
19
19
|
import type { RGBA } from '../types'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
parseColor,
|
|
22
|
+
TERMINAL_DEFAULT,
|
|
23
|
+
ansiColor,
|
|
24
|
+
isAnsiColor,
|
|
25
|
+
adjustLightnessForContrast,
|
|
26
|
+
} from '../types/color'
|
|
21
27
|
|
|
22
28
|
// =============================================================================
|
|
23
29
|
// THEME COLOR TYPE
|
|
@@ -270,6 +276,222 @@ export const themes = {
|
|
|
270
276
|
border: 0x586e75,
|
|
271
277
|
borderFocus: 0x268bd2,
|
|
272
278
|
},
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Catppuccin Mocha - soothing pastel theme (most popular variant).
|
|
282
|
+
*/
|
|
283
|
+
catppuccin: {
|
|
284
|
+
name: 'catppuccin',
|
|
285
|
+
description: 'Catppuccin Mocha theme',
|
|
286
|
+
primary: 0x89b4fa, // blue
|
|
287
|
+
secondary: 0xcba6f7, // mauve
|
|
288
|
+
tertiary: 0x94e2d5, // teal
|
|
289
|
+
accent: 0xf9e2af, // yellow
|
|
290
|
+
success: 0xa6e3a1, // green
|
|
291
|
+
warning: 0xf9e2af, // yellow
|
|
292
|
+
error: 0xf38ba8, // red
|
|
293
|
+
info: 0x89dceb, // sky
|
|
294
|
+
text: 0xcdd6f4, // text
|
|
295
|
+
textMuted: 0x6c7086, // overlay0
|
|
296
|
+
textDim: 0x585b70, // surface2
|
|
297
|
+
textDisabled: 0x45475a, // surface0
|
|
298
|
+
textBright: 0xffffff,
|
|
299
|
+
background: 0x1e1e2e, // base
|
|
300
|
+
backgroundMuted: 0x313244, // surface0
|
|
301
|
+
surface: 0x45475a, // surface1
|
|
302
|
+
overlay: 0x181825, // mantle
|
|
303
|
+
border: 0x6c7086, // overlay0
|
|
304
|
+
borderFocus: 0x89b4fa, // blue
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Gruvbox Dark - retro groove color scheme.
|
|
309
|
+
*/
|
|
310
|
+
gruvbox: {
|
|
311
|
+
name: 'gruvbox',
|
|
312
|
+
description: 'Gruvbox Dark theme',
|
|
313
|
+
primary: 0x458588, // blue
|
|
314
|
+
secondary: 0xb16286, // purple
|
|
315
|
+
tertiary: 0x689d6a, // aqua
|
|
316
|
+
accent: 0xd79921, // yellow
|
|
317
|
+
success: 0x98971a, // green
|
|
318
|
+
warning: 0xd79921, // yellow
|
|
319
|
+
error: 0xcc241d, // red
|
|
320
|
+
info: 0x458588, // blue
|
|
321
|
+
text: 0xebdbb2, // fg
|
|
322
|
+
textMuted: 0xa89984, // gray
|
|
323
|
+
textDim: 0x928374, // gray
|
|
324
|
+
textDisabled: 0x665c54, // bg3
|
|
325
|
+
textBright: 0xfbf1c7, // fg0
|
|
326
|
+
background: 0x282828, // bg
|
|
327
|
+
backgroundMuted: 0x3c3836, // bg1
|
|
328
|
+
surface: 0x504945, // bg2
|
|
329
|
+
overlay: 0x1d2021, // bg0_h
|
|
330
|
+
border: 0x665c54, // bg3
|
|
331
|
+
borderFocus: 0xfe8019, // orange
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Tokyo Night - clean, dark theme inspired by Tokyo city lights.
|
|
336
|
+
*/
|
|
337
|
+
tokyoNight: {
|
|
338
|
+
name: 'tokyoNight',
|
|
339
|
+
description: 'Tokyo Night theme',
|
|
340
|
+
primary: 0x7aa2f7, // blue
|
|
341
|
+
secondary: 0xbb9af7, // purple
|
|
342
|
+
tertiary: 0x7dcfff, // cyan
|
|
343
|
+
accent: 0xe0af68, // yellow
|
|
344
|
+
success: 0x9ece6a, // green
|
|
345
|
+
warning: 0xe0af68, // yellow
|
|
346
|
+
error: 0xf7768e, // red
|
|
347
|
+
info: 0x7dcfff, // cyan
|
|
348
|
+
text: 0xa9b1d6, // fg
|
|
349
|
+
textMuted: 0x565f89, // comment
|
|
350
|
+
textDim: 0x414868, // dark3
|
|
351
|
+
textDisabled: 0x3b4261, // dark2
|
|
352
|
+
textBright: 0xc0caf5, // fg_bright
|
|
353
|
+
background: 0x1a1b26, // bg
|
|
354
|
+
backgroundMuted: 0x24283b, // bg_highlight
|
|
355
|
+
surface: 0x414868, // dark3
|
|
356
|
+
overlay: 0x16161e, // bg_dark
|
|
357
|
+
border: 0x414868, // dark3
|
|
358
|
+
borderFocus: 0x7aa2f7, // blue
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One Dark - Atom's iconic dark theme.
|
|
363
|
+
*/
|
|
364
|
+
oneDark: {
|
|
365
|
+
name: 'oneDark',
|
|
366
|
+
description: 'One Dark (Atom) theme',
|
|
367
|
+
primary: 0x61afef, // blue
|
|
368
|
+
secondary: 0xc678dd, // purple
|
|
369
|
+
tertiary: 0x56b6c2, // cyan
|
|
370
|
+
accent: 0xe5c07b, // yellow
|
|
371
|
+
success: 0x98c379, // green
|
|
372
|
+
warning: 0xe5c07b, // yellow
|
|
373
|
+
error: 0xe06c75, // red
|
|
374
|
+
info: 0x56b6c2, // cyan
|
|
375
|
+
text: 0xabb2bf, // fg
|
|
376
|
+
textMuted: 0x5c6370, // comment
|
|
377
|
+
textDim: 0x4b5263, // gutter
|
|
378
|
+
textDisabled: 0x3e4451, // guide
|
|
379
|
+
textBright: 0xffffff,
|
|
380
|
+
background: 0x282c34, // bg
|
|
381
|
+
backgroundMuted: 0x21252b, // bg_dark
|
|
382
|
+
surface: 0x3e4451, // guide
|
|
383
|
+
overlay: 0x1e2127, // bg_darker
|
|
384
|
+
border: 0x3e4451, // guide
|
|
385
|
+
borderFocus: 0x61afef, // blue
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Rosé Pine - all natural pine, faux fur and a bit of soho vibes.
|
|
390
|
+
*/
|
|
391
|
+
rosePine: {
|
|
392
|
+
name: 'rosePine',
|
|
393
|
+
description: 'Rosé Pine theme',
|
|
394
|
+
primary: 0x9ccfd8, // foam
|
|
395
|
+
secondary: 0xc4a7e7, // iris
|
|
396
|
+
tertiary: 0x31748f, // pine
|
|
397
|
+
accent: 0xf6c177, // gold
|
|
398
|
+
success: 0x31748f, // pine
|
|
399
|
+
warning: 0xf6c177, // gold
|
|
400
|
+
error: 0xeb6f92, // love
|
|
401
|
+
info: 0x9ccfd8, // foam
|
|
402
|
+
text: 0xe0def4, // text
|
|
403
|
+
textMuted: 0x908caa, // subtle
|
|
404
|
+
textDim: 0x6e6a86, // muted
|
|
405
|
+
textDisabled: 0x524f67, // highlight_med
|
|
406
|
+
textBright: 0xffffff,
|
|
407
|
+
background: 0x191724, // base
|
|
408
|
+
backgroundMuted: 0x1f1d2e, // surface
|
|
409
|
+
surface: 0x26233a, // overlay
|
|
410
|
+
overlay: 0x16141f, // nc
|
|
411
|
+
border: 0x524f67, // highlight_med
|
|
412
|
+
borderFocus: 0xebbcba, // rose
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Kanagawa - theme inspired by Katsushika Hokusai's famous wave painting.
|
|
417
|
+
*/
|
|
418
|
+
kanagawa: {
|
|
419
|
+
name: 'kanagawa',
|
|
420
|
+
description: 'Kanagawa wave theme',
|
|
421
|
+
primary: 0x7e9cd8, // crystalBlue
|
|
422
|
+
secondary: 0x957fb8, // oniViolet
|
|
423
|
+
tertiary: 0x7aa89f, // waveAqua2
|
|
424
|
+
accent: 0xdca561, // carpYellow
|
|
425
|
+
success: 0x98bb6c, // springGreen
|
|
426
|
+
warning: 0xdca561, // carpYellow
|
|
427
|
+
error: 0xc34043, // autumnRed
|
|
428
|
+
info: 0x7fb4ca, // springBlue
|
|
429
|
+
text: 0xdcd7ba, // fujiWhite
|
|
430
|
+
textMuted: 0x727169, // fujiGray
|
|
431
|
+
textDim: 0x54546d, // sumiInk4
|
|
432
|
+
textDisabled: 0x363646, // sumiInk3
|
|
433
|
+
textBright: 0xffffff,
|
|
434
|
+
background: 0x1f1f28, // sumiInk1
|
|
435
|
+
backgroundMuted: 0x2a2a37, // sumiInk2
|
|
436
|
+
surface: 0x363646, // sumiInk3
|
|
437
|
+
overlay: 0x16161d, // sumiInk0
|
|
438
|
+
border: 0x54546d, // sumiInk4
|
|
439
|
+
borderFocus: 0x7e9cd8, // crystalBlue
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Everforest - comfortable green-tinted theme.
|
|
444
|
+
*/
|
|
445
|
+
everforest: {
|
|
446
|
+
name: 'everforest',
|
|
447
|
+
description: 'Everforest theme',
|
|
448
|
+
primary: 0x7fbbb3, // aqua
|
|
449
|
+
secondary: 0xd699b6, // purple
|
|
450
|
+
tertiary: 0x83c092, // green
|
|
451
|
+
accent: 0xdbbc7f, // yellow
|
|
452
|
+
success: 0xa7c080, // green
|
|
453
|
+
warning: 0xdbbc7f, // yellow
|
|
454
|
+
error: 0xe67e80, // red
|
|
455
|
+
info: 0x7fbbb3, // aqua
|
|
456
|
+
text: 0xd3c6aa, // fg
|
|
457
|
+
textMuted: 0x9da9a0, // grey1
|
|
458
|
+
textDim: 0x859289, // grey0
|
|
459
|
+
textDisabled: 0x5c6a72, // bg5
|
|
460
|
+
textBright: 0xfdf6e3,
|
|
461
|
+
background: 0x2d353b, // bg_dim
|
|
462
|
+
backgroundMuted: 0x343f44, // bg1
|
|
463
|
+
surface: 0x3d484d, // bg3
|
|
464
|
+
overlay: 0x272e33, // bg0
|
|
465
|
+
border: 0x5c6a72, // bg5
|
|
466
|
+
borderFocus: 0xa7c080, // green
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Night Owl - designed with accessibility in mind.
|
|
471
|
+
*/
|
|
472
|
+
nightOwl: {
|
|
473
|
+
name: 'nightOwl',
|
|
474
|
+
description: 'Night Owl theme',
|
|
475
|
+
primary: 0x82aaff, // blue
|
|
476
|
+
secondary: 0xc792ea, // purple
|
|
477
|
+
tertiary: 0x7fdbca, // cyan
|
|
478
|
+
accent: 0xffcb6b, // yellow
|
|
479
|
+
success: 0xaddb67, // green
|
|
480
|
+
warning: 0xffcb6b, // yellow
|
|
481
|
+
error: 0xef5350, // red
|
|
482
|
+
info: 0x7fdbca, // cyan
|
|
483
|
+
text: 0xd6deeb, // fg
|
|
484
|
+
textMuted: 0x637777, // comment
|
|
485
|
+
textDim: 0x5f7e97, // lineHighlight
|
|
486
|
+
textDisabled: 0x3b4252, // guide
|
|
487
|
+
textBright: 0xffffff,
|
|
488
|
+
background: 0x011627, // bg
|
|
489
|
+
backgroundMuted: 0x0b2942, // bg_light
|
|
490
|
+
surface: 0x1d3b53, // selection
|
|
491
|
+
overlay: 0x010e1a, // bg_dark
|
|
492
|
+
border: 0x5f7e97, // lineHighlight
|
|
493
|
+
borderFocus: 0x82aaff, // blue
|
|
494
|
+
},
|
|
273
495
|
}
|
|
274
496
|
|
|
275
497
|
// =============================================================================
|
|
@@ -568,9 +790,37 @@ export interface VariantStyle {
|
|
|
568
790
|
borderFocus: RGBA
|
|
569
791
|
}
|
|
570
792
|
|
|
793
|
+
/**
|
|
794
|
+
* Get a foreground color with proper contrast against the background.
|
|
795
|
+
*
|
|
796
|
+
* For ANSI colors (terminal theme): Returns the desired fg as-is, trusting
|
|
797
|
+
* the standard ANSI color pairings and terminal's contrast handling.
|
|
798
|
+
*
|
|
799
|
+
* For RGB colors (custom themes): Adjusts the fg lightness using OKLCH
|
|
800
|
+
* to ensure WCAG AA contrast (4.5:1 ratio).
|
|
801
|
+
*/
|
|
802
|
+
function getContrastFg(desiredFg: RGBA, bg: RGBA): RGBA {
|
|
803
|
+
// If background is ANSI, trust terminal's color handling
|
|
804
|
+
if (isAnsiColor(bg)) {
|
|
805
|
+
return desiredFg
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// If foreground is ANSI but background is RGB, also trust it
|
|
809
|
+
// (mixed case, probably intentional)
|
|
810
|
+
if (isAnsiColor(desiredFg)) {
|
|
811
|
+
return desiredFg
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Both are RGB - ensure proper contrast using OKLCH
|
|
815
|
+
return adjustLightnessForContrast(desiredFg, bg, 4.5)
|
|
816
|
+
}
|
|
817
|
+
|
|
571
818
|
/**
|
|
572
819
|
* Get variant styles resolved to RGBA.
|
|
573
820
|
* Returns colors based on variant name and current theme.
|
|
821
|
+
*
|
|
822
|
+
* For terminal theme (ANSI colors): Uses standard ANSI pairings.
|
|
823
|
+
* For custom themes (RGB colors): Calculates proper OKLCH contrast.
|
|
574
824
|
*/
|
|
575
825
|
export function getVariantStyle(variant: Variant): VariantStyle {
|
|
576
826
|
const resolved = resolvedTheme.value
|
|
@@ -578,56 +828,56 @@ export function getVariantStyle(variant: Variant): VariantStyle {
|
|
|
578
828
|
switch (variant) {
|
|
579
829
|
case 'primary':
|
|
580
830
|
return {
|
|
581
|
-
fg: resolved.textBright,
|
|
831
|
+
fg: getContrastFg(resolved.textBright, resolved.primary),
|
|
582
832
|
bg: resolved.primary,
|
|
583
833
|
border: resolved.primary,
|
|
584
834
|
borderFocus: resolved.accent,
|
|
585
835
|
}
|
|
586
836
|
case 'secondary':
|
|
587
837
|
return {
|
|
588
|
-
fg: resolved.textBright,
|
|
838
|
+
fg: getContrastFg(resolved.textBright, resolved.secondary),
|
|
589
839
|
bg: resolved.secondary,
|
|
590
840
|
border: resolved.secondary,
|
|
591
841
|
borderFocus: resolved.accent,
|
|
592
842
|
}
|
|
593
843
|
case 'tertiary':
|
|
594
844
|
return {
|
|
595
|
-
fg: resolved.textBright,
|
|
845
|
+
fg: getContrastFg(resolved.textBright, resolved.tertiary),
|
|
596
846
|
bg: resolved.tertiary,
|
|
597
847
|
border: resolved.tertiary,
|
|
598
848
|
borderFocus: resolved.accent,
|
|
599
849
|
}
|
|
600
850
|
case 'accent':
|
|
601
851
|
return {
|
|
602
|
-
fg: { r: 0, g: 0, b: 0, a: 255 },
|
|
852
|
+
fg: getContrastFg({ r: 0, g: 0, b: 0, a: 255 }, resolved.accent),
|
|
603
853
|
bg: resolved.accent,
|
|
604
854
|
border: resolved.accent,
|
|
605
855
|
borderFocus: resolved.primary,
|
|
606
856
|
}
|
|
607
857
|
case 'success':
|
|
608
858
|
return {
|
|
609
|
-
fg: resolved.textBright,
|
|
859
|
+
fg: getContrastFg(resolved.textBright, resolved.success),
|
|
610
860
|
bg: resolved.success,
|
|
611
861
|
border: resolved.success,
|
|
612
862
|
borderFocus: resolved.accent,
|
|
613
863
|
}
|
|
614
864
|
case 'warning':
|
|
615
865
|
return {
|
|
616
|
-
fg: { r: 0, g: 0, b: 0, a: 255 },
|
|
866
|
+
fg: getContrastFg({ r: 0, g: 0, b: 0, a: 255 }, resolved.warning),
|
|
617
867
|
bg: resolved.warning,
|
|
618
868
|
border: resolved.warning,
|
|
619
869
|
borderFocus: resolved.accent,
|
|
620
870
|
}
|
|
621
871
|
case 'error':
|
|
622
872
|
return {
|
|
623
|
-
fg: resolved.textBright,
|
|
873
|
+
fg: getContrastFg(resolved.textBright, resolved.error),
|
|
624
874
|
bg: resolved.error,
|
|
625
875
|
border: resolved.error,
|
|
626
876
|
borderFocus: resolved.accent,
|
|
627
877
|
}
|
|
628
878
|
case 'info':
|
|
629
879
|
return {
|
|
630
|
-
fg: resolved.textBright,
|
|
880
|
+
fg: getContrastFg(resolved.textBright, resolved.info),
|
|
631
881
|
bg: resolved.info,
|
|
632
882
|
border: resolved.info,
|
|
633
883
|
borderFocus: resolved.accent,
|
|
@@ -648,7 +898,7 @@ export function getVariantStyle(variant: Variant): VariantStyle {
|
|
|
648
898
|
}
|
|
649
899
|
case 'elevated':
|
|
650
900
|
return {
|
|
651
|
-
fg: resolved.textBright,
|
|
901
|
+
fg: getContrastFg(resolved.textBright, resolved.surface),
|
|
652
902
|
bg: resolved.surface,
|
|
653
903
|
border: resolved.primary,
|
|
654
904
|
borderFocus: resolved.borderFocus,
|
package/src/types/color.ts
CHANGED
|
@@ -232,6 +232,121 @@ function oklchToRgb(l: number, c: number, h: number): { r: number; g: number; b:
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Convert RGB to OKLCH.
|
|
237
|
+
* Inverse of oklchToRgb. Used for color manipulation while preserving perceptual uniformity.
|
|
238
|
+
*
|
|
239
|
+
* @returns { l: lightness (0-1), c: chroma (0-~0.4), h: hue (0-360) }
|
|
240
|
+
*/
|
|
241
|
+
export function rgbToOklch(color: RGBA): { l: number; c: number; h: number } {
|
|
242
|
+
// sRGB to linear sRGB (inverse gamma)
|
|
243
|
+
const toLinear = (x: number) => {
|
|
244
|
+
const s = x / 255
|
|
245
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const rLinear = toLinear(color.r)
|
|
249
|
+
const gLinear = toLinear(color.g)
|
|
250
|
+
const bLinear = toLinear(color.b)
|
|
251
|
+
|
|
252
|
+
// Linear sRGB to LMS (cone responses)
|
|
253
|
+
const l = 0.4122214708 * rLinear + 0.5363325363 * gLinear + 0.0514459929 * bLinear
|
|
254
|
+
const m = 0.2119034982 * rLinear + 0.6806995451 * gLinear + 0.1073969566 * bLinear
|
|
255
|
+
const s = 0.0883024619 * rLinear + 0.2817188376 * gLinear + 0.6299787005 * bLinear
|
|
256
|
+
|
|
257
|
+
// LMS to OKLab (cube root for perceptual linearity)
|
|
258
|
+
const l_ = Math.cbrt(l)
|
|
259
|
+
const m_ = Math.cbrt(m)
|
|
260
|
+
const s_ = Math.cbrt(s)
|
|
261
|
+
|
|
262
|
+
const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
|
|
263
|
+
const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
|
|
264
|
+
const b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
|
265
|
+
|
|
266
|
+
// OKLab to OKLCH (polar coordinates)
|
|
267
|
+
const c = Math.sqrt(a * a + b * b)
|
|
268
|
+
let h = Math.atan2(b, a) * (180 / Math.PI)
|
|
269
|
+
if (h < 0) h += 360
|
|
270
|
+
|
|
271
|
+
return { l: L, c, h }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Adjust OKLCH lightness to achieve minimum contrast ratio against a background.
|
|
276
|
+
* Preserves hue and chroma while adjusting only lightness.
|
|
277
|
+
*
|
|
278
|
+
* @param fg Foreground color to adjust
|
|
279
|
+
* @param bg Background color
|
|
280
|
+
* @param minRatio Minimum contrast ratio (WCAG AA = 4.5, AAA = 7)
|
|
281
|
+
* @returns Adjusted foreground color as RGBA
|
|
282
|
+
*/
|
|
283
|
+
export function adjustLightnessForContrast(fg: RGBA, bg: RGBA, minRatio: number = 4.5): RGBA {
|
|
284
|
+
const bgOklch = rgbToOklch(bg)
|
|
285
|
+
let fgOklch = rgbToOklch(fg)
|
|
286
|
+
|
|
287
|
+
// Calculate current contrast
|
|
288
|
+
const getContrast = (fgL: number): number => {
|
|
289
|
+
const fgRgb = oklchToRgb(fgL, fgOklch.c, fgOklch.h)
|
|
290
|
+
const fgRgba = { ...fgRgb, a: 255 }
|
|
291
|
+
return contrastRatio(fgRgba, bg)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let currentRatio = getContrast(fgOklch.l)
|
|
295
|
+
|
|
296
|
+
// If already sufficient, return original
|
|
297
|
+
if (currentRatio >= minRatio) {
|
|
298
|
+
return fg
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Determine direction: if bg is dark, go lighter; if bg is light, go darker
|
|
302
|
+
const direction = bgOklch.l > 0.5 ? -1 : 1
|
|
303
|
+
let newL = fgOklch.l
|
|
304
|
+
|
|
305
|
+
// Binary search for the right lightness
|
|
306
|
+
let step = 0.25
|
|
307
|
+
for (let i = 0; i < 20 && currentRatio < minRatio; i++) {
|
|
308
|
+
newL = Math.max(0, Math.min(1, newL + direction * step))
|
|
309
|
+
currentRatio = getContrast(newL)
|
|
310
|
+
|
|
311
|
+
// If we overshot, reverse and halve step
|
|
312
|
+
if (currentRatio >= minRatio) {
|
|
313
|
+
// Found a valid lightness, but let's fine-tune to not over-adjust
|
|
314
|
+
const testL = newL - direction * step * 0.5
|
|
315
|
+
if (getContrast(testL) >= minRatio) {
|
|
316
|
+
newL = testL
|
|
317
|
+
currentRatio = getContrast(newL)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
step *= 0.6
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Convert back to RGB
|
|
324
|
+
const adjusted = oklchToRgb(newL, fgOklch.c, fgOklch.h)
|
|
325
|
+
return { ...adjusted, a: fg.a }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Calculate contrast ratio between two colors (WCAG formula).
|
|
330
|
+
*/
|
|
331
|
+
function contrastRatio(fg: RGBA, bg: RGBA): number {
|
|
332
|
+
const lumFg = relativeLuminance(fg)
|
|
333
|
+
const lumBg = relativeLuminance(bg)
|
|
334
|
+
const lighter = Math.max(lumFg, lumBg)
|
|
335
|
+
const darker = Math.min(lumFg, lumBg)
|
|
336
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Calculate relative luminance per WCAG 2.1.
|
|
341
|
+
*/
|
|
342
|
+
function relativeLuminance(color: RGBA): number {
|
|
343
|
+
const toLinear = (c: number) => {
|
|
344
|
+
const s = c / 255
|
|
345
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
346
|
+
}
|
|
347
|
+
return 0.2126 * toLinear(color.r) + 0.7152 * toLinear(color.g) + 0.0722 * toLinear(color.b)
|
|
348
|
+
}
|
|
349
|
+
|
|
235
350
|
// =============================================================================
|
|
236
351
|
// Color Comparison
|
|
237
352
|
// =============================================================================
|