@miragon/mcp-toolkit-ui 2.5.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.
- package/README.md +8 -2
- package/dist/app/builder/BuilderBanners.d.ts +7 -2
- package/dist/app/builder/BuilderBanners.d.ts.map +1 -1
- package/dist/app/builder/BuilderBanners.js +8 -3
- package/dist/app/builder/BuilderBanners.js.map +1 -1
- package/dist/app/builder/CanvasRow.d.ts.map +1 -1
- package/dist/app/builder/CanvasRow.js +12 -14
- package/dist/app/builder/CanvasRow.js.map +1 -1
- package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
- package/dist/app/builder/CatalogueSheet.js +6 -5
- package/dist/app/builder/CatalogueSheet.js.map +1 -1
- package/dist/app/builder/EmptyHint.d.ts +4 -0
- package/dist/app/builder/EmptyHint.d.ts.map +1 -1
- package/dist/app/builder/EmptyHint.js +6 -1
- package/dist/app/builder/EmptyHint.js.map +1 -1
- package/dist/app/builder/LayoutSurface.js +3 -3
- package/dist/app/builder/LayoutSurface.js.map +1 -1
- package/dist/app/builder/PipelineStrip.d.ts +3 -1
- package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
- package/dist/app/builder/PipelineStrip.js +11 -6
- package/dist/app/builder/PipelineStrip.js.map +1 -1
- package/dist/app/builder/SaveDialog.d.ts.map +1 -1
- package/dist/app/builder/SaveDialog.js +2 -2
- package/dist/app/builder/SaveDialog.js.map +1 -1
- package/dist/app/builder/Toolbar.d.ts +3 -1
- package/dist/app/builder/Toolbar.d.ts.map +1 -1
- package/dist/app/builder/Toolbar.js +9 -3
- package/dist/app/builder/Toolbar.js.map +1 -1
- package/dist/app/builder/Workspace.js +2 -2
- package/dist/app/builder/Workspace.js.map +1 -1
- package/dist/app/builder/builder-model.d.ts +3 -2
- package/dist/app/builder/builder-model.d.ts.map +1 -1
- package/dist/app/builder/builder-model.js +5 -4
- package/dist/app/builder/builder-model.js.map +1 -1
- package/dist/app/builder/labels.d.ts +13 -62
- package/dist/app/builder/labels.d.ts.map +1 -1
- package/dist/app/builder/labels.js +9 -58
- package/dist/app/builder/labels.js.map +1 -1
- package/dist/app/builder/status-chip.d.ts.map +1 -1
- package/dist/app/builder/status-chip.js +3 -2
- package/dist/app/builder/status-chip.js.map +1 -1
- package/dist/app/builder/use-builder-catalogue.d.ts +1 -0
- package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
- package/dist/app/builder/use-builder-catalogue.js +6 -0
- package/dist/app/builder/use-builder-catalogue.js.map +1 -1
- package/dist/app/builder/use-save-dashboard.d.ts +3 -1
- package/dist/app/builder/use-save-dashboard.d.ts.map +1 -1
- package/dist/app/builder/use-save-dashboard.js +3 -1
- package/dist/app/builder/use-save-dashboard.js.map +1 -1
- package/dist/app/index.d.ts +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js.map +1 -1
- package/dist/app/layout-builder.d.ts +7 -2
- package/dist/app/layout-builder.d.ts.map +1 -1
- package/dist/app/layout-builder.js +17 -5
- package/dist/app/layout-builder.js.map +1 -1
- package/dist/app/mcp-app-view-chrome.d.ts +94 -20
- package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
- package/dist/app/mcp-app-view-chrome.js +109 -40
- package/dist/app/mcp-app-view-chrome.js.map +1 -1
- package/dist/app/mcp-app-view-states.d.ts +15 -0
- package/dist/app/mcp-app-view-states.d.ts.map +1 -0
- package/dist/app/mcp-app-view-states.js +34 -0
- package/dist/app/mcp-app-view-states.js.map +1 -0
- package/dist/app/mcp-app-view.d.ts +40 -5
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +174 -128
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/app/mcp-toolkit-app.d.ts +2 -1
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
- package/dist/app/mcp-toolkit-app.js +25 -3
- package/dist/app/mcp-toolkit-app.js.map +1 -1
- package/dist/app/use-view-lifecycle.d.ts +38 -0
- package/dist/app/use-view-lifecycle.d.ts.map +1 -0
- package/dist/app/use-view-lifecycle.js +79 -0
- package/dist/app/use-view-lifecycle.js.map +1 -0
- package/dist/app/view-lifecycle.d.ts +109 -0
- package/dist/app/view-lifecycle.d.ts.map +1 -0
- package/dist/app/view-lifecycle.js +120 -0
- package/dist/app/view-lifecycle.js.map +1 -0
- package/dist/app/widget-props-sheet.d.ts +3 -9
- package/dist/app/widget-props-sheet.d.ts.map +1 -1
- package/dist/app/widget-props-sheet.js +6 -13
- package/dist/app/widget-props-sheet.js.map +1 -1
- package/dist/app/widget-renderer.d.ts +41 -1
- package/dist/app/widget-renderer.d.ts.map +1 -1
- package/dist/app/widget-renderer.js +82 -31
- package/dist/app/widget-renderer.js.map +1 -1
- package/dist/components/DrillButton.d.ts.map +1 -1
- package/dist/components/DrillButton.js +2 -1
- package/dist/components/DrillButton.js.map +1 -1
- package/dist/components/EmptyState.d.ts +33 -0
- package/dist/components/EmptyState.d.ts.map +1 -0
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/EmptyState.js.map +1 -0
- package/dist/components/ErrorState.d.ts +50 -0
- package/dist/components/ErrorState.d.ts.map +1 -0
- package/dist/components/ErrorState.js +60 -0
- package/dist/components/ErrorState.js.map +1 -0
- package/dist/components/FilterBar.d.ts +1 -1
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterBar.js +7 -4
- package/dist/components/FilterBar.js.map +1 -1
- package/dist/components/GroupCard.d.ts.map +1 -1
- package/dist/components/GroupCard.js +2 -1
- package/dist/components/GroupCard.js.map +1 -1
- package/dist/components/KpiGrid.d.ts +10 -3
- package/dist/components/KpiGrid.d.ts.map +1 -1
- package/dist/components/KpiGrid.js +31 -9
- package/dist/components/KpiGrid.js.map +1 -1
- package/dist/components/ListFooter.d.ts +3 -2
- package/dist/components/ListFooter.d.ts.map +1 -1
- package/dist/components/ListFooter.js +14 -4
- package/dist/components/ListFooter.js.map +1 -1
- package/dist/components/Pills.d.ts +10 -6
- package/dist/components/Pills.d.ts.map +1 -1
- package/dist/components/Pills.js +12 -6
- package/dist/components/Pills.js.map +1 -1
- package/dist/components/Toast.d.ts +37 -0
- package/dist/components/Toast.d.ts.map +1 -0
- package/dist/components/Toast.js +91 -0
- package/dist/components/Toast.js.map +1 -0
- package/dist/components/WidgetHeader.js +1 -1
- package/dist/hooks/use-tool-result-recovery.d.ts +23 -1
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
- package/dist/hooks/use-tool-result-recovery.js +21 -7
- package/dist/hooks/use-tool-result-recovery.js.map +1 -1
- package/dist/hooks/use-view-data.d.ts +6 -0
- package/dist/hooks/use-view-data.d.ts.map +1 -1
- package/dist/hooks/use-view-data.js +6 -1
- package/dist/hooks/use-view-data.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/focus-ring.d.ts +18 -0
- package/dist/lib/focus-ring.d.ts.map +1 -0
- package/dist/lib/focus-ring.js +18 -0
- package/dist/lib/focus-ring.js.map +1 -0
- package/dist/lib/icon-stroke.d.ts +12 -0
- package/dist/lib/icon-stroke.d.ts.map +1 -0
- package/dist/lib/icon-stroke.js +12 -0
- package/dist/lib/icon-stroke.js.map +1 -0
- package/dist/lib/toast-model.d.ts +46 -0
- package/dist/lib/toast-model.d.ts.map +1 -0
- package/dist/lib/toast-model.js +36 -0
- package/dist/lib/toast-model.js.map +1 -0
- package/dist/lib/tone-utils.d.ts +43 -9
- package/dist/lib/tone-utils.d.ts.map +1 -1
- package/dist/lib/tone-utils.js +36 -7
- package/dist/lib/tone-utils.js.map +1 -1
- package/dist/locale/index.d.ts +3 -1
- package/dist/locale/index.d.ts.map +1 -1
- package/dist/locale/index.js +2 -1
- package/dist/locale/index.js.map +1 -1
- package/dist/locale/locale-provider.d.ts +42 -1
- package/dist/locale/locale-provider.d.ts.map +1 -1
- package/dist/locale/locale-provider.js +44 -3
- package/dist/locale/locale-provider.js.map +1 -1
- package/dist/locale/message-types.d.ts +223 -0
- package/dist/locale/message-types.d.ts.map +1 -0
- package/dist/locale/message-types.js +7 -0
- package/dist/locale/message-types.js.map +1 -0
- package/dist/locale/messages-de.d.ts +9 -0
- package/dist/locale/messages-de.d.ts.map +1 -0
- package/dist/locale/messages-de.js +151 -0
- package/dist/locale/messages-de.js.map +1 -0
- package/dist/locale/messages-en.d.ts +8 -0
- package/dist/locale/messages-en.d.ts.map +1 -0
- package/dist/locale/messages-en.js +150 -0
- package/dist/locale/messages-en.js.map +1 -0
- package/dist/locale/toolkit-messages.d.ts +39 -0
- package/dist/locale/toolkit-messages.d.ts.map +1 -0
- package/dist/locale/toolkit-messages.js +50 -0
- package/dist/locale/toolkit-messages.js.map +1 -0
- package/dist/primitives/alert.d.ts.map +1 -1
- package/dist/primitives/alert.js +4 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/badge.d.ts +1 -1
- package/dist/primitives/badge.d.ts.map +1 -1
- package/dist/primitives/badge.js +14 -6
- package/dist/primitives/badge.js.map +1 -1
- package/dist/primitives/button.d.ts.map +1 -1
- package/dist/primitives/button.js +12 -8
- package/dist/primitives/button.js.map +1 -1
- package/dist/primitives/card.js +4 -4
- package/dist/primitives/card.js.map +1 -1
- package/dist/primitives/dialog.d.ts +7 -3
- package/dist/primitives/dialog.d.ts.map +1 -1
- package/dist/primitives/dialog.js +14 -7
- package/dist/primitives/dialog.js.map +1 -1
- package/dist/primitives/input.d.ts.map +1 -1
- package/dist/primitives/input.js +4 -1
- package/dist/primitives/input.js.map +1 -1
- package/dist/primitives/scroll-area.js +1 -1
- package/dist/primitives/scroll-area.js.map +1 -1
- package/dist/primitives/select.d.ts.map +1 -1
- package/dist/primitives/select.js +5 -2
- package/dist/primitives/select.js.map +1 -1
- package/dist/primitives/sheet.d.ts +3 -1
- package/dist/primitives/sheet.d.ts.map +1 -1
- package/dist/primitives/sheet.js +12 -6
- package/dist/primitives/sheet.js.map +1 -1
- package/dist/primitives/skeleton.js +1 -1
- package/dist/primitives/skeleton.js.map +1 -1
- package/dist/primitives/switch.d.ts.map +1 -1
- package/dist/primitives/switch.js +2 -1
- package/dist/primitives/switch.js.map +1 -1
- package/dist/primitives/tabs.d.ts.map +1 -1
- package/dist/primitives/tabs.js +5 -3
- package/dist/primitives/tabs.js.map +1 -1
- package/dist/render-cases.d.ts.map +1 -1
- package/dist/render-cases.js +20 -2
- package/dist/render-cases.js.map +1 -1
- package/dist/render-scan.d.ts +25 -0
- package/dist/render-scan.d.ts.map +1 -0
- package/dist/render-scan.js +66 -0
- package/dist/render-scan.js.map +1 -0
- package/dist/theme/cd-tokens.d.ts +66 -0
- package/dist/theme/cd-tokens.d.ts.map +1 -0
- package/dist/theme/cd-tokens.js +64 -0
- package/dist/theme/cd-tokens.js.map +1 -0
- package/dist/theme/create-theme.d.ts +196 -39
- package/dist/theme/create-theme.d.ts.map +1 -1
- package/dist/theme/create-theme.js +189 -13
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/fonts.d.ts +38 -0
- package/dist/theme/fonts.d.ts.map +1 -0
- package/dist/theme/fonts.js +56 -0
- package/dist/theme/fonts.js.map +1 -0
- package/dist/theme/host-fonts.d.ts +39 -0
- package/dist/theme/host-fonts.d.ts.map +1 -0
- package/dist/theme/host-fonts.js +44 -0
- package/dist/theme/host-fonts.js.map +1 -0
- package/dist/theme/index.d.ts +8 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +5 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/portal-container.d.ts +16 -0
- package/dist/theme/portal-container.d.ts.map +1 -0
- package/dist/theme/portal-container.js +19 -0
- package/dist/theme/portal-container.js.map +1 -0
- package/dist/theme/presets.d.ts +24 -9
- package/dist/theme/presets.d.ts.map +1 -1
- package/dist/theme/presets.js +44 -11
- package/dist/theme/presets.js.map +1 -1
- package/dist/theme/theme-provider.d.ts +27 -9
- package/dist/theme/theme-provider.d.ts.map +1 -1
- package/dist/theme/theme-provider.js +42 -20
- package/dist/theme/theme-provider.js.map +1 -1
- package/package.json +13 -8
- package/src/app/builder/BuilderBanners.tsx +26 -13
- package/src/app/builder/CanvasRow.tsx +22 -21
- package/src/app/builder/CatalogueSheet.tsx +16 -14
- package/src/app/builder/EmptyHint.tsx +16 -11
- package/src/app/builder/LayoutSurface.tsx +7 -7
- package/src/app/builder/PipelineStrip.tsx +26 -17
- package/src/app/builder/SaveDialog.tsx +11 -12
- package/src/app/builder/Toolbar.tsx +19 -7
- package/src/app/builder/Workspace.tsx +5 -3
- package/src/app/builder/builder-feedback.test.tsx +75 -0
- package/src/app/builder/builder-model.test.ts +5 -0
- package/src/app/builder/builder-model.ts +5 -4
- package/src/app/builder/labels.ts +15 -121
- package/src/app/builder/status-chip.tsx +8 -4
- package/src/app/builder/use-builder-catalogue.ts +6 -0
- package/src/app/builder/use-save-dashboard.ts +5 -0
- package/src/app/index.ts +8 -1
- package/src/app/layout-builder.tsx +23 -3
- package/src/app/mcp-app-view-chrome.test.tsx +347 -14
- package/src/app/mcp-app-view-chrome.tsx +278 -100
- package/src/app/mcp-app-view-states.tsx +100 -0
- package/src/app/mcp-app-view.test.tsx +258 -0
- package/src/app/mcp-app-view.tsx +292 -185
- package/src/app/mcp-toolkit-app.tsx +31 -7
- package/src/app/use-view-lifecycle.ts +116 -0
- package/src/app/view-lifecycle.test.ts +227 -0
- package/src/app/view-lifecycle.ts +174 -0
- package/src/app/widget-props-sheet.tsx +13 -26
- package/src/app/widget-renderer.render.test.tsx +131 -0
- package/src/app/widget-renderer.tsx +136 -38
- package/src/components/DrillButton.tsx +2 -1
- package/src/components/EmptyState.tsx +81 -0
- package/src/components/ErrorState.tsx +182 -0
- package/src/components/FilterBar.tsx +9 -6
- package/src/components/GroupCard.tsx +3 -2
- package/src/components/KpiGrid.tsx +75 -44
- package/src/components/ListFooter.tsx +17 -6
- package/src/components/Pills.tsx +20 -11
- package/src/components/Toast.tsx +188 -0
- package/src/components/WidgetHeader.tsx +1 -1
- package/src/components/feedback-states.test.tsx +225 -0
- package/src/components/tone-figures.test.tsx +54 -0
- package/src/fonts.css +19 -0
- package/src/globals.css +9 -165
- package/src/hooks/use-tool-result-recovery.test.ts +25 -1
- package/src/hooks/use-tool-result-recovery.ts +34 -6
- package/src/hooks/use-view-data.test.tsx +60 -0
- package/src/hooks/use-view-data.ts +12 -1
- package/src/i18n-coverage.test.tsx +323 -0
- package/src/index.ts +57 -5
- package/src/lib/focus-ring.ts +20 -0
- package/src/lib/icon-stroke.test.ts +59 -0
- package/src/lib/icon-stroke.ts +13 -0
- package/src/lib/toast-model.test.ts +68 -0
- package/src/lib/toast-model.ts +63 -0
- package/src/lib/tone-utils.test.ts +47 -14
- package/src/lib/tone-utils.ts +61 -13
- package/src/locale/index.ts +13 -1
- package/src/locale/locale-provider.test.tsx +87 -0
- package/src/locale/locale-provider.tsx +90 -3
- package/src/locale/message-types.ts +221 -0
- package/src/locale/messages-de.ts +159 -0
- package/src/locale/messages-en.ts +157 -0
- package/src/locale/toolkit-messages.test.ts +216 -0
- package/src/locale/toolkit-messages.ts +82 -0
- package/src/no-hardcoded-copy.test.ts +432 -0
- package/src/primitives/alert.tsx +4 -2
- package/src/primitives/badge.tsx +14 -7
- package/src/primitives/button.tsx +13 -8
- package/src/primitives/card.tsx +4 -4
- package/src/primitives/dialog.tsx +32 -7
- package/src/primitives/input.tsx +5 -3
- package/src/primitives/scroll-area.tsx +1 -1
- package/src/primitives/select.tsx +6 -3
- package/src/primitives/sheet.tsx +26 -6
- package/src/primitives/skeleton.tsx +1 -1
- package/src/primitives/switch.tsx +4 -2
- package/src/primitives/tabs.tsx +9 -6
- package/src/render-cases.tsx +45 -1
- package/src/render-scan.ts +75 -0
- package/src/theme/cd-tokens.generated.css +48 -0
- package/src/theme/cd-tokens.test.ts +107 -0
- package/src/theme/cd-tokens.ts +67 -0
- package/src/theme/create-theme.test.ts +243 -5
- package/src/theme/create-theme.ts +313 -41
- package/src/theme/fonts.test.ts +85 -0
- package/src/theme/fonts.ts +72 -0
- package/src/theme/host-fonts.test.ts +135 -0
- package/src/theme/host-fonts.ts +52 -0
- package/src/theme/index.ts +15 -1
- package/src/theme/portal-container.ts +20 -0
- package/src/theme/presets.ts +44 -11
- package/src/theme/theme-css.test.ts +568 -0
- package/src/theme/theme-provider.test.tsx +82 -0
- package/src/theme/theme-provider.tsx +60 -24
- package/src/theme.css +314 -0
- 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 */
|