|
23 | 23 | ANSI_COLOR_NAMES, |
24 | 24 | EXTENDED_COLOR_LABELS, |
25 | 25 | } 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'; |
27 | 34 | import ShadeGrid from './ShadeGrid.svelte'; |
28 | 35 |
|
29 | 36 | const ROLE_LABELS: Record<string, string> = { |
|
162 | 169 | applyColor(rgbToHex(c.r, c.g, c.b)); |
163 | 170 | } |
164 | 171 |
|
| 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 | +
|
165 | 230 | function toggleLock() { |
166 | 231 | if (!isExtended && !isOverride) setLockedColor(idx, !locked); |
167 | 232 | } |
|
181 | 246 | const hi = {...rgb, [channel]: 255}; |
182 | 247 | return `linear-gradient(to right, ${rgbToHex(lo.r, lo.g, lo.b)}, ${rgbToHex(hi.r, hi.g, hi.b)})`; |
183 | 248 | } |
| 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 | + } |
184 | 265 | </script> |
185 | 266 |
|
186 | 267 | <div class="flex h-full flex-col"> |
|
298 | 379 | </div> |
299 | 380 | </div> |
300 | 381 |
|
| 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 | + |
301 | 410 | <div class="space-y-2"> |
302 | 411 | <span class="text-fg-dimmed text-[9px] uppercase tracking-wider" |
303 | 412 | >RGB Channels</span |
|
340 | 449 | {/each} |
341 | 450 | </div> |
342 | 451 |
|
| 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 | + |
343 | 495 | <div> |
344 | 496 | <span |
345 | 497 | class="text-fg-dimmed mb-2 block text-[9px] uppercase tracking-wider" |
|
0 commit comments