返回 DeepSeek-Reasonix
codeReadability.ts
根目录 / desktop / frontend / src / lib / codeReadability.ts
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
286 lines TYPESCRIPT