| ... | ... | @@ -475,12 +475,6 @@ type ThemeRgbColor = { |
| 475 | 475 | b: number; |
| 476 | 476 | }; |
| 477 | 477 | |
| 478 | | type ThemeHslColor = { |
| 479 | | h: number; |
| 480 | | s: number; |
| 481 | | l: number; |
| 482 | | }; |
| 483 | | |
| 484 | 478 | type ThemeOklch = { L: number; C: number; h: number }; |
| 485 | 479 | type ParsedThemeColor = { color: ThemeOklch; alpha: number }; |
| 486 | 480 | |
| ... | ... | @@ -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 | 598 | function mixThemeRgbColors( |
| 647 | 599 | base: ThemeRgbColor, |
| 648 | 600 | overlay: ThemeRgbColor, |
| ... | ... | @@ -1046,205 +998,6 @@ function standardMutedThemeText( |
| 1046 | 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 | 1001 | /** Theme-file defaults follow the flagship palette for the requested mode. */ |
| 1249 | 1002 | export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { |
| 1250 | 1003 | return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; |