v1-migrate.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { expect, test } from "bun:test"
  2. import {
  3. DEFAULT_CATEGORICAL,
  4. DEFAULT_THEME,
  5. migrateV1,
  6. resolveThemeDocument,
  7. selectThemeMode,
  8. themeModes,
  9. } from "@opencode-ai/theme/tui"
  10. import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
  11. test("migrates resolved V1 modes into V2 tokens", () => {
  12. const migrated = migrateV1(DEFAULT_THEMES.opencode)
  13. if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
  14. const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
  15. const resolved = resolveThemeDocument(migrated, "light")
  16. expect(migrated.standalone).toBeTrue()
  17. expect(migrated.light.categorical?.length).toBeGreaterThan(0)
  18. expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
  19. expect(migrated.light.hue?.accent).toMatch(/^\$hue\.[^.]+$/)
  20. expect(migrated.light.hue?.interactive).toMatch(/^\$hue\.[^.]+$/)
  21. expect(migrated.light.text?.default).toBe("$hue.neutral.800")
  22. expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
  23. expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
  24. expect(migrated.light.background?.default).toBe("$hue.neutral.200")
  25. expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.300")
  26. expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.400")
  27. expect(migrated.dark.background?.default).toBe("$hue.neutral.800")
  28. expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
  29. expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
  30. expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
  31. expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
  32. expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
  33. expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
  34. expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
  35. expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
  36. expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
  37. expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
  38. expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
  39. expect(resolved.hue.accent[800].toInts()).toEqual(legacy.accent.toInts())
  40. expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
  41. expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
  42. expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
  43. expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
  44. expect(resolved.contextual.elevated.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
  45. expect(resolved.contextual.elevated.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
  46. expect(resolved.contextual.elevated.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
  47. expect(resolved.contextual.overlay.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
  48. expect(resolved.contextual.overlay.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
  49. })
  50. test("references generated hues from matching token colors", () => {
  51. const source = structuredClone(DEFAULT_THEMES.opencode)
  52. source.theme.border = source.theme.primary
  53. source.theme.borderActive = source.theme.accent
  54. source.theme.syntaxKeyword = source.theme.error
  55. source.theme.markdownEmph = "#123456"
  56. const migrated = migrateV1(source)
  57. if (!migrated.light) throw new Error("Expected light mode")
  58. expect(migrated.light.border?.default).toBe("$hue.interactive.800")
  59. expect(migrated.light.scrollbar?.default).toBe("$hue.accent.800")
  60. expect(migrated.light.syntax?.keyword).toMatch(/^\$hue\.[^.]+\.800$/)
  61. expect(migrated.light.markdown?.emphasis).toBe("#123456")
  62. })
  63. test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
  64. const source = structuredClone(DEFAULT_THEMES.opencode)
  65. const ambiguous = { light: "#808080", dark: "#808080" }
  66. source.theme.accent = ambiguous
  67. source.theme.warning = ambiguous
  68. source.theme.primary = ambiguous
  69. source.theme.error = ambiguous
  70. source.theme.info = ambiguous
  71. source.theme.secondary = "transparent"
  72. source.theme.success = { light: "#ff6666", dark: "#450000" }
  73. const migrated = migrateV1(source)
  74. if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
  75. const lightRed = migrated.light.hue?.red
  76. const darkRed = migrated.dark.hue?.red
  77. if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
  78. expect(lightRed[800]).toBe("#ff6666")
  79. expect(darkRed[200]).toBe("#450000")
  80. expect(lightRed[900]).not.toBe(lightRed[800])
  81. expect(darkRed[100]).not.toBe(darkRed[200])
  82. expect(migrated.light.hue?.orange).toBe("$hue.gray")
  83. expect(migrated.light.hue?.yellow).toBe("$hue.gray")
  84. expect(migrated.light.hue?.green).toBe("$hue.gray")
  85. expect(migrated.light.hue?.cyan).toBe("$hue.gray")
  86. expect(migrated.light.hue?.blue).toBe("$hue.gray")
  87. expect(migrated.light.hue?.purple).toBe("$hue.gray")
  88. expect(migrated.light.hue?.accent).toBe("$hue.gray")
  89. expect(migrated.light.hue?.interactive).toBe("$hue.gray")
  90. expect(() => resolveThemeDocument(migrated, "light")).not.toThrow()
  91. expect(() => resolveThemeDocument(migrated, "dark")).not.toThrow()
  92. })
  93. test("orders categorical hues by V1 semantic color mapping", () => {
  94. const source = structuredClone(DEFAULT_THEMES.opencode)
  95. const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
  96. light: DEFAULT_THEME.light.hue[name][700],
  97. dark: DEFAULT_THEME.dark.hue[name][300],
  98. })
  99. source.theme.secondary = mapped("purple")
  100. source.theme.accent = mapped("orange")
  101. source.theme.success = mapped("green")
  102. source.theme.warning = mapped("yellow")
  103. source.theme.primary = mapped("blue")
  104. source.theme.error = mapped("red")
  105. const migrated = migrateV1(source)
  106. expect(migrated.light?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
  107. expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
  108. source.theme.accent = source.theme.secondary
  109. expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
  110. })
  111. test("gives accent and primary ownership of their inferred hues", () => {
  112. const source = structuredClone(DEFAULT_THEMES.opencode)
  113. source.theme.success = DEFAULT_THEME.light.hue.orange[300]
  114. source.theme.accent = DEFAULT_THEME.light.hue.orange[400]
  115. source.theme.info = DEFAULT_THEME.light.hue.blue[300]
  116. source.theme.primary = DEFAULT_THEME.light.hue.blue[400]
  117. const migrated = migrateV1(source)
  118. if (!migrated.light) throw new Error("Expected light mode")
  119. const orange = migrated.light.hue?.orange
  120. const blue = migrated.light.hue?.blue
  121. if (typeof orange !== "object" || typeof blue !== "object") throw new Error("Expected concrete hue scales")
  122. expect(orange[800]).toBe(source.theme.accent)
  123. expect(blue[800]).toBe(source.theme.primary)
  124. expect(migrated.light.hue?.accent).toBe("$hue.orange")
  125. expect(migrated.light.hue?.interactive).toBe("$hue.blue")
  126. source.theme.primary = DEFAULT_THEME.light.hue.orange[500]
  127. const collisionMode = migrateV1(source).light
  128. const collision = collisionMode?.hue?.orange
  129. if (typeof collision !== "object") throw new Error("Expected concrete orange scale")
  130. expect(collision[800]).toBe(source.theme.primary)
  131. expect(collisionMode?.hue?.accent).toBe("$hue.orange")
  132. expect(collisionMode?.hue?.interactive).toBe("$hue.orange")
  133. })
  134. test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
  135. const source = structuredClone(DEFAULT_THEMES.opencode)
  136. source.theme.secondary = "transparent"
  137. source.theme.accent = "transparent"
  138. source.theme.success = "transparent"
  139. source.theme.warning = "transparent"
  140. source.theme.primary = "transparent"
  141. source.theme.error = "transparent"
  142. const migrated = migrateV1(source)
  143. expect(migrated.light?.categorical).toEqual(DEFAULT_CATEGORICAL)
  144. expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
  145. })
  146. test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
  147. const source = structuredClone(DEFAULT_THEMES.opencode)
  148. source.theme.background = { light: "#eeeeee", dark: "#111111" }
  149. source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
  150. source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
  151. source.theme.textMuted = { light: "#777777", dark: "#999999" }
  152. source.theme.text = { light: "#333333", dark: "#dddddd" }
  153. source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
  154. const light = resolveV1(source, "light")
  155. const dark = resolveV1(source, "dark")
  156. const migrated = migrateV1(source)
  157. if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
  158. const lightGray = migrated.light.hue?.gray
  159. const darkGray = migrated.dark.hue?.gray
  160. if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
  161. expect(lightGray[100]).not.toBe(lightGray[200])
  162. expect(lightGray[200]).toBe(hex(light.background))
  163. expect(lightGray[300]).toBe(hex(light.backgroundPanel))
  164. expect(lightGray[400]).toBe(hex(light.backgroundElement))
  165. expect(lightGray[600]).toBe(hex(light.textMuted))
  166. expect(lightGray[800]).toBe(hex(light.text))
  167. expect(lightGray[900]).not.toBe(lightGray[800])
  168. expect(darkGray[100]).not.toBe(darkGray[200])
  169. expect(darkGray[200]).toBe(hex(dark.text))
  170. expect(darkGray[400]).toBe(hex(dark.textMuted))
  171. expect(darkGray[600]).toBe(hex(dark.backgroundElement))
  172. expect(darkGray[700]).toBe(hex(dark.backgroundPanel))
  173. expect(darkGray[800]).toBe(hex(dark.background))
  174. expect(darkGray[900]).not.toBe(darkGray[800])
  175. source.theme.borderSubtle = "#ff00ff"
  176. source.theme.border = "#00ff00"
  177. source.theme.borderActive = "#00ffff"
  178. const withBorders = migrateV1(source)
  179. expect(withBorders.light?.hue?.gray).toEqual(lightGray)
  180. expect(withBorders.dark?.hue?.gray).toEqual(darkGray)
  181. })
  182. test("uses the default text reference for primary actions on transparent backgrounds", () => {
  183. const source = structuredClone(DEFAULT_THEMES.opencode)
  184. source.theme.background = "transparent"
  185. source.theme.primary = { light: "#ffffff", dark: "#000000" }
  186. delete source.theme.selectedListItemText
  187. const migrated = migrateV1(source)
  188. if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
  189. expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
  190. expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
  191. })
  192. test("retains V1 circular reference errors", () => {
  193. const source = structuredClone(DEFAULT_THEMES.opencode)
  194. source.defs = { ...source.defs, one: "two", two: "one" }
  195. source.theme.primary = "one"
  196. expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
  197. })
  198. test("migrates every built-in V1 theme in its supported modes", () => {
  199. for (const source of Object.values(DEFAULT_THEMES)) {
  200. const migrated = migrateV1(source)
  201. for (const mode of themeModes(migrated)) {
  202. expect(resolveThemeDocument(migrated, mode).text.default).toBeDefined()
  203. }
  204. }
  205. })
  206. test("collapses identical V1 backgrounds when both variants infer one mode", () => {
  207. const dark = structuredClone(DEFAULT_THEMES.opencode)
  208. dark.theme.background = "#111111"
  209. dark.theme.text = "#eeeeee"
  210. const migratedDark = migrateV1(dark)
  211. expect(migratedDark.light).toBeUndefined()
  212. expect(migratedDark.dark).toBeDefined()
  213. expect(themeModes(migratedDark)).toEqual(["dark"])
  214. expect(selectThemeMode(migratedDark, "light").mode).toBe("dark")
  215. const light = structuredClone(DEFAULT_THEMES.opencode)
  216. light.theme.background = "#eeeeee"
  217. light.theme.text = "#111111"
  218. const migratedLight = migrateV1(light)
  219. expect(migratedLight.light).toBeDefined()
  220. expect(migratedLight.dark).toBeUndefined()
  221. expect(themeModes(migratedLight)).toEqual(["light"])
  222. expect(selectThemeMode(migratedLight, "dark").mode).toBe("light")
  223. })
  224. test("keeps both modes when a shared background has different contrast", () => {
  225. const source = structuredClone(DEFAULT_THEMES.opencode)
  226. source.theme.background = "#808080"
  227. source.theme.text = { light: "#111111", dark: "#eeeeee" }
  228. const migrated = migrateV1(source)
  229. expect(themeModes(migrated)).toEqual(["light", "dark"])
  230. })
  231. function hex(color: { toInts(): [number, number, number, number] }) {
  232. const [r, g, b, a] = color.toInts()
  233. const byte = (value: number) => value.toString(16).padStart(2, "0")
  234. return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
  235. }