Procházet zdrojové kódy

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

James Long před 4 týdny
rodič
revize
6702ce0d3f

+ 9 - 27
packages/tui/src/routes/session/form.tsx

@@ -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
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" } },

+ 29 - 18
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<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
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"