authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 01:49:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 01:49:44-07:00
log80f775a42c448290c102e572fc889e4e4a1dd546
tree858ddc2b0a52d9cd684d37ec16e45bb5c8a2fd05
parent5b7f6bcfbcd5f150a67f323e0ec5aeb70b3c4009
signature Signed by PGP key B5690EEEBB952194

refactor(web): remove obsolete HSL theme generator (#10061)


2 files changed, 19 insertions(+), 294 deletions(-)

apps/web/src/themePalette.test.ts+19-47
...@@ -34,7 +34,6 @@ import {...@@ -34,7 +34,6 @@ import {
34 OCEAN_THEME,34 OCEAN_THEME,
35 updateCustomTheme,35 updateCustomTheme,
36 CUSTOM_THEMES_STORAGE_KEY,36 CUSTOM_THEMES_STORAGE_KEY,
37 createManagedThemeColors,
38 createVividThemeColors,37 createVividThemeColors,
39 getDefaultThemeColors,38 getDefaultThemeColors,
40 themeColorToHex,39 themeColorToHex,
...@@ -91,50 +90,6 @@ describe("theme files", () => {...@@ -91,50 +90,6 @@ describe("theme files", () => {
91 }90 }
92 });91 });
9392
94 it("derives a readable palette from extreme simple-editor colors", () => {
95 const light = createManagedThemeColors("light", "#111827", "#ffff00");
96 const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00");
97 const darkDefaults = getDefaultThemeColors("dark");
98
99 expect(asHex(light.canvas)).not.toBe("#111827");
100 expect(asHex(dark.canvas)).not.toBe("#ffffff");
101 expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5);
102 expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5);
103 expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5);
104 expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5);
105 expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5);
106 expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5);
107 expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1);
108 expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1);
109 expect(light.secondaryLabel).toBe(light.textMuted);
110 expect(dark.secondaryLabel).toBe(dark.textMuted);
111 expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5);
112 expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5);
113 // Status colors fall back to T3 Code's standard red and amber rather than
114 // the flagship palette's, so no generated theme inherits a brand tint.
115 const channels = (value: string) =>
116 [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [
117 number,
118 number,
119 number,
120 ];
121 for (const colors of [light, dark]) {
122 const [errorRed, errorGreen, errorBlue] = channels(colors.error);
123 // Red leads by a wide margin; the old default was a pink whose blue sat
124 // close behind its red.
125 expect(errorRed).toBeGreaterThan(errorGreen * 2);
126 expect(errorRed).toBeGreaterThan(errorBlue * 2);
127 expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5);
128 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
129 4.5,
130 );
131 const [warnRed, warnGreen, warnBlue] = channels(colors.warning);
132 expect(warnRed).toBeGreaterThan(warnBlue);
133 expect(warnGreen).toBeGreaterThan(warnBlue);
134 }
135 expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error));
136 });
137
138 it("keeps stock dark controls in the neutral-black surface hierarchy", () => {93 it("keeps stock dark controls in the neutral-black surface hierarchy", () => {
139 expectThemeColors(getStandardThemeColors("dark"), {94 expectThemeColors(getStandardThemeColors("dark"), {
140 canvas: "#0a0a0a",95 canvas: "#0a0a0a",
...@@ -158,6 +113,12 @@ describe("theme files", () => {...@@ -158,6 +113,12 @@ describe("theme files", () => {
158 ["light", "#111827", "#8ab4f8"],113 ["light", "#111827", "#8ab4f8"],
159 ["dark", "#f5ecf5", "#a84370"],114 ["dark", "#f5ecf5", "#a84370"],
160 ];115 ];
116 const channels = (value: string) =>
117 [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [
118 number,
119 number,
120 number,
121 ];
161 for (const [appearance, canvas, accent] of seeds) {122 for (const [appearance, canvas, accent] of seeds) {
162 const colors = createVividThemeColors(appearance, canvas, accent);123 const colors = createVividThemeColors(appearance, canvas, accent);
163 // Exact seeds are honored.124 // Exact seeds are honored.
...@@ -193,6 +154,17 @@ describe("theme files", () => {...@@ -193,6 +154,17 @@ describe("theme files", () => {
193 expect(colors.messageAction).not.toBe(colors.accent);154 expect(colors.messageAction).not.toBe(colors.accent);
194 // Update family follows the theme, not the default palette.155 // Update family follows the theme, not the default palette.
195 expect(asHex(colors.update)).toBe(accent);156 expect(asHex(colors.update)).toBe(accent);
157 // Semantic statuses stay red and amber instead of inheriting a brand tint.
158 const [errorRed, errorGreen, errorBlue] = channels(colors.error);
159 expect(errorRed).toBeGreaterThan(errorGreen * 2);
160 expect(errorRed).toBeGreaterThan(errorBlue * 2);
161 expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5);
162 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
163 4.5,
164 );
165 const [warnRed, warnGreen, warnBlue] = channels(colors.warning);
166 expect(warnRed).toBeGreaterThan(warnBlue);
167 expect(warnGreen).toBeGreaterThan(warnBlue);
196 }168 }
197 });169 });
198170
...@@ -202,8 +174,8 @@ describe("theme files", () => {...@@ -202,8 +174,8 @@ describe("theme files", () => {
202 const inverted = [174 const inverted = [
203 createVividThemeColors("light", "#111827", "#8ab4f8"),175 createVividThemeColors("light", "#111827", "#8ab4f8"),
204 createVividThemeColors("dark", "#f5ecf5", "#a84370"),176 createVividThemeColors("dark", "#f5ecf5", "#a84370"),
205 createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }),177 createVividThemeColors("light", "#0d1117", "#69b1ff"),
206 createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }),178 createVividThemeColors("dark", "#fdfdfd", "#c2571b"),
207 ];179 ];
208 for (const colors of inverted) {180 for (const colors of inverted) {
209 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(181 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
apps/web/src/themePalette.ts-247
...@@ -475,12 +475,6 @@ type ThemeRgbColor = {...@@ -475,12 +475,6 @@ type ThemeRgbColor = {
475 b: number;475 b: number;
476};476};
477477
478type ThemeHslColor = {
479 h: number;
480 s: number;
481 l: number;
482};
483
484type ThemeOklch = { L: number; C: number; h: number };478type ThemeOklch = { L: number; C: number; h: number };
485type ParsedThemeColor = { color: ThemeOklch; alpha: number };479type ParsedThemeColor = { color: ThemeOklch; alpha: number };
486480
...@@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition {...@@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition {
601 };595 };
602}596}
603597
604function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor {
605 const red = color.r / 255;
606 const green = color.g / 255;
607 const blue = color.b / 255;
608 const max = Math.max(red, green, blue);
609 const min = Math.min(red, green, blue);
610 const delta = max - min;
611 const lightness = (max + min) / 2;
612
613 if (delta === 0) return { h: 0, s: 0, l: lightness };
614
615 const saturation = delta / (1 - Math.abs(2 * lightness - 1));
616 let hue = 0;
617 if (max === red) hue = ((green - blue) / delta) % 6;
618 else if (max === green) hue = (blue - red) / delta + 2;
619 else hue = (red - green) / delta + 4;
620
621 return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness };
622}
623
624function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor {
625 const hue = ((color.h % 360) + 360) % 360;
626 const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s;
627 const hueSector = hue / 60;
628 const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1));
629 const match = color.l - chroma / 2;
630 const [red, green, blue] =
631 hueSector < 1
632 ? [chroma, secondary, 0]
633 : hueSector < 2
634 ? [secondary, chroma, 0]
635 : hueSector < 3
636 ? [0, chroma, secondary]
637 : hueSector < 4
638 ? [0, secondary, chroma]
639 : hueSector < 5
640 ? [secondary, 0, chroma]
641 : [chroma, 0, secondary];
642
643 return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 };
644}
645
646function mixThemeRgbColors(598function mixThemeRgbColors(
647 base: ThemeRgbColor,599 base: ThemeRgbColor,
648 overlay: ThemeRgbColor,600 overlay: ThemeRgbColor,
...@@ -1046,205 +998,6 @@ function standardMutedThemeText(...@@ -1046,205 +998,6 @@ function standardMutedThemeText(
1046 return readableThemeText(background, foreground, 1, target);998 return readableThemeText(background, foreground, 1, target);
1047}999}
10481000
1049function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor {
1050 const selected = parseThemeRgbColor(
1051 value,
1052 appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 },
1053 );
1054 const hsl = themeRgbToHsl(selected);
1055 return themeHslToRgb({
1056 h: hsl.h,
1057 // A background tint should support the selected mode, not turn the whole
1058 // app into a high-saturation surface.
1059 s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2),
1060 l:
1061 appearance === "dark"
1062 ? Math.min(0.13, Math.max(0.07, hsl.l))
1063 : Math.min(0.985, Math.max(0.94, hsl.l)),
1064 });
1065}
1066
1067function managedThemeAccent(
1068 value: string,
1069 appearance: ThemeAppearance,
1070 background: ThemeRgbColor,
1071): ThemeRgbColor {
1072 const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 });
1073 const hsl = themeRgbToHsl(selected);
1074 const preferredLightness =
1075 appearance === "dark"
1076 ? Math.min(0.72, Math.max(0.42, hsl.l))
1077 : Math.min(0.58, Math.max(0.35, hsl.l));
1078 const lightnessRange: readonly [number, number] =
1079 appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58];
1080 const saturation = Math.min(hsl.s, 0.82);
1081 const candidates = Array.from({ length: 61 }, (_, index) => {
1082 const lightness =
1083 lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1);
1084 const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness });
1085 return { color, lightness, contrast: themeContrastRatio(color, background) };
1086 });
1087 // Leave a little room for browser color conversion at render time.
1088 const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7);
1089 const pool = readableCandidates.length > 0 ? readableCandidates : candidates;
1090
1091 return pool.reduce((best, candidate) => {
1092 const distance = Math.abs(candidate.lightness - preferredLightness);
1093 const bestDistance = Math.abs(best.lightness - preferredLightness);
1094 return distance < bestDistance ||
1095 (distance === bestDistance && candidate.contrast > best.contrast)
1096 ? candidate
1097 : best;
1098 }).color;
1099}
1100
1101/**
1102 * Creates the guided palette used by the basic theme editor. The two user
1103 * colors control the mood, while dependent roles are generated together so
1104 * text, surfaces, message actions, code, and terminal UI stay coherent.
1105 */
1106export function createManagedThemeColors(
1107 appearance: ThemeAppearance,
1108 backgroundValue: string,
1109 accentValue: string,
1110 options?: {
1111 /** Use the seeds exactly as given instead of nudging them into the
1112 * readability envelope. Derived foregrounds still adapt for contrast. */
1113 exactSeeds?: boolean;
1114 },
1115): ThemeColors {
1116 const defaults = getDefaultThemeColors(appearance);
1117 const canvas = options?.exactSeeds
1118 ? parseThemeRgbColor(
1119 backgroundValue,
1120 appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 },
1121 )
1122 : managedThemeBackground(backgroundValue, appearance);
1123 const accent = options?.exactSeeds
1124 ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 })
1125 : managedThemeAccent(accentValue, appearance, canvas);
1126 const text = readableThemeForeground(canvas);
1127 const textMuted = standardMutedThemeText(canvas, text);
1128 // The top bar is part of the main panel, not a separate chrome layer: it
1129 // shares the canvas, and its controls sit on the panel's own surfaces.
1130 const chrome = canvas;
1131 const sidebar = mixThemeRgbColors(canvas, accent, 0.08);
1132 const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035);
1133 const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06);
1134 const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08);
1135 const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06);
1136 const mutedForeground = readableThemeText(muted, text, 1, 4.6);
1137 const placeholder = readableThemeText(surfaceRaised, text, 1, 4.6);
1138 const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14);
1139 const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18);
1140 const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08);
1141 const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14);
1142 const accentForeground = readableThemeForeground(accent);
1143 // Code and terminal are large surfaces: they keep the canvas hue instead of
1144 // drifting toward the foreground grey. Code sits just above the canvas —
1145 // a whisper of the text tint — and the terminal sits on the canvas itself.
1146 const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025);
1147 const terminalBackground = canvas;
1148 const messageActionHover = mixThemeRgbColors(
1149 accent,
1150 accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND
1151 ? THEME_BLACK_FOREGROUND
1152 : THEME_WHITE_FOREGROUND,
1153 0.12,
1154 );
1155
1156 // The update family follows the accent instead of inheriting the default
1157 // palette's brand color, so generated themes carry their own identity in
1158 // update pills and banners. Error and warning stay semantic defaults.
1159 const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16);
1160 const updateForeground = mixThemeRgbColors(
1161 accent,
1162 appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND,
1163 0.35,
1164 );
1165
1166 return {
1167 ...defaults,
1168 ...standardStatusColors(canvas),
1169 update: themeRgbToThemeColor(accent),
1170 updateForeground: themeRgbToThemeColor(updateForeground),
1171 updateSurface: themeRgbToThemeColor(updateSurface),
1172 canvas: themeRgbToThemeColor(canvas),
1173 chrome: themeRgbToThemeColor(chrome),
1174 toolbar: themeRgbToThemeColor(chrome),
1175 toolbarForeground: themeRgbToThemeColor(text),
1176 toolbarBorder: themeRgbToThemeColor(toolbarBorder),
1177 toolbarControl: themeRgbToThemeColor(toolbarControl),
1178 toolbarControlForeground: themeRgbToThemeColor(text),
1179 toolbarControlHover: themeRgbToThemeColor(accentSurface),
1180 surface: themeRgbToThemeColor(canvas),
1181 surfaceRaised: themeRgbToThemeColor(surfaceRaised),
1182 surfaceOverlay: themeRgbToThemeColor(surfaceOverlay),
1183 text: themeRgbToThemeColor(text),
1184 textMuted: themeRgbToThemeColor(textMuted),
1185 // Borders blend through the accent before lightening so control chrome
1186 // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1)
1187 // instead of flattening to grey.
1188 border: themeRgbToThemeColor(
1189 mixThemeRgbColors(
1190 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1),
1191 text,
1192 0.1,
1193 ),
1194 ),
1195 input: themeRgbToThemeColor(
1196 mixThemeRgbColors(
1197 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14),
1198 text,
1199 appearance === "dark" ? 0.14 : 0.13,
1200 ),
1201 ),
1202 focus: themeRgbToThemeColor(accent),
1203 accent: themeRgbToThemeColor(accent),
1204 accentForeground: themeRgbToThemeColor(accentForeground),
1205 secondary: themeRgbToThemeColor(secondary),
1206 secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)),
1207 muted: themeRgbToThemeColor(muted),
1208 mutedForeground: themeRgbToThemeColor(mutedForeground),
1209 placeholder: themeRgbToThemeColor(placeholder),
1210 secondaryLabel: themeRgbToThemeColor(textMuted),
1211 iconMuted: themeRgbToThemeColor(textMuted),
1212 accentSurface: themeRgbToThemeColor(accentSurface),
1213 accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)),
1214 messageSurface: themeRgbToThemeColor(messageSurface),
1215 messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)),
1216 messageAction: themeRgbToThemeColor(accent),
1217 messageActionForeground: themeRgbToThemeColor(accentForeground),
1218 messageActionHover: themeRgbToThemeColor(messageActionHover),
1219 codeBackground: themeRgbToThemeColor(codeBackground),
1220 codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)),
1221 sidebar: themeRgbToThemeColor(sidebar),
1222 sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)),
1223 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)),
1224 sidebarControlSurface: themeRgbToThemeColor(
1225 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08),
1226 ),
1227 sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)),
1228 sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)),
1229 sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)),
1230 sidebarBorder: themeRgbToThemeColor(
1231 mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12),
1232 ),
1233 terminalBackground: themeRgbToThemeColor(terminalBackground),
1234 terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)),
1235 terminalCursor: themeRgbToThemeColor(accent),
1236 terminalSelection: themeRgbToThemeColor(
1237 mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18),
1238 ),
1239 terminalScrollbar: themeRgbToThemeColor(
1240 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22),
1241 ),
1242 terminalScrollbarHover: themeRgbToThemeColor(
1243 mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32),
1244 ),
1245 };
1246}
1247
1248/** Theme-file defaults follow the flagship palette for the requested mode. */1001/** Theme-file defaults follow the flagship palette for the requested mode. */
1249export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {1002export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors {
1250 return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;1003 return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors;