diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 4025a99d9c7a..d6dc541a3fda 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -145,7 +145,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() @@ -754,7 +754,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { @@ -857,11 +857,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { {external().url} {store.answers[external().key] === true ? "✓ Acknowledged" @@ -925,25 +921,15 @@ export function FormPrompt(props: { form: FormWithLocation }) { > - - {`${i() + 1}.`} - + {`${i() + 1}.`} - + {multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label} @@ -978,9 +964,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { {`${rows().length + 1}.`} - + {truncate(fieldLabel(item), 40)}:{" "} {acknowledged() ? "Acknowledged" : "(acknowledgement required)"} diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts index f58b2af2560d..cae67681a869 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/tui/src/theme/v2/defaults.ts @@ -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" } }, diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index fafc0ad1f422..83be1abe92d3 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -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 @@ -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)}` } diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index e7e704f7c018..00a04c5a76ee 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -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"