Skip to content

Commit c4d56ec

Browse files
committed
Add WCAG contrast pills and HSL sliders to color picker
Shows live contrast ratio + WCAG level (AAA/AA/AA-L/fail) against the terminal background and foreground anchors, color-coded per level. Suppresses the 'vs self' pill when editing the anchor itself. HSL sliders complement the existing RGB block with hue/saturation/lightness tracks using the same gradient + cursor pattern. Adds relativeLuminance, contrastRatio, and contrastLevel helpers to color.ts.
1 parent d909cf4 commit c4d56ec

2 files changed

Lines changed: 187 additions & 1 deletion

File tree

frontend/src/lib/components/color-picker/ColorPickerDialog.svelte

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@
2323
ANSI_COLOR_NAMES,
2424
EXTENDED_COLOR_LABELS,
2525
} from '$lib/constants/colors';
26-
import {hexToRgb, rgbToHex} from '$lib/utils/color';
26+
import {
27+
hexToRgb,
28+
rgbToHex,
29+
hexToHsl,
30+
hslToHex,
31+
relativeLuminance,
32+
contrastLevel,
33+
} from '$lib/utils/color';
2734
import ShadeGrid from './ShadeGrid.svelte';
2835
2936
const ROLE_LABELS: Record<string, string> = {
@@ -162,6 +169,64 @@
162169
applyColor(rgbToHex(c.r, c.g, c.b));
163170
}
164171
172+
let hsl = $derived(hexToHsl(currentColor));
173+
174+
function handleHslChange(channel: 'h' | 's' | 'l', value: number) {
175+
const next = {...hsl, [channel]: value};
176+
applyColor(hslToHex(next.h, next.s, next.l));
177+
}
178+
179+
// When editing an app override, prefer the role map's computed bg/fg so
180+
// the ratio reflects what the target app will actually render against.
181+
let contrastBg = $derived(
182+
(isOverride && computedVars['background']) ||
183+
getPalette()[0] ||
184+
'#000000'
185+
);
186+
let contrastFg = $derived(
187+
(isOverride && computedVars['foreground']) ||
188+
getPalette()[15] ||
189+
'#ffffff'
190+
);
191+
192+
let isBgAnchor = $derived(!isExtended && !isOverride && idx === 0);
193+
let isFgAnchor = $derived(!isExtended && !isOverride && idx === 15);
194+
195+
// Cache the current-color luminance so a slider tick doesn't gamma-decode
196+
// it twice (once per ratio derived).
197+
let currentLum = $derived(relativeLuminance(currentColor));
198+
const ratio = (otherHex: string) => {
199+
const l = relativeLuminance(otherHex);
200+
const [hi, lo] = currentLum > l ? [currentLum, l] : [l, currentLum];
201+
return (hi + 0.05) / (lo + 0.05);
202+
};
203+
let ratioBg = $derived(ratio(contrastBg));
204+
let ratioFg = $derived(ratio(contrastFg));
205+
206+
const LEVEL_CLASSES: Record<ReturnType<typeof contrastLevel>, string> = {
207+
AAA: 'text-success border-success/40',
208+
AA: 'text-accent border-accent/40',
209+
'AA-L': 'text-warning border-warning/40',
210+
fail: 'text-destructive border-destructive/40',
211+
};
212+
213+
let contrastPills = $derived(
214+
[
215+
{
216+
label: 'vs bg',
217+
ratio: ratioBg,
218+
anchor: contrastBg,
219+
show: !isBgAnchor,
220+
},
221+
{
222+
label: 'vs fg',
223+
ratio: ratioFg,
224+
anchor: contrastFg,
225+
show: !isFgAnchor,
226+
},
227+
].filter(p => p.show)
228+
);
229+
165230
function toggleLock() {
166231
if (!isExtended && !isOverride) setLockedColor(idx, !locked);
167232
}
@@ -181,6 +246,22 @@
181246
const hi = {...rgb, [channel]: 255};
182247
return `linear-gradient(to right, ${rgbToHex(lo.r, lo.g, lo.b)}, ${rgbToHex(hi.r, hi.g, hi.b)})`;
183248
}
249+
250+
const HSL_CHANNELS = ['h', 's', 'l'] as const;
251+
const HSL_LABELS = {h: 'H', s: 'S', l: 'L'};
252+
const HSL_MAX = {h: 360, s: 100, l: 100};
253+
const HSL_SUFFIX = {h: '°', s: '%', l: '%'};
254+
const HUE_RAINBOW =
255+
'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)';
256+
257+
function hslChannelGradient(channel: 'h' | 's' | 'l'): string {
258+
if (channel === 'h') return HUE_RAINBOW;
259+
if (channel === 's') {
260+
return `linear-gradient(to right, ${hslToHex(hsl.h, 0, hsl.l)}, ${hslToHex(hsl.h, 100, hsl.l)})`;
261+
}
262+
// l: black → pure hue at 50% lightness → white
263+
return `linear-gradient(to right, #000, ${hslToHex(hsl.h, hsl.s, 50)}, #fff)`;
264+
}
184265
</script>
185266

186267
<div class="flex h-full flex-col">
@@ -298,6 +379,34 @@
298379
</div>
299380
</div>
300381

382+
{#if contrastPills.length > 0}
383+
<div class="space-y-1.5">
384+
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
385+
>Contrast</span
386+
>
387+
<div class="flex gap-1.5">
388+
{#each contrastPills as pill}
389+
{@const level = contrastLevel(pill.ratio)}
390+
<div
391+
class="flex flex-1 items-center justify-between border px-2 py-1 {LEVEL_CLASSES[
392+
level
393+
]}"
394+
title="Contrast against {pill.anchor}"
395+
>
396+
<span class="text-fg-dimmed text-[9px]"
397+
>{pill.label}</span
398+
>
399+
<span class="font-mono text-[10px] tabular-nums"
400+
>{pill.ratio.toFixed(1)}:1</span
401+
>
402+
<span class="text-[9px] font-semibold">{level}</span
403+
>
404+
</div>
405+
{/each}
406+
</div>
407+
</div>
408+
{/if}
409+
301410
<div class="space-y-2">
302411
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
303412
>RGB Channels</span
@@ -340,6 +449,49 @@
340449
{/each}
341450
</div>
342451

452+
<div class="space-y-2">
453+
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
454+
>HSL Channels</span
455+
>
456+
{#each HSL_CHANNELS as channel}
457+
<div class="flex items-center gap-2">
458+
<span class="text-fg-dimmed w-3 font-mono text-[10px]"
459+
>{HSL_LABELS[channel]}</span
460+
>
461+
<div
462+
class="relative h-3 flex-1"
463+
style="background: {hslChannelGradient(
464+
channel
465+
)}; border: 1px solid var(--color-border);"
466+
>
467+
<input
468+
type="range"
469+
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
470+
min="0"
471+
max={HSL_MAX[channel]}
472+
step="1"
473+
value={hsl[channel]}
474+
oninput={e =>
475+
handleHslChange(
476+
channel,
477+
parseFloat(e.currentTarget.value)
478+
)}
479+
disabled={locked}
480+
/>
481+
<div
482+
class="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-white shadow-sm"
483+
style:left="{(hsl[channel] / HSL_MAX[channel]) *
484+
100}%"
485+
></div>
486+
</div>
487+
<span
488+
class="text-fg-dimmed w-7 text-right font-mono text-[10px] tabular-nums"
489+
>{Math.round(hsl[channel])}{HSL_SUFFIX[channel]}</span
490+
>
491+
</div>
492+
{/each}
493+
</div>
494+
343495
<div>
344496
<span
345497
class="text-fg-dimmed mb-2 block text-[9px] uppercase tracking-wider"

frontend/src/lib/utils/color.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,37 @@ export function copyColor(hex: string): void {
111111
.then(() => showToast(`Copied ${hex}`))
112112
.catch(() => {});
113113
}
114+
115+
// WCAG 2.1 relative luminance — gamma-decode sRGB channels, then weight by
116+
// photopic luminous efficiency (Rec. 709). Returns 0..1.
117+
export function relativeLuminance(hex: string): number {
118+
const {r, g, b} = hexToRgb(hex);
119+
const chan = (v: number) => {
120+
const s = v / 255;
121+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
122+
};
123+
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b);
124+
}
125+
126+
// WCAG 2.1 contrast ratio. Symmetric: caller doesn't need to pass lighter first.
127+
// Range is 1 (no contrast) to 21 (pure black on pure white).
128+
export function contrastRatio(a: string, b: string): number {
129+
const la = relativeLuminance(a);
130+
const lb = relativeLuminance(b);
131+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
132+
return (hi + 0.05) / (lo + 0.05);
133+
}
134+
135+
/**
136+
* Categorize a WCAG contrast ratio. Thresholds per WCAG 2.1 SC 1.4.3 / 1.4.6:
137+
* AAA ≥ 7 (normal text)
138+
* AA ≥ 4.5 (normal text / AAA large)
139+
* AA-large ≥ 3 (large text only)
140+
* fail < 3
141+
*/
142+
export function contrastLevel(ratio: number): 'AAA' | 'AA' | 'AA-L' | 'fail' {
143+
if (ratio >= 7) return 'AAA';
144+
if (ratio >= 4.5) return 'AA';
145+
if (ratio >= 3) return 'AA-L';
146+
return 'fail';
147+
}

0 commit comments

Comments
 (0)