fix(tui): soften theme scale extremes (#37555)

This commit is contained in:
James Long
2026-07-20 09:20:20 -04:00
committed by GitHub
parent a9895a1e8a
commit 6702ce0d3f
4 changed files with 98 additions and 100 deletions
+9 -27
View File
@@ -44,7 +44,7 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const { themeV2 } = useTheme().contextual("elevated")
const { themeV2, mode: themeMode } = useTheme().contextual("elevated")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
@@ -626,7 +626,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={themeV2.hue.accent(500)}
borderColor={themeV2.hue.interactive(themeMode() === "light" ? 800 : 200)}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
@@ -729,11 +729,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{external().url}
</text>
<text
fg={
store.answers[external().key] === true
? themeV2.text.feedback.success()
: themeV2.text.subdued()
}
fg={store.answers[external().key] === true ? themeV2.text.feedback.success() : themeV2.text.subdued()}
>
{store.answers[external().key] === true
? "✓ Acknowledged"
@@ -799,25 +795,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={
active() ? themeV2.background.formfield("focused") : themeV2.background()
}
backgroundColor={active() ? themeV2.background.formfield("focused") : themeV2.background()}
paddingRight={1}
>
<text fg={themeV2.text.formfield(active() ? "focused" : "default")}>
{`${i() + 1}.`}
</text>
<text fg={themeV2.text.formfield(active() ? "focused" : "default")}>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={
active() ? themeV2.background.formfield("focused") : themeV2.background()
}
backgroundColor={active() ? themeV2.background.formfield("focused") : themeV2.background()}
>
<text
fg={themeV2.text.formfield(
active() ? "focused" : picked() ? "selected" : "default",
)}
>
<text fg={themeV2.text.formfield(active() ? "focused" : picked() ? "selected" : "default")}>
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
</text>
</box>
@@ -852,9 +838,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{`${rows().length + 1}.`}
</text>
</box>
<box
backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}
>
<box backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}>
<text
fg={
other()
@@ -926,9 +910,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<span style={{ fg: themeV2.text.subdued() }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? themeV2.text.feedback.success()
: themeV2.text.feedback.error(),
fg: acknowledged() ? themeV2.text.feedback.success() : themeV2.text.feedback.error(),
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
+19 -19
View File
@@ -97,18 +97,18 @@ export const DEFAULT_THEME = {
neutral: "$hue.gray",
},
text: {
default: "$hue.neutral.900",
default: "$hue.neutral.800",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900",
default: "$hue.neutral.800",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.600",
$selected: "$hue.interactive.700",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
@@ -118,10 +118,10 @@ export const DEFAULT_THEME = {
},
},
background: {
default: "$hue.neutral.100",
default: "$hue.neutral.200",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
offset: "$hue.neutral.300",
overlay: "$hue.neutral.400",
},
action: {
primary: {
@@ -309,16 +309,16 @@ export const DEFAULT_THEME = {
neutral: "$hue.gray",
},
text: {
default: "$hue.neutral.100",
default: "$hue.neutral.200",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
primary: { default: "$hue.neutral.200", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.200", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100",
default: "$hue.neutral.200",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$pressed: "$hue.neutral.200",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
@@ -330,10 +330,10 @@ export const DEFAULT_THEME = {
},
},
background: {
default: "$hue.neutral.900",
default: "$hue.neutral.800",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
offset: "$hue.neutral.700",
overlay: "$hue.neutral.600",
},
action: {
primary: {
@@ -412,14 +412,14 @@ export const DEFAULT_THEME = {
codeBlock: "$hue.neutral.100",
},
"@context:elevated": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.400", $hovered: "$background.surface.overlay" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.900" } } },
text: { action: { primary: { default: "$hue.neutral.200" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.400" } },
+29 -18
View File
@@ -26,12 +26,12 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
const hues = inferHues(theme, mode)
const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
const text = mode === "light" ? "$hue.neutral.800" : "$hue.neutral.200"
const textMuted = mode === "light" ? "$hue.neutral.600" : "$hue.neutral.400"
const primary = mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200"
const background = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundPanel = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
const backgroundMenu = mode === "light" ? "$hue.neutral.400" : "$hue.neutral.600"
return {
hue: {
@@ -233,13 +233,13 @@ function selectedForeground(theme: Theme, background: RGBA) {
function hueScale(color: RGBA, mode: "light" | "dark") {
const value = toOklch(color)
const anchor = mode === "light" ? 900 : 100
const anchor = mode === "light" ? 800 : 200
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
const alpha = color.toInts()[3]
return Object.fromEntries(
HueStep.literals.map((step) => {
if (step === anchor) return [step, hex(color)]
const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
const progress = mode === "light" ? (anchor - step) / (anchor - 100) : (step - anchor) / (900 - anchor)
const generated = oklchToHex({
l: value.l + (endpoint - value.l) * progress,
c: value.c * (1 - progress * 0.5),
@@ -256,8 +256,14 @@ function neutralScale(theme: Theme, mode: "light" | "dark") {
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
if (exact) return [step, hex(exact.color)]
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
const upper = anchors.find((anchor) => anchor.step > step)!
const first = anchors[0]!
const last = anchors.at(-1)!
const [lower, upper] =
step < first.step
? [first, anchors[1]!]
: step > last.step
? [anchors.at(-2)!, last]
: [anchors.filter((anchor) => anchor.step < step).at(-1)!, anchors.find((anchor) => anchor.step > step)!]
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
}),
) as Record<HueStep, string>
@@ -265,11 +271,11 @@ function neutralScale(theme: Theme, mode: "light" | "dark") {
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: theme.background },
{ step: 200, color: theme.backgroundPanel },
{ step: 300, color: theme.backgroundElement || theme.backgroundMenu },
{ step: 700, color: theme.textMuted },
{ step: 900, color: theme.text },
{ step: 200, color: theme.background },
{ step: 300, color: theme.backgroundPanel },
{ step: 400, color: theme.backgroundElement || theme.backgroundMenu },
{ step: 600, color: theme.textMuted },
{ step: 800, color: theme.text },
]
if (mode === "light") return light
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
@@ -278,13 +284,18 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
function interpolate(first: RGBA, second: RGBA, amount: number) {
const start = toOklch(first)
const end = toOklch(second)
const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
const startHue = Number.isFinite(start.h) ? start.h : Number.isFinite(end.h) ? end.h : 0
const endHue = Number.isFinite(end.h) ? end.h : startHue
const hue = ((((endHue - startHue) % 360) + 540) % 360) - 180
const generated = oklchToHex({
l: start.l + (end.l - start.l) * amount,
c: start.c + (end.c - start.c) * amount,
h: start.h + hue * amount,
h: startHue + hue * amount,
})
const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
const alpha = Math.max(
0,
Math.min(255, Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)),
)
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
}
+41 -36
View File
@@ -14,44 +14,38 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
throw new Error("Expected concrete accent and interactive scales")
}
expect(migrated.light.hue.accent[900]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[900]).toBe(hex(legacy.primary))
expect(migrated.light.text?.default).toBe("$hue.neutral.900")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.700")
expect(migrated.light.hue.accent[800]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[800]).toBe(hex(legacy.primary))
expect(migrated.light.text?.default).toBe("$hue.neutral.800")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
expect(migrated.light.background?.default).toBe("$hue.neutral.100")
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.200")
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.300")
expect(migrated.dark.background?.default).toBe("$hue.neutral.900")
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
expect(migrated.light.background?.default).toBe("$hue.neutral.200")
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.300")
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.400")
expect(migrated.dark.background?.default).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.900")
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.800")
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[800].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
legacy.text.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
})
@@ -71,8 +65,10 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
const darkRed = migrated.dark.hue?.red
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
expect(lightRed[900]).toBe("#ff6666")
expect(darkRed[100]).toBe("#450000")
expect(lightRed[800]).toBe("#ff6666")
expect(darkRed[200]).toBe("#450000")
expect(lightRed[900]).not.toBe(lightRed[800])
expect(darkRed[100]).not.toBe(darkRed[200])
expect(migrated.light.hue?.orange).toBe("$hue.gray")
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
expect(migrated.light.hue?.green).toBe("$hue.gray")
@@ -85,8 +81,13 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
})
test("builds gray from V1 surfaces and text without using menus or borders", () => {
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = { light: "#eeeeee", dark: "#111111" }
source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
source.theme.textMuted = { light: "#777777", dark: "#999999" }
source.theme.text = { light: "#333333", dark: "#dddddd" }
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
const light = resolveV1(source, "light")
const dark = resolveV1(source, "dark")
@@ -95,16 +96,20 @@ test("builds gray from V1 surfaces and text without using menus or borders", ()
const darkGray = migrated.dark.hue?.gray
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
expect(lightGray[100]).toBe(hex(light.background))
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
expect(lightGray[300]).toBe(hex(light.backgroundElement))
expect(lightGray[700]).toBe(hex(light.textMuted))
expect(lightGray[900]).toBe(hex(light.text))
expect(darkGray[100]).toBe(hex(dark.text))
expect(darkGray[300]).toBe(hex(dark.textMuted))
expect(darkGray[700]).toBe(hex(dark.backgroundElement))
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
expect(darkGray[900]).toBe(hex(dark.background))
expect(lightGray[100]).not.toBe(lightGray[200])
expect(lightGray[200]).toBe(hex(light.background))
expect(lightGray[300]).toBe(hex(light.backgroundPanel))
expect(lightGray[400]).toBe(hex(light.backgroundElement))
expect(lightGray[600]).toBe(hex(light.textMuted))
expect(lightGray[800]).toBe(hex(light.text))
expect(lightGray[900]).not.toBe(lightGray[800])
expect(darkGray[100]).not.toBe(darkGray[200])
expect(darkGray[200]).toBe(hex(dark.text))
expect(darkGray[400]).toBe(hex(dark.textMuted))
expect(darkGray[600]).toBe(hex(dark.backgroundElement))
expect(darkGray[700]).toBe(hex(dark.backgroundPanel))
expect(darkGray[800]).toBe(hex(dark.background))
expect(darkGray[900]).not.toBe(darkGray[800])
source.theme.borderSubtle = "#ff00ff"
source.theme.border = "#00ff00"