v1-migrate.test.ts 11 KB

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