| 1 | export type CodeReadabilityMode = "light" | "dark"; |
| 2 | |
| 3 | export type CodeReadabilityPalette = { |
| 4 | background: string; |
| 5 | border: string; |
| 6 | foreground: string; |
| 7 | keyword: string; |
| 8 | string: string; |
| 9 | number: string; |
| 10 | comment: string; |
| 11 | function: string; |
| 12 | type: string; |
| 13 | builtin: string; |
| 14 | meta: string; |
| 15 | addition: string; |
| 16 | deletion: string; |
| 17 | additionBackground: string; |
| 18 | deletionBackground: string; |
| 19 | }; |
| 20 | |
| 21 | type RGB = { r: number; g: number; b: number; a: number }; |
| 22 | |
| 23 | const MIN_CODE_CONTRAST = 4.5; |
| 24 | const DIFF_TINT_ALPHA = 0.12; |
| 25 | |
| 26 | const BASE_SURFACES: Record<string, Record<CodeReadabilityMode, { bg: string; code: string; fg: string }>> = { |
| 27 | graphite: { |
| 28 | dark: { bg: "#0c0d10", code: "#101115", fg: "#f1f1ef" }, |
| 29 | light: { bg: "#f4f3ef", code: "#f6f5f1", fg: "#16181d" }, |
| 30 | }, |
| 31 | aurora: { |
| 32 | dark: { bg: "#0e0d18", code: "#0f0e1c", fg: "#ecebf7" }, |
| 33 | light: { bg: "#f6f3fb", code: "#f3effb", fg: "#1c1830" }, |
| 34 | }, |
| 35 | slate: { |
| 36 | dark: { bg: "#0d0f12", code: "#0c0e12", fg: "#e7eaf0" }, |
| 37 | light: { bg: "#f5f6f9", code: "#f1f3f7", fg: "#15181f" }, |
| 38 | }, |
| 39 | carbon: { |
| 40 | dark: { bg: "#0e0d0c", code: "#0d0c0b", fg: "#ede9e3" }, |
| 41 | light: { bg: "#f6f4f0", code: "#f2efe9", fg: "#1a1714" }, |
| 42 | }, |
| 43 | nocturne: { |
| 44 | dark: { bg: "#101019", code: "#0e0e18", fg: "#eceaf3" }, |
| 45 | light: { bg: "#f6f5fb", code: "#f2f0fb", fg: "#1b1830" }, |
| 46 | }, |
| 47 | amber: { |
| 48 | dark: { bg: "#090a0c", code: "#111319", fg: "#f4f5f7" }, |
| 49 | light: { bg: "#f7f8fb", code: "#f2f5f9", fg: "#111827" }, |
| 50 | }, |
| 51 | }; |
| 52 | |
| 53 | const DARK_SYNTAX = { |
| 54 | keyword: "#c678dd", |
| 55 | string: "#98c379", |
| 56 | number: "#d19a66", |
| 57 | comment: "#6a6a72", |
| 58 | function: "#61afef", |
| 59 | type: "#e5c07b", |
| 60 | builtin: "#56b6c2", |
| 61 | meta: "#7f848e", |
| 62 | addition: "#74b87a", |
| 63 | deletion: "#e0696a", |
| 64 | }; |
| 65 | |
| 66 | const LIGHT_SYNTAX = { |
| 67 | keyword: "#cf222e", |
| 68 | string: "#0a3069", |
| 69 | number: "#0550ae", |
| 70 | comment: "#6e7781", |
| 71 | function: "#8250df", |
| 72 | type: "#116329", |
| 73 | builtin: "#0550ae", |
| 74 | meta: "#6e7781", |
| 75 | addition: "#15803d", |
| 76 | deletion: "#dc2626", |
| 77 | }; |
| 78 | |
| 79 | /** |
| 80 | * Build an opaque, WCAG-safe code surface from user-editable theme tokens. |
| 81 | * Eight-digit colors are composited before use so a background image can never |
| 82 | * leak through code or diff text. |
| 83 | */ |
| 84 | export function deriveCodeReadabilityPalette( |
| 85 | mode: CodeReadabilityMode, |
| 86 | baseStyle: string, |
| 87 | tokens: Record<string, string> = {}, |
| 88 | ): CodeReadabilityPalette { |
| 89 | const fallback = (BASE_SURFACES[baseStyle] || BASE_SURFACES.graphite)[mode]; |
| 90 | const baseBackground = flattenHex(tokens.bg || fallback.bg, fallback.bg); |
| 91 | const background = flattenHex(tokens.bgSoft || fallback.code, baseBackground); |
| 92 | const rawForeground = flattenHex(tokens.fg || fallback.fg, background); |
| 93 | |
| 94 | // Palette direction follows the final, opaque code surface rather than the |
| 95 | // global theme switch. This covers intentionally inverted custom themes. |
| 96 | const syntax = contrastRatio("#f1f1ef", background) >= contrastRatio("#111827", background) |
| 97 | ? DARK_SYNTAX |
| 98 | : LIGHT_SYNTAX; |
| 99 | const rawAddition = flattenHex(tokens.ok || syntax.addition, background); |
| 100 | const rawDeletion = flattenHex(tokens.err || syntax.deletion, background); |
| 101 | const contrastTarget = preferredContrastTarget([background]); |
| 102 | const additionBackground = readableTintBackground(background, rawAddition, contrastTarget); |
| 103 | const deletionBackground = readableTintBackground(background, rawDeletion, contrastTarget); |
| 104 | const renderedBackgrounds = [background, additionBackground, deletionBackground]; |
| 105 | const foreground = ensureContrastAgainst(rawForeground, renderedBackgrounds); |
| 106 | const rawBorder = tokens.borderSoft || tokens.border; |
| 107 | const border = rawBorder |
| 108 | ? flattenHex(rawBorder, background) |
| 109 | : mixHex(background, foreground, 0.16); |
| 110 | |
| 111 | return { |
| 112 | background, |
| 113 | border, |
| 114 | foreground, |
| 115 | keyword: ensureContrastAgainst(syntax.keyword, renderedBackgrounds), |
| 116 | string: ensureContrastAgainst(syntax.string, renderedBackgrounds), |
| 117 | number: ensureContrastAgainst(syntax.number, renderedBackgrounds), |
| 118 | comment: ensureContrastAgainst(syntax.comment, renderedBackgrounds), |
| 119 | function: ensureContrastAgainst(syntax.function, renderedBackgrounds), |
| 120 | type: ensureContrastAgainst(syntax.type, renderedBackgrounds), |
| 121 | builtin: ensureContrastAgainst(syntax.builtin, renderedBackgrounds), |
| 122 | meta: ensureContrastAgainst(syntax.meta, renderedBackgrounds), |
| 123 | addition: ensureContrastAgainst(rawAddition, renderedBackgrounds), |
| 124 | deletion: ensureContrastAgainst(rawDeletion, renderedBackgrounds), |
| 125 | additionBackground, |
| 126 | deletionBackground, |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | export function deriveCreationCodeReadabilityPalette(mode: CodeReadabilityMode): CodeReadabilityPalette { |
| 131 | return deriveCodeReadabilityPalette(mode, "graphite", mode === "light" |
| 132 | ? { bg: "#f6f4f1", bgSoft: "#f0eeeb", fg: "#18181b", borderSoft: "#18181b14" } |
| 133 | : { bg: "#090c10", bgSoft: "#0b0e12", fg: "#e4e6ea", borderSoft: "#ffffff12" }); |
| 134 | } |
| 135 | |
| 136 | export function codeReadabilityDecls(palette: CodeReadabilityPalette): string { |
| 137 | return [ |
| 138 | `--code-bg:${palette.background}`, |
| 139 | `--code-border:${palette.border}`, |
| 140 | `--code-fg:${palette.foreground}`, |
| 141 | `--hl-keyword:${palette.keyword}`, |
| 142 | `--hl-string:${palette.string}`, |
| 143 | `--hl-number:${palette.number}`, |
| 144 | `--hl-comment:${palette.comment}`, |
| 145 | `--hl-func:${palette.function}`, |
| 146 | `--hl-type:${palette.type}`, |
| 147 | `--hl-builtin:${palette.builtin}`, |
| 148 | `--hl-meta:${palette.meta}`, |
| 149 | `--add-fg:${palette.addition}`, |
| 150 | `--del-fg:${palette.deletion}`, |
| 151 | `--code-add-bg:${palette.additionBackground}`, |
| 152 | `--code-del-bg:${palette.deletionBackground}`, |
| 153 | ].join(";"); |
| 154 | } |
| 155 | |
| 156 | export function codeReadabilityRatios(palette: CodeReadabilityPalette): Record<string, number> { |
| 157 | const backgrounds = [palette.background, palette.additionBackground, palette.deletionBackground]; |
| 158 | return Object.fromEntries( |
| 159 | ["foreground", "keyword", "string", "number", "comment", "function", "type", "builtin", "meta", "addition", "deletion"] |
| 160 | .map((key) => { |
| 161 | const foreground = palette[key as keyof CodeReadabilityPalette]; |
| 162 | return [key, Math.min(...backgrounds.map((background) => contrastRatio(foreground, background)))]; |
| 163 | }), |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Install complete code palettes for every built-in style before any optional |
| 169 | * theme-pack overlay. Creation intentionally uses its own neutral surface, so |
| 170 | * it receives a complete local palette instead of overriding only --code-bg. |
| 171 | */ |
| 172 | export function baseCodeReadabilityStylesheet(styles: readonly string[]): string { |
| 173 | const rules: string[] = []; |
| 174 | for (const style of styles) { |
| 175 | const darkDecls = codeReadabilityDecls(deriveCodeReadabilityPalette("dark", style)); |
| 176 | const lightDecls = codeReadabilityDecls(deriveCodeReadabilityPalette("light", style)); |
| 177 | rules.push(`:root[data-theme-style="${style}"]{${darkDecls}}`); |
| 178 | rules.push(`:root[data-theme="light"][data-theme-style="${style}"]{${lightDecls}}`); |
| 179 | rules.push(`@media (prefers-color-scheme: light){:root:not([data-theme])[data-theme-style="${style}"]{${lightDecls}}}`); |
| 180 | } |
| 181 | |
| 182 | const creationDark = codeReadabilityDecls(deriveCreationCodeReadabilityPalette("dark")); |
| 183 | const creationLight = codeReadabilityDecls(deriveCreationCodeReadabilityPalette("light")); |
| 184 | rules.push(`:root[data-theme-style] .app--creation{${creationDark}}`); |
| 185 | rules.push(`:root[data-theme="light"][data-theme-style] .app--creation{${creationLight}}`); |
| 186 | rules.push(`@media (prefers-color-scheme: light){:root:not([data-theme])[data-theme-style] .app--creation{${creationLight}}}`); |
| 187 | return rules.join("\n"); |
| 188 | } |
| 189 | |
| 190 | export function contrastRatio(a: string, b: string): number { |
| 191 | const la = relativeLuminance(flattenHex(a, "#000000")); |
| 192 | const lb = relativeLuminance(flattenHex(b, "#000000")); |
| 193 | return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); |
| 194 | } |
| 195 | |
| 196 | function ensureContrastAgainst(color: string, backgrounds: string[]): string { |
| 197 | const primaryBackground = backgrounds[0] || "#000000"; |
| 198 | const opaque = flattenHex(color, primaryBackground); |
| 199 | const minimumRatio = (candidate: string) => Math.min( |
| 200 | ...backgrounds.map((background) => contrastRatio(candidate, background)), |
| 201 | ); |
| 202 | if (minimumRatio(opaque) >= MIN_CODE_CONTRAST) return opaque; |
| 203 | const target = preferredContrastTarget(backgrounds); |
| 204 | let low = 0; |
| 205 | let high = 1; |
| 206 | for (let i = 0; i < 16; i += 1) { |
| 207 | const middle = (low + high) / 2; |
| 208 | if (minimumRatio(mixHex(opaque, target, middle)) >= MIN_CODE_CONTRAST) high = middle; |
| 209 | else low = middle; |
| 210 | } |
| 211 | return mixHex(opaque, target, high); |
| 212 | } |
| 213 | |
| 214 | function preferredContrastTarget(backgrounds: string[]): "#ffffff" | "#000000" { |
| 215 | const minimumRatio = (candidate: string) => Math.min( |
| 216 | ...backgrounds.map((background) => contrastRatio(candidate, background)), |
| 217 | ); |
| 218 | return minimumRatio("#ffffff") >= minimumRatio("#000000") ? "#ffffff" : "#000000"; |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Keep as much semantic diff tint as possible without crossing the contrast |
| 223 | * direction selected by the opaque code surface. The zero-tint candidate is |
| 224 | * always safe, so every syntax role can share one palette across plain, added, |
| 225 | * and deleted rows instead of requiring row-specific token colors. |
| 226 | */ |
| 227 | function readableTintBackground(background: string, tint: string, foreground: string): string { |
| 228 | const steps = 24; |
| 229 | for (let step = steps; step >= 0; step -= 1) { |
| 230 | const candidate = mixHex(background, tint, DIFF_TINT_ALPHA * (step / steps)); |
| 231 | if (contrastRatio(foreground, candidate) >= MIN_CODE_CONTRAST) return candidate; |
| 232 | } |
| 233 | return background; |
| 234 | } |
| 235 | |
| 236 | function flattenHex(color: string, backdrop: string): string { |
| 237 | const foreground = parseHex(color); |
| 238 | const background = parseHex(backdrop); |
| 239 | if (!foreground || !background) return "#000000"; |
| 240 | const alpha = foreground.a; |
| 241 | return rgbToHex({ |
| 242 | r: foreground.r * alpha + background.r * (1 - alpha), |
| 243 | g: foreground.g * alpha + background.g * (1 - alpha), |
| 244 | b: foreground.b * alpha + background.b * (1 - alpha), |
| 245 | a: 1, |
| 246 | }); |
| 247 | } |
| 248 | |
| 249 | function mixHex(from: string, to: string, amount: number): string { |
| 250 | const a = parseHex(from) || parseHex("#000000")!; |
| 251 | const b = parseHex(to) || parseHex("#000000")!; |
| 252 | const t = Math.min(1, Math.max(0, amount)); |
| 253 | return rgbToHex({ |
| 254 | r: a.r + (b.r - a.r) * t, |
| 255 | g: a.g + (b.g - a.g) * t, |
| 256 | b: a.b + (b.b - a.b) * t, |
| 257 | a: 1, |
| 258 | }); |
| 259 | } |
| 260 | |
| 261 | function parseHex(value: string): RGB | null { |
| 262 | const match = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(value.trim()); |
| 263 | if (!match) return null; |
| 264 | const rgb = match[1]; |
| 265 | return { |
| 266 | r: parseInt(rgb.slice(0, 2), 16), |
| 267 | g: parseInt(rgb.slice(2, 4), 16), |
| 268 | b: parseInt(rgb.slice(4, 6), 16), |
| 269 | a: match[2] ? parseInt(match[2], 16) / 255 : 1, |
| 270 | }; |
| 271 | } |
| 272 | |
| 273 | function rgbToHex(color: RGB): string { |
| 274 | const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0"); |
| 275 | return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`; |
| 276 | } |
| 277 | |
| 278 | function relativeLuminance(hex: string): number { |
| 279 | const color = parseHex(hex) || parseHex("#000000")!; |
| 280 | const channel = (value: number) => { |
| 281 | const normalized = value / 255; |
| 282 | return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; |
| 283 | }; |
| 284 | return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b); |
| 285 | } |
| 286 |