Browse Source

feat(theme): extract TUI theme package (#39378)

James Long 2 weeks ago
parent
commit
c445d98188
36 changed files with 419 additions and 235 deletions
  1. 18 0
      bun.lock
  2. 36 0
      packages/theme/package.json
  3. 9 0
      packages/theme/script/build.ts
  4. 45 0
      packages/theme/script/publish.ts
  5. 77 0
      packages/theme/src/tui/color.ts
  6. 1 1
      packages/theme/src/tui/defaults.ts
  7. 2 2
      packages/theme/src/tui/expand.ts
  8. 57 0
      packages/theme/src/tui/fallback.ts
  9. 7 5
      packages/theme/src/tui/index.ts
  10. 6 6
      packages/theme/src/tui/resolve.ts
  11. 0 0
      packages/theme/src/tui/schema.ts
  12. 2 2
      packages/theme/src/tui/select.ts
  13. 1 1
      packages/theme/src/tui/syntax.ts
  14. 1 1
      packages/theme/src/tui/types.ts
  15. 5 5
      packages/theme/src/tui/v1-migrate.ts
  16. 76 0
      packages/theme/src/tui/v1.ts
  17. 10 0
      packages/theme/sst-env.d.ts
  18. 11 0
      packages/theme/tsconfig.build.json
  19. 12 0
      packages/theme/tsconfig.json
  20. 1 0
      packages/tui/package.json
  21. 2 4
      packages/tui/src/context/theme.tsx
  22. 1 1
      packages/tui/src/theme/component.ts
  23. 1 3
      packages/tui/src/theme/index.ts
  24. 2 74
      packages/tui/src/theme/v1.ts
  25. 0 57
      packages/tui/src/theme/v2/fallback.ts
  26. 0 52
      packages/tui/src/theme/v2/solid.ts
  27. 1 2
      packages/tui/test/cli/tui/theme-mode.test.tsx
  28. 2 5
      packages/tui/test/theme/v2/component.test.ts
  29. 8 4
      packages/tui/test/theme/v2/resolve.test.ts
  30. 9 2
      packages/tui/test/theme/v2/select.test.ts
  31. 1 1
      packages/tui/test/theme/v2/types.test.ts
  32. 8 4
      packages/tui/test/theme/v2/v1-migrate.test.ts
  33. 1 0
      packages/www/package.json
  34. 2 2
      packages/www/script/generate-theme-tokens.ts
  35. 1 1
      packages/www/snippets/generated/theme-tokens.mdx
  36. 3 0
      script/publish.ts

+ 18 - 0
bun.lock

@@ -859,6 +859,20 @@
         "vite": "catalog:",
       },
     },
+    "packages/theme": {
+      "name": "@opencode-ai/theme",
+      "version": "0.0.0",
+      "dependencies": {
+        "@opentui/core": "catalog:",
+        "effect": "catalog:",
+      },
+      "devDependencies": {
+        "@tsconfig/bun": "catalog:",
+        "@types/bun": "catalog:",
+        "@typescript/native-preview": "catalog:",
+        "typescript": "catalog:",
+      },
+    },
     "packages/tui": {
       "name": "@opencode-ai/tui",
       "version": "1.18.4",
@@ -868,6 +882,7 @@
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/simulation": "workspace:*",
+        "@opencode-ai/theme": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@opentui/core": "catalog:",
@@ -1030,6 +1045,7 @@
       },
       "devDependencies": {
         "@astrojs/cloudflare": "14.1.4",
+        "@opencode-ai/theme": "workspace:*",
         "@types/bun": "catalog:",
         "astro": "7.1.3",
         "effect": "catalog:",
@@ -2075,6 +2091,8 @@
 
     "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
 
+    "@opencode-ai/theme": ["@opencode-ai/theme@workspace:packages/theme"],
+
     "@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
 
     "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],

+ 36 - 0
packages/theme/package.json

@@ -0,0 +1,36 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/theme",
+  "version": "0.0.0",
+  "type": "module",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/anomalyco/opencode.git",
+    "directory": "packages/theme"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "files": [
+    "dist"
+  ],
+  "exports": {
+    "./tui": "./src/tui/index.ts",
+    "./tui/v1": "./src/tui/v1.ts"
+  },
+  "scripts": {
+    "build": "bun run script/build.ts",
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "@opentui/core": "catalog:",
+    "effect": "catalog:"
+  },
+  "devDependencies": {
+    "@tsconfig/bun": "catalog:",
+    "@types/bun": "catalog:",
+    "@typescript/native-preview": "catalog:",
+    "typescript": "catalog:"
+  }
+}

+ 9 - 0
packages/theme/script/build.ts

@@ -0,0 +1,9 @@
+#!/usr/bin/env bun
+
+import { $ } from "bun"
+import { fileURLToPath } from "node:url"
+
+process.chdir(fileURLToPath(new URL("..", import.meta.url)))
+
+await $`rm -rf dist`
+await $`bun tsc -p tsconfig.build.json`

+ 45 - 0
packages/theme/script/publish.ts

@@ -0,0 +1,45 @@
+#!/usr/bin/env bun
+
+import { Script } from "@opencode-ai/script"
+import { $ } from "bun"
+import { rm } from "node:fs/promises"
+import { fileURLToPath } from "node:url"
+
+process.chdir(fileURLToPath(new URL("..", import.meta.url)))
+
+const originalText = await Bun.file("package.json").text()
+const pkg = JSON.parse(originalText) as {
+  name: string
+  version: string
+  exports: Record<string, string | { import: string; types: string }>
+}
+const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
+
+if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
+  console.log(`already published ${pkg.name}@${pkg.version}`)
+  process.exit(0)
+}
+
+try {
+  await $`bun run typecheck`
+  await $`bun run build`
+  pkg.exports = Object.fromEntries(
+    Object.entries(pkg.exports).map(([key, value]) => {
+      if (typeof value !== "string") return [key, value]
+      return [
+        key,
+        {
+          import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
+          types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
+        },
+      ]
+    }),
+  )
+  await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
+  await rm(tarball, { force: true })
+  await $`bun pm pack`
+  await $`npm publish ${tarball} --tag ${Script.channel} --access public`
+} finally {
+  await Bun.write("package.json", originalText)
+  await rm(tarball, { force: true })
+}

+ 77 - 0
packages/theme/src/tui/color.ts

@@ -0,0 +1,77 @@
+type OklchColor = {
+  l: number
+  c: number
+  h: number
+}
+
+function clamp(value: number, min: number, max: number) {
+  return Math.max(min, Math.min(max, value))
+}
+
+function hue(value: number) {
+  return ((value % 360) + 360) % 360
+}
+
+function linearToSrgb(value: number) {
+  if (value <= 0.0031308) return value * 12.92
+  return 1.055 * Math.pow(value, 1 / 2.4) - 0.055
+}
+
+function srgbToLinear(value: number) {
+  if (value <= 0.04045) return value / 12.92
+  return Math.pow((value + 0.055) / 1.055, 2.4)
+}
+
+export function rgbToOklch(red: number, green: number, blue: number): OklchColor {
+  const linearRed = srgbToLinear(red)
+  const linearGreen = srgbToLinear(green)
+  const linearBlue = srgbToLinear(blue)
+  const lRoot = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
+  const mRoot = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
+  const sRoot = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
+  const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot
+  const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot
+  const b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot
+  const chroma = Math.sqrt(a * a + b * b)
+  const angle = Math.atan2(b, a) * (180 / Math.PI)
+  return { l: lightness, c: chroma, h: angle < 0 ? angle + 360 : angle }
+}
+
+function oklchToRgb(color: OklchColor) {
+  const a = color.c * Math.cos((color.h * Math.PI) / 180)
+  const b = color.c * Math.sin((color.h * Math.PI) / 180)
+  const lRoot = color.l + 0.3963377774 * a + 0.2158037573 * b
+  const mRoot = color.l - 0.1055613458 * a - 0.0638541728 * b
+  const sRoot = color.l - 0.0894841775 * a - 1.291485548 * b
+  const l = lRoot * lRoot * lRoot
+  const m = mRoot * mRoot * mRoot
+  const s = sRoot * sRoot * sRoot
+  return {
+    r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
+    g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
+    b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
+  }
+}
+
+function fitOklch(color: OklchColor): OklchColor {
+  const base = { l: clamp(color.l, 0, 1), c: Math.max(0, color.c), h: hue(color.h) }
+  const rgb = oklchToRgb(base)
+  if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) return base
+
+  const fitted = Array.from({ length: 24 }).reduce<OklchColor | undefined>((result, _, index) => {
+    if (result) return result
+    const next = { ...base, c: base.c * Math.pow(0.9, index + 1) }
+    const output = oklchToRgb(next)
+    if (output.r >= 0 && output.r <= 1 && output.g >= 0 && output.g <= 1 && output.b >= 0 && output.b <= 1) return next
+  }, undefined)
+  return fitted ?? { ...base, c: 0 }
+}
+
+export function oklchToHex(color: OklchColor) {
+  const rgb = oklchToRgb(fitOklch(color))
+  const toHex = (value: number) =>
+    Math.round(clamp(value, 0, 1) * 255)
+      .toString(16)
+      .padStart(2, "0")
+  return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
+}

+ 1 - 1
packages/tui/src/theme/v2/defaults.ts → packages/theme/src/tui/defaults.ts

@@ -1,4 +1,4 @@
-import type { HueName, ThemeDocument } from "./schema"
+import type { HueName, ThemeDocument } from "./schema.js"
 
 export const DEFAULT_CATEGORICAL = [
   "blue",

+ 2 - 2
packages/tui/src/theme/v2/expand.ts → packages/theme/src/tui/expand.ts

@@ -4,8 +4,8 @@ import type {
   StatefulColorDefinition,
   TextDefinition,
   ThemeTokensDefinition,
-} from "./index"
-import { ActionState } from "./schema"
+} from "./index.js"
+import { ActionState } from "./schema.js"
 
 export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
   return {

+ 57 - 0
packages/theme/src/tui/fallback.ts

@@ -0,0 +1,57 @@
+import type { ThemeTokensDefinition } from "./index.js"
+import { ActionVariant, FeedbackKind } from "./schema.js"
+
+export function fallback(): ThemeTokensDefinition {
+  const red = "#ff0000"
+
+  return {
+    text: {
+      default: red,
+      action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
+      formfield: { default: red },
+      feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
+    },
+    background: {
+      default: red,
+      surface: { offset: red, overlay: red },
+      action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
+      formfield: { 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,
+    },
+  }
+}

+ 7 - 5
packages/tui/src/theme/v2/index.ts → packages/theme/src/tui/index.ts

@@ -29,7 +29,7 @@ export {
   type ContextKey,
   type TextDefinition,
   type ThemeTokensDefinition,
-} from "./schema"
+} from "./schema.js"
 
 export type {
   Categorical,
@@ -42,7 +42,9 @@ export type {
   ResolvedTheme,
   ResolvedThemeView,
   StatefulColor,
-} from "./types"
-export { DEFAULT_CATEGORICAL } from "./defaults"
-export { migrateV1 } from "./v1-migrate"
-export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
+} from "./types.js"
+export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
+export { migrateV1 } from "./v1-migrate.js"
+export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
+export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
+export { generateSyntax } from "./syntax.js"

+ 6 - 6
packages/tui/src/theme/v2/resolve.ts → packages/theme/src/tui/resolve.ts

@@ -1,8 +1,8 @@
 import { RGBA } from "@opentui/core"
 import { Schema } from "effect"
-import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
-import { expandTheme, expandTokens, mergeTheme } from "./expand"
-import { fallback } from "./fallback"
+import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
+import { expandTheme, expandTokens, mergeTheme } from "./expand.js"
+import { fallback } from "./fallback.js"
 import {
   ActionState,
   ActionVariant,
@@ -12,7 +12,7 @@ import {
   HueStep,
   ThemeDefinition,
   ThemeDocument,
-} from "./schema"
+} from "./schema.js"
 import type {
   ActionStateKey,
   HueDefinition,
@@ -22,8 +22,8 @@ import type {
   ResolvedThemeView,
   StatefulColorDefinition,
   ThemeTokensDefinition,
-} from "./index"
-import { selectTheme, selectThemeMode } from "./select"
+} from "./index.js"
+import { selectTheme, selectThemeMode } from "./select.js"
 
 const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
 

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


+ 2 - 2
packages/tui/src/theme/v2/select.ts → packages/theme/src/tui/select.ts

@@ -1,4 +1,4 @@
-import { expandTheme, mergeTheme } from "./expand"
+import { expandTheme, mergeTheme } from "./expand.js"
 import type {
   FileThemeDefinition,
   MergeModeDefinition,
@@ -6,7 +6,7 @@ import type {
   ModeDefinition,
   ThemeDefinition,
   ThemeDocument,
-} from "./index"
+} from "./index.js"
 
 export function selectTheme(
   document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },

+ 1 - 1
packages/tui/src/theme/v2/syntax.ts → packages/theme/src/tui/syntax.ts

@@ -1,5 +1,5 @@
 import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
-import type { Mode, ResolvedThemeView } from "./index"
+import type { Mode, ResolvedThemeView } from "./index.js"
 
 export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
   const step = mode === "light" ? 800 : 200

+ 1 - 1
packages/tui/src/theme/v2/types.ts → packages/theme/src/tui/types.ts

@@ -9,7 +9,7 @@ import type {
   MarkdownToken,
   ContextKey,
   SyntaxToken,
-} from "./schema"
+} from "./schema.js"
 
 export type ResolvedActionState = "default" | ActionState
 export type ResolvedFormfieldState = ResolvedActionState

+ 5 - 5
packages/tui/src/theme/v2/v1-migrate.ts → packages/theme/src/tui/v1-migrate.ts

@@ -1,9 +1,9 @@
 import { RGBA } from "@opentui/core"
-import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
-import type { Theme, ThemeV1Json } from "../v1"
-import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
-import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
-import { HueStep } from "./schema"
+import { oklchToHex, rgbToOklch } from "./color.js"
+import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
+import type { FileThemeDefinition, Mode, ThemeDocument } from "./index.js"
+import { HueStep } from "./schema.js"
+import type { Theme, ThemeV1Json } from "./v1.js"
 
 type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
 type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"

+ 76 - 0
packages/theme/src/tui/v1.ts

@@ -0,0 +1,76 @@
+import type { RGBA } from "@opentui/core"
+
+export type Theme = {
+  readonly primary: RGBA
+  readonly secondary: RGBA
+  readonly accent: RGBA
+  readonly error: RGBA
+  readonly warning: RGBA
+  readonly success: RGBA
+  readonly info: RGBA
+  readonly text: RGBA
+  readonly textMuted: RGBA
+  readonly selectedListItemText: RGBA
+  readonly background: RGBA
+  readonly backgroundPanel: RGBA
+  readonly backgroundElement: RGBA
+  readonly backgroundMenu: RGBA
+  readonly border: RGBA
+  readonly borderActive: RGBA
+  readonly borderSubtle: RGBA
+  readonly diffAdded: RGBA
+  readonly diffRemoved: RGBA
+  readonly diffContext: RGBA
+  readonly diffHunkHeader: RGBA
+  readonly diffHighlightAdded: RGBA
+  readonly diffHighlightRemoved: RGBA
+  readonly diffAddedBg: RGBA
+  readonly diffRemovedBg: RGBA
+  readonly diffContextBg: RGBA
+  readonly diffLineNumber: RGBA
+  readonly diffAddedLineNumberBg: RGBA
+  readonly diffRemovedLineNumberBg: RGBA
+  readonly markdownText: RGBA
+  readonly markdownHeading: RGBA
+  readonly markdownLink: RGBA
+  readonly markdownLinkText: RGBA
+  readonly markdownCode: RGBA
+  readonly markdownBlockQuote: RGBA
+  readonly markdownEmph: RGBA
+  readonly markdownStrong: RGBA
+  readonly markdownHorizontalRule: RGBA
+  readonly markdownListItem: RGBA
+  readonly markdownListEnumeration: RGBA
+  readonly markdownImage: RGBA
+  readonly markdownImageText: RGBA
+  readonly markdownCodeBlock: RGBA
+  readonly syntaxComment: RGBA
+  readonly syntaxKeyword: RGBA
+  readonly syntaxFunction: RGBA
+  readonly syntaxVariable: RGBA
+  readonly syntaxString: RGBA
+  readonly syntaxNumber: RGBA
+  readonly syntaxType: RGBA
+  readonly syntaxOperator: RGBA
+  readonly syntaxPunctuation: RGBA
+  readonly thinkingOpacity: number
+  _hasSelectedListItemText: boolean
+}
+
+export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
+export type HexColor = `#${string}`
+export type RefName = string
+export type Variant = {
+  dark: HexColor | RefName
+  light: HexColor | RefName
+}
+export type ColorValue = HexColor | RefName | Variant | RGBA | number
+export type ThemeV1Json = {
+  $schema?: string
+  defs?: Record<string, HexColor | RefName>
+  theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
+    selectedListItemText?: ColorValue
+    backgroundMenu?: ColorValue
+    thinkingOpacity?: number
+  }
+}

+ 10 - 0
packages/theme/sst-env.d.ts

@@ -0,0 +1,10 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+/* biome-ignore-all lint: auto-generated */
+
+/// <reference path="../../sst-env.d.ts" />
+
+import "sst"
+export {}

+ 11 - 0
packages/theme/tsconfig.build.json

@@ -0,0 +1,11 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "dist",
+    "noEmit": false,
+    "declaration": true
+  },
+  "include": ["src"]
+}

+ 12 - 0
packages/theme/tsconfig.json

@@ -0,0 +1,12 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@tsconfig/bun/tsconfig.json",
+  "compilerOptions": {
+    "lib": ["ESNext", "DOM", "DOM.Iterable"],
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "allowImportingTsExtensions": false,
+    "allowJs": false,
+    "noUncheckedIndexedAccess": false
+  }
+}

+ 1 - 0
packages/tui/package.json

@@ -83,6 +83,7 @@
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/simulation": "workspace:*",
+    "@opencode-ai/theme": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@opencode-ai/util": "workspace:*",
     "@opentui/core": "catalog:",

+ 2 - 4
packages/tui/src/context/theme.tsx

@@ -1,5 +1,6 @@
 import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
 import { useRenderer } from "@opentui/solid"
+import { generateSyntax, resolveThemeDocument, themeModes } from "@opencode-ai/theme/tui"
 import {
   DEFAULT_THEMES,
   addTheme,
@@ -14,12 +15,9 @@ import {
   type Theme,
   type ThemeDocumentSource,
 } from "../theme"
-import { generateSyntax } from "../theme/v2/syntax"
 import { generateSystem, terminalMode } from "../theme/system"
 import { discoverThemes, themeDirectories } from "../theme/discovery"
-import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
-import { resolveThemeDocument } from "../theme/v2/resolve"
-import { themeModes } from "../theme/v2/select"
+import { createComponentTheme, type ComponentTheme } from "../theme/component"
 import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "./helper"

+ 1 - 1
packages/tui/src/theme/v2/component.ts → packages/tui/src/theme/component.ts

@@ -1,6 +1,6 @@
 import type { RGBA } from "@opentui/core"
 import type { Accessor } from "solid-js"
-import type { Mode, ResolvedThemeView } from "./index"
+import type { Mode, ResolvedThemeView } from "@opencode-ai/theme/tui"
 
 export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode: Accessor<Mode>) {
   return {

+ 1 - 3
packages/tui/src/theme/index.ts

@@ -1,9 +1,7 @@
 import { Schema } from "effect"
+import { migrateV1, resolveThemeDocument, ThemeDocument, themeDecodeError } from "@opencode-ai/theme/tui"
 import { resolveThemeColors } from "./resolve"
 import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
-import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
-import { ThemeDocument } from "./v2/schema"
-import { migrateV1 } from "./v2/v1-migrate"
 
 export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
 export { resolveThemeDocument, type ThemeDocument }

+ 2 - 74
packages/tui/src/theme/v1.ts

@@ -1,4 +1,5 @@
 import { RGBA, SyntaxStyle } from "@opentui/core"
+import type { Theme, ThemeV1Json } from "@opencode-ai/theme/tui/v1"
 import aura from "./assets/aura.json" with { type: "json" }
 import ayu from "./assets/ayu.json" with { type: "json" }
 import carbonfox from "./assets/carbonfox.json" with { type: "json" }
@@ -33,80 +34,7 @@ import vercel from "./assets/vercel.json" with { type: "json" }
 import vesper from "./assets/vesper.json" with { type: "json" }
 import zenburn from "./assets/zenburn.json" with { type: "json" }
 
-export type Theme = {
-  readonly primary: RGBA
-  readonly secondary: RGBA
-  readonly accent: RGBA
-  readonly error: RGBA
-  readonly warning: RGBA
-  readonly success: RGBA
-  readonly info: RGBA
-  readonly text: RGBA
-  readonly textMuted: RGBA
-  readonly selectedListItemText: RGBA
-  readonly background: RGBA
-  readonly backgroundPanel: RGBA
-  readonly backgroundElement: RGBA
-  readonly backgroundMenu: RGBA
-  readonly border: RGBA
-  readonly borderActive: RGBA
-  readonly borderSubtle: RGBA
-  readonly diffAdded: RGBA
-  readonly diffRemoved: RGBA
-  readonly diffContext: RGBA
-  readonly diffHunkHeader: RGBA
-  readonly diffHighlightAdded: RGBA
-  readonly diffHighlightRemoved: RGBA
-  readonly diffAddedBg: RGBA
-  readonly diffRemovedBg: RGBA
-  readonly diffContextBg: RGBA
-  readonly diffLineNumber: RGBA
-  readonly diffAddedLineNumberBg: RGBA
-  readonly diffRemovedLineNumberBg: RGBA
-  readonly markdownText: RGBA
-  readonly markdownHeading: RGBA
-  readonly markdownLink: RGBA
-  readonly markdownLinkText: RGBA
-  readonly markdownCode: RGBA
-  readonly markdownBlockQuote: RGBA
-  readonly markdownEmph: RGBA
-  readonly markdownStrong: RGBA
-  readonly markdownHorizontalRule: RGBA
-  readonly markdownListItem: RGBA
-  readonly markdownListEnumeration: RGBA
-  readonly markdownImage: RGBA
-  readonly markdownImageText: RGBA
-  readonly markdownCodeBlock: RGBA
-  readonly syntaxComment: RGBA
-  readonly syntaxKeyword: RGBA
-  readonly syntaxFunction: RGBA
-  readonly syntaxVariable: RGBA
-  readonly syntaxString: RGBA
-  readonly syntaxNumber: RGBA
-  readonly syntaxType: RGBA
-  readonly syntaxOperator: RGBA
-  readonly syntaxPunctuation: RGBA
-  readonly thinkingOpacity: number
-  _hasSelectedListItemText: boolean
-}
-
-export type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
-export type HexColor = `#${string}`
-export type RefName = string
-export type Variant = {
-  dark: HexColor | RefName
-  light: HexColor | RefName
-}
-export type ColorValue = HexColor | RefName | Variant | RGBA | number
-export type ThemeV1Json = {
-  $schema?: string
-  defs?: Record<string, HexColor | RefName>
-  theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
-    selectedListItemText?: ColorValue
-    backgroundMenu?: ColorValue
-    thinkingOpacity?: number
-  }
-}
+export type { ColorValue, HexColor, RefName, Theme, ThemeColor, ThemeV1Json, Variant } from "@opencode-ai/theme/tui/v1"
 
 export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
   aura,

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

@@ -1,57 +0,0 @@
-import type { ThemeTokensDefinition } from "./index"
-import { ActionVariant, FeedbackKind } from "./schema"
-
-export function fallback(): ThemeTokensDefinition {
-  const red = "#ff0000"
-
-  return {
-    text: {
-        default: red,
-        action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
-        formfield: { default: red },
-        feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
-    },
-    background: {
-        default: red,
-        surface: { offset: red, overlay: red },
-        action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
-        formfield: { 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,
-    },
-  }
-}

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

@@ -1,52 +0,0 @@
-import { createComponent, createContext, useContext, type Accessor, type ParentProps } from "solid-js"
-import { createComponentTheme, type ComponentTheme } from "./component"
-import type { ContextKey, Mode, ResolvedTheme } from "./index"
-
-type ThemeRuntime = {
-  readonly resolved: Accessor<ResolvedTheme>
-  readonly mode: Accessor<Mode>
-  readonly component: ComponentTheme
-}
-
-const ThemeContext = createContext<ThemeRuntime>()
-
-export function ThemeProvider(props: ParentProps<{ theme: ResolvedTheme; mode?: Mode }>) {
-  const resolved = () => props.theme
-  const mode = () => props.mode ?? "light"
-  return createComponent(ThemeContext.Provider, {
-    value: { resolved, mode, component: createComponentTheme(resolved, mode) },
-    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, mode: parent.mode, component: createComponentTheme(context, parent.mode) },
-    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
-}

+ 1 - 2
packages/tui/test/cli/tui/theme-mode.test.tsx

@@ -2,10 +2,9 @@
 import { testRender } from "@opentui/solid"
 import { expect, test } from "bun:test"
 import { RGBA } from "@opentui/core"
+import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { DEFAULT_THEMES } from "../../../src/theme"
-import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
-import { selectTheme } from "../../../src/theme/v2/select"
 import { ConfigProvider } from "../../../src/config"
 import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
 

+ 2 - 5
packages/tui/test/theme/v2/component.test.ts

@@ -1,11 +1,8 @@
 import { expect, test } from "bun:test"
 import { createSignal } from "solid-js"
 import { RGBA } from "@opentui/core"
-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"
+import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextKey } from "@opencode-ai/theme/tui"
+import { createComponentTheme } from "../../../src/theme/component"
 
 test("provides reactive properties, states, contexts, and color operations", () => {
   const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))

+ 8 - 4
packages/tui/test/theme/v2/resolve.test.ts

@@ -1,10 +1,14 @@
 import { expect, test } from "bun:test"
 import { RGBA } from "@opentui/core"
+import {
+  DEFAULT_THEME,
+  resolveTheme,
+  resolveThemeDocument,
+  selectTheme,
+  type Mode,
+  type ThemeDefinition,
+} from "@opencode-ai/theme/tui"
 import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
-import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
-import type { Mode, ThemeDefinition } from "../../../src/theme/v2"
-import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve"
-import { selectTheme } from "../../../src/theme/v2/select"
 
 const light = selectTheme(DEFAULT_THEME, "light")
 const dark = selectTheme(DEFAULT_THEME, "dark")

+ 9 - 2
packages/tui/test/theme/v2/select.test.ts

@@ -1,6 +1,13 @@
 import { expect, test } from "bun:test"
-import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
-import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
+import {
+  selectTheme,
+  selectThemeMode,
+  supportsThemeMode,
+  themeModes,
+  type HueDefinition,
+  type ThemeDefinition,
+  type ThemeDocument,
+} from "@opencode-ai/theme/tui"
 
 const hue = {} as HueDefinition
 const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition

+ 1 - 1
packages/tui/test/theme/v2/types.test.ts

@@ -1,5 +1,5 @@
 import { expect, test } from "bun:test"
-import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2"
+import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
 
 const text = {
   default: "$hue.neutral.900",

+ 8 - 4
packages/tui/test/theme/v2/v1-migrate.test.ts

@@ -1,9 +1,13 @@
 import { expect, test } from "bun:test"
+import {
+  DEFAULT_CATEGORICAL,
+  DEFAULT_THEME,
+  migrateV1,
+  resolveThemeDocument,
+  selectThemeMode,
+  themeModes,
+} from "@opencode-ai/theme/tui"
 import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
-import { resolveThemeDocument } from "../../../src/theme/v2/resolve"
-import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
-import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
-import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
 
 test("migrates resolved V1 modes into V2 tokens", () => {
   const migrated = migrateV1(DEFAULT_THEMES.opencode)

+ 1 - 0
packages/www/package.json

@@ -18,6 +18,7 @@
   },
   "devDependencies": {
     "@astrojs/cloudflare": "14.1.4",
+    "@opencode-ai/theme": "workspace:*",
     "@types/bun": "catalog:",
     "astro": "7.1.3",
     "effect": "catalog:",

+ 2 - 2
packages/www/script/generate-theme-tokens.ts

@@ -2,7 +2,7 @@
 
 import { Schema, SchemaAST } from "effect"
 import { format } from "prettier"
-import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema"
+import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
 
 const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
 const root = requireObject(ThemeDefinition.ast)
@@ -64,7 +64,7 @@ ${JSON.stringify(example, null, 2)}
 ## Token reference
 
 This reference is generated from the Effect schema in
-\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update
+\`@opencode-ai/theme/tui\`. Changes to the runtime schema update
 this section through \`bun run generate\`.
 
 ### Hue tokens

+ 1 - 1
packages/www/snippets/generated/theme-tokens.mdx

@@ -30,7 +30,7 @@
 ## Token reference
 
 This reference is generated from the Effect schema in
-`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update
+`@opencode-ai/theme/tui`. Changes to the runtime schema update
 this section through `bun run generate`.
 
 ### Hue tokens

+ 3 - 0
script/publish.ts

@@ -38,6 +38,9 @@ await prepareReleaseFiles()
 console.log("\n=== schema ===\n")
 await $`bun ./packages/schema/script/publish.ts`
 
+console.log("\n=== theme ===\n")
+await $`bun ./packages/theme/script/publish.ts`
+
 console.log("\n=== ai ===\n")
 await $`bun ./packages/ai/script/publish.ts`