resolve.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import { expect, test } from "bun:test"
  2. import { RGBA } from "@opentui/core"
  3. import {
  4. DEFAULT_THEME,
  5. resolveTheme,
  6. resolveThemeDocument,
  7. selectTheme,
  8. type Mode,
  9. type ThemeDefinition,
  10. } from "@opencode-ai/theme/tui"
  11. import { parseTheme, type ThemeDocumentSource } from "../../../src/theme"
  12. const light = selectTheme(DEFAULT_THEME, "light")
  13. const dark = selectTheme(DEFAULT_THEME, "dark")
  14. function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string) {
  15. return resolveThemeDocument(parseTheme(source, name), mode)
  16. }
  17. test("resolves one-mode documents with defaults for the available mode", () => {
  18. const resolvedLight = resolveSource({ version: 2, light: {} }, "dark")
  19. const resolvedDark = resolveSource({ version: 2, dark: {} }, "light")
  20. expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
  21. expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
  22. expect(resolvedLight.categorical.length).toBeGreaterThan(0)
  23. expect(resolvedDark.categorical.length).toBeGreaterThan(0)
  24. })
  25. test("rejects theme documents without a mode", () => {
  26. expect(() => resolveSource({ version: 2 })).toThrow("Invalid theme")
  27. })
  28. test("validates and resolves categorical hues in configured order", () => {
  29. const theme = resolveSource({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
  30. expect(theme.categorical[0]).toBe(theme.hue.accent)
  31. expect(theme.categorical[1]).toBe(theme.hue.red)
  32. expect(theme.categorical[2]).toBe(theme.hue.interactive)
  33. expect(theme.contextual.elevated.categorical).toBe(theme.categorical)
  34. expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
  35. expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
  36. })
  37. test("uses the default categorical order for direct definitions", () => {
  38. const theme = resolveTheme({ ...light, categorical: undefined })
  39. expect(theme.categorical[0]).toBe(theme.hue.blue)
  40. expect(theme.categorical[1]).toBe(theme.hue.purple)
  41. })
  42. test("resolves independent definitions and hue aliases", () => {
  43. const lightTheme = resolveTheme(light)
  44. const darkTheme = resolveTheme(dark)
  45. expect(lightTheme.hue.accent).not.toBe(lightTheme.hue.blue)
  46. expect(lightTheme.hue.accent[500].equals(lightTheme.hue.blue[500])).toBeTrue()
  47. expect(lightTheme.hue.interactive).not.toBe(lightTheme.hue.blue)
  48. expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
  49. expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
  50. expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
  51. expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
  52. expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
  53. expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
  54. expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
  55. expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
  56. expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
  57. expect(lightTheme.contextual.elevated.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
  58. expect(lightTheme.text.default).toBeInstanceOf(RGBA)
  59. expect(darkTheme.background.default).toBeInstanceOf(RGBA)
  60. expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
  61. expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
  62. expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
  63. expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
  64. expect(lightTheme.contextual.elevated.background.action.primary.default).toBe(
  65. lightTheme.hue.interactive[500],
  66. )
  67. expect(lightTheme.contextual.elevated.background.default).toBe(lightTheme.background.surface.offset)
  68. expect(lightTheme.contextual.elevated.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
  69. expect(lightTheme.contextual.overlay.background.action.primary.default).toBe(
  70. lightTheme.hue.interactive[500],
  71. )
  72. expect(lightTheme.contextual.overlay.background.default).toBe(lightTheme.background.surface.overlay)
  73. expect(lightTheme.contextual.overlay.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
  74. expect(darkTheme.contextual.elevated.background.action.primary.default).toBe(
  75. darkTheme.hue.interactive[400],
  76. )
  77. expect(darkTheme.contextual.elevated.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
  78. expect(darkTheme.contextual.overlay.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
  79. expect(darkTheme.contextual.overlay.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
  80. })
  81. test("resolves base hue aliases and rejects circular hue aliases", () => {
  82. const aliased = resolveTheme({
  83. ...light,
  84. hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
  85. })
  86. const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
  87. expect(aliased.hue.blue).not.toBe(aliased.hue.red)
  88. expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
  89. expect(aliased.hue.purple).not.toBe(aliased.hue.blue)
  90. expect(aliased.hue.purple[500].equals(aliased.hue.red[500])).toBeTrue()
  91. expect(overridden.hue.blue).not.toBe(overridden.hue.red)
  92. expect(overridden.hue.blue[500].equals(overridden.hue.red[500])).toBeTrue()
  93. expect(aliased.source(aliased.hue.red[500])).toEqual({ hue: "red", step: 500 })
  94. expect(aliased.source(aliased.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
  95. expect(aliased.source(aliased.hue.purple[500])).toEqual({ hue: "purple", step: 500 })
  96. expect(() =>
  97. resolveTheme({
  98. ...light,
  99. hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
  100. }),
  101. ).toThrow("Circular hue reference: red -> blue -> red")
  102. })
  103. test("steps by hue source when adjacent colors have equal values", () => {
  104. if (typeof light.hue.gray !== "object") throw new Error("Expected a concrete gray scale")
  105. const theme = resolveTheme({
  106. ...light,
  107. hue: {
  108. ...light.hue,
  109. gray: { ...light.hue.gray, 200: "#eee8d5", 300: "#eee8d5", 400: "#d3d7c6" },
  110. neutral: "$hue.gray",
  111. },
  112. })
  113. expect(theme.hue.neutral[200]).not.toBe(theme.hue.neutral[300])
  114. expect(theme.hue.neutral[200].equals(theme.hue.neutral[300])).toBeTrue()
  115. expect(theme.source(theme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
  116. expect(theme.source(theme.hue.neutral[300])).toEqual({ hue: "neutral", step: 300 })
  117. expect(theme.increase(theme.hue.neutral[200])).toBe(theme.hue.neutral[300])
  118. expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400])
  119. })
  120. test("merges partial documents with the selected OpenCode defaults", () => {
  121. const theme = resolveSource(
  122. {
  123. version: 2,
  124. light: {
  125. hue: light.hue,
  126. text: { default: "#123456" },
  127. },
  128. dark: { hue: dark.hue },
  129. },
  130. "light",
  131. )
  132. expect(theme.text.default.toInts()).toEqual([18, 52, 86, 255])
  133. expect(theme.text.subdued.toInts()).toEqual([18, 52, 86, 255])
  134. expect(theme.background.action.destructive.pressed).toBeInstanceOf(RGBA)
  135. })
  136. test("expands user structural fallbacks before merging defaults", () => {
  137. const expanded = resolveSource(
  138. {
  139. version: 2,
  140. light: {
  141. hue: light.hue,
  142. background: { action: { primary: { default: "#123456" } } },
  143. },
  144. dark: { hue: dark.hue },
  145. },
  146. "light",
  147. )
  148. const isolatedState = resolveSource(
  149. {
  150. version: 2,
  151. light: {
  152. hue: light.hue,
  153. background: { action: { primary: { $pressed: "#654321" } } },
  154. },
  155. dark: { hue: dark.hue },
  156. },
  157. "light",
  158. )
  159. expect(expanded.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
  160. expect(isolatedState.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
  161. expect(isolatedState.background.action.primary.focused.toInts()).toEqual(
  162. resolveTheme(light).background.action.primary.focused.toInts(),
  163. )
  164. })
  165. test("standalone themes skip OpenCode defaults and use the red core fallback", () => {
  166. const document = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const
  167. const lightTheme = resolveSource(document, "light")
  168. const darkTheme = resolveSource(document, "dark")
  169. expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
  170. expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
  171. expect(darkTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
  172. expect(darkTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
  173. })
  174. test("uses defaults for the selected mode when it merges the other mode", () => {
  175. const theme = resolveSource(
  176. {
  177. version: 2,
  178. light: { hue: light.hue, background: { default: "#123456" } },
  179. dark: { mergeMode: true },
  180. },
  181. "dark",
  182. )
  183. expect(theme.background.default.toInts()).toEqual([18, 52, 86, 255])
  184. })
  185. test("resolves matched action variants and states", () => {
  186. const theme = resolveTheme(light)
  187. expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
  188. expect(theme.text.action.primary.hovered).toBeInstanceOf(RGBA)
  189. expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA)
  190. expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
  191. expect(theme.background.action.primary.hovered).toBeInstanceOf(RGBA)
  192. expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA)
  193. expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA)
  194. expect(theme.background.formfield.hovered).toBeInstanceOf(RGBA)
  195. })
  196. test("resolves elevated hover surfaces from direct colors", () => {
  197. const theme = resolveSource(
  198. {
  199. version: 2,
  200. light: { background: { surface: { offset: "#123456", overlay: "#234567" } } },
  201. dark: {},
  202. },
  203. "light",
  204. )
  205. expect(theme.contextual.elevated.background.default.toInts()).toEqual([18, 52, 86, 255])
  206. expect(theme.contextual.elevated.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
  207. })
  208. test("resolves transparent colors", () => {
  209. const theme = resolveSource({
  210. version: 2,
  211. light: { background: { formfield: { default: "transparent" } } },
  212. dark: { background: { formfield: { default: "transparent" } } },
  213. })
  214. expect(theme.background.formfield.default.toInts()).toEqual([0, 0, 0, 0])
  215. })
  216. test("reports theme decoding failures as native errors", () => {
  217. expect(() =>
  218. resolveSource(
  219. {
  220. version: 2,
  221. light: { text: { default: "opaque" } },
  222. dark: {},
  223. } as never,
  224. "light",
  225. "custom",
  226. ),
  227. ).toThrow('Invalid theme: custom "opaque" is an invalid value')
  228. })
  229. test("context overrides rewire semantic references and apply state precedence", () => {
  230. const definition = override(light, {
  231. text: {
  232. default: "#111111",
  233. action: {
  234. primary: { default: "$text.default", $pressed: "#222222" },
  235. },
  236. },
  237. "@context:elevated": {
  238. text: {
  239. default: "#333333",
  240. action: { primary: { default: "#444444", $focused: "#555555" } },
  241. },
  242. },
  243. })
  244. const theme = resolveTheme(definition)
  245. const overlay = theme.contextual.elevated
  246. expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255])
  247. expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
  248. expect(overlay.text.action.primary.focused.toInts()).toEqual([85, 85, 85, 255])
  249. })
  250. test("rejects missing, base, and contextual reference cycles", () => {
  251. expect(() => resolveTheme(override(light, { text: { default: "$missing" } }))).toThrow(
  252. 'Theme reference "$missing" was not found',
  253. )
  254. expect(() =>
  255. resolveTheme(
  256. override(light, {
  257. text: { default: "$text.subdued", subdued: "$text.default" },
  258. }),
  259. ),
  260. ).toThrow("Circular theme reference")
  261. expect(() =>
  262. resolveTheme(
  263. override(light, {
  264. "@context:elevated": { text: { default: "$text.default" } },
  265. }),
  266. ),
  267. ).toThrow("Circular theme reference")
  268. })
  269. test("validates complete hues, resolved groups, and hue-only syntax", () => {
  270. expect(() =>
  271. resolveTheme({
  272. ...light,
  273. hue: { ...light.hue, accent: "$hue.missing" },
  274. } as unknown as ThemeDefinition),
  275. ).toThrow("$hue.missing")
  276. expect(() =>
  277. resolveTheme({
  278. ...light,
  279. syntax: { ...light.syntax, keyword: "$text.default" },
  280. } as unknown as ThemeDefinition),
  281. ).toThrow("$text.default")
  282. })
  283. function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
  284. return merge(base, value) as ThemeDefinition
  285. }
  286. function merge(...values: unknown[]): Record<string, unknown> {
  287. return values.reduce<Record<string, unknown>>((result, value) => {
  288. if (!value || typeof value !== "object" || Array.isArray(value)) return result
  289. for (const [key, item] of Object.entries(value)) {
  290. if (item === undefined) continue
  291. result[key] = item && typeof item === "object" && !Array.isArray(item) ? merge(result[key], item) : item
  292. }
  293. return result
  294. }, {})
  295. }