| ... | @@ -475,12 +475,6 @@ type ThemeRgbColor = { | ... | @@ -475,12 +475,6 @@ type ThemeRgbColor = { |
| 475 | b: number; | 475 | b: number; |
| 476 | }; | 476 | }; |
| 477 | | 477 | |
| 478 | type ThemeHslColor = { | | |
| 479 | h: number; | | |
| 480 | s: number; | | |
| 481 | l: number; | | |
| 482 | }; | | |
| 483 | | | |
| 484 | type ThemeOklch = { L: number; C: number; h: number }; | 478 | type ThemeOklch = { L: number; C: number; h: number }; |
| 485 | type ParsedThemeColor = { color: ThemeOklch; alpha: number }; | 479 | type ParsedThemeColor = { color: ThemeOklch; alpha: number }; |
| 486 | | 480 | |
| ... | @@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { | ... | @@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { |
| 601 | }; | 595 | }; |
| 602 | } | 596 | } |
| 603 | | 597 | |
| 604 | function 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 | | | |
| 624 | function 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 | | | |
| 646 | function mixThemeRgbColors( | 598 | function 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 | } |
| 1048 | | 1000 | |
| 1049 | function 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 | | | |
| 1067 | function 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 | */ | | |
| 1106 | export 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. */ |
| 1249 | export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { | 1002 | export 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; |