| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | createVividThemeColors, |
| 3 | getStandardThemeColors, |
| 3 | 4 | getThemeModes, |
| 4 | 5 | isReservedThemeId, |
| 5 | 6 | parseThemeFile, |
| ... | ... | @@ -16,10 +17,10 @@ import { |
| 16 | 17 | * |
| 17 | 18 | * VS Code themes describe editor chrome, not an app palette: they carry a few |
| 18 | 19 | * hundred workbench keys, leave most of them unset, and freely use 8-digit |
| 19 | | * hex with alpha for overlays. So the conversion derives a complete, contrast |
| 20 | | * -solved palette from the theme's editor background and accent, then layers |
| 21 | | * the workbench colors it did specify on top. Anything the file omits keeps |
| 22 | | * the derived value instead of falling back to an unrelated palette. |
| 20 | * hex with alpha for overlays. The conversion derives a complete palette from |
| 21 | * a visible accent and the editor background, then layers usable workbench |
| 22 | * colors on top. Foregrounds must remain readable, while authored focus and |
| 23 | * control candidates must remain distinct from adjacent surfaces and states. |
| 23 | 24 | */ |
| 24 | 25 | |
| 25 | 26 | type VsCodeRgba = { r: number; g: number; b: number; a: number }; |
| ... | ... | @@ -212,26 +213,61 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 212 | 213 | const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; |
| 213 | 214 | const appearance = resolveAppearance(value, canvas); |
| 214 | 215 | |
| 215 | | const accentColor = pick( |
| 216 | const canvasHex = toHex(canvas); |
| 217 | const raisedSurfaceCandidateHex = solidOver( |
| 218 | canvas, |
| 219 | "editorWidget.background", |
| 220 | "dropdown.background", |
| 221 | ); |
| 222 | |
| 223 | /** Accent and control candidates must clear this small separation floor |
| 224 | * from every adjacent surface or state checked by the importer. */ |
| 225 | const standsApart = (first: string, second: string) => |
| 226 | contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1; |
| 227 | |
| 228 | let accentColor: VsCodeRgba | null = null; |
| 229 | let accentHex: string | null = null; |
| 230 | for (const key of [ |
| 216 | 231 | "focusBorder", |
| 217 | 232 | "button.background", |
| 218 | 233 | "textLink.foreground", |
| 219 | 234 | "activityBarBadge.background", |
| 220 | 235 | "progressBar.background", |
| 221 | 236 | "badge.background", |
| 222 | | ); |
| 223 | | const canvasHex = toHex(canvas); |
| 224 | | const accentHex = accentColor ? flattenOver(accentColor, canvas) : null; |
| 237 | ]) { |
| 238 | const candidate = parseVsCodeColor(colors[key]); |
| 239 | if (!candidate) continue; |
| 240 | const candidateHex = flattenOver(candidate, canvas); |
| 241 | if ( |
| 242 | !standsApart(candidateHex, canvasHex) || |
| 243 | (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex)) |
| 244 | ) |
| 245 | continue; |
| 246 | accentColor = candidate; |
| 247 | accentHex = candidateHex; |
| 248 | break; |
| 249 | } |
| 250 | if (!accentColor || !accentHex) { |
| 251 | const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!; |
| 252 | accentHex = |
| 253 | [standardAccentHex, "#ffffff", "#000000"].find( |
| 254 | (candidate) => |
| 255 | standsApart(candidate, canvasHex) && |
| 256 | (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)), |
| 257 | ) ?? standardAccentHex; |
| 258 | accentColor = parseVsCodeColor(accentHex)!; |
| 259 | } |
| 225 | 260 | |
| 226 | 261 | // The derived palette is the floor: every role starts contrast-solved, then |
| 227 | 262 | // the theme's own workbench colors replace what it actually specified. The |
| 228 | 263 | // floor derives from a muted accent -- the vivid engine carries the accent |
| 229 | 264 | // hue into every surface, which washes an imported neutral palette (a gray |
| 230 | 265 | // theme with a blue focusBorder would get blue code and text surfaces). |
| 231 | | const mutedAccentHex = accentColor |
| 232 | | ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas) |
| 233 | | : null; |
| 234 | | const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex); |
| 266 | const mutedAccentHex = flattenOver( |
| 267 | { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, |
| 268 | canvas, |
| 269 | ); |
| 270 | const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex); |
| 235 | 271 | const sidebarHex = |
| 236 | 272 | solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; |
| 237 | 273 | const sidebar = hexToRgb(sidebarHex); |
| ... | ... | @@ -258,6 +294,34 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 258 | 294 | return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; |
| 259 | 295 | }; |
| 260 | 296 | |
| 297 | const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised; |
| 298 | // The checked switch track maps to messageAction, so resolve it before |
| 299 | // choosing the input role used by the unchecked track. |
| 300 | const buttonHex = solidOver(canvas, "button.background"); |
| 301 | const actionHex = |
| 302 | buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex) |
| 303 | ? buttonHex |
| 304 | : accentHex; |
| 305 | const inputCandidates = [ |
| 306 | derived.input, |
| 307 | derived.surfaceRaised, |
| 308 | getStandardThemeColors(appearance).input, |
| 309 | "#000000", |
| 310 | "#ffffff", |
| 311 | "#808080", |
| 312 | ]; |
| 313 | let inputHex = |
| 314 | inputCandidates.find( |
| 315 | (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex), |
| 316 | ) ?? "#808080"; |
| 317 | for (const key of ["input.background", "input.border"]) { |
| 318 | const candidate = solidOver(canvas, key); |
| 319 | if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) { |
| 320 | inputHex = candidate; |
| 321 | break; |
| 322 | } |
| 323 | } |
| 324 | |
| 261 | 325 | const overrides: Partial<Record<ThemeColorRole, string>> = { |
| 262 | 326 | canvas: canvasHex, |
| 263 | 327 | text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), |
| ... | ... | @@ -268,15 +332,14 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 268 | 332 | "disabledForeground", |
| 269 | 333 | ), |
| 270 | 334 | surface: solidOver(canvas, "editorWidget.background") ?? derived.surface, |
| 271 | | surfaceRaised: |
| 272 | | solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised, |
| 335 | surfaceRaised: surfaceRaisedHex, |
| 273 | 336 | surfaceOverlay: |
| 274 | 337 | solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? |
| 275 | 338 | derived.surfaceOverlay, |
| 276 | 339 | border: |
| 277 | 340 | solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border, |
| 278 | | input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input, |
| 279 | | placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"), |
| 341 | input: inputHex, |
| 342 | placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"), |
| 280 | 343 | error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"), |
| 281 | 344 | warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"), |
| 282 | 345 | accentSurface: |
| ... | ... | @@ -303,23 +366,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 303 | 366 | terminalScrollbar: |
| 304 | 367 | solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, |
| 305 | 368 | }; |
| 306 | | if (accentHex) { |
| 307 | | overrides.accent = accentHex; |
| 308 | | overrides.focus = accentHex; |
| 309 | | // The button pair is the closest thing VS Code has to our action color. |
| 310 | | const actionHex = solidOver(canvas, "button.background") ?? accentHex; |
| 311 | | overrides.messageAction = actionHex; |
| 312 | | overrides.messageActionForeground = readableOn( |
| 313 | | actionHex, |
| 314 | | derived.messageActionForeground, |
| 315 | | "button.foreground", |
| 316 | | ); |
| 317 | | overrides.accentForeground = readableOn( |
| 318 | | accentHex, |
| 319 | | derived.accentForeground, |
| 320 | | "button.foreground", |
| 321 | | ); |
| 322 | | } |
| 369 | overrides.accent = accentHex; |
| 370 | overrides.focus = accentHex; |
| 371 | overrides.messageAction = actionHex; |
| 372 | overrides.messageActionForeground = readableOn( |
| 373 | actionHex, |
| 374 | derived.messageActionForeground, |
| 375 | "button.foreground", |
| 376 | ); |
| 377 | overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground"); |
| 323 | 378 | |
| 324 | 379 | // Reuse the theme-file parser so ids, names, and color values go through the |
| 325 | 380 | // same validation as a hand-written file. |