Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@ export const Terminal = (props: TerminalProps) => {
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const defaultSettings: Settings = {
releaseNotes: true,
followup: "steer",
showReasoningSummaries: false,
shellToolPartsExpanded: true,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
},
updates: {
Expand Down
146 changes: 61 additions & 85 deletions packages/ui/src/theme/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ export function hexToOklch(hex: HexColor): OklchColor {
return rgbToOklch(r, g, b)
}

function mix(a: OklchColor, b: OklchColor, t: number): OklchColor {
const delta = ((((b.h - a.h) % 360) + 540) % 360) - 180
return {
l: a.l + (b.l - a.l) * t,
c: a.c + (b.c - a.c) * t,
h: a.h + delta * t,
}
}

function paint(base: OklchColor, tone: OklchColor, c: number, max: number): OklchColor {
return fitOklch({
l: tone.l,
c: Math.min(max, Math.max(tone.c, base.c * c)),
h: base.h,
})
}

export function fitOklch(oklch: OklchColor): OklchColor {
const base = {
l: clamp(oklch.l, 0, 1),
Expand Down Expand Up @@ -132,87 +149,54 @@ export function oklchToHex(oklch: OklchColor): HexColor {

export function generateScale(seed: HexColor, isDark: boolean): HexColor[] {
const base = hexToOklch(seed)
const scale: HexColor[] = []

const lightSteps = isDark
? [
0.118,
0.138,
0.167,
0.202,
0.246,
0.304,
0.378,
0.468,
clamp(base.l * 0.825, 0.53, 0.705),
clamp(base.l * 0.89, 0.61, 0.79),
clamp(base.l + 0.033, 0.868, 0.943),
0.984,
]
: [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.49, 0.27]

const chromaMultipliers = isDark
? [0.52, 0.68, 0.86, 1.02, 1.14, 1.24, 1.36, 1.48, 1.56, 1.64, 1.62, 1.15]
: [0.12, 0.24, 0.46, 0.68, 0.84, 0.98, 1.08, 1.16, 1.22, 1.26, 1.18, 0.98]

for (let i = 0; i < 12; i++) {
scale.push(
oklchToHex({
l: lightSteps[i],
c: base.c * chromaMultipliers[i],
h: base.h,
}),
)
}
const tint = isDark
? [0.029, 0.064, 0.11, 0.174, 0.263, 0.382, 0.542, 0.746]
: [0.018, 0.042, 0.082, 0.146, 0.238, 0.368, 0.542, 0.764]
const shade = isDark ? [0, 0.115, 0.524, 0.871] : [0, 0.124, 0.514, 0.83]
const curve = isDark
? [0.48, 0.58, 0.69, 0.82, 0.94, 1.05, 1.16, 1.23, 1.04, 0.97, 0.82, 0.6]
: [0.24, 0.32, 0.42, 0.56, 0.72, 0.88, 1.04, 1.14, 1, 0.94, 0.82, 0.64]
const mid = fitOklch({
l: clamp(base.l + (isDark ? 0.009 : 0), isDark ? 0.61 : 0.5, isDark ? 0.75 : 0.68),
c: clamp(base.c * (isDark ? 1.04 : 1), 0, isDark ? 0.29 : 0.26),
h: base.h,
})
const bg = fitOklch({
l: isDark ? clamp(0.13 + base.c * 0.065, 0.11, 0.175) : clamp(0.995 - base.c * 0.1, 0.962, 0.995),
c: Math.min(base.c * (isDark ? 0.38 : 0.18), isDark ? 0.07 : 0.03),
h: base.h,
})
const fg = fitOklch({
l: isDark ? 0.952 : 0.24,
c: Math.min(mid.c * (isDark ? 0.55 : 0.72), isDark ? 0.13 : 0.14),
h: base.h,
})

return scale
return [
...tint.map((step, i) => oklchToHex(paint(base, mix(bg, mid, step), curve[i]!, isDark ? 0.32 : 0.28))),
...shade.map((step, i) =>
oklchToHex(paint(base, mix(mid, fg, step), curve[i + tint.length]!, isDark ? 0.32 : 0.28)),
),
]
}

export function generateNeutralScale(seed: HexColor, isDark: boolean, ink?: HexColor): HexColor[] {
if (ink) {
const base = hexToOklch(seed)
const lift = (tone: number) =>
oklchToHex({
l: base.l + (1 - base.l) * tone,
c: base.c * Math.max(0, 1 - tone),
h: base.h,
})
const sink = (tone: number) =>
oklchToHex({
l: base.l * (1 - tone),
c: base.c * Math.max(0, 1 - tone * (isDark ? 0.12 : 0.3)),
h: base.h,
})
const bg = isDark
? sink(clamp(0.19 + Math.max(0, base.l - 0.12) * 0.33 + base.c * 1.95, 0.17, 0.27))
: base.l < 0.82
? lift(0.86)
: lift(clamp(0.1 + base.c * 3.2 + Math.max(0, 0.95 - base.l) * 0.35, 0.1, 0.28))
const steps = isDark
? [0, 0.018, 0.039, 0.064, 0.097, 0.143, 0.212, 0.31, 0.46, 0.649, 0.845, 0.984]
: [0, 0.022, 0.042, 0.068, 0.102, 0.146, 0.208, 0.296, 0.432, 0.61, 0.81, 0.965]
return steps.map((step) => mixColors(bg, ink, step))
}

export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] {
const base = hexToOklch(seed)
const scale: HexColor[] = []
const neutralChroma = Math.min(base.c, isDark ? 0.068 : 0.04)

const lightSteps = isDark
? [0.138, 0.156, 0.178, 0.202, 0.232, 0.272, 0.326, 0.404, clamp(base.l * 0.83, 0.43, 0.55), 0.596, 0.719, 0.956]
: [0.991, 0.979, 0.964, 0.946, 0.931, 0.913, 0.891, 0.83, base.l, 0.617, 0.542, 0.205]

for (let i = 0; i < 12; i++) {
scale.push(
oklchToHex({
l: lightSteps[i],
c: neutralChroma,
h: base.h,
}),
)
}
const stop = isDark
? [0, 0.02, 0.046, 0.086, 0.142, 0.218, 0.322, 0.461, 0.631, 0.777, 0.889, 0.975]
: [0, 0.016, 0.036, 0.064, 0.104, 0.158, 0.23, 0.336, 0.486, 0.668, 0.822, 0.984]
const bg = fitOklch({
l: isDark ? clamp(base.l * 0.79 + base.c * 0.02, 0.09, 0.19) : clamp(base.l, 0.965, 0.995),
c: Math.min(base.c * (isDark ? 1 : 1), isDark ? 0.05 : 0.02),
h: base.h,
})
const fg = fitOklch({
l: isDark ? 0.956 : 0.18,
c: Math.min(base.c * (isDark ? 0.75 : 0.54), isDark ? 0.055 : 0.04),
h: base.h,
})

return scale
return stop.map((step) => oklchToHex(mix(bg, fg, step)))
}

export function generateAlphaScale(scale: HexColor[], isDark: boolean): HexColor[] {
Expand All @@ -234,15 +218,7 @@ export function generateAlphaScale(scale: HexColor[], isDark: boolean): HexColor
}

export function mixColors(color1: HexColor, color2: HexColor, amount: number): HexColor {
const c1 = hexToOklch(color1)
const c2 = hexToOklch(color2)
const delta = ((((c2.h - c1.h) % 360) + 540) % 360) - 180

return oklchToHex({
l: c1.l + (c2.l - c1.l) * amount,
c: c1.c + (c2.c - c1.c) * amount,
h: c1.h + delta * amount,
})
return oklchToHex(mix(hexToOklch(color1), hexToOklch(color2), amount))
}

export function shift(color: HexColor, value: { l?: number; c?: number; h?: number }): HexColor {
Expand Down
68 changes: 8 additions & 60 deletions packages/ui/src/theme/desktop-theme.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
},
"ThemeSeedColors": {
"type": "object",
"description": "The legacy semantic seed set used to generate a theme",
"description": "The semantic seed set used to generate a theme",
"additionalProperties": false,
"required": ["neutral", "primary", "success", "warning", "error", "info", "interactive", "diffAdd", "diffDelete"],
"required": ["neutral", "primary", "success", "warning", "error", "info"],
"properties": {
"neutral": {
"$ref": "#/definitions/HexColor",
Expand All @@ -69,65 +69,17 @@
"$ref": "#/definitions/HexColor",
"description": "Informational state color (typically purple/blue)"
},
"interactive": {
"$ref": "#/definitions/HexColor",
"description": "Interactive element color (links, buttons)"
},
"diffAdd": {
"$ref": "#/definitions/HexColor",
"description": "Color for diff additions"
},
"diffDelete": {
"$ref": "#/definitions/HexColor",
"description": "Color for diff deletions"
}
}
},
"ThemePaletteColors": {
"type": "object",
"description": "A compact semantic palette used to derive the full theme programmatically",
"additionalProperties": false,
"required": ["neutral", "ink", "primary", "success", "warning", "error", "info"],
"properties": {
"neutral": {
"$ref": "#/definitions/HexColor",
"description": "Base neutral color for generating the gray scale"
},
"ink": {
"$ref": "#/definitions/HexColor",
"description": "Foreground or chrome color used to derive text and border tones"
},
"primary": {
"$ref": "#/definitions/HexColor",
"description": "Primary brand color used for brand surfaces and strong emphasis"
},
"success": {
"$ref": "#/definitions/HexColor",
"description": "Success state color"
},
"warning": {
"$ref": "#/definitions/HexColor",
"description": "Warning state color"
},
"error": {
"$ref": "#/definitions/HexColor",
"description": "Error or critical state color"
},
"info": {
"$ref": "#/definitions/HexColor",
"description": "Informational state color"
},
"accent": {
"$ref": "#/definitions/HexColor",
"description": "Optional extra expressive accent for syntax and rich content"
"description": "Optional expressive accent seed used for syntax and prose emphasis"
},
"interactive": {
"$ref": "#/definitions/HexColor",
"description": "Optional dedicated interactive color; falls back to primary"
"description": "Optional interactive element seed; falls back to primary"
},
"diffAdd": {
"$ref": "#/definitions/HexColor",
"description": "Optional diff-add seed; falls back to a softened success color"
"description": "Optional diff-add seed; falls back to success"
},
"diffDelete": {
"$ref": "#/definitions/HexColor",
Expand All @@ -137,16 +89,12 @@
},
"ThemeVariant": {
"type": "object",
"description": "A theme variant (light or dark) with either a compact palette or legacy seeds and optional overrides",
"oneOf": [{ "required": ["seeds"] }, { "required": ["palette"] }],
"description": "A theme variant generated from seed colors with optional token overrides",
"required": ["seeds"],
"properties": {
"seeds": {
"$ref": "#/definitions/ThemeSeedColors",
"description": "Legacy seed colors used to generate the full palette"
},
"palette": {
"$ref": "#/definitions/ThemePaletteColors",
"description": "Compact palette used to derive the full token set"
"description": "Seed colors used to generate the full token set"
},
"overrides": {
"type": "object",
Expand Down
1 change: 0 additions & 1 deletion packages/ui/src/theme/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export type {
DesktopTheme,
ThemePaletteColors,
ThemeSeedColors,
ThemeVariant,
HexColor,
Expand Down
70 changes: 70 additions & 0 deletions packages/ui/src/theme/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test"
import type { HexColor, ThemeVariant } from "./types"
import { generateNeutralScale, generateScale, hexToOklch } from "./color"
import { DEFAULT_THEMES } from "./default-themes"
import { resolveThemeVariant } from "./resolve"

function dist(a: HexColor, b: HexColor) {
const x = hexToOklch(a)
const y = hexToOklch(b)
const hue = Math.abs(((((y.h - x.h) % 360) + 540) % 360) - 180) / 360
return Math.abs(x.l - y.l) + Math.abs(x.c - y.c) + hue
}

describe("theme resolve", () => {
test("resolves every bundled theme from seeds", () => {
for (const theme of Object.values(DEFAULT_THEMES)) {
const light = resolveThemeVariant(theme.light, false)
const dark = resolveThemeVariant(theme.dark, true)

expect(light["background-base"]).toStartWith("#")
expect(light["text-base"]).toBeTruthy()
expect(light["surface-brand-base"]).toStartWith("#")
expect(dark["background-base"]).toStartWith("#")
expect(dark["text-base"]).toBeTruthy()
expect(dark["surface-brand-base"]).toStartWith("#")
}
})

test("applies token overrides after generation", () => {
const variant: ThemeVariant = {
seeds: {
neutral: "#f4f4f5",
primary: "#3b7dd8",
success: "#3d9a57",
warning: "#d68c27",
error: "#d1383d",
info: "#318795",
},
overrides: {
"text-base": "#111111",
},
}
const tokens = resolveThemeVariant(variant, false)

expect(tokens["text-base"]).toBe("#111111")
expect(tokens["markdown-text"]).toBe("#111111")
expect(tokens["text-stronger"]).toBe(tokens["text-strong"])
})

test("keeps accent scales centered on step 9", () => {
const seed = "#3b7dd8" as HexColor
const light = generateScale(seed, false)
const dark = generateScale(seed, true)

expect(dist(light[8], seed)).toBeLessThan(dist(light[7], seed))
expect(dist(light[8], seed)).toBeLessThan(dist(light[10], seed))
expect(dist(dark[8], seed)).toBeLessThan(dist(dark[7], seed))
expect(dist(dark[8], seed)).toBeLessThan(dist(dark[10], seed))
})

test("keeps neutral scales monotonic", () => {
const light = generateNeutralScale("#f7f7f7", false).map((hex) => hexToOklch(hex).l)
const dark = generateNeutralScale("#1f1f1f", true).map((hex) => hexToOklch(hex).l)

for (let i = 1; i < light.length; i++) {
expect(light[i - 1]).toBeGreaterThanOrEqual(light[i])
expect(dark[i - 1]).toBeLessThanOrEqual(dark[i])
}
})
})
Loading
Loading