@miragon/mcp-toolkit-ui 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/README.md +7 -1
  2. package/dist/app/builder/BuilderBanners.d.ts +7 -2
  3. package/dist/app/builder/BuilderBanners.d.ts.map +1 -1
  4. package/dist/app/builder/BuilderBanners.js +8 -3
  5. package/dist/app/builder/BuilderBanners.js.map +1 -1
  6. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  7. package/dist/app/builder/CanvasRow.js +12 -14
  8. package/dist/app/builder/CanvasRow.js.map +1 -1
  9. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  10. package/dist/app/builder/CatalogueSheet.js +6 -5
  11. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  12. package/dist/app/builder/EmptyHint.d.ts +4 -0
  13. package/dist/app/builder/EmptyHint.d.ts.map +1 -1
  14. package/dist/app/builder/EmptyHint.js +6 -1
  15. package/dist/app/builder/EmptyHint.js.map +1 -1
  16. package/dist/app/builder/LayoutSurface.js +3 -3
  17. package/dist/app/builder/LayoutSurface.js.map +1 -1
  18. package/dist/app/builder/PipelineStrip.d.ts +3 -1
  19. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  20. package/dist/app/builder/PipelineStrip.js +11 -6
  21. package/dist/app/builder/PipelineStrip.js.map +1 -1
  22. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  23. package/dist/app/builder/SaveDialog.js +2 -2
  24. package/dist/app/builder/SaveDialog.js.map +1 -1
  25. package/dist/app/builder/Toolbar.d.ts +3 -1
  26. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  27. package/dist/app/builder/Toolbar.js +9 -3
  28. package/dist/app/builder/Toolbar.js.map +1 -1
  29. package/dist/app/builder/Workspace.js +2 -2
  30. package/dist/app/builder/Workspace.js.map +1 -1
  31. package/dist/app/builder/builder-model.d.ts +3 -2
  32. package/dist/app/builder/builder-model.d.ts.map +1 -1
  33. package/dist/app/builder/builder-model.js +5 -4
  34. package/dist/app/builder/builder-model.js.map +1 -1
  35. package/dist/app/builder/labels.d.ts +13 -62
  36. package/dist/app/builder/labels.d.ts.map +1 -1
  37. package/dist/app/builder/labels.js +9 -58
  38. package/dist/app/builder/labels.js.map +1 -1
  39. package/dist/app/builder/status-chip.d.ts.map +1 -1
  40. package/dist/app/builder/status-chip.js +3 -2
  41. package/dist/app/builder/status-chip.js.map +1 -1
  42. package/dist/app/builder/use-builder-catalogue.d.ts +1 -0
  43. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  44. package/dist/app/builder/use-builder-catalogue.js +6 -0
  45. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  46. package/dist/app/builder/use-save-dashboard.d.ts +3 -1
  47. package/dist/app/builder/use-save-dashboard.d.ts.map +1 -1
  48. package/dist/app/builder/use-save-dashboard.js +3 -1
  49. package/dist/app/builder/use-save-dashboard.js.map +1 -1
  50. package/dist/app/index.d.ts +1 -1
  51. package/dist/app/index.d.ts.map +1 -1
  52. package/dist/app/index.js.map +1 -1
  53. package/dist/app/layout-builder.d.ts +7 -2
  54. package/dist/app/layout-builder.d.ts.map +1 -1
  55. package/dist/app/layout-builder.js +17 -5
  56. package/dist/app/layout-builder.js.map +1 -1
  57. package/dist/app/mcp-app-view-chrome.d.ts +94 -20
  58. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  59. package/dist/app/mcp-app-view-chrome.js +109 -40
  60. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  61. package/dist/app/mcp-app-view-states.d.ts +15 -0
  62. package/dist/app/mcp-app-view-states.d.ts.map +1 -0
  63. package/dist/app/mcp-app-view-states.js +34 -0
  64. package/dist/app/mcp-app-view-states.js.map +1 -0
  65. package/dist/app/mcp-app-view.d.ts +40 -5
  66. package/dist/app/mcp-app-view.d.ts.map +1 -1
  67. package/dist/app/mcp-app-view.js +174 -128
  68. package/dist/app/mcp-app-view.js.map +1 -1
  69. package/dist/app/mcp-toolkit-app.d.ts +2 -1
  70. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  71. package/dist/app/mcp-toolkit-app.js +25 -3
  72. package/dist/app/mcp-toolkit-app.js.map +1 -1
  73. package/dist/app/use-view-lifecycle.d.ts +38 -0
  74. package/dist/app/use-view-lifecycle.d.ts.map +1 -0
  75. package/dist/app/use-view-lifecycle.js +79 -0
  76. package/dist/app/use-view-lifecycle.js.map +1 -0
  77. package/dist/app/view-lifecycle.d.ts +109 -0
  78. package/dist/app/view-lifecycle.d.ts.map +1 -0
  79. package/dist/app/view-lifecycle.js +120 -0
  80. package/dist/app/view-lifecycle.js.map +1 -0
  81. package/dist/app/widget-props-sheet.d.ts +3 -9
  82. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  83. package/dist/app/widget-props-sheet.js +6 -13
  84. package/dist/app/widget-props-sheet.js.map +1 -1
  85. package/dist/app/widget-renderer.d.ts +41 -1
  86. package/dist/app/widget-renderer.d.ts.map +1 -1
  87. package/dist/app/widget-renderer.js +82 -31
  88. package/dist/app/widget-renderer.js.map +1 -1
  89. package/dist/components/DrillButton.d.ts.map +1 -1
  90. package/dist/components/DrillButton.js +2 -1
  91. package/dist/components/DrillButton.js.map +1 -1
  92. package/dist/components/EmptyState.d.ts +33 -0
  93. package/dist/components/EmptyState.d.ts.map +1 -0
  94. package/dist/components/EmptyState.js +17 -0
  95. package/dist/components/EmptyState.js.map +1 -0
  96. package/dist/components/ErrorState.d.ts +50 -0
  97. package/dist/components/ErrorState.d.ts.map +1 -0
  98. package/dist/components/ErrorState.js +60 -0
  99. package/dist/components/ErrorState.js.map +1 -0
  100. package/dist/components/FilterBar.d.ts +1 -1
  101. package/dist/components/FilterBar.d.ts.map +1 -1
  102. package/dist/components/FilterBar.js +7 -4
  103. package/dist/components/FilterBar.js.map +1 -1
  104. package/dist/components/GroupCard.d.ts.map +1 -1
  105. package/dist/components/GroupCard.js +2 -1
  106. package/dist/components/GroupCard.js.map +1 -1
  107. package/dist/components/KpiGrid.d.ts +10 -3
  108. package/dist/components/KpiGrid.d.ts.map +1 -1
  109. package/dist/components/KpiGrid.js +31 -9
  110. package/dist/components/KpiGrid.js.map +1 -1
  111. package/dist/components/ListFooter.d.ts +3 -2
  112. package/dist/components/ListFooter.d.ts.map +1 -1
  113. package/dist/components/ListFooter.js +14 -4
  114. package/dist/components/ListFooter.js.map +1 -1
  115. package/dist/components/Pills.d.ts +10 -6
  116. package/dist/components/Pills.d.ts.map +1 -1
  117. package/dist/components/Pills.js +12 -6
  118. package/dist/components/Pills.js.map +1 -1
  119. package/dist/components/Toast.d.ts +37 -0
  120. package/dist/components/Toast.d.ts.map +1 -0
  121. package/dist/components/Toast.js +91 -0
  122. package/dist/components/Toast.js.map +1 -0
  123. package/dist/components/WidgetHeader.js +1 -1
  124. package/dist/hooks/use-tool-result-recovery.d.ts +23 -1
  125. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  126. package/dist/hooks/use-tool-result-recovery.js +21 -7
  127. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  128. package/dist/hooks/use-view-data.d.ts +6 -0
  129. package/dist/hooks/use-view-data.d.ts.map +1 -1
  130. package/dist/hooks/use-view-data.js +6 -1
  131. package/dist/hooks/use-view-data.js.map +1 -1
  132. package/dist/index.d.ts +12 -5
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +14 -4
  135. package/dist/index.js.map +1 -1
  136. package/dist/lib/focus-ring.d.ts +18 -0
  137. package/dist/lib/focus-ring.d.ts.map +1 -0
  138. package/dist/lib/focus-ring.js +18 -0
  139. package/dist/lib/focus-ring.js.map +1 -0
  140. package/dist/lib/icon-stroke.d.ts +12 -0
  141. package/dist/lib/icon-stroke.d.ts.map +1 -0
  142. package/dist/lib/icon-stroke.js +12 -0
  143. package/dist/lib/icon-stroke.js.map +1 -0
  144. package/dist/lib/toast-model.d.ts +46 -0
  145. package/dist/lib/toast-model.d.ts.map +1 -0
  146. package/dist/lib/toast-model.js +36 -0
  147. package/dist/lib/toast-model.js.map +1 -0
  148. package/dist/lib/tone-utils.d.ts +43 -9
  149. package/dist/lib/tone-utils.d.ts.map +1 -1
  150. package/dist/lib/tone-utils.js +36 -7
  151. package/dist/lib/tone-utils.js.map +1 -1
  152. package/dist/locale/index.d.ts +3 -1
  153. package/dist/locale/index.d.ts.map +1 -1
  154. package/dist/locale/index.js +2 -1
  155. package/dist/locale/index.js.map +1 -1
  156. package/dist/locale/locale-provider.d.ts +42 -1
  157. package/dist/locale/locale-provider.d.ts.map +1 -1
  158. package/dist/locale/locale-provider.js +44 -3
  159. package/dist/locale/locale-provider.js.map +1 -1
  160. package/dist/locale/message-types.d.ts +223 -0
  161. package/dist/locale/message-types.d.ts.map +1 -0
  162. package/dist/locale/message-types.js +7 -0
  163. package/dist/locale/message-types.js.map +1 -0
  164. package/dist/locale/messages-de.d.ts +9 -0
  165. package/dist/locale/messages-de.d.ts.map +1 -0
  166. package/dist/locale/messages-de.js +151 -0
  167. package/dist/locale/messages-de.js.map +1 -0
  168. package/dist/locale/messages-en.d.ts +8 -0
  169. package/dist/locale/messages-en.d.ts.map +1 -0
  170. package/dist/locale/messages-en.js +150 -0
  171. package/dist/locale/messages-en.js.map +1 -0
  172. package/dist/locale/toolkit-messages.d.ts +39 -0
  173. package/dist/locale/toolkit-messages.d.ts.map +1 -0
  174. package/dist/locale/toolkit-messages.js +50 -0
  175. package/dist/locale/toolkit-messages.js.map +1 -0
  176. package/dist/primitives/alert.d.ts.map +1 -1
  177. package/dist/primitives/alert.js +4 -2
  178. package/dist/primitives/alert.js.map +1 -1
  179. package/dist/primitives/badge.d.ts +1 -1
  180. package/dist/primitives/badge.d.ts.map +1 -1
  181. package/dist/primitives/badge.js +14 -6
  182. package/dist/primitives/badge.js.map +1 -1
  183. package/dist/primitives/button.d.ts.map +1 -1
  184. package/dist/primitives/button.js +12 -8
  185. package/dist/primitives/button.js.map +1 -1
  186. package/dist/primitives/card.js +4 -4
  187. package/dist/primitives/card.js.map +1 -1
  188. package/dist/primitives/dialog.d.ts +7 -3
  189. package/dist/primitives/dialog.d.ts.map +1 -1
  190. package/dist/primitives/dialog.js +14 -7
  191. package/dist/primitives/dialog.js.map +1 -1
  192. package/dist/primitives/input.d.ts.map +1 -1
  193. package/dist/primitives/input.js +4 -1
  194. package/dist/primitives/input.js.map +1 -1
  195. package/dist/primitives/scroll-area.js +1 -1
  196. package/dist/primitives/scroll-area.js.map +1 -1
  197. package/dist/primitives/select.d.ts.map +1 -1
  198. package/dist/primitives/select.js +5 -2
  199. package/dist/primitives/select.js.map +1 -1
  200. package/dist/primitives/sheet.d.ts +3 -1
  201. package/dist/primitives/sheet.d.ts.map +1 -1
  202. package/dist/primitives/sheet.js +12 -6
  203. package/dist/primitives/sheet.js.map +1 -1
  204. package/dist/primitives/skeleton.js +1 -1
  205. package/dist/primitives/skeleton.js.map +1 -1
  206. package/dist/primitives/switch.d.ts.map +1 -1
  207. package/dist/primitives/switch.js +2 -1
  208. package/dist/primitives/switch.js.map +1 -1
  209. package/dist/primitives/tabs.d.ts.map +1 -1
  210. package/dist/primitives/tabs.js +5 -3
  211. package/dist/primitives/tabs.js.map +1 -1
  212. package/dist/render-cases.d.ts.map +1 -1
  213. package/dist/render-cases.js +20 -2
  214. package/dist/render-cases.js.map +1 -1
  215. package/dist/render-scan.d.ts +25 -0
  216. package/dist/render-scan.d.ts.map +1 -0
  217. package/dist/render-scan.js +66 -0
  218. package/dist/render-scan.js.map +1 -0
  219. package/dist/theme/cd-tokens.d.ts +66 -0
  220. package/dist/theme/cd-tokens.d.ts.map +1 -0
  221. package/dist/theme/cd-tokens.js +64 -0
  222. package/dist/theme/cd-tokens.js.map +1 -0
  223. package/dist/theme/create-theme.d.ts +196 -39
  224. package/dist/theme/create-theme.d.ts.map +1 -1
  225. package/dist/theme/create-theme.js +189 -13
  226. package/dist/theme/create-theme.js.map +1 -1
  227. package/dist/theme/fonts.d.ts +38 -0
  228. package/dist/theme/fonts.d.ts.map +1 -0
  229. package/dist/theme/fonts.js +56 -0
  230. package/dist/theme/fonts.js.map +1 -0
  231. package/dist/theme/host-fonts.d.ts +39 -0
  232. package/dist/theme/host-fonts.d.ts.map +1 -0
  233. package/dist/theme/host-fonts.js +44 -0
  234. package/dist/theme/host-fonts.js.map +1 -0
  235. package/dist/theme/index.d.ts +8 -1
  236. package/dist/theme/index.d.ts.map +1 -1
  237. package/dist/theme/index.js +5 -1
  238. package/dist/theme/index.js.map +1 -1
  239. package/dist/theme/portal-container.d.ts +16 -0
  240. package/dist/theme/portal-container.d.ts.map +1 -0
  241. package/dist/theme/portal-container.js +19 -0
  242. package/dist/theme/portal-container.js.map +1 -0
  243. package/dist/theme/presets.d.ts +24 -9
  244. package/dist/theme/presets.d.ts.map +1 -1
  245. package/dist/theme/presets.js +44 -11
  246. package/dist/theme/presets.js.map +1 -1
  247. package/dist/theme/theme-provider.d.ts +27 -9
  248. package/dist/theme/theme-provider.d.ts.map +1 -1
  249. package/dist/theme/theme-provider.js +42 -20
  250. package/dist/theme/theme-provider.js.map +1 -1
  251. package/package.json +9 -4
  252. package/src/app/builder/BuilderBanners.tsx +26 -13
  253. package/src/app/builder/CanvasRow.tsx +22 -21
  254. package/src/app/builder/CatalogueSheet.tsx +16 -14
  255. package/src/app/builder/EmptyHint.tsx +16 -11
  256. package/src/app/builder/LayoutSurface.tsx +7 -7
  257. package/src/app/builder/PipelineStrip.tsx +26 -17
  258. package/src/app/builder/SaveDialog.tsx +11 -12
  259. package/src/app/builder/Toolbar.tsx +19 -7
  260. package/src/app/builder/Workspace.tsx +5 -3
  261. package/src/app/builder/builder-feedback.test.tsx +75 -0
  262. package/src/app/builder/builder-model.test.ts +5 -0
  263. package/src/app/builder/builder-model.ts +5 -4
  264. package/src/app/builder/labels.ts +15 -121
  265. package/src/app/builder/status-chip.tsx +8 -4
  266. package/src/app/builder/use-builder-catalogue.ts +6 -0
  267. package/src/app/builder/use-save-dashboard.ts +5 -0
  268. package/src/app/index.ts +8 -1
  269. package/src/app/layout-builder.tsx +23 -3
  270. package/src/app/mcp-app-view-chrome.test.tsx +347 -14
  271. package/src/app/mcp-app-view-chrome.tsx +278 -100
  272. package/src/app/mcp-app-view-states.tsx +100 -0
  273. package/src/app/mcp-app-view.test.tsx +258 -0
  274. package/src/app/mcp-app-view.tsx +292 -185
  275. package/src/app/mcp-toolkit-app.tsx +31 -7
  276. package/src/app/use-view-lifecycle.ts +116 -0
  277. package/src/app/view-lifecycle.test.ts +227 -0
  278. package/src/app/view-lifecycle.ts +174 -0
  279. package/src/app/widget-props-sheet.tsx +13 -26
  280. package/src/app/widget-renderer.render.test.tsx +131 -0
  281. package/src/app/widget-renderer.tsx +136 -38
  282. package/src/components/DrillButton.tsx +2 -1
  283. package/src/components/EmptyState.tsx +81 -0
  284. package/src/components/ErrorState.tsx +182 -0
  285. package/src/components/FilterBar.tsx +9 -6
  286. package/src/components/GroupCard.tsx +3 -2
  287. package/src/components/KpiGrid.tsx +75 -44
  288. package/src/components/ListFooter.tsx +17 -6
  289. package/src/components/Pills.tsx +20 -11
  290. package/src/components/Toast.tsx +188 -0
  291. package/src/components/WidgetHeader.tsx +1 -1
  292. package/src/components/feedback-states.test.tsx +225 -0
  293. package/src/components/tone-figures.test.tsx +54 -0
  294. package/src/fonts.css +19 -0
  295. package/src/globals.css +9 -165
  296. package/src/hooks/use-tool-result-recovery.test.ts +25 -1
  297. package/src/hooks/use-tool-result-recovery.ts +34 -6
  298. package/src/hooks/use-view-data.test.tsx +60 -0
  299. package/src/hooks/use-view-data.ts +12 -1
  300. package/src/i18n-coverage.test.tsx +323 -0
  301. package/src/index.ts +57 -5
  302. package/src/lib/focus-ring.ts +20 -0
  303. package/src/lib/icon-stroke.test.ts +59 -0
  304. package/src/lib/icon-stroke.ts +13 -0
  305. package/src/lib/toast-model.test.ts +68 -0
  306. package/src/lib/toast-model.ts +63 -0
  307. package/src/lib/tone-utils.test.ts +47 -14
  308. package/src/lib/tone-utils.ts +61 -13
  309. package/src/locale/index.ts +13 -1
  310. package/src/locale/locale-provider.test.tsx +87 -0
  311. package/src/locale/locale-provider.tsx +90 -3
  312. package/src/locale/message-types.ts +221 -0
  313. package/src/locale/messages-de.ts +159 -0
  314. package/src/locale/messages-en.ts +157 -0
  315. package/src/locale/toolkit-messages.test.ts +216 -0
  316. package/src/locale/toolkit-messages.ts +82 -0
  317. package/src/no-hardcoded-copy.test.ts +432 -0
  318. package/src/primitives/alert.tsx +4 -2
  319. package/src/primitives/badge.tsx +14 -7
  320. package/src/primitives/button.tsx +13 -8
  321. package/src/primitives/card.tsx +4 -4
  322. package/src/primitives/dialog.tsx +32 -7
  323. package/src/primitives/input.tsx +5 -3
  324. package/src/primitives/scroll-area.tsx +1 -1
  325. package/src/primitives/select.tsx +6 -3
  326. package/src/primitives/sheet.tsx +26 -6
  327. package/src/primitives/skeleton.tsx +1 -1
  328. package/src/primitives/switch.tsx +4 -2
  329. package/src/primitives/tabs.tsx +9 -6
  330. package/src/render-cases.tsx +45 -1
  331. package/src/render-scan.ts +75 -0
  332. package/src/theme/cd-tokens.generated.css +48 -0
  333. package/src/theme/cd-tokens.test.ts +107 -0
  334. package/src/theme/cd-tokens.ts +67 -0
  335. package/src/theme/create-theme.test.ts +243 -5
  336. package/src/theme/create-theme.ts +313 -41
  337. package/src/theme/fonts.test.ts +85 -0
  338. package/src/theme/fonts.ts +72 -0
  339. package/src/theme/host-fonts.test.ts +135 -0
  340. package/src/theme/host-fonts.ts +52 -0
  341. package/src/theme/index.ts +15 -1
  342. package/src/theme/portal-container.ts +20 -0
  343. package/src/theme/presets.ts +44 -11
  344. package/src/theme/theme-css.test.ts +568 -0
  345. package/src/theme/theme-provider.test.tsx +82 -0
  346. package/src/theme/theme-provider.tsx +60 -24
  347. package/src/theme.css +314 -0
  348. package/ui-catalog.json +344 -17
@@ -0,0 +1,568 @@
1
+ /// <reference types="node" />
2
+ import { readFileSync } from "node:fs"
3
+ import { describe, expect, it } from "vitest"
4
+ import {
5
+ MODE_INVARIANT_VARS,
6
+ ROLE_TOKENS,
7
+ TOKEN_TO_CSS_VAR,
8
+ resolveThemeVars,
9
+ type ThemeVars,
10
+ } from "./create-theme.js"
11
+ import { HOST_FONT_VARIABLES } from "./host-fonts.js"
12
+ import { themePresets } from "./presets.js"
13
+
14
+ /**
15
+ * The role-token layer (`theme.css`) against its contract and the Miragon CI:
16
+ * every role defined in light and — for colours — dark, pointing only at the
17
+ * vendored `--cd-*` tokens, and every role/tone pair the kit actually renders
18
+ * meeting WCAG contrast (text 4.5:1, non-text 3:1) in BOTH modes, computed
19
+ * from the vendored token values — for the default theme and for every
20
+ * shipped preset.
21
+ */
22
+
23
+ const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8")
24
+ const themeCss = read("../theme.css")
25
+ const globalsCss = read("../globals.css")
26
+ const fontsCss = read("../fonts.css")
27
+ const cdCss = read("./cd-tokens.generated.css")
28
+
29
+ const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "")
30
+ const rules = stripComments(themeCss)
31
+
32
+ const LIGHT_SELECTOR =
33
+ ':root, :where(.dark, [data-theme="dark"], [data-theme-mode="dark"]) [data-theme-mode="light"]'
34
+ const DARK_SELECTOR = '.dark, [data-theme="dark"], [data-theme-mode="dark"]'
35
+ const norm = (s: string) => s.replace(/\s+/g, " ").trim()
36
+
37
+ /** Every innermost block of theme.css: its normalized selector, offset and declarations. */
38
+ const BLOCKS = [...rules.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => {
39
+ // The prelude runs back to the previous brace; drop finished statements
40
+ // (`@import …;`, `@custom-variant …;`) in front of the selector.
41
+ const selector = norm((match[1] as string).split(";").pop() ?? "")
42
+ const decls: Record<string, string> = {}
43
+ for (const decl of (match[2] as string).split(";")) {
44
+ const at = decl.indexOf(":")
45
+ if (at < 0) continue
46
+ const name = decl.slice(0, at).trim()
47
+ if (name.startsWith("--") || name === "color-scheme") decls[name] = norm(decl.slice(at + 1))
48
+ }
49
+ return { selector, offset: match.index, decls }
50
+ })
51
+
52
+ /** Declarations of the innermost block whose (normalized) selector is `selector`. */
53
+ function block(selector: string): Record<string, string> {
54
+ const found = BLOCKS.find((b) => b.selector === norm(selector))
55
+ if (!found) throw new Error(`theme.css has no block "${selector}"`)
56
+ return found.decls
57
+ }
58
+
59
+ const ROOT = block(":root")
60
+ const LIGHT = block(LIGHT_SELECTOR)
61
+ const DARK = block(DARK_SELECTOR)
62
+ const THEME_INLINE = block("@theme inline")
63
+ const THEME_REFERENCE = block("@theme inline reference")
64
+
65
+ const CD: Record<string, string> = {}
66
+ for (const m of cdCss.matchAll(/(--cd-[a-z0-9-]+):\s*([^;]+);/g))
67
+ CD[m[1] as string] = m[2] as string
68
+
69
+ // ── Colour maths (sRGB, WCAG 2.x relative luminance) ──────────────────────────
70
+
71
+ type Rgba = { r: number; g: number; b: number; a: number }
72
+
73
+ function hex(value: string): Rgba {
74
+ const h = value.trim().replace(/^#/, "")
75
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) throw new Error(`not a #rrggbb colour: ${value}`)
76
+ return {
77
+ r: parseInt(h.slice(0, 2), 16),
78
+ g: parseInt(h.slice(2, 4), 16),
79
+ b: parseInt(h.slice(4, 6), 16),
80
+ a: 1,
81
+ }
82
+ }
83
+
84
+ /** CSS Color 4 `oklch(L C H)` → sRGB (gamut-clipped), for preset values. */
85
+ function oklch(l: number, c: number, h: number): Rgba {
86
+ const rad = (h * Math.PI) / 180
87
+ const a = c * Math.cos(rad)
88
+ const b = c * Math.sin(rad)
89
+ const l3 = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
90
+ const m3 = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
91
+ const s3 = (l - 0.0894841775 * a - 1.291485548 * b) ** 3
92
+ const encode = (linear: number) => {
93
+ const v = Math.min(1, Math.max(0, linear))
94
+ return 255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)
95
+ }
96
+ return {
97
+ r: encode(4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3),
98
+ g: encode(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3),
99
+ b: encode(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3),
100
+ a: 1,
101
+ }
102
+ }
103
+
104
+ const TRANSPARENT: Rgba = { r: 0, g: 0, b: 0, a: 0 }
105
+
106
+ /**
107
+ * A role value → colour. theme.css uses var(--cd-*), color-mix(in srgb, …) and
108
+ * transparent; a theme (preset) may also use #rrggbb and oklch(L C H).
109
+ */
110
+ function colour(value: string): Rgba {
111
+ const v = value.trim()
112
+ if (v === "transparent") return TRANSPARENT
113
+ const ref = /^var\((--cd-[a-z0-9-]+)\)$/.exec(v)
114
+ if (ref) {
115
+ const cd = CD[ref[1] as string]
116
+ if (!cd) throw new Error(`unknown token ${ref[1]}`)
117
+ return hex(cd)
118
+ }
119
+ if (v.startsWith("#")) return hex(v)
120
+ const lch = /^oklch\(([\d.]+) ([\d.]+) ([\d.]+)\)$/.exec(v)
121
+ if (lch) return oklch(Number(lch[1]), Number(lch[2]), Number(lch[3]))
122
+ const mix = /^color-mix\(in srgb, (.+) (\d+(?:\.\d+)?)%, (.+)\)$/.exec(v)
123
+ if (mix) {
124
+ const a = colour(mix[1] as string)
125
+ const b = colour(mix[3] as string)
126
+ const p = Number(mix[2]) / 100
127
+ // Premultiplied interpolation (CSS Color 5): mixing with transparent keeps
128
+ // the colour and scales its alpha.
129
+ const alpha = a.a * p + b.a * (1 - p)
130
+ if (alpha === 0) return TRANSPARENT
131
+ const ch = (k: "r" | "g" | "b") => (a[k] * a.a * p + b[k] * b.a * (1 - p)) / alpha
132
+ return { r: ch("r"), g: ch("g"), b: ch("b"), a: alpha }
133
+ }
134
+ throw new Error(`value outside the token grammar: ${v}`)
135
+ }
136
+
137
+ /** `top` painted over an opaque `bottom`. */
138
+ function over(top: Rgba, bottom: Rgba): Rgba {
139
+ const a = top.a
140
+ return {
141
+ r: top.r * a + bottom.r * (1 - a),
142
+ g: top.g * a + bottom.g * (1 - a),
143
+ b: top.b * a + bottom.b * (1 - a),
144
+ a: 1,
145
+ }
146
+ }
147
+
148
+ function luminance(c: Rgba): number {
149
+ const lin = (v: number) => {
150
+ const s = v / 255
151
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
152
+ }
153
+ return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
154
+ }
155
+
156
+ function contrast(a: Rgba, b: Rgba): number {
157
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]
158
+ return (hi + 0.05) / (lo + 0.05)
159
+ }
160
+
161
+ type Mode = "light" | "dark"
162
+ const MODES: Mode[] = ["light", "dark"]
163
+
164
+ /**
165
+ * A role's value in `mode`: the theme scope's own variables first (`theme`,
166
+ * what resolveThemeVars puts on a ThemeProvider), then theme.css (dark falls
167
+ * back to light/root where not redeclared).
168
+ */
169
+ function role(name: string, mode: Mode, theme: ThemeVars = {}): string {
170
+ const value =
171
+ theme[name as `--${string}`] ??
172
+ (mode === "dark" ? DARK[name] : undefined) ??
173
+ LIGHT[name] ??
174
+ ROOT[name]
175
+ if (!value) throw new Error(`role ${name} is not defined for ${mode}`)
176
+ return value
177
+ }
178
+
179
+ /** A surface role resolved to an opaque colour: translucent surfaces sit on --background. */
180
+ function surface(name: string, mode: Mode, theme?: ThemeVars): Rgba {
181
+ const ground = colour(role("--background", mode, theme))
182
+ return over(colour(role(name, mode, theme)), ground)
183
+ }
184
+
185
+ /** Contrast of a foreground role on a surface role (the surface itself may be a tint). */
186
+ function ratio(fg: string, bg: string, mode: Mode, theme?: ThemeVars): number {
187
+ const back = surface(bg, mode, theme)
188
+ return contrast(over(colour(role(fg, mode, theme)), back), back)
189
+ }
190
+
191
+ // ── The contract ──────────────────────────────────────────────────────────────
192
+
193
+ const COLOUR_ROLES = ROLE_TOKENS.filter((name) => name in LIGHT)
194
+
195
+ describe("theme.css — the role-token contract", () => {
196
+ it("defines every contract role", () => {
197
+ for (const name of ROLE_TOKENS) {
198
+ expect(name in LIGHT || name in ROOT, `${name} missing from theme.css`).toBe(true)
199
+ }
200
+ })
201
+
202
+ it("defines every colour role for light AND dark", () => {
203
+ expect(COLOUR_ROLES.length).toBeGreaterThanOrEqual(25)
204
+ for (const name of COLOUR_ROLES) {
205
+ expect(name in DARK, `${name} has no dark value`).toBe(true)
206
+ }
207
+ })
208
+
209
+ it("points every role at --cd-* tokens only (never at another role; fonts also at the host's font)", () => {
210
+ const hostSlots = new Set<string>(Object.values(HOST_FONT_VARIABLES))
211
+ for (const decls of [ROOT, LIGHT, DARK]) {
212
+ for (const [name, value] of Object.entries(decls)) {
213
+ for (const ref of value.matchAll(/var\((--[a-z0-9-]+)/g)) {
214
+ const target = ref[1] as string
215
+ if (hostSlots.has(target)) {
216
+ // Only the font role may name the host's font, and only its own slot.
217
+ expect(HOST_FONT_VARIABLES[name as keyof typeof HOST_FONT_VARIABLES], name).toBe(target)
218
+ } else {
219
+ expect(target, `${name}: ${value}`).toMatch(/^--cd-/)
220
+ }
221
+ }
222
+ }
223
+ }
224
+ })
225
+
226
+ it("resolves every colour role to a colour from the vendored tokens", () => {
227
+ for (const mode of MODES) {
228
+ for (const name of COLOUR_ROLES) {
229
+ expect(() => colour(role(name, mode)), `${name} (${mode})`).not.toThrow()
230
+ }
231
+ }
232
+ })
233
+
234
+ it("maps the self-named utilities (rounded-*, shadow-*, font-*) onto the roles, not calc()", () => {
235
+ for (const name of ["--radius-sm", "--radius-md", "--radius-lg", "--radius-pill"]) {
236
+ expect(THEME_REFERENCE[name], name).toBe(`var(${name})`)
237
+ expect(THEME_INLINE, name).not.toHaveProperty(name)
238
+ }
239
+ for (const name of ["--shadow-1", "--shadow-2", "--shadow-3", "--font-sans", "--font-mono"]) {
240
+ expect(THEME_REFERENCE[name], name).toBe(`var(${name})`)
241
+ }
242
+ for (const value of Object.values(THEME_REFERENCE)) expect(value).not.toContain("calc(")
243
+ })
244
+
245
+ it("exposes the new colour roles as utilities (bg-scrim, text-link, ring-focus, text-*-ink, …)", () => {
246
+ for (const name of [
247
+ "link",
248
+ "focus",
249
+ "primary-hover",
250
+ "scrim",
251
+ "destructive-foreground",
252
+ "success-ink",
253
+ "warning-ink",
254
+ "danger-ink",
255
+ "info-ink",
256
+ ]) {
257
+ expect(THEME_INLINE[`--color-${name}`], name).toBe(`var(--${name})`)
258
+ }
259
+ })
260
+
261
+ it("follows the NEAREST mode scope for dark: (dark scope, or a dark scope nested in a light island)", () => {
262
+ const D = String.raw`:is\(\.dark, \[data-theme="dark"\], \[data-theme-mode="dark"\]\)`
263
+ const L = String.raw`\[data-theme-mode="light"\]`
264
+ expect(rules).toMatch(
265
+ new RegExp(
266
+ String.raw`@custom-variant dark \(&:is\(${D} \*:not\(${L} \*\), ${L} ${D} \*:not\(${L} ${D} ${L} \*\)\)\);`,
267
+ ),
268
+ )
269
+ })
270
+
271
+ it("re-declares the light roles on a light island only inside a dark scope (a light document keeps a consumer's :root brand)", () => {
272
+ expect(BLOCKS.some((b) => b.selector === LIGHT_SELECTOR)).toBe(true)
273
+ // No rule re-declares roles on EVERY light scope: that would reset a
274
+ // consumer's `:root { --primary: … }` inside each light ThemeProvider.
275
+ for (const b of BLOCKS) {
276
+ const selectors = b.selector.split(/,(?![^(]*\))/).map(norm)
277
+ if (Object.keys(b.decls).some((d) => d.startsWith("--"))) {
278
+ expect(selectors, b.selector).not.toContain('[data-theme-mode="light"]')
279
+ }
280
+ }
281
+ })
282
+
283
+ it("declares the dark roles AFTER the light ones (same specificity on <html>: order decides)", () => {
284
+ const light = BLOCKS.find((b) => b.selector === norm(LIGHT_SELECTOR))
285
+ const dark = BLOCKS.find((b) => b.selector === norm(DARK_SELECTOR))
286
+ expect(light && dark && light.offset < dark.offset).toBe(true)
287
+ })
288
+
289
+ it("keeps MODE_INVARIANT_VARS in step with theme.css", () => {
290
+ for (const name of Object.values(TOKEN_TO_CSS_VAR)) {
291
+ const light = LIGHT[name] ?? ROOT[name]
292
+ const invariant = DARK[name] === undefined || DARK[name] === light
293
+ expect(MODE_INVARIANT_VARS.has(name), `${name} invariant=${invariant}`).toBe(invariant)
294
+ }
295
+ })
296
+
297
+ it("forces no font: no font import, no !important font-family, Geist named first", () => {
298
+ for (const css of [themeCss, globalsCss]) {
299
+ const r = stripComments(css)
300
+ expect(r).not.toMatch(/@fontsource/)
301
+ expect(r).not.toMatch(/font-family[^;}]*!important/)
302
+ }
303
+ expect(ROOT["--font-sans"]).toMatch(/^"Geist", /)
304
+ expect(ROOT["--font-mono"]).toMatch(/^"Geist Mono", /)
305
+ // The opt-in file is the only place that loads them.
306
+ expect(stripComments(fontsCss)).toMatch(/@import "@fontsource-variable\/geist\/wght\.css";/)
307
+ expect(stripComments(fontsCss)).toMatch(
308
+ /@import "@fontsource-variable\/geist-mono\/wght\.css";/,
309
+ )
310
+ })
311
+
312
+ it("puts the host's font right after Geist and before the system stack", () => {
313
+ expect(ROOT["--font-sans"]).toBe(
314
+ '"Geist", "Geist Variable", var(--host-font-sans, ui-sans-serif), system-ui, sans-serif',
315
+ )
316
+ expect(ROOT["--font-mono"]).toBe(
317
+ '"Geist Mono", "Geist Mono Variable", var(--host-font-mono, ui-monospace), SFMono-Regular, Menlo, Consolas, monospace',
318
+ )
319
+ })
320
+
321
+ it("respects prefers-reduced-motion (modeler-tool-design §7)", () => {
322
+ expect(rules).toMatch(
323
+ /@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{\s*animation-duration: 0\.001ms !important;\s*animation-iteration-count: 1 !important;\s*transition-duration: 0\.001ms !important;/,
324
+ )
325
+ })
326
+
327
+ it("gives every focusable element an opaque focus outline in --focus", () => {
328
+ expect(rules).toMatch(/:focus-visible \{\s*outline: 2px solid var\(--focus\);/)
329
+ })
330
+
331
+ it("sets color-scheme per mode on <body> and the mode scopes, never on <html>", () => {
332
+ const schemes = BLOCKS.filter((b) => "color-scheme" in b.decls)
333
+ const light = schemes.find((b) => b.decls["color-scheme"] === "light")
334
+ const dark = schemes.find((b) => b.decls["color-scheme"] === "dark")
335
+ expect(light?.selector).toBe('body, :where(body) [data-theme-mode="light"]')
336
+ expect(dark?.selector).toBe(
337
+ ':is(.dark, [data-theme="dark"]) body, body:is(.dark, [data-theme="dark"], [data-theme-mode="dark"]), :where(body) :is(.dark, [data-theme="dark"], [data-theme-mode="dark"])',
338
+ )
339
+ // A root color-scheme that differs from the host's iframe paints an opaque
340
+ // canvas behind the view; mcp-use keeps it transparent on purpose.
341
+ for (const b of BLOCKS) {
342
+ if (b.selector.includes(":root") || /(^|[\s,])html\b/.test(b.selector)) {
343
+ expect(b.decls, b.selector).not.toHaveProperty("color-scheme")
344
+ }
345
+ }
346
+ })
347
+
348
+ it("is what globals.css loads, after Tailwind, with the vendored tokens", () => {
349
+ const g = stripComments(globalsCss)
350
+ expect(g.indexOf('@import "tailwindcss";')).toBeGreaterThanOrEqual(0)
351
+ expect(g.indexOf('@import "./theme.css";')).toBeGreaterThan(g.indexOf('@import "tailwindcss";'))
352
+ expect(rules).toContain('@import "./theme/cd-tokens.generated.css";')
353
+ })
354
+ })
355
+
356
+ // ── Contrast over every role/tone pair the kit renders ───────────────────────
357
+
358
+ const TONES = ["success", "warning", "danger", "info"] as const
359
+
360
+ /** Text pairs (WCAG 1.4.3, 4.5:1): [foreground role, surface role, where it renders]. */
361
+ const TEXT_PAIRS: [string, string, string][] = [
362
+ ["--foreground", "--background", "body text"],
363
+ ["--foreground", "--muted", "text on quiet surfaces (table heads, chips)"],
364
+ ["--card-foreground", "--card", "Card"],
365
+ ["--popover-foreground", "--popover", "Dialog, Sheet, Select menu"],
366
+ ["--secondary-foreground", "--secondary", "secondary Button/Badge"],
367
+ ["--accent-foreground", "--accent", "hovered outline/ghost Button, active FilterBar chip"],
368
+ ["--muted-foreground", "--background", "secondary text"],
369
+ ["--muted-foreground", "--card", "CardDescription, AlertDescription"],
370
+ ["--muted-foreground", "--popover", "SelectLabel, dialog descriptions"],
371
+ ["--muted-foreground", "--muted", "KpiGrid header, TabsList, TONE_SOFT.neutral"],
372
+ ["--primary-foreground", "--primary", "primary Button, default Badge"],
373
+ ["--primary-foreground", "--primary-hover", "hovered primary Button"],
374
+ ["--destructive-foreground", "--destructive", "destructive Button"],
375
+ ["--link", "--background", "link Button, links"],
376
+ ["--link", "--card", "links in cards"],
377
+ ["--link", "--popover", "links in dialogs"],
378
+ ["--link", "--muted", "links on quiet surfaces"],
379
+ ["--link", "--accent", "a link-coloured control while hovered (builder props button)"],
380
+ ["--sidebar-foreground", "--sidebar", "sidebar"],
381
+ ["--sidebar-primary-foreground", "--sidebar-primary", "sidebar primary"],
382
+ ["--sidebar-accent-foreground", "--sidebar-accent", "sidebar accent"],
383
+ ...TONES.flatMap((tone): [string, string, string][] => [
384
+ [`--foreground`, `--${tone}-soft`, `TONE_SOFT.${tone} (Badge, Pills, banners, Alert)`],
385
+ [`--${tone}-ink`, "--background", `TONE_TEXT.${tone}`],
386
+ [`--${tone}-ink`, "--card", `TONE_TEXT.${tone} in a card`],
387
+ [`--${tone}-ink`, "--popover", `TONE_TEXT.${tone} in a dialog or sheet`],
388
+ [`--${tone}-ink`, "--muted", `TONE_TEXT.${tone} on a quiet surface`],
389
+ ]),
390
+ ]
391
+
392
+ /**
393
+ * Text pairs that hold in LIGHT only, by design: dark lifts the hover/selected
394
+ * (--accent) and secondary surfaces 10 % so they stay visible, and
395
+ * --cd-kontur keeps 4.5:1 only up to a 3 % lift. On these surfaces quiet text
396
+ * takes the surface's own foreground (`group-hover:text-accent-foreground`);
397
+ * the design-system gate checks that a class string switching to one of these
398
+ * surfaces switches the text too. Pinned below so the exemption is dropped
399
+ * the day it is no longer needed.
400
+ */
401
+ const LIGHT_ONLY_TEXT_PAIRS: [string, string, string][] = [
402
+ ["--muted-foreground", "--accent", "quiet text on a hovered/selected item"],
403
+ ["--muted-foreground", "--secondary", "quiet text on a secondary surface"],
404
+ ]
405
+
406
+ /** Non-text pairs (WCAG 1.4.11, 3:1) that identify a control or its state. */
407
+ const NON_TEXT_PAIRS: [string, string, string][] = [
408
+ ["--input", "--background", "Input/Select border, unchecked Switch track"],
409
+ ["--input", "--card", "inputs in cards"],
410
+ ["--input", "--popover", "inputs in dialogs and sheets"],
411
+ ["--input", "--muted", "inputs on quiet surfaces"],
412
+ ...["--background", "--card", "--popover", "--muted", "--accent"].flatMap(
413
+ (bg): [string, string, string][] => [
414
+ ["--focus", bg, "FOCUS_RING, :focus-visible outline"],
415
+ ["--ring", bg, "legacy ring utilities"],
416
+ ],
417
+ ),
418
+ ["--primary", "--background", "checked Switch track, active tab indicator"],
419
+ ["--primary", "--card", "checked Switch in a card"],
420
+ ["--primary", "--popover", "checked Switch in a sheet"],
421
+ ]
422
+
423
+ /**
424
+ * Status colours as fills/dots/borders that are NOT asserted on light, by CI
425
+ * rule (§3.3): success and amber reach only 1.67:1 / 1.83:1 on white, so a
426
+ * status is never colour alone — every toned dot, border or icon sits next to
427
+ * text that carries it. The test pins that the exemption is still needed.
428
+ */
429
+ const LIGHT_EXEMPT_STATUS = ["--success", "--warning"]
430
+
431
+ /**
432
+ * Where a status colour renders as a dot, border or icon (non-text, 3:1), per
433
+ * tone: the page (KpiGrid dot, EmptyState icon, Pills), an overlay card (the
434
+ * Toast's left edge and icon) and the tone's own tint (the ErrorState's rim and
435
+ * icon, the builder banners). Same CI exemption as above on light.
436
+ */
437
+ const STATUS_SURFACES = (tone: (typeof TONES)[number]): [string, string][] => [
438
+ ["--background", "dot, border or icon on the page"],
439
+ ["--popover", "Toast edge and icon"],
440
+ [`--${tone}-soft`, "ErrorState rim and icon on its own tint"],
441
+ ]
442
+
443
+ describe.each(MODES)("contrast in %s mode (computed from the vendored tokens)", (mode) => {
444
+ it.each(TEXT_PAIRS)("text %s on %s ≥ 4.5:1 — %s", (fg, bg) => {
445
+ expect(ratio(fg, bg, mode)).toBeGreaterThanOrEqual(4.5)
446
+ })
447
+
448
+ it.each(LIGHT_ONLY_TEXT_PAIRS)(
449
+ "text %s on %s ≥ 4.5:1 in light only (dark exemption still needed) — %s",
450
+ (fg, bg) => {
451
+ if (mode === "light") expect(ratio(fg, bg, mode)).toBeGreaterThanOrEqual(4.5)
452
+ else expect(ratio(fg, bg, mode)).toBeLessThan(4.5)
453
+ },
454
+ )
455
+
456
+ it.each(NON_TEXT_PAIRS)("non-text %s on %s ≥ 3:1 — %s", (fg, bg) => {
457
+ expect(ratio(fg, bg, mode)).toBeGreaterThanOrEqual(3)
458
+ })
459
+
460
+ it.each(
461
+ TONES.flatMap((tone) =>
462
+ STATUS_SURFACES(tone).map(([bg, where]): [string, string, string] => [
463
+ `--${tone}`,
464
+ bg,
465
+ where,
466
+ ]),
467
+ ),
468
+ )("status colour %s on %s ≥ 3:1 unless CI-exempt on light — %s", (name, bg) => {
469
+ const r = ratio(name, bg, mode)
470
+ if (mode === "light" && LIGHT_EXEMPT_STATUS.includes(name)) {
471
+ // Still exempt? If the CI ever lifts it to 3:1, drop the exemption.
472
+ expect(r).toBeLessThan(3)
473
+ } else {
474
+ expect(r).toBeGreaterThanOrEqual(3)
475
+ }
476
+ })
477
+ })
478
+
479
+ // The presets are customer-brand demos: their oklch values are data here.
480
+ /* brand-lint-disable */
481
+ const PRESETS = Object.entries(themePresets)
482
+
483
+ describe.each(PRESETS)("preset %s meets the same bar in both modes", (_name, preset) => {
484
+ it.each(MODES)("every text pair ≥ 4.5:1 and non-text pair ≥ 3:1 in %s", (mode) => {
485
+ const scope = resolveThemeVars(preset, mode)
486
+ for (const [fg, bg, where] of TEXT_PAIRS) {
487
+ expect(ratio(fg, bg, mode, scope), `${fg} on ${bg} (${where})`).toBeGreaterThanOrEqual(4.5)
488
+ }
489
+ for (const [fg, bg, where] of NON_TEXT_PAIRS) {
490
+ expect(ratio(fg, bg, mode, scope), `${fg} on ${bg} (${where})`).toBeGreaterThanOrEqual(3)
491
+ }
492
+ })
493
+
494
+ it("sets its own hover, link and focus in both modes (never the Miragon defaults)", () => {
495
+ for (const name of ["--primary-hover", "--link", "--focus"] as const) {
496
+ expect(preset.vars[name], `${name} light`).toBeDefined()
497
+ expect(preset.darkVars?.[name], `${name} dark`).toBeDefined()
498
+ }
499
+ })
500
+ })
501
+ /* brand-lint-enable */
502
+
503
+ describe("dark mode follows the CI dark-ground rules", () => {
504
+ it("grounds on --cd-schwarz with white text, green links, blue buttons, blau-hell focus", () => {
505
+ expect(DARK["--background"]).toBe("var(--cd-schwarz)")
506
+ expect(DARK["--foreground"]).toBe("var(--cd-weiss)")
507
+ expect(DARK["--link"]).toBe("var(--cd-gruen)")
508
+ expect(DARK["--primary"]).toBe("var(--cd-blau)")
509
+ expect(DARK["--primary-foreground"]).toBe("var(--cd-weiss)")
510
+ expect(DARK["--destructive-foreground"]).toBe("var(--cd-weiss)")
511
+ expect(DARK["--focus"]).toBe("var(--cd-blau-hell)")
512
+ expect(DARK["--muted-foreground"]).toBe("var(--cd-kontur)")
513
+ })
514
+
515
+ it("uses the -hell status values with no tinted fills", () => {
516
+ for (const tone of TONES) {
517
+ expect(DARK[`--${tone}`]).toBe(`var(--cd-${tone}-hell)`)
518
+ expect(DARK[`--${tone}-ink`]).toBe(`var(--cd-${tone}-hell)`)
519
+ expect(DARK[`--${tone}-soft`]).toBe("transparent")
520
+ }
521
+ })
522
+
523
+ it("lifts surfaces only with white mixed into --cd-schwarz", () => {
524
+ for (const name of ["--muted", "--secondary", "--accent"]) {
525
+ expect(DARK[name]).toMatch(
526
+ /^color-mix\(in srgb, var\(--cd-weiss\) \d+%, var\(--cd-schwarz\)\)$/,
527
+ )
528
+ }
529
+ })
530
+
531
+ it("keeps overlays on the ground (any lift drops a checked Switch below 3:1) and pushes the page back harder", () => {
532
+ expect(DARK["--popover"]).toBe("var(--cd-schwarz)")
533
+ const onLift = over(
534
+ colour(role("--primary", "dark")),
535
+ colour("color-mix(in srgb, var(--cd-weiss) 2%, var(--cd-schwarz))"),
536
+ )
537
+ // Why not lift: --cd-blau is 3.08:1 on the ground and under 3:1 from a 2 % lift.
538
+ expect(
539
+ contrast(onLift, colour("color-mix(in srgb, var(--cd-weiss) 2%, var(--cd-schwarz))")),
540
+ ).toBeLessThan(3)
541
+ const scrimAlpha = (mode: Mode) => colour(role("--scrim", mode)).a
542
+ expect(role("--scrim", "dark")).toMatch(
543
+ /^color-mix\(in srgb, var\(--cd-schwarz\) \d+%, transparent\)$/,
544
+ )
545
+ expect(scrimAlpha("dark")).toBeGreaterThan(scrimAlpha("light"))
546
+ })
547
+
548
+ it("draws the overlay edge in dark with the 3:1 input border (shadows vanish on the ground)", () => {
549
+ for (const name of ["dialog", "sheet", "select"]) {
550
+ const panel = read(`../primitives/${name}.tsx`)
551
+ .split("\n")
552
+ .find((line) => /\bbg-popover\b/.test(line))
553
+ expect(panel, name).toMatch(/\sdark:border-input\s/)
554
+ }
555
+ expect(ratio("--input", "--popover", "dark")).toBeGreaterThanOrEqual(3)
556
+ })
557
+ })
558
+
559
+ describe("light mode text never uses a status colour that fails on white", () => {
560
+ it("routes success/warning text to the foreground ink", () => {
561
+ expect(LIGHT["--success-ink"]).toBe("var(--cd-schwarz)")
562
+ expect(LIGHT["--warning-ink"]).toBe("var(--cd-schwarz)")
563
+ })
564
+
565
+ it("keeps the CI backdrop on light (modeler-tool-design §8)", () => {
566
+ expect(LIGHT["--scrim"]).toBe("color-mix(in srgb, var(--cd-schwarz) 42%, transparent)")
567
+ })
568
+ })
@@ -0,0 +1,82 @@
1
+ /// <reference types="node" />
2
+ // The colours below are a CUSTOMER brand's values (white-label fixtures).
3
+ /* brand-lint-disable */
4
+ import { readFileSync } from "node:fs"
5
+ import { renderToStaticMarkup } from "react-dom/server"
6
+ import { describe, expect, it } from "vitest"
7
+ import { createTheme } from "./create-theme.js"
8
+ import { ThemeProvider, useTheme } from "./theme-provider.js"
9
+
10
+ /**
11
+ * ThemeProvider's scope markup (SSR, the house render path — no jsdom) and the
12
+ * portal-into-scope wiring (#178 N36). Radix renders portal content only after
13
+ * mount, so the overlays themselves cannot be rendered here; the source gate
14
+ * below pins that Dialog, Sheet and Select keep passing the scope's node.
15
+ */
16
+
17
+ const acme = () =>
18
+ createTheme({ primary: "#0f766e", primaryForeground: "#ffffff" }, { dark: { link: "#5eead4" } })
19
+
20
+ describe("ThemeProvider scope", () => {
21
+ it("opens the scope with the empty display:contents portal node, before the children", () => {
22
+ const html = renderToStaticMarkup(
23
+ <ThemeProvider mode="dark" theme={acme()} className="space-y-4 divide-y">
24
+ <p>first</p>
25
+ <p>last</p>
26
+ </ThemeProvider>,
27
+ )
28
+ // The real last child stays :last-child, so space-*/divide-*/last: are unaffected.
29
+ expect(html).toMatch(
30
+ /^<div class="dark [^"]*space-y-4 divide-y" style="[^"]*--primary:#0f766e[^"]*" data-theme-mode="dark"><div data-slot="theme-portal" style="display:contents"><\/div><p>first<\/p><p>last<\/p><\/div>$/,
31
+ )
32
+ })
33
+
34
+ it("re-applies the enclosing theme in a nested provider's own mode", () => {
35
+ function Probe() {
36
+ const { theme, resolvedMode } = useTheme()
37
+ return (
38
+ <i>
39
+ {resolvedMode}:{theme ? "themed" : "none"}
40
+ </i>
41
+ )
42
+ }
43
+ const html = renderToStaticMarkup(
44
+ <ThemeProvider mode="dark" theme={acme()}>
45
+ <ThemeProvider mode="light">
46
+ <Probe />
47
+ </ThemeProvider>
48
+ </ThemeProvider>,
49
+ )
50
+ const inner = /<div class="[^"]*" style="([^"]*)" data-theme-mode="light">/.exec(html)
51
+ // The light island inside the dark brand scope keeps the brand's LIGHT vars
52
+ // and none of its dark ones.
53
+ expect(inner?.[1]).toContain("--primary:#0f766e")
54
+ expect(inner?.[1]).toContain("--primary-foreground:#ffffff")
55
+ expect(inner?.[1]).not.toContain("#5eead4")
56
+ expect(html).toContain("<i>light:themed</i>")
57
+ })
58
+
59
+ it("an explicit theme on a nested provider wins over the enclosing one", () => {
60
+ const other = createTheme({ primary: "#7c2d12" })
61
+ const html = renderToStaticMarkup(
62
+ <ThemeProvider theme={acme()}>
63
+ <ThemeProvider theme={other}>x</ThemeProvider>
64
+ </ThemeProvider>,
65
+ )
66
+ expect(html).toMatch(
67
+ /style="[^"]*--primary:#7c2d12[^"]*" data-theme-mode="light"><div data-slot="theme-portal"[^>]*><\/div>x<\/div>/,
68
+ )
69
+ })
70
+ })
71
+
72
+ describe("overlays portal into the theme scope (source gate, #178 N36)", () => {
73
+ it.each(["dialog", "sheet", "select"])(
74
+ "%s passes the scope's portal node as the container",
75
+ (name) => {
76
+ const src = readFileSync(new URL(`../primitives/${name}.tsx`, import.meta.url), "utf8")
77
+ expect(src).toMatch(/const portalContainer = usePortalContainer\(\)/)
78
+ expect(src).toMatch(/<\w+Primitive\.Portal\b[^>]*container=\{[^}]*\bportalContainer\b[^}]*\}/)
79
+ },
80
+ )
81
+ })
82
+ /* brand-lint-enable */