James Long 1 месяц назад
Родитель
Сommit
9a25673c37

+ 106 - 0
packages/tui/src/theme/v2/component.ts

@@ -0,0 +1,106 @@
+import type { RGBA } from "@opentui/core"
+import type { Accessor } from "solid-js"
+import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index"
+
+export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
+  const textAction = actions((variant, state) => current().color.text.action[variant][state])
+  const backgroundAction = actions((variant, state) => current().color.background.action[variant][state])
+  const text = Object.assign(() => current().color.text.default, {
+    subdued: () => current().color.text.subdued,
+    action: textAction,
+    feedback: {
+      error: feedbackText("error"),
+      warning: feedbackText("warning"),
+      success: feedbackText("success"),
+      info: feedbackText("info"),
+    },
+  })
+  const background = Object.assign(() => current().color.background.default, {
+    action: backgroundAction,
+    feedback: {
+      error: () => current().color.background.feedback.error.default,
+      warning: () => current().color.background.feedback.warning.default,
+      success: () => current().color.background.feedback.success.default,
+      info: () => current().color.background.feedback.info.default,
+    },
+  })
+  const markdown = Object.assign(() => current().color.markdown.text, {
+    heading: () => current().color.markdown.heading,
+    link: () => current().color.markdown.link,
+    linkText: () => current().color.markdown.linkText,
+    code: () => current().color.markdown.code,
+    blockQuote: () => current().color.markdown.blockQuote,
+    emphasis: () => current().color.markdown.emphasis,
+    strong: () => current().color.markdown.strong,
+    horizontalRule: () => current().color.markdown.horizontalRule,
+    listItem: () => current().color.markdown.listItem,
+    listEnumeration: () => current().color.markdown.listEnumeration,
+    image: () => current().color.markdown.image,
+    imageText: () => current().color.markdown.imageText,
+    codeBlock: () => current().color.markdown.codeBlock,
+  })
+
+  function feedbackText(kind: "error" | "warning" | "success" | "info") {
+    return Object.assign(() => current().color.text.feedback[kind].default, {
+      subdued: () => current().color.text.feedback[kind].subdued,
+    })
+  }
+
+  return {
+    hue: () => current().hue,
+    color: {
+      text,
+      background,
+      border: () => current().color.border.default,
+      scrollbar: () => current().color.scrollbar.default,
+      diff: {
+        text: {
+          added: () => current().color.diff.text.added,
+          removed: () => current().color.diff.text.removed,
+          context: () => current().color.diff.text.context,
+          hunkHeader: () => current().color.diff.text.hunkHeader,
+        },
+        background: {
+          added: () => current().color.diff.background.added,
+          removed: () => current().color.diff.background.removed,
+          context: () => current().color.diff.background.context,
+        },
+        highlight: {
+          added: () => current().color.diff.highlight.added,
+          removed: () => current().color.diff.highlight.removed,
+        },
+        lineNumber: {
+          text: () => current().color.diff.lineNumber.text,
+          background: {
+            added: () => current().color.diff.lineNumber.background.added,
+            removed: () => current().color.diff.lineNumber.background.removed,
+          },
+        },
+      },
+      syntax: {
+        comment: () => current().color.syntax.comment,
+        keyword: () => current().color.syntax.keyword,
+        function: () => current().color.syntax.function,
+        variable: () => current().color.syntax.variable,
+        string: () => current().color.syntax.string,
+        number: () => current().color.syntax.number,
+        type: () => current().color.syntax.type,
+        operator: () => current().color.syntax.operator,
+        punctuation: () => current().color.syntax.punctuation,
+      },
+      markdown,
+    },
+  }
+}
+
+function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RGBA) {
+  const action = (variant: ActionVariant) => (state: ActionState | "default" = "default") => get(variant, state)
+  const primary = action("primary")
+  return Object.assign(primary, {
+    primary,
+    secondary: action("secondary"),
+    destructive: action("destructive"),
+  })
+}
+
+export type ComponentTheme = ReturnType<typeof createComponentTheme>

+ 391 - 0
packages/tui/src/theme/v2/defaults.ts

@@ -0,0 +1,391 @@
+import type { ThemeFile } from "./index"
+
+export const DEFAULT_THEME = {
+  version: 2,
+  light: {
+    hue: {
+      gray: {
+        100: "#f3f4f6",
+        200: "#e5e7eb",
+        300: "#d1d5db",
+        400: "#9ca3af",
+        500: "#6b7280",
+        600: "#4b5563",
+        700: "#374151",
+        800: "#1f2937",
+        900: "#111827",
+      },
+      red: {
+        100: "#fee2e2",
+        200: "#fecaca",
+        300: "#fca5a5",
+        400: "#f87171",
+        500: "#ef4444",
+        600: "#dc2626",
+        700: "#b91c1c",
+        800: "#991b1b",
+        900: "#7f1d1d",
+      },
+      orange: {
+        100: "#ffedd5",
+        200: "#fed7aa",
+        300: "#fdba74",
+        400: "#fb923c",
+        500: "#f97316",
+        600: "#ea580c",
+        700: "#c2410c",
+        800: "#9a3412",
+        900: "#7c2d12",
+      },
+      yellow: {
+        100: "#fef9c3",
+        200: "#fef08a",
+        300: "#fde047",
+        400: "#facc15",
+        500: "#eab308",
+        600: "#ca8a04",
+        700: "#a16207",
+        800: "#854d0e",
+        900: "#713f12",
+      },
+      green: {
+        100: "#dcfce7",
+        200: "#bbf7d0",
+        300: "#86efac",
+        400: "#4ade80",
+        500: "#22c55e",
+        600: "#16a34a",
+        700: "#15803d",
+        800: "#166534",
+        900: "#14532d",
+      },
+      cyan: {
+        100: "#cffafe",
+        200: "#a5f3fc",
+        300: "#67e8f9",
+        400: "#22d3ee",
+        500: "#06b6d4",
+        600: "#0891b2",
+        700: "#0e7490",
+        800: "#155e75",
+        900: "#164e63",
+      },
+      blue: {
+        100: "#dbeafe",
+        200: "#bfdbfe",
+        300: "#93c5fd",
+        400: "#60a5fa",
+        500: "#3b82f6",
+        600: "#2563eb",
+        700: "#1d4ed8",
+        800: "#1e40af",
+        900: "#1e3a8a",
+      },
+      purple: {
+        100: "#f3e8ff",
+        200: "#e9d5ff",
+        300: "#d8b4fe",
+        400: "#c084fc",
+        500: "#a855f7",
+        600: "#9333ea",
+        700: "#7e22ce",
+        800: "#6b21a8",
+        900: "#581c87",
+      },
+      accent: "$hue.blue",
+      neutral: "$hue.gray",
+    },
+    color: {
+      text: {
+        default: "$hue.neutral.900",
+        subdued: "$hue.neutral.600",
+        action: {
+          primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
+          secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
+          destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
+        },
+        feedback: {
+          error: { default: "$hue.red.700", subdued: "$hue.red.600" },
+          warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
+          success: { default: "$hue.green.700", subdued: "$hue.green.600" },
+          info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
+        },
+      },
+      background: {
+        default: "$hue.neutral.100",
+        action: {
+          primary: {
+            default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
+            $selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
+          },
+          secondary: {
+            default: "$hue.neutral.200", $hovered: "$hue.neutral.300", $pressed: "$hue.neutral.400",
+            $selected: "$hue.neutral.300", $disabled: "$hue.neutral.200",
+          },
+          destructive: {
+            default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
+            $selected: "$hue.red.700", $disabled: "$hue.neutral.300",
+          },
+        },
+        feedback: {
+          error: { default: "$color.background.default" },
+          warning: { default: "$color.background.default" },
+          success: { default: "$color.background.default" },
+          info: { default: "$color.background.default" },
+        },
+      },
+      border: { default: "$hue.neutral.300" },
+      scrollbar: { default: "$hue.neutral.400" },
+      diff: {
+        text: {
+          added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
+          hunkHeader: "$hue.purple.600",
+        },
+        background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
+        highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
+        lineNumber: {
+          text: "$hue.neutral.600",
+          background: { added: "$hue.green.200", removed: "$hue.red.200" },
+        },
+      },
+      syntax: {
+      comment: "$hue.neutral.600",
+      keyword: "$hue.purple.600",
+      function: "$hue.accent.600",
+      variable: "$hue.neutral.900",
+      string: "$hue.green.700",
+      number: "$hue.yellow.800",
+      type: "$hue.yellow.500",
+      operator: "$hue.cyan.600",
+      punctuation: "$hue.neutral.900",
+    },
+      markdown: {
+      text: "$hue.neutral.900",
+      heading: "$hue.purple.600",
+      link: "$hue.accent.600",
+      linkText: "$hue.cyan.600",
+      code: "$hue.green.700",
+      blockQuote: "$hue.neutral.600",
+      emphasis: "$hue.yellow.500",
+      strong: "$hue.neutral.900",
+      horizontalRule: "$hue.neutral.300",
+      listItem: "$hue.accent.600",
+      listEnumeration: "$hue.cyan.600",
+      image: "$hue.accent.600",
+      imageText: "$hue.cyan.600",
+      codeBlock: "$hue.neutral.900",
+      },
+    },
+    "@context:elevated": {
+      color: {
+        text: { action: { primary: { default: "$hue.neutral.100" } } },
+        background: {
+          default: "$hue.neutral.200",
+          action: { primary: { default: "$hue.accent.500" } },
+        },
+      },
+    },
+    "@context:overlay": {
+      color: {
+        text: { action: { primary: { default: "$hue.neutral.100" } } },
+        background: {
+          default: "$hue.neutral.300",
+          action: { primary: { default: "$hue.accent.500" } },
+        },
+      },
+    },
+  },
+  dark: {
+    hue: {
+      gray: {
+        100: "#f3f4f6",
+        200: "#e5e7eb",
+        300: "#d1d5db",
+        400: "#9ca3af",
+        500: "#6b7280",
+        600: "#4b5563",
+        700: "#374151",
+        800: "#1f2937",
+        900: "#111827",
+      },
+      red: {
+        100: "#fee2e2",
+        200: "#fecaca",
+        300: "#fca5a5",
+        400: "#f87171",
+        500: "#ef4444",
+        600: "#dc2626",
+        700: "#b91c1c",
+        800: "#991b1b",
+        900: "#7f1d1d",
+      },
+      orange: {
+        100: "#ffedd5",
+        200: "#fed7aa",
+        300: "#fdba74",
+        400: "#fb923c",
+        500: "#f97316",
+        600: "#ea580c",
+        700: "#c2410c",
+        800: "#9a3412",
+        900: "#7c2d12",
+      },
+      yellow: {
+        100: "#fef9c3",
+        200: "#fef08a",
+        300: "#fde047",
+        400: "#facc15",
+        500: "#eab308",
+        600: "#ca8a04",
+        700: "#a16207",
+        800: "#854d0e",
+        900: "#713f12",
+      },
+      green: {
+        100: "#dcfce7",
+        200: "#bbf7d0",
+        300: "#86efac",
+        400: "#4ade80",
+        500: "#22c55e",
+        600: "#16a34a",
+        700: "#15803d",
+        800: "#166534",
+        900: "#14532d",
+      },
+      cyan: {
+        100: "#cffafe",
+        200: "#a5f3fc",
+        300: "#67e8f9",
+        400: "#22d3ee",
+        500: "#06b6d4",
+        600: "#0891b2",
+        700: "#0e7490",
+        800: "#155e75",
+        900: "#164e63",
+      },
+      blue: {
+        100: "#dbeafe",
+        200: "#bfdbfe",
+        300: "#93c5fd",
+        400: "#60a5fa",
+        500: "#3b82f6",
+        600: "#2563eb",
+        700: "#1d4ed8",
+        800: "#1e40af",
+        900: "#1e3a8a",
+      },
+      purple: {
+        100: "#f3e8ff",
+        200: "#e9d5ff",
+        300: "#d8b4fe",
+        400: "#c084fc",
+        500: "#a855f7",
+        600: "#9333ea",
+        700: "#7e22ce",
+        800: "#6b21a8",
+        900: "#581c87",
+      },
+      accent: "$hue.blue",
+      neutral: "$hue.gray",
+    },
+    color: {
+      text: {
+        default: "$hue.neutral.100",
+        subdued: "$hue.neutral.400",
+        action: {
+          primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
+          secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
+          destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
+        },
+        feedback: {
+          error: { default: "$hue.red.300", subdued: "$hue.red.400" },
+          warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
+          success: { default: "$hue.green.300", subdued: "$hue.green.400" },
+          info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
+        },
+      },
+      background: {
+        default: "$hue.neutral.900",
+        action: {
+          primary: {
+            default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
+            $selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
+          },
+          secondary: {
+            default: "$hue.neutral.800", $hovered: "$hue.neutral.700", $pressed: "$hue.neutral.900",
+            $selected: "$hue.neutral.700", $disabled: "$hue.neutral.900",
+          },
+          destructive: {
+            default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
+            $selected: "$hue.red.700", $disabled: "$hue.neutral.800",
+          },
+        },
+        feedback: {
+          error: { default: "$color.background.default" },
+          warning: { default: "$color.background.default" },
+          success: { default: "$color.background.default" },
+          info: { default: "$color.background.default" },
+        },
+      },
+      border: { default: "$hue.neutral.700" },
+      scrollbar: { default: "$hue.neutral.600" },
+      diff: {
+        text: {
+          added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
+          hunkHeader: "$hue.purple.400",
+        },
+        background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
+        highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
+        lineNumber: {
+          text: "$hue.neutral.400",
+          background: { added: "$hue.green.800", removed: "$hue.red.800" },
+        },
+      },
+      syntax: {
+      comment: "$hue.neutral.400",
+      keyword: "$hue.purple.400",
+      function: "$hue.accent.400",
+      variable: "$hue.neutral.100",
+      string: "$hue.green.300",
+      number: "$hue.yellow.200",
+      type: "$hue.yellow.500",
+      operator: "$hue.cyan.400",
+      punctuation: "$hue.neutral.100",
+    },
+      markdown: {
+      text: "$hue.neutral.100",
+      heading: "$hue.purple.400",
+      link: "$hue.accent.400",
+      linkText: "$hue.cyan.400",
+      code: "$hue.green.300",
+      blockQuote: "$hue.neutral.400",
+      emphasis: "$hue.yellow.500",
+      strong: "$hue.neutral.100",
+      horizontalRule: "$hue.neutral.700",
+      listItem: "$hue.accent.400",
+      listEnumeration: "$hue.cyan.400",
+      image: "$hue.accent.400",
+      imageText: "$hue.cyan.400",
+      codeBlock: "$hue.neutral.100",
+      },
+    },
+    "@context:elevated": {
+      color: {
+        text: { action: { primary: { default: "$hue.neutral.100" } } },
+        background: {
+          default: "$hue.neutral.800",
+          action: { primary: { default: "$hue.accent.400" } },
+        },
+      },
+    },
+    "@context:overlay": {
+      color: {
+        text: { action: { primary: { default: "$hue.neutral.900" } } },
+        background: {
+          default: "$hue.neutral.700",
+          action: { primary: { default: "$hue.accent.400" } },
+        },
+      },
+    },
+  },
+} satisfies ThemeFile

+ 97 - 0
packages/tui/src/theme/v2/expand.ts

@@ -0,0 +1,97 @@
+import type {
+  BackgroundDefinition,
+  ModeDefinition,
+  StatefulColorDefinition,
+  TextDefinition,
+  ThemeTokensDefinition,
+} from "./index"
+import { ActionState } from "./schema"
+
+export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
+  return {
+    ...definition,
+    ...expandTokens(definition),
+    ...Object.fromEntries(
+      Object.entries(definition)
+        .filter(([key]) => key.startsWith("@context:"))
+        .map(([key, value]) => [key, expandTokens(value as ThemeTokensDefinition)]),
+    ),
+  }
+}
+
+export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
+  if (!definition.color) return { ...definition }
+  return {
+    ...definition,
+    color: {
+      ...definition.color,
+      text: expandText(definition.color.text),
+      background: expandBackground(definition.color.background),
+    },
+  }
+}
+
+export function mergeTheme(...values: unknown[]): Record<string, unknown> {
+  return values.reduce<Record<string, unknown>>((result, value) => {
+    if (!isRecord(value)) return result
+    return Object.entries(value).reduce<Record<string, unknown>>((next, [key, item]) => {
+      if (item === undefined || key === "mergeMode") return next
+      return {
+        ...next,
+        [key]: isRecord(item) ? mergeTheme(next[key], item) : item,
+      }
+    }, result)
+  }, {})
+}
+
+function expandText(definition: TextDefinition | undefined): TextDefinition | undefined {
+  if (!definition) return
+  return {
+    ...definition,
+    subdued: definition.subdued ?? (definition.default ? "$color.text.default" : undefined),
+    action: expandActions(definition.action, "color.text.action"),
+    feedback: definition.feedback
+      ? Object.fromEntries(
+          Object.entries(definition.feedback).map(([kind, feedback]) => {
+            return [
+              kind,
+              {
+                ...feedback,
+                subdued: feedback.subdued ?? (feedback.default ? `$color.text.feedback.${kind}.default` : undefined),
+              },
+            ]
+          }),
+        )
+      : undefined,
+  }
+}
+
+function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
+  if (!definition) return
+  return { ...definition, action: expandActions(definition.action, "color.background.action") }
+}
+
+function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(
+  definition: Definition | undefined,
+  path: string,
+) {
+  if (!definition) return
+  return Object.fromEntries(
+    Object.entries(definition).map(([variant, value]) => {
+      if (!value?.default) return [variant, value]
+      return [
+        variant,
+        {
+          ...value,
+          ...Object.fromEntries(
+            ActionState.literals.map((state) => [`$${state}`, value[`$${state}`] ?? `$${path}.${variant}.default`]),
+          ),
+        },
+      ]
+    }),
+  ) as Definition
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}

+ 56 - 0
packages/tui/src/theme/v2/fallback.ts

@@ -0,0 +1,56 @@
+import type { ThemeTokensDefinition } from "./index"
+import { ActionVariant, FeedbackKind } from "./schema"
+
+export function fallback(): ThemeTokensDefinition {
+  const red = "#ff0000"
+
+  return {
+    color: {
+      text: {
+        default: red,
+        action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
+        feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
+      },
+      background: {
+        default: red,
+        action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
+        feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
+      },
+      border: { default: red },
+      scrollbar: { default: red },
+      diff: {
+        text: { added: red, removed: red, context: red, hunkHeader: red },
+        background: { added: red, removed: red, context: red },
+        highlight: { added: red, removed: red },
+        lineNumber: { text: red, background: { added: red, removed: red } },
+      },
+      syntax: {
+        comment: red,
+        keyword: red,
+        function: red,
+        variable: red,
+        string: red,
+        number: red,
+        type: red,
+        operator: red,
+        punctuation: red,
+      },
+      markdown: {
+        text: red,
+        heading: red,
+        link: red,
+        linkText: red,
+        code: red,
+        blockQuote: red,
+        emphasis: red,
+        strong: red,
+        horizontalRule: red,
+        listItem: red,
+        listEnumeration: red,
+        image: red,
+        imageText: red,
+        codeBlock: red,
+      },
+    },
+  }
+}

+ 30 - 0
packages/tui/src/theme/v2/index.ts

@@ -0,0 +1,30 @@
+export {
+  ActionState,
+  type ActionStateKey,
+  ActionVariant,
+  BaseHue,
+  FeedbackKind,
+  HueAlias,
+  HueStep,
+  MarkdownDefinition,
+  MarkdownToken,
+  ModeDefinition,
+  SyntaxDefinition,
+  SyntaxToken,
+  ThemeDefinition,
+  ThemeFile,
+  type BackgroundDefinition,
+  type DiffDefinition,
+  type FileThemeDefinition,
+  type HueDefinition,
+  type HueOverrideDefinition,
+  type MergeModeDefinition,
+  type Mode,
+  type StatefulColorDefinition,
+  type ContextKey,
+  type TextDefinition,
+  type ThemeTokensDefinition,
+} from "./schema"
+
+export type { Hue, HueScale, ResolvedActionState, ResolvedTheme, ResolvedThemeView, StatefulColor } from "./types"
+export { migrateV1 } from "./v1-migrate"

+ 207 - 0
packages/tui/src/theme/v2/resolve.ts

@@ -0,0 +1,207 @@
+import { RGBA } from "@opentui/core"
+import { Schema } from "effect"
+import { DEFAULT_THEME } from "./defaults"
+import { expandTheme, expandTokens, mergeTheme } from "./expand"
+import { fallback } from "./fallback"
+import {
+  ActionState,
+  ActionVariant,
+  BaseHue,
+  FeedbackKind,
+  HueAlias,
+  HueStep,
+  ThemeDefinition,
+  ThemeFile,
+} from "./schema"
+import type {
+  ActionStateKey,
+  HueDefinition,
+  HueScale,
+  ResolvedActionState,
+  ResolvedTheme,
+  ResolvedThemeView,
+  StatefulColorDefinition,
+  ThemeTokensDefinition,
+} from "./index"
+import { selectTheme, selectThemeMode } from "./select"
+
+const decodeThemeDefinition = Schema.decodeUnknownSync(ThemeDefinition)
+const decodeThemeFile = Schema.decodeUnknownSync(ThemeFile)
+
+export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark") {
+  const decoded = decodeThemeFile(file)
+  const selected = selectThemeMode(decoded, mode)
+  const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
+  const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
+  const core = expandTokens(fallback())
+  const merged = decoded.standalone
+    ? mergeTheme(core, definition)
+    : mergeTheme(core, defaults, definition)
+  if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
+  return resolveExpandedTheme(merged as ThemeDefinition)
+}
+
+export function resolveTheme(definition: ThemeDefinition): ResolvedTheme {
+  return resolveExpandedTheme(expandTheme(decodeThemeDefinition(definition)))
+}
+
+function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
+  const hue = resolveHue(definition.hue)
+  const base = tokens(definition)
+  const resolved = resolveView(base, hue)
+  const contexts = Object.fromEntries(
+    Object.entries(definition)
+      .filter(([key]) => key.startsWith("@context:"))
+      .map(([key, override]) => {
+        const contextual = contextualize(base, override as ThemeTokensDefinition)
+        return [key, resolveView(contextual, hue)]
+      }),
+  )
+
+  return { ...resolved, contexts } as ResolvedTheme
+}
+
+function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
+  return {
+    color: definition.color,
+  }
+}
+
+function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
+  const result = mergeTheme(base, override)
+  const baseText = base.color?.text?.action
+  const contextText = override.color?.text?.action
+  const baseBackground = base.color?.background?.action
+  const contextBackground = override.color?.background?.action
+  const color = result["color"] as NonNullable<ThemeTokensDefinition["color"]>
+  return {
+    ...result,
+    color: {
+      ...color,
+      text: { ...color.text, action: contextualActions(baseText, contextText) },
+      background: { ...color.background, action: contextualActions(baseBackground, contextBackground) },
+    },
+  } as ThemeTokensDefinition
+}
+
+function contextualActions(
+  base: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
+  context: Partial<Record<ActionVariant, StatefulColorDefinition>> | undefined,
+) {
+  return Object.fromEntries(
+    ActionVariant.literals.map((variant) => {
+      const baseVariant = base?.[variant]
+      const contextVariant = context?.[variant]
+      return [
+        variant,
+        Object.fromEntries(
+          (["default", ...ActionState.literals] as readonly ResolvedActionState[]).map((state) => {
+            const key = state === "default" ? undefined : (`$${state}` as ActionStateKey)
+            return [
+              key ?? "default",
+              (key ? contextVariant?.[key] : undefined) ??
+                contextVariant?.default ??
+                (key ? baseVariant?.[key] : undefined) ??
+                baseVariant?.default,
+            ]
+          }),
+        ),
+      ]
+    }),
+  )
+}
+
+function resolveView(definition: ThemeTokensDefinition, hue: ResolvedThemeView["hue"]): ResolvedThemeView {
+  const source: Record<string, unknown> = { hue, ...definition }
+  return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue }
+}
+
+function resolveHue(definition: HueDefinition) {
+  const source = definition as Record<string, unknown>
+  const cache = new Map<string, HueScale>()
+  const expected = new Set<string>([...BaseHue.literals, ...HueAlias.literals])
+  for (const name of Object.keys(source)) {
+    if (!expected.has(name)) throw new Error(`Unknown hue "${name}"`)
+  }
+
+  function resolve(name: string, stack: string[]): HueScale {
+    const hit = cache.get(name)
+    if (hit) return hit
+    if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
+    const value = source[name]
+    if (typeof value === "string") {
+      if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
+      const match = /^\$hue\.([^.]+)$/.exec(value)
+      if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
+      const result = resolve(match[1], [...stack, name])
+      cache.set(name, result)
+      return result
+    }
+    if (!isRecord(value)) throw new Error(`Hue "${name}" was not found`)
+    const result = Object.fromEntries(
+      HueStep.literals.map((step) => {
+        const color = value[step]
+        if (typeof color !== "string" || !isHex(color)) throw new Error(`Invalid hue color at "hue.${name}.${step}"`)
+        return [step, RGBA.fromHex(color)]
+      }),
+    ) as HueScale
+    for (const step of Object.keys(value)) {
+      if (!HueStep.literals.includes(Number(step) as HueStep)) throw new Error(`Unknown hue step at "hue.${name}.${step}"`)
+    }
+    cache.set(name, result)
+    return result
+  }
+
+  return Object.fromEntries(
+    [...BaseHue.literals, ...HueAlias.literals].map((name) => [name, resolve(name, [])]),
+  ) as ResolvedThemeView["hue"]
+}
+
+function createResolver(source: Record<string, unknown>) {
+  const cache = new Map<string, RGBA>()
+
+  function resolve(value: unknown, path: string, stack: string[] = []): unknown {
+    if (value instanceof RGBA) return value
+    if (typeof value === "string") return resolveColor(value, path, stack)
+    if (typeof value === "number") return value
+    if (!isRecord(value)) throw new Error(`Invalid theme value at "${path}"`)
+    return Object.fromEntries(
+      Object.entries(value).map(([key, item]) => [resolvedKey(key), resolve(item, `${path}.${key}`, stack)]),
+    )
+  }
+
+  function resolveColor(value: string, path: string, stack: string[]) {
+    if (isHex(value)) return RGBA.fromHex(value)
+    if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`)
+    const target = value.slice(1)
+    const hit = cache.get(target)
+    if (hit) return hit
+    if (stack.includes(target)) throw new Error(`Circular theme reference: ${[...stack, target].join(" -> ")}`)
+    const result = resolve(read(source, target), target, [...stack, target])
+    if (!(result instanceof RGBA)) throw new Error(`Theme reference "${value}" at "${path}" is not a color`)
+    cache.set(target, result)
+    return result
+  }
+
+  return (value: unknown, path: string) => resolve(value, path)
+}
+
+function resolvedKey(key: string) {
+  if (!key.startsWith("$")) return key
+  const state = key.slice(1)
+  return (ActionState.literals as readonly string[]).includes(state) ? state : key
+}
+
+function read(source: Record<string, unknown>, path: string) {
+  const result = path.split(".").reduce<unknown>((value, key) => (isRecord(value) ? value[key] : undefined), source)
+  if (result === undefined) throw new Error(`Theme reference "$${path}" was not found`)
+  return result
+}
+
+function isHex(value: string) {
+  return /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof RGBA)
+}

+ 214 - 0
packages/tui/src/theme/v2/schema.ts

@@ -0,0 +1,214 @@
+import { Schema } from "effect"
+
+export const HueStep = Schema.Literals([100, 200, 300, 400, 500, 600, 700, 800, 900])
+export type HueStep = Schema.Schema.Type<typeof HueStep>
+
+export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
+export type BaseHue = Schema.Schema.Type<typeof BaseHue>
+
+export const HueAlias = Schema.Literals(["accent", "neutral"])
+export type HueAlias = Schema.Schema.Type<typeof HueAlias>
+
+export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
+export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
+
+export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"])
+export type ActionState = Schema.Schema.Type<typeof ActionState>
+export type ActionStateKey = `$${ActionState}`
+
+export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
+export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
+
+const Mode = Schema.Literals(["light", "dark"])
+export type Mode = Schema.Schema.Type<typeof Mode>
+
+const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
+
+const ColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$", Schema.NonEmptyString])])
+
+const HueName = Schema.Union([BaseHue, HueAlias])
+const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
+
+const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
+export type ContextKey = Schema.Schema.Type<typeof ContextKey>
+
+const HueScaleDefinition = Schema.Record(HueStep, HexColor)
+const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
+
+const HueDefinition = Schema.Struct({
+  gray: HueScaleDefinition,
+  red: HueScaleDefinition,
+  orange: HueScaleDefinition,
+  yellow: HueScaleDefinition,
+  green: HueScaleDefinition,
+  cyan: HueScaleDefinition,
+  blue: HueScaleDefinition,
+  purple: HueScaleDefinition,
+  accent: HueAliasDefinition,
+  neutral: HueAliasDefinition,
+})
+export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
+
+const HueOverrideDefinition = Schema.Struct({
+  gray: Schema.optional(HueScaleDefinition),
+  red: Schema.optional(HueScaleDefinition),
+  orange: Schema.optional(HueScaleDefinition),
+  yellow: Schema.optional(HueScaleDefinition),
+  green: Schema.optional(HueScaleDefinition),
+  cyan: Schema.optional(HueScaleDefinition),
+  blue: Schema.optional(HueScaleDefinition),
+  purple: Schema.optional(HueScaleDefinition),
+  accent: Schema.optional(HueAliasDefinition),
+  neutral: Schema.optional(HueAliasDefinition),
+})
+export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
+
+const StatefulColorDefinition = Schema.Struct({
+  default: Schema.optional(ColorValue),
+  $hovered: Schema.optional(ColorValue),
+  $pressed: Schema.optional(ColorValue),
+  $selected: Schema.optional(ColorValue),
+  $focused: Schema.optional(ColorValue),
+  $disabled: Schema.optional(ColorValue),
+})
+export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
+
+const ActionColorDefinition = Schema.Struct({
+  primary: Schema.optional(StatefulColorDefinition),
+  secondary: Schema.optional(StatefulColorDefinition),
+  destructive: Schema.optional(StatefulColorDefinition),
+})
+
+const TextFeedbackDefinition = Schema.Struct({
+  default: Schema.optional(ColorValue),
+  subdued: Schema.optional(ColorValue),
+})
+
+const BackgroundFeedbackDefinition = Schema.Struct({
+  default: Schema.optional(ColorValue),
+})
+
+const TextDefinition = Schema.Struct({
+  default: Schema.optional(ColorValue),
+  subdued: Schema.optional(ColorValue),
+  action: Schema.optional(ActionColorDefinition),
+  feedback: Schema.optional(
+    Schema.Struct({
+      error: Schema.optional(TextFeedbackDefinition),
+      warning: Schema.optional(TextFeedbackDefinition),
+      success: Schema.optional(TextFeedbackDefinition),
+      info: Schema.optional(TextFeedbackDefinition),
+    }),
+  ),
+})
+export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
+
+const BackgroundDefinition = Schema.Struct({
+  default: Schema.optional(ColorValue),
+  action: Schema.optional(ActionColorDefinition),
+  feedback: Schema.optional(
+    Schema.Struct({
+      error: Schema.optional(BackgroundFeedbackDefinition),
+      warning: Schema.optional(BackgroundFeedbackDefinition),
+      success: Schema.optional(BackgroundFeedbackDefinition),
+      info: Schema.optional(BackgroundFeedbackDefinition),
+    }),
+  ),
+})
+export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
+
+export const SyntaxToken = Schema.Literals([
+  "comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
+])
+export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
+export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
+export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
+
+export const MarkdownToken = Schema.Literals([
+  "text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
+  "listEnumeration", "image", "imageText", "codeBlock",
+])
+export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
+export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
+export type MarkdownDefinition = Schema.Schema.Type<typeof MarkdownDefinition>
+
+const DiffDefinition = Schema.Struct({
+  text: Schema.optional(
+    Schema.Struct({
+      added: Schema.optional(ColorValue),
+      removed: Schema.optional(ColorValue),
+      context: Schema.optional(ColorValue),
+      hunkHeader: Schema.optional(ColorValue),
+    }),
+  ),
+  background: Schema.optional(
+    Schema.Struct({
+      added: Schema.optional(ColorValue),
+      removed: Schema.optional(ColorValue),
+      context: Schema.optional(ColorValue),
+    }),
+  ),
+  highlight: Schema.optional(
+    Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
+  ),
+  lineNumber: Schema.optional(
+    Schema.Struct({
+      text: Schema.optional(ColorValue),
+      background: Schema.optional(
+        Schema.Struct({ added: Schema.optional(ColorValue), removed: Schema.optional(ColorValue) }),
+      ),
+    }),
+  ),
+})
+export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
+
+const ThemeTokensDefinition = Schema.Struct({
+  color: Schema.optional(
+    Schema.Struct({
+      text: Schema.optional(TextDefinition),
+      background: Schema.optional(BackgroundDefinition),
+      border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
+      scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
+      diff: Schema.optional(DiffDefinition),
+      syntax: Schema.optional(SyntaxDefinition),
+      markdown: Schema.optional(MarkdownDefinition),
+    }),
+  ),
+})
+export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
+
+const ThemeDefinitionFields = Schema.Struct({
+  hue: HueDefinition,
+  ...ThemeTokensDefinition.fields,
+  "@context:elevated": Schema.optional(ThemeTokensDefinition),
+  "@context:overlay": Schema.optional(ThemeTokensDefinition),
+})
+export const ThemeDefinition = ThemeDefinitionFields
+export type ThemeDefinition = Schema.Schema.Type<typeof ThemeDefinition>
+
+const FileThemeDefinition = Schema.Struct({
+  hue: Schema.optional(HueOverrideDefinition),
+  ...ThemeTokensDefinition.fields,
+  "@context:elevated": Schema.optional(ThemeTokensDefinition),
+  "@context:overlay": Schema.optional(ThemeTokensDefinition),
+})
+export type FileThemeDefinition = Schema.Schema.Type<typeof FileThemeDefinition>
+
+const MergeModeDefinition = Schema.Struct({
+  mergeMode: Schema.Literal(true),
+  hue: Schema.optional(HueOverrideDefinition),
+  ...ThemeTokensDefinition.fields,
+  "@context:elevated": Schema.optional(ThemeTokensDefinition),
+  "@context:overlay": Schema.optional(ThemeTokensDefinition),
+})
+export type MergeModeDefinition = Schema.Schema.Type<typeof MergeModeDefinition>
+export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition])
+export type ModeDefinition = Schema.Schema.Type<typeof ModeDefinition>
+
+const FileMetadata = {
+  $schema: Schema.optional(Schema.String),
+  version: Schema.Literal(2),
+  standalone: Schema.optional(Schema.Boolean),
+}
+export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
+export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>

+ 37 - 0
packages/tui/src/theme/v2/select.ts

@@ -0,0 +1,37 @@
+import { expandTheme, mergeTheme } from "./expand"
+import type {
+  FileThemeDefinition,
+  MergeModeDefinition,
+  Mode,
+  ModeDefinition,
+  ThemeDefinition,
+  ThemeFile,
+} from "./index"
+
+export function selectTheme(
+  file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
+  mode?: Mode,
+): ThemeDefinition
+export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
+export function selectTheme(file: ThemeFile, mode?: Mode) {
+  return selectThemeMode(file, mode).theme
+}
+
+export function selectThemeMode(
+  file: ThemeFile,
+  mode: Mode = "light",
+): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
+  if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
+  const selected = file[mode]
+  if (!merges(selected)) return { theme: selected, mode, expanded: false }
+
+  const otherMode = mode === "light" ? "dark" : "light"
+  const other = file[otherMode]
+  const merged = mergeTheme(expandTheme(other), expandTheme(selected))
+  if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
+  return { theme: merged as FileThemeDefinition, mode, expanded: true }
+}
+
+function merges(definition: ModeDefinition): definition is MergeModeDefinition {
+  return "mergeMode" in definition && definition.mergeMode === true
+}

+ 50 - 0
packages/tui/src/theme/v2/solid.ts

@@ -0,0 +1,50 @@
+import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
+import { createComponentTheme, type ComponentTheme } from "./component"
+import type { ContextKey, ResolvedTheme } from "./index"
+
+type ThemeRuntime = {
+  readonly resolved: Accessor<ResolvedTheme>
+  readonly component: ComponentTheme
+}
+
+const ThemeContext = createContext<ThemeRuntime>()
+
+export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme }>) {
+  const resolved = () => props.theme
+  return createComponent(ThemeContext.Provider, {
+    value: { resolved, component: createComponentTheme(resolved) },
+    get children() {
+      return props.children
+    },
+  })
+}
+
+export function ContextProvider(props: ParentProps<{ context: ContextKey }>) {
+  const parent = runtime()
+  const context = () => {
+    const value = parent.resolved().contexts[props.context]
+    if (!value) throw new Error(`Theme context is not defined: ${props.context}`)
+    return value
+  }
+  context()
+  return createComponent(ThemeContext.Provider, {
+    value: { resolved: parent.resolved, component: createComponentTheme(context) },
+    get children() {
+      return props.children
+    },
+  })
+}
+
+export function useTheme() {
+  return runtime().component
+}
+
+export function useResolvedTheme() {
+  return runtime().resolved
+}
+
+function runtime() {
+  const context = useContext(ThemeContext)
+  if (!context) throw new Error("Theme context must be used within a ThemeProvider")
+  return context
+}

+ 56 - 0
packages/tui/src/theme/v2/types.ts

@@ -0,0 +1,56 @@
+import type { RGBA } from "@opentui/core"
+import type {
+  ActionState,
+  ActionVariant,
+  BaseHue,
+  FeedbackKind,
+  HueAlias,
+  HueStep,
+  MarkdownToken,
+  ContextKey,
+  SyntaxToken,
+} from "./schema"
+
+export type ResolvedActionState = "default" | ActionState
+export type HueScale = Readonly<Record<HueStep, RGBA>>
+export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
+export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
+
+export type ResolvedThemeView = {
+  readonly hue: Hue
+  readonly color: {
+    readonly text: {
+      readonly default: RGBA
+      readonly subdued: RGBA
+      readonly action: Readonly<Record<ActionVariant, StatefulColor>>
+      readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
+    }
+    readonly background: {
+      readonly default: RGBA
+      readonly action: Readonly<Record<ActionVariant, StatefulColor>>
+      readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
+    }
+    readonly border: { readonly default: RGBA }
+    readonly scrollbar: { readonly default: RGBA }
+    readonly diff: {
+      readonly text: {
+        readonly added: RGBA
+        readonly removed: RGBA
+        readonly context: RGBA
+        readonly hunkHeader: RGBA
+      }
+      readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
+      readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
+      readonly lineNumber: {
+        readonly text: RGBA
+        readonly background: { readonly added: RGBA; readonly removed: RGBA }
+      }
+    }
+    readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
+    readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
+  }
+}
+
+export type ResolvedTheme = ResolvedThemeView & {
+  readonly contexts: Readonly<Partial<Record<ContextKey, ResolvedThemeView>>>
+}

+ 213 - 0
packages/tui/src/theme/v2/v1-migrate.ts

@@ -0,0 +1,213 @@
+import { RGBA } from "@opentui/core"
+import type { Theme, ThemeJson } from "../index"
+import type { ThemeFile } from "./index"
+
+type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
+
+export function migrateV1(theme: ThemeJson): ThemeFile {
+  return {
+    version: 2,
+    light: migrateMode(resolveV1(theme, "light")),
+    dark: migrateMode(resolveV1(theme, "dark")),
+  }
+}
+
+function migrateMode(theme: Theme): ThemeFile["light"] {
+  const color = (key: ThemeColor) => hex(theme[key])
+  const selected = hex(selectedForeground(theme, theme.primary))
+  const destructive = hex(selectedForeground(theme, theme.error))
+
+  return {
+    hue: { accent: accentScale(theme.accent) },
+    color: {
+      text: {
+        default: color("text"),
+        subdued: color("textMuted"),
+        action: {
+          primary: { default: selected },
+          secondary: {
+            default: color("text"),
+            $hovered: color("text"),
+            $pressed: color("text"),
+            $selected: color("text"),
+            $focused: color("text"),
+            $disabled: color("textMuted"),
+          },
+          destructive: { default: destructive },
+        },
+        feedback: {
+          error: { default: color("error") },
+          warning: { default: color("warning") },
+          success: { default: color("success") },
+          info: { default: color("info") },
+        },
+      },
+      background: {
+        default: color("background"),
+        action: {
+          primary: { default: color("primary") },
+          secondary: {
+            default: color("backgroundMenu"),
+            $hovered: color("backgroundElement"),
+            $pressed: color("backgroundElement"),
+            $selected: color("backgroundMenu"),
+            $focused: color("backgroundElement"),
+            $disabled: color("backgroundMenu"),
+          },
+          destructive: { default: color("error") },
+        },
+      },
+      border: { default: color("border") },
+      scrollbar: { default: color("borderActive") },
+      diff: {
+        text: {
+          added: color("diffAdded"),
+          removed: color("diffRemoved"),
+          context: color("diffContext"),
+          hunkHeader: color("diffHunkHeader"),
+        },
+        background: {
+          added: color("diffAddedBg"),
+          removed: color("diffRemovedBg"),
+          context: color("diffContextBg"),
+        },
+        highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
+        lineNumber: {
+          text: color("diffLineNumber"),
+          background: {
+            added: color("diffAddedLineNumberBg"),
+            removed: color("diffRemovedLineNumberBg"),
+          },
+        },
+      },
+      syntax: {
+        comment: color("syntaxComment"),
+        keyword: color("syntaxKeyword"),
+        function: color("syntaxFunction"),
+        variable: color("syntaxVariable"),
+        string: color("syntaxString"),
+        number: color("syntaxNumber"),
+        type: color("syntaxType"),
+        operator: color("syntaxOperator"),
+        punctuation: color("syntaxPunctuation"),
+      },
+      markdown: {
+        text: color("markdownText"),
+        heading: color("markdownHeading"),
+        link: color("markdownLink"),
+        linkText: color("markdownLinkText"),
+        code: color("markdownCode"),
+        blockQuote: color("markdownBlockQuote"),
+        emphasis: color("markdownEmph"),
+        strong: color("markdownStrong"),
+        horizontalRule: color("markdownHorizontalRule"),
+        listItem: color("markdownListItem"),
+        listEnumeration: color("markdownListEnumeration"),
+        image: color("markdownImage"),
+        imageText: color("markdownImageText"),
+        codeBlock: color("markdownCodeBlock"),
+      },
+    },
+    "@context:elevated": { color: { background: { default: color("backgroundPanel") } } },
+    "@context:overlay": { color: { background: { default: color("backgroundMenu") } } },
+  }
+}
+
+function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
+  const defs = theme.defs ?? {}
+
+  function resolveColor(value: unknown, chain: string[] = []): RGBA {
+    if (value instanceof RGBA) return value
+    if (typeof value === "string") {
+      if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0)
+      if (value.startsWith("#")) return RGBA.fromHex(value)
+      if (chain.includes(value)) throw new Error(`Circular color reference: ${[...chain, value].join(" -> ")}`)
+      const next = defs[value] ?? theme.theme[value as ThemeColor]
+      if (next === undefined) throw new Error(`Color reference "${value}" not found in defs or theme`)
+      return resolveColor(next, [...chain, value])
+    }
+    if (typeof value === "number") return ansi(value)
+    if (!value || typeof value !== "object" || !(mode in value)) throw new Error("Invalid V1 theme color")
+    return resolveColor((value as Record<"dark" | "light", unknown>)[mode], chain)
+  }
+
+  const resolved = Object.fromEntries(
+    Object.entries(theme.theme)
+      .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
+      .map(([key, value]) => [key, resolveColor(value)]),
+  ) as Partial<Record<ThemeColor, RGBA>>
+  const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
+  resolved.selectedListItemText = hasSelectedListItemText
+    ? resolveColor(theme.theme.selectedListItemText)
+    : resolved.background
+  resolved.backgroundMenu = theme.theme.backgroundMenu
+    ? resolveColor(theme.theme.backgroundMenu)
+    : resolved.backgroundElement
+
+  return {
+    ...resolved,
+    _hasSelectedListItemText: hasSelectedListItemText,
+    thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
+  } as Theme
+}
+
+function selectedForeground(theme: Theme, background: RGBA) {
+  if (theme._hasSelectedListItemText) return theme.selectedListItemText
+  if (theme.background.a !== 0) return theme.background
+  return 0.299 * background.r + 0.587 * background.g + 0.114 * background.b > 0.5
+    ? RGBA.fromInts(0, 0, 0)
+    : RGBA.fromInts(255, 255, 255)
+}
+
+function accentScale(accent: RGBA) {
+  return {
+    100: mix(accent, 255, 0.66),
+    200: mix(accent, 255, 0.33),
+    300: hex(accent),
+    400: mix(accent, 0, 0.1),
+    500: mix(accent, 0, 0.2),
+    600: mix(accent, 0, 0.3),
+    700: mix(accent, 0, 0.4),
+    800: mix(accent, 0, 0.5),
+    900: mix(accent, 0, 0.6),
+  }
+}
+
+function mix(color: RGBA, target: number, amount: number) {
+  const [r, g, b, a] = color.toInts()
+  return hexInts(
+    Math.round(r + (target - r) * amount),
+    Math.round(g + (target - g) * amount),
+    Math.round(b + (target - b) * amount),
+    a,
+  )
+}
+
+function hex(color: RGBA) {
+  return hexInts(...color.toInts())
+}
+
+function hexInts(r: number, g: number, b: number, a: number) {
+  const byte = (value: number) => value.toString(16).padStart(2, "0")
+  return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
+}
+
+function ansi(code: number) {
+  if (code < 16) {
+    const colors = [
+      "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
+      "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
+    ]
+    return RGBA.fromHex(colors[code] ?? "#000000")
+  }
+  if (code < 232) {
+    const index = code - 16
+    const value = (part: number) => (part === 0 ? 0 : part * 40 + 55)
+    return RGBA.fromInts(value(Math.floor(index / 36)), value(Math.floor(index / 6) % 6), value(index % 6))
+  }
+  if (code < 256) {
+    const gray = (code - 232) * 10 + 8
+    return RGBA.fromInts(gray, gray, gray)
+  }
+  return RGBA.fromInts(0, 0, 0)
+}

+ 35 - 0
packages/tui/test/theme/v2/component.test.ts

@@ -0,0 +1,35 @@
+import { expect, test } from "bun:test"
+import { createSignal } from "solid-js"
+import { createComponentTheme } from "../../../src/theme/v2/component"
+import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
+import { resolveTheme } from "../../../src/theme/v2/resolve"
+import { selectTheme } from "../../../src/theme/v2/select"
+import type { ContextKey } from "../../../src/theme/v2"
+
+test("provides reactive property, variant, state, and context accessors", () => {
+  const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
+  const [context, setContext] = createSignal<ContextKey>()
+  const theme = createComponentTheme(() => {
+    const key = context()
+    return key ? resolved().contexts[key] ?? resolved() : resolved()
+  })
+
+  expect(theme.color.text()).toBe(resolved().color.text.default)
+  expect(theme.color.text.subdued()).toBe(resolved().color.text.subdued)
+  expect(theme.color.text.action()).toBe(resolved().color.text.action.primary.default)
+  expect(theme.color.text.action.primary("pressed")).toBe(resolved().color.text.action.primary.pressed)
+  expect(theme.color.background.action.secondary("disabled")).toBe(
+    resolved().color.background.action.secondary.disabled,
+  )
+  expect(theme.color.scrollbar()).toBe(resolved().color.scrollbar.default)
+  expect(theme.color.diff.text.added()).toBe(resolved().color.diff.text.added)
+
+  setContext("@context:elevated")
+  expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
+  expect(theme.color.background.action.primary("selected")).toBe(
+    resolved().contexts["@context:elevated"]!.color.background.action.primary.selected,
+  )
+
+  setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
+  expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
+})

+ 200 - 0
packages/tui/test/theme/v2/resolve.test.ts

@@ -0,0 +1,200 @@
+import { expect, test } from "bun:test"
+import { RGBA } from "@opentui/core"
+import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
+import type { ThemeDefinition } from "../../../src/theme/v2"
+import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve"
+import { selectTheme } from "../../../src/theme/v2/select"
+
+const light = selectTheme(DEFAULT_THEME, "light")
+const dark = selectTheme(DEFAULT_THEME, "dark")
+
+test("resolves independent definitions and hue aliases", () => {
+  const lightTheme = resolveTheme(light)
+  const darkTheme = resolveTheme(dark)
+
+  expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
+  expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
+  expect(lightTheme.color.text.default).toBeInstanceOf(RGBA)
+  expect(darkTheme.color.background.default).toBeInstanceOf(RGBA)
+  expect(lightTheme.color.syntax.keyword).toBeInstanceOf(RGBA)
+  expect(lightTheme.color.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
+  expect(lightTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
+    lightTheme.hue.accent[500],
+  )
+  expect(lightTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
+    lightTheme.hue.neutral[100],
+  )
+  expect(lightTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
+    lightTheme.hue.accent[500],
+  )
+  expect(lightTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
+    lightTheme.hue.neutral[100],
+  )
+  expect(darkTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
+    darkTheme.hue.accent[400],
+  )
+  expect(darkTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
+    darkTheme.hue.neutral[100],
+  )
+  expect(darkTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
+    darkTheme.hue.accent[400],
+  )
+  expect(darkTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
+    darkTheme.hue.neutral[900],
+  )
+})
+
+test("merges partial files with the selected OpenCode defaults", () => {
+  const theme = resolveThemeFile(
+    {
+      version: 2,
+      light: {
+        hue: light.hue,
+        color: { text: { default: "#123456" } },
+      },
+      dark: { hue: dark.hue },
+    },
+    "light",
+  )
+
+  expect(theme.color.text.default.toInts()).toEqual([18, 52, 86, 255])
+  expect(theme.color.text.subdued.toInts()).toEqual([18, 52, 86, 255])
+  expect(theme.color.background.action.destructive.pressed).toBeInstanceOf(RGBA)
+})
+
+test("expands user structural fallbacks before merging defaults", () => {
+  const expanded = resolveThemeFile(
+    {
+      version: 2,
+      light: {
+        hue: light.hue,
+        color: { background: { action: { primary: { default: "#123456" } } } },
+      },
+      dark: { hue: dark.hue },
+    },
+    "light",
+  )
+  const isolatedState = resolveThemeFile(
+    {
+      version: 2,
+      light: {
+        hue: light.hue,
+        color: { background: { action: { primary: { $pressed: "#654321" } } } },
+      },
+      dark: { hue: dark.hue },
+    },
+    "light",
+  )
+
+  expect(expanded.color.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
+  expect(isolatedState.color.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
+  expect(isolatedState.color.background.action.primary.hovered.toInts()).toEqual(
+    resolveTheme(light).color.background.action.primary.hovered.toInts(),
+  )
+})
+
+test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
+  const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
+  const lightTheme = resolveThemeFile(file, "light")
+  const darkTheme = resolveThemeFile(file, "dark")
+
+  expect(lightTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
+  expect(lightTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
+  expect(darkTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
+  expect(darkTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
+})
+
+test("uses defaults for the selected mode when it merges the other mode", () => {
+  const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark")
+  expect(theme.color.background.default.toInts()).toEqual(resolveTheme(dark).color.background.default.toInts())
+})
+
+test("resolves matched action variants and states", () => {
+  const theme = resolveTheme(light)
+
+  expect(theme.color.text.action.primary.pressed).toBeInstanceOf(RGBA)
+  expect(theme.color.background.action.primary.pressed).toBeInstanceOf(RGBA)
+  expect(theme.color.text.action.secondary.default).toBeInstanceOf(RGBA)
+  expect(theme.color.background.action.destructive.disabled).toBeInstanceOf(RGBA)
+})
+
+test("context overrides rewire semantic references and apply state precedence", () => {
+  const definition = override(light, {
+    color: {
+      text: {
+        default: "#111111",
+        action: {
+          primary: { default: "$color.text.default", $pressed: "#222222" },
+          secondary: { default: "$color.text.default" },
+        },
+      },
+    },
+    "@context:elevated": {
+      color: {
+        text: {
+          default: "#333333",
+          action: { primary: { default: "#444444", $selected: "#555555" } },
+        },
+      },
+    },
+  })
+  const theme = resolveTheme(definition)
+  const overlay = theme.contexts["@context:elevated"]!
+
+  expect(overlay.color.text.default.toInts()).toEqual([51, 51, 51, 255])
+  expect(overlay.color.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255])
+  expect(overlay.color.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
+  expect(overlay.color.text.action.primary.selected.toInts()).toEqual([85, 85, 85, 255])
+})
+
+test("rejects missing, base, and contextual reference cycles", () => {
+  expect(() => resolveTheme(override(light, { color: { text: { default: "$missing.color" } } }))).toThrow(
+    'Theme reference "$missing.color" was not found',
+  )
+  expect(() =>
+    resolveTheme(
+      override(light, {
+        color: { text: { default: "$color.text.subdued", subdued: "$color.text.default" } },
+      }),
+    ),
+  ).toThrow("Circular theme reference")
+  expect(() =>
+    resolveTheme(
+      override(light, {
+        "@context:elevated": { color: { text: { default: "$color.text.default" } } },
+      }),
+    ),
+  ).toThrow("Circular theme reference")
+})
+
+test("validates complete hues, resolved groups, and hue-only syntax", () => {
+  expect(() =>
+    resolveTheme(
+      {
+        ...light,
+        hue: { ...light.hue, accent: "$hue.missing" },
+      } as unknown as ThemeDefinition,
+    ),
+  ).toThrow("$hue.missing")
+  expect(() =>
+    resolveTheme({
+      ...light,
+      color: { ...light.color, syntax: { ...light.color?.syntax, keyword: "$color.text.default" } },
+    } as unknown as ThemeDefinition),
+  ).toThrow("$color.text.default")
+})
+
+function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
+  return merge(base, value) as ThemeDefinition
+}
+
+function merge(...values: unknown[]): Record<string, unknown> {
+  return values.reduce<Record<string, unknown>>((result, value) => {
+    if (!value || typeof value !== "object" || Array.isArray(value)) return result
+    for (const [key, item] of Object.entries(value)) {
+      if (item === undefined) continue
+      result[key] = item && typeof item === "object" && !Array.isArray(item) ? merge(result[key], item) : item
+    }
+    return result
+  }, {})
+}

+ 37 - 0
packages/tui/test/theme/v2/select.test.ts

@@ -0,0 +1,37 @@
+import { expect, test } from "bun:test"
+import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
+import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
+
+const hue = {} as HueDefinition
+const light = { hue, color: { text: { default: "#111111", subdued: "#222222" } } } satisfies ThemeDefinition
+const dark = { hue, color: { text: { default: "#eeeeee", subdued: "#dddddd" } } } satisfies ThemeDefinition
+
+test("requires and selects independent light and dark themes", () => {
+  const file = { version: 2, light, dark } satisfies ThemeFile
+  expect(selectTheme(file)).toBe(light)
+  expect(selectTheme(file, "light")).toBe(light)
+  expect(selectTheme(file, "dark")).toBe(dark)
+  expect(selectThemeMode(file, "dark").mode).toBe("dark")
+})
+
+test("merges an expanded mode override over the other mode", () => {
+  const file = {
+    version: 2,
+    light,
+    dark: { mergeMode: true, color: { text: { default: "#ffffff" } } },
+  } satisfies ThemeFile
+  const selected = selectTheme(file, "dark")
+
+  expect(selected.hue).toBeDefined()
+  expect(selected.color?.text?.default).toBe("#ffffff")
+  expect(selected.color?.text?.subdued).toBe("$color.text.default")
+})
+
+test("rejects mutual mode merging", () => {
+  const file = {
+    version: 2,
+    light: { mergeMode: true },
+    dark: { mergeMode: true },
+  } satisfies ThemeFile
+  expect(() => selectTheme(file)).toThrow("cannot both merge")
+})

+ 47 - 0
packages/tui/test/theme/v2/types.test.ts

@@ -0,0 +1,47 @@
+import { expect, test } from "bun:test"
+import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
+
+const text = {
+  default: "$hue.neutral.900",
+  subdued: "$hue.neutral.600",
+  action: {
+    primary: { default: "$hue.neutral.100", $pressed: "$hue.neutral.200" },
+    secondary: { default: "$hue.neutral.900" },
+    destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
+  },
+  feedback: {
+    error: { default: "$hue.red.700", subdued: "$hue.red.600" },
+  },
+} satisfies TextDefinition
+
+const background = {
+  default: "$hue.neutral.100",
+  action: {
+    primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
+    secondary: { default: "$hue.neutral.200" },
+    destructive: { default: "$hue.red.600" },
+  },
+  feedback: { error: { default: "$hue.red.100" } },
+} satisfies BackgroundDefinition
+
+const definition = {
+  hue: {} as ThemeDefinition["hue"],
+  color: { text, background, border: { default: "$hue.neutral.300" } },
+  "@context:elevated": {
+    color: {
+      text: { default: "$hue.neutral.800" },
+      background: { default: "$hue.neutral.200" },
+    },
+  },
+  "@context:overlay": { color: { background: { default: "$hue.neutral.300" } } },
+} satisfies ThemeDefinition
+
+const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
+
+test("supports property-first definitions, variants, states, and contexts", () => {
+  expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
+  expect(background.action.destructive.default).toBe("$hue.red.600")
+  expect(definition["@context:elevated"].color?.text?.default).toBe("$hue.neutral.800")
+  expect(definition["@context:overlay"].color?.background?.default).toBe("$hue.neutral.300")
+  expect(file.light).toBe(definition)
+})

+ 62 - 0
packages/tui/test/theme/v2/v1-migrate.test.ts

@@ -0,0 +1,62 @@
+import { expect, test } from "bun:test"
+import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
+import { resolveThemeFile } from "../../../src/theme/v2/resolve"
+import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
+
+test("migrates resolved V1 modes into literal V2 tokens", () => {
+  const migrated = migrateV1(DEFAULT_THEMES.opencode)
+  const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
+  const resolved = resolveThemeFile(migrated, "light")
+
+  expect(migrated.standalone).toBeUndefined()
+  expect(migrated.light.hue?.accent).toBeObject()
+  if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
+  expect(migrated.light.hue.accent[300]).toBe(hex(legacy.accent))
+  expect(migrated.light.color?.background?.default).toBe(hex(legacy.background))
+  expect(migrated.light.color?.background?.action?.primary?.default).toBe(hex(legacy.primary))
+  expect(migrated.light.color?.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
+  expect(migrated.light.color?.scrollbar?.default).toBe(hex(legacy.borderActive))
+  expect(migrated.light.color?.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
+  expect(migrated.light.color?.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
+  expect(resolved.color.background.action.secondary.hovered.toInts()).toEqual(legacy.backgroundElement.toInts())
+  expect(resolved.color.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
+  expect(resolved.contexts["@context:elevated"]?.color.background.default.toInts()).toEqual(
+    legacy.backgroundPanel.toInts(),
+  )
+  expect(resolved.contexts["@context:overlay"]?.color.background.default.toInts()).toEqual(
+    legacy.backgroundMenu.toInts(),
+  )
+})
+
+test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
+  const source = structuredClone(DEFAULT_THEMES.opencode)
+  source.theme.background = "transparent"
+  source.theme.primary = { light: "#ffffff", dark: "#000000" }
+  delete source.theme.selectedListItemText
+  const migrated = migrateV1(source)
+
+  expect(migrated.light.color?.text?.action?.primary?.default).toBe("#000000")
+  expect(migrated.dark.color?.text?.action?.primary?.default).toBe("#ffffff")
+})
+
+test("retains V1 circular reference errors", () => {
+  const source = structuredClone(DEFAULT_THEMES.opencode)
+  source.defs = { ...source.defs, one: "two", two: "one" }
+  source.theme.primary = "one"
+
+  expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
+})
+
+test("migrates every built-in V1 theme in both modes", () => {
+  for (const source of Object.values(DEFAULT_THEMES)) {
+    const migrated = migrateV1(source)
+    expect(resolveThemeFile(migrated, "light").color.text.default).toBeDefined()
+    expect(resolveThemeFile(migrated, "dark").color.text.default).toBeDefined()
+  }
+})
+
+function hex(color: { toInts(): [number, number, number, number] }) {
+  const [r, g, b, a] = color.toInts()
+  const byte = (value: number) => value.toString(16).padStart(2, "0")
+  return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
+}