diff --git a/.gitignore b/.gitignore index 5090c60..7f99154 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ r/ .DS_Store site/dist/ site/src/generated/ + +# JetBrains IDE settings +.idea/ diff --git a/README.md b/README.md index 36af9ea..540e585 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,10 @@ pnpm site:dev # run the component showcase locally (one-time: pnpm --dir site pnpm site:build # typecheck and build the showcase ``` +`tests/hosts/*.json` pin the `--crowdin-*` variables each Crowdin host delivers to app iframes through `AP.getCssVariables`, trimmed to what the `app-theme` bridge reads plus the variables those values reference. The tests require every variable the bridge reads to be present on every host, and the `theme` literals to equal what crowdin.com sends - so when a host changes its variables, refresh the fixture and let the tests tell you what to update. To refresh one, open the host in a browser and copy `window.cssVariables` from the console: it is the same map the host sends to iframes. + `registry/src/**` is generated by `pnpm sync` - never edit it by hand. Crowdin styling changes go into the `theme`/`style`/`app-theme` items in `registry.json`. `registry/crowdin/**` is hand-written (the host-bridge sources shipped by `app-theme`) and survives sync. To refresh the components from upstream shadcn, run the **Sync upstream** workflow from the Actions tab - it syncs, runs the full check suite, and opens a PR with the diff (only when something actually changed). New upstream components are opt-in: add the name to `scripts/upstream-items.json` first. -The showcase at [crowdin.github.io/ui-registry](https://crowdin.github.io/ui-registry/) is built from `site/` and renders `registry/src/**` directly with the generated Crowdin theme - use its header toggles to preview dark mode and simulated Crowdin host variables. +The showcase at [crowdin.github.io/ui-registry](https://crowdin.github.io/ui-registry/) is built from `site/` and renders `registry/src/**` directly with the generated Crowdin theme - use its header toggles to preview dark mode and the captured host palettes from `tests/hosts/`. diff --git a/registry.json b/registry.json index 9a43e84..7c53fc5 100644 --- a/registry.json +++ b/registry.json @@ -21,65 +21,65 @@ }, "light": { "radius": "0.5rem", - "background": "oklch(1 0 0)", - "foreground": "oklch(0.268 0.022 230)", - "card": "oklch(1 0 0)", - "card-foreground": "oklch(0.268 0.022 230)", - "popover": "oklch(1 0 0)", - "popover-foreground": "oklch(0.268 0.022 230)", - "primary": "oklch(0.268 0.022 230)", - "primary-foreground": "oklch(1 0 0)", - "secondary": "oklch(0.935 0.007 228)", - "secondary-foreground": "oklch(0.268 0.022 230)", - "muted": "oklch(0.935 0.007 228)", - "muted-foreground": "oklch(0.577 0.014 228)", - "accent": "oklch(0.965 0.004 228)", - "accent-foreground": "oklch(0.268 0.022 230)", - "destructive": "oklch(0.577 0.21 27.3)", - "border": "oklch(0.91 0.006 228)", - "input": "oklch(0.91 0.006 228)", - "ring": "oklch(0.268 0.022 230)", + "background": "#ffffff", + "foreground": "rgba(38, 38, 38, 0.87)", + "card": "#ffffff", + "card-foreground": "rgba(38, 38, 38, 0.87)", + "popover": "#ffffff", + "popover-foreground": "rgba(38, 38, 38, 0.87)", + "primary": "#262626", + "primary-foreground": "#ffffff", + "secondary": "rgba(38, 38, 38, 0.1)", + "secondary-foreground": "rgba(38, 38, 38, 0.87)", + "muted": "rgba(38, 38, 38, 0.05)", + "muted-foreground": "rgba(38, 38, 38, 0.67)", + "accent": "rgba(38, 38, 38, 0.05)", + "accent-foreground": "rgba(38, 38, 38, 0.87)", + "destructive": "#dc5242", + "border": "rgba(38, 38, 38, 0.1)", + "input": "rgba(38, 38, 38, 0.1)", + "ring": "#262626", "chart-1": "#3b82f6", "chart-2": "#22c55e", "chart-3": "#eab308", "chart-4": "#c99eff", "chart-5": "#dc5242", - "sidebar": "oklch(0.975 0.003 228)", - "sidebar-foreground": "oklch(0.268 0.022 230)", - "sidebar-primary": "oklch(0.268 0.022 230)", - "sidebar-primary-foreground": "oklch(1 0 0)", - "sidebar-accent": "oklch(0.965 0.004 228)", - "sidebar-accent-foreground": "oklch(0.268 0.022 230)", - "sidebar-border": "oklch(0.91 0.006 228)", - "sidebar-ring": "oklch(0.268 0.022 230)" + "sidebar": "#eeeeee", + "sidebar-foreground": "rgba(38, 38, 38, 0.87)", + "sidebar-primary": "#262626", + "sidebar-primary-foreground": "#ffffff", + "sidebar-accent": "rgba(38, 38, 38, 0.05)", + "sidebar-accent-foreground": "rgba(38, 38, 38, 0.87)", + "sidebar-border": "rgba(38, 38, 38, 0.1)", + "sidebar-ring": "#262626" }, "dark": { - "background": "oklch(0.253 0.02 248)", - "foreground": "oklch(1 0 0)", - "card": "oklch(0.306 0.02 248)", - "card-foreground": "oklch(1 0 0)", - "popover": "oklch(0.306 0.02 248)", - "popover-foreground": "oklch(1 0 0)", - "primary": "oklch(0.636 0.165 142.5)", - "primary-foreground": "oklch(1 0 0)", - "secondary": "oklch(0.4 0.022 248)", - "secondary-foreground": "oklch(1 0 0)", - "muted": "oklch(0.21 0.017 248)", - "muted-foreground": "oklch(0.644 0.009 248)", - "accent": "oklch(0.385 0.016 248)", - "accent-foreground": "oklch(1 0 0)", - "destructive": "oklch(0.704 0.191 22.216)", - "border": "oklch(1 0 0 / 10%)", - "input": "oklch(1 0 0 / 10%)", - "ring": "oklch(0.636 0.165 142.5)", - "sidebar": "oklch(0.21 0.017 248)", - "sidebar-foreground": "oklch(1 0 0)", - "sidebar-primary": "oklch(0.636 0.165 142.5)", - "sidebar-primary-foreground": "oklch(1 0 0)", - "sidebar-accent": "oklch(0.385 0.016 248)", - "sidebar-accent-foreground": "oklch(1 0 0)", - "sidebar-border": "oklch(1 0 0 / 10%)", - "sidebar-ring": "oklch(0.636 0.165 142.5)" + "background": "#1c1c1c", + "foreground": "rgba(244, 244, 244, 0.87)", + "card": "#262626", + "card-foreground": "rgba(244, 244, 244, 0.87)", + "popover": "#1c1c1c", + "popover-foreground": "rgba(244, 244, 244, 0.87)", + "primary": "#f4f4f4", + "primary-foreground": "#262626", + "secondary": "rgba(244, 244, 244, 0.1)", + "secondary-foreground": "rgba(244, 244, 244, 0.87)", + "muted": "rgba(244, 244, 244, 0.05)", + "muted-foreground": "rgba(244, 244, 244, 0.54)", + "accent": "rgba(244, 244, 244, 0.05)", + "accent-foreground": "rgba(244, 244, 244, 0.87)", + "destructive": "#fd413c", + "border": "rgba(244, 244, 244, 0.1)", + "input": "rgba(244, 244, 244, 0.1)", + "ring": "#f4f4f4", + "sidebar": "#141414", + "sidebar-foreground": "rgba(244, 244, 244, 0.87)", + "sidebar-primary": "#f4f4f4", + "sidebar-primary-foreground": "#262626", + "sidebar-accent": "rgba(244, 244, 244, 0.05)", + "sidebar-accent-foreground": "rgba(244, 244, 244, 0.87)", + "sidebar-border": "rgba(244, 244, 244, 0.1)", + "sidebar-ring": "#f4f4f4" } } }, @@ -144,60 +144,60 @@ ], "css": { ":root:root": { - "--background": "var(--crowdin-level-2-bg, oklch(1 0 0))", - "--foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--card": "var(--crowdin-level-3-bg, oklch(1 0 0))", - "--card-foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--popover": "var(--crowdin-level-2-bg, oklch(1 0 0))", - "--popover-foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--primary": "var(--crowdin-primary, oklch(0.268 0.022 230))", - "--primary-foreground": "var(--crowdin-primary-btn-color, oklch(1 0 0))", - "--secondary": "var(--crowdin-gray-01, oklch(0.935 0.007 228))", - "--secondary-foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--muted": "var(--crowdin-gray-005, oklch(0.935 0.007 228))", - "--muted-foreground": "var(--crowdin-text-muted, oklch(0.577 0.014 228))", - "--accent": "var(--crowdin-gray-005, oklch(0.965 0.004 228))", - "--accent-foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--destructive": "var(--crowdin-danger, oklch(0.577 0.21 27.3))", - "--border": "var(--crowdin-border-color, oklch(0.91 0.006 228))", - "--input": "var(--crowdin-border-color, oklch(0.91 0.006 228))", - "--ring": "var(--crowdin-primary, oklch(0.268 0.022 230))", - "--sidebar": "var(--crowdin-level-0-bg, oklch(0.975 0.003 228))", - "--sidebar-foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--sidebar-primary": "var(--crowdin-primary, oklch(0.268 0.022 230))", - "--sidebar-primary-foreground": "var(--crowdin-primary-btn-color, oklch(1 0 0))", - "--sidebar-accent": "var(--crowdin-gray-005, oklch(0.965 0.004 228))", - "--sidebar-accent-foreground": "var(--crowdin-body-color, oklch(0.268 0.022 230))", - "--sidebar-border": "var(--crowdin-border-color, oklch(0.91 0.006 228))", - "--sidebar-ring": "var(--crowdin-primary, oklch(0.268 0.022 230))" + "--background": "var(--crowdin-level-2-bg, #ffffff)", + "--foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--card": "var(--crowdin-level-3-bg, #ffffff)", + "--card-foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--popover": "var(--crowdin-level-2-bg, #ffffff)", + "--popover-foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--primary": "var(--crowdin-primary, #262626)", + "--primary-foreground": "var(--crowdin-primary-foreground, #ffffff)", + "--secondary": "var(--crowdin-gray-01, rgba(38, 38, 38, 0.1))", + "--secondary-foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--muted": "var(--crowdin-gray-005, rgba(38, 38, 38, 0.05))", + "--muted-foreground": "var(--crowdin-text-muted, rgba(38, 38, 38, 0.67))", + "--accent": "var(--crowdin-gray-005, rgba(38, 38, 38, 0.05))", + "--accent-foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--destructive": "var(--crowdin-danger, #dc5242)", + "--border": "var(--crowdin-border-color, rgba(38, 38, 38, 0.1))", + "--input": "var(--crowdin-border-color, rgba(38, 38, 38, 0.1))", + "--ring": "var(--crowdin-primary, #262626)", + "--sidebar": "var(--crowdin-level-0-bg, #eeeeee)", + "--sidebar-foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--sidebar-primary": "var(--crowdin-primary, #262626)", + "--sidebar-primary-foreground": "var(--crowdin-primary-foreground, #ffffff)", + "--sidebar-accent": "var(--crowdin-gray-005, rgba(38, 38, 38, 0.05))", + "--sidebar-accent-foreground": "var(--crowdin-body-color, rgba(38, 38, 38, 0.87))", + "--sidebar-border": "var(--crowdin-border-color, rgba(38, 38, 38, 0.1))", + "--sidebar-ring": "var(--crowdin-primary, #262626)" }, ":root:root.dark": { - "--background": "var(--crowdin-level-2-bg, oklch(0.253 0.02 248))", - "--foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--card": "var(--crowdin-level-3-bg, oklch(0.306 0.02 248))", - "--card-foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--popover": "var(--crowdin-level-2-bg, oklch(0.306 0.02 248))", - "--popover-foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--primary": "var(--crowdin-primary, oklch(0.636 0.165 142.5))", - "--primary-foreground": "var(--crowdin-primary-btn-color, oklch(1 0 0))", - "--secondary": "var(--crowdin-gray-01, oklch(0.4 0.022 248))", - "--secondary-foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--muted": "var(--crowdin-gray-005, oklch(0.21 0.017 248))", - "--muted-foreground": "var(--crowdin-text-muted, oklch(0.644 0.009 248))", - "--accent": "var(--crowdin-gray-005, oklch(0.385 0.016 248))", - "--accent-foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--destructive": "var(--crowdin-danger, oklch(0.704 0.191 22.216))", - "--border": "var(--crowdin-border-color, oklch(1 0 0 / 10%))", - "--input": "var(--crowdin-border-color, oklch(1 0 0 / 10%))", - "--ring": "var(--crowdin-primary, oklch(0.636 0.165 142.5))", - "--sidebar": "var(--crowdin-level-0-bg, oklch(0.21 0.017 248))", - "--sidebar-foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--sidebar-primary": "var(--crowdin-primary, oklch(0.636 0.165 142.5))", - "--sidebar-primary-foreground": "var(--crowdin-primary-btn-color, oklch(1 0 0))", - "--sidebar-accent": "var(--crowdin-gray-005, oklch(0.385 0.016 248))", - "--sidebar-accent-foreground": "var(--crowdin-body-color, oklch(1 0 0))", - "--sidebar-border": "var(--crowdin-border-color, oklch(1 0 0 / 10%))", - "--sidebar-ring": "var(--crowdin-primary, oklch(0.636 0.165 142.5))" + "--background": "var(--crowdin-level-2-bg, #1c1c1c)", + "--foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--card": "var(--crowdin-level-3-bg, #262626)", + "--card-foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--popover": "var(--crowdin-level-2-bg, #1c1c1c)", + "--popover-foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--primary": "var(--crowdin-primary, #f4f4f4)", + "--primary-foreground": "var(--crowdin-primary-foreground, #262626)", + "--secondary": "var(--crowdin-gray-01, rgba(244, 244, 244, 0.1))", + "--secondary-foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--muted": "var(--crowdin-gray-005, rgba(244, 244, 244, 0.05))", + "--muted-foreground": "var(--crowdin-text-muted, rgba(244, 244, 244, 0.54))", + "--accent": "var(--crowdin-gray-005, rgba(244, 244, 244, 0.05))", + "--accent-foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--destructive": "var(--crowdin-danger, #fd413c)", + "--border": "var(--crowdin-border-color, rgba(244, 244, 244, 0.1))", + "--input": "var(--crowdin-border-color, rgba(244, 244, 244, 0.1))", + "--ring": "var(--crowdin-primary, #f4f4f4)", + "--sidebar": "var(--crowdin-level-0-bg, #141414)", + "--sidebar-foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--sidebar-primary": "var(--crowdin-primary, #f4f4f4)", + "--sidebar-primary-foreground": "var(--crowdin-primary-foreground, #262626)", + "--sidebar-accent": "var(--crowdin-gray-005, rgba(244, 244, 244, 0.05))", + "--sidebar-accent-foreground": "var(--crowdin-body-color, rgba(244, 244, 244, 0.87))", + "--sidebar-border": "var(--crowdin-border-color, rgba(244, 244, 244, 0.1))", + "--sidebar-ring": "var(--crowdin-primary, #f4f4f4)" } } } diff --git a/scripts/registry.test.mjs b/scripts/registry.test.mjs index 5be795e..312b148 100644 --- a/scripts/registry.test.mjs +++ b/scripts/registry.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { test } from "node:test"; const registry = JSON.parse( @@ -116,7 +116,7 @@ const HOST_VAR_BY_TOKEN = { "popover": "--crowdin-level-2-bg", "popover-foreground": "--crowdin-body-color", "primary": "--crowdin-primary", - "primary-foreground": "--crowdin-primary-btn-color", + "primary-foreground": "--crowdin-primary-foreground", "secondary": "--crowdin-gray-01", "secondary-foreground": "--crowdin-body-color", "muted": "--crowdin-gray-005", @@ -130,7 +130,7 @@ const HOST_VAR_BY_TOKEN = { "sidebar": "--crowdin-level-0-bg", "sidebar-foreground": "--crowdin-body-color", "sidebar-primary": "--crowdin-primary", - "sidebar-primary-foreground": "--crowdin-primary-btn-color", + "sidebar-primary-foreground": "--crowdin-primary-foreground", "sidebar-accent": "--crowdin-gray-005", "sidebar-accent-foreground": "--crowdin-body-color", "sidebar-border": "--crowdin-border-color", @@ -149,3 +149,55 @@ test("each token reads its canonical host variable", () => { } } }); + +// tests/hosts/*.json pin what each Crowdin host actually sends to app iframes +// (copied from the host's window.cssVariables, see README). They are the ground truth for +// the host variable names the bridge may read and for the canonical values. +const hosts = readdirSync(new URL("../tests/hosts/", import.meta.url)) + .filter((file) => file.endsWith(".json")) + .map((file) => + JSON.parse(readFileSync(new URL(`../tests/hosts/${file}`, import.meta.url), "utf8")), + ); + +test("every captured host sends every host variable the bridge reads", () => { + assert.ok(hosts.length > 0, "no host fixtures in tests/hosts/"); + const hostVars = new Set(Object.values(HOST_VAR_BY_TOKEN)); + for (const host of hosts) { + for (const mode of ["light", "dark"]) { + for (const hostVar of hostVars) { + assert.ok( + hostVar in host[mode], + `${host.host} (${mode}) does not send ${hostVar}`, + ); + } + } + } +}); + +// Resolves a value the way the app sees it: the bridge applies the host's +// --crowdin-*/--color-* variables on , so var() chains resolve through +// the captured map; an unknown variable falls back like the browser would. +function resolveHostValue(value, vars) { + const ref = /var\((--[a-zA-Z0-9_-]+)(?:,\s*([^()]*(?:\([^()]*\))*[^()]*))?\)/; + for (let match = value.match(ref), i = 0; match && i < 32; match = value.match(ref), i++) { + const [whole, name, fallback] = match; + value = value.replace(whole, name in vars ? vars[name] : (fallback ?? "")); + } + return value.trim(); +} + +// The theme literals (and so the bridge fallbacks) are the canonical Crowdin +// values: exactly what crowdin.com sends for each token's host variable. +test("theme literals equal the values crowdin.com sends", () => { + const com = hosts.find((host) => host.host === "crowdin-com"); + assert.ok(com, "tests/hosts/crowdin-com.json is missing"); + for (const { cssVars } of MODES) { + for (const [token, hostVar] of Object.entries(HOST_VAR_BY_TOKEN)) { + assert.equal( + theme.cssVars[cssVars][token], + resolveHostValue(`var(${hostVar})`, com[cssVars]), + `theme ${cssVars} ${token} drifted from crowdin.com ${hostVar}`, + ); + } + } +}); diff --git a/site/package.json b/site/package.json index 9afa3d9..367daf0 100644 --- a/site/package.json +++ b/site/package.json @@ -7,9 +7,9 @@ "url": "https://github.com/andrii-bodnar/" }, "scripts": { - "predev": "node scripts/gen-theme.mjs", + "predev": "node scripts/gen-theme.mjs && node scripts/gen-host-sim.mjs", "dev": "vite", - "prebuild": "node scripts/gen-theme.mjs", + "prebuild": "node scripts/gen-theme.mjs && node scripts/gen-host-sim.mjs", "build": "tsc --noEmit && vite build", "preview": "vite preview" }, diff --git a/site/scripts/gen-host-sim.mjs b/site/scripts/gen-host-sim.mjs new file mode 100644 index 0000000..9b1c687 --- /dev/null +++ b/site/scripts/gen-host-sim.mjs @@ -0,0 +1,34 @@ +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Host palettes for the "Simulate host" control: exactly the variables each +// Crowdin host sends to app iframes (tests/hosts/*.json, see README), +// applied on the way the app-theme +// bridge applies them - var() chains included, so they resolve like in the host. +export function generateHostSimCss(hosts) { + const decls = (vars) => + Object.entries(vars) + .map(([name, value]) => ` ${name}: ${value};`) + .join("\n"); + return [ + "/* generated from tests/hosts/*.json by scripts/gen-host-sim.mjs - do not edit */", + ...hosts.flatMap((host) => [ + `/* ${host.host}: ${host.source}, captured ${host.capturedAt} */`, + `[data-crowdin-host="${host.host}"]:not(.dark) {\n${decls(host.light)}\n}`, + `[data-crowdin-host="${host.host}"].dark {\n${decls(host.dark)}\n}`, + ]), + "", + ].join("\n\n"); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const dir = new URL("../../tests/hosts/", import.meta.url); + const hosts = readdirSync(dir) + .filter((file) => file.endsWith(".json")) + .sort() + .map((file) => JSON.parse(readFileSync(new URL(file, dir), "utf8"))); + const outDir = new URL("../src/generated/", import.meta.url); + mkdirSync(outDir, { recursive: true }); + writeFileSync(new URL("host-sim.css", outDir), generateHostSimCss(hosts)); + console.log(`Wrote src/generated/host-sim.css (${hosts.map((h) => h.host).join(", ")})`); +} diff --git a/site/scripts/gen-host-sim.test.mjs b/site/scripts/gen-host-sim.test.mjs new file mode 100644 index 0000000..63bdea4 --- /dev/null +++ b/site/scripts/gen-host-sim.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { generateHostSimCss } from "./gen-host-sim.mjs"; + +const hosts = [ + { + host: "crowdin-com", + source: "window.cssVariables on crowdin.com", + capturedAt: "2026-10-14", + light: { "--crowdin-primary": "#262626", "--crowdin-level-2-bg": "#ffffff" }, + dark: { "--crowdin-primary": "#f4f4f4", "--crowdin-level-2-bg": "#1c1c1c" }, + }, + { + host: "enterprise", + source: "window.cssVariables on Crowdin Enterprise", + capturedAt: "2026-10-14", + light: { "--color-gray-800": "#262626", "--crowdin-primary": "var(--color-gray-800)" }, + dark: { "--color-gray-100": "#f4f4f4", "--crowdin-primary": "var(--color-gray-100)" }, + }, +]; + +// The light block must not also apply under .dark, or dark mode would get the +// union of both maps instead of exactly what the host sends in dark mode. +test("emits a light block that excludes .dark and a dark block per host", () => { + const css = generateHostSimCss(hosts); + for (const host of ["crowdin-com", "enterprise"]) { + assert.ok(css.includes(`[data-crowdin-host="${host}"]:not(.dark) {`), `${host} light block`); + assert.ok(css.includes(`[data-crowdin-host="${host}"].dark {`), `${host} dark block`); + assert.ok(!css.includes(`[data-crowdin-host="${host}"] {`), `${host} light block leaks into dark`); + } +}); + +test("declares each captured variable verbatim, var() chains included", () => { + const css = generateHostSimCss(hosts); + assert.ok(css.includes(" --crowdin-primary: #262626;")); + assert.ok(css.includes(" --crowdin-primary: #f4f4f4;")); + assert.ok(css.includes(" --crowdin-primary: var(--color-gray-800);")); + assert.ok(css.includes(" --color-gray-100: #f4f4f4;")); +}); + +test("records where each palette came from and warns against editing", () => { + const css = generateHostSimCss(hosts); + assert.ok(css.startsWith("/* generated from tests/hosts/*.json")); + assert.ok(css.includes("/* crowdin-com: window.cssVariables on crowdin.com, captured 2026-10-14 */")); +}); diff --git a/site/scripts/gen-theme.test.mjs b/site/scripts/gen-theme.test.mjs index c4b0e02..4245dd3 100644 --- a/site/scripts/gen-theme.test.mjs +++ b/site/scripts/gen-theme.test.mjs @@ -6,6 +6,7 @@ import { generateThemeCss } from "./gen-theme.mjs"; const registry = JSON.parse( readFileSync(new URL("../../registry.json", import.meta.url), "utf8"), ); +const bridge = registry.items.find((item) => item.name === "app-theme").css; test("generated css contains mappings, token blocks, style and bridge css", () => { const css = generateThemeCss(registry); @@ -20,7 +21,8 @@ test("generated css contains mappings, token blocks, style and bridge css", () = "scrollbar-color: color-mix(in oklch, var(--muted-foreground) 30%, transparent) transparent;", ":root:root {", ":root:root.dark {", - "--background: var(--crowdin-level-2-bg, oklch(1 0 0));", + `--background: ${bridge[":root:root"]["--background"]};`, + `--background: ${bridge[":root:root.dark"]["--background"]};`, "@layer base {", "@apply border-border outline-ring/50;", "@apply bg-background text-foreground;", diff --git a/site/src/app.tsx b/site/src/app.tsx index 41b102f..12655cf 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -15,7 +15,7 @@ import { Forms } from "./sections/forms"; import { Navigation } from "./sections/navigation"; import { Overlays } from "./sections/overlays"; -type Host = "off" | "com" | "enterprise"; +type Host = "off" | "crowdin-com" | "enterprise"; export function App() { const [dark, setDark] = useState(false); @@ -57,7 +57,7 @@ export function App() { onValueChange={(value) => setHost((value || "off") as Host)} > Off - crowdin.com + crowdin.com Enterprise diff --git a/site/src/host-sim.css b/site/src/host-sim.css deleted file mode 100644 index d48a55a..0000000 --- a/site/src/host-sim.css +++ /dev/null @@ -1,72 +0,0 @@ -/* Host palettes for the "Simulate host" control: the --crowdin-* variables - crowdin.com and Crowdin Enterprise inject into app iframes, as of - 2026-08-06. Only the variables the app-theme bridge reads are simulated, - plus the *-color RGB triplets their formulas resolve through (kept as the - same var() chains the hosts use). - - Notable host differences: - - crowdin.com does NOT send --crowdin-primary in light mode - apps - intentionally fall back to the canonical navy. Dark sends Crowdin green. - - Enterprise sends --crowdin-primary in both modes, derived from the - org-brandable --crowdin-primaries-color triplet (default Crowdin green). - - danger differs in dark mode: #fd413c (com) vs #fa644a (enterprise). */ - -[data-crowdin-host="com"] { - --crowdin-cards-color: 38, 50, 56; - --crowdin-level-0-bg: #f5f7f8; - --crowdin-level-2-bg: #ffffff; - --crowdin-level-3-bg: #ffffff; - --crowdin-body-color: rgba(38, 50, 56, 0.87); - --crowdin-primary-btn-color: #ffffff; - --crowdin-gray-01: rgba(var(--crowdin-cards-color), 0.1); - --crowdin-gray-005: rgba(var(--crowdin-cards-color), 0.05); - --crowdin-text-muted: rgba(38, 50, 56, 0.54); - --crowdin-danger: #dc5242; - --crowdin-border-color: rgba(38, 50, 56, 0.1); -} - -[data-crowdin-host="com"].dark { - --crowdin-cards-color: 255, 255, 255; - --crowdin-level-0-bg: #16191d; - --crowdin-level-2-bg: #2c3139; - --crowdin-level-3-bg: #373e48; - --crowdin-body-color: #bdc1c6; - --crowdin-primary: #57bc5c; - --crowdin-primary-btn-color: #ffffff; - --crowdin-text-muted: #949aa3; - --crowdin-danger: #fd413c; - --crowdin-border-color: rgba(255, 255, 255, 0.12); -} - -[data-crowdin-host="enterprise"] { - --crowdin-primaries-color: 67, 160, 71; - --crowdin-typeface-color: 38, 50, 56; - --crowdin-cards-color: 38, 50, 56; - --crowdin-whites: 255, 255, 255; - --crowdin-white: rgba(var(--crowdin-whites), 1); - --crowdin-level-1-bg: #f5f7f8; - --crowdin-level-0-bg: var(--crowdin-level-1-bg); - --crowdin-level-2-bg: var(--crowdin-white); - --crowdin-level-3-bg: var(--crowdin-level-2-bg); - --crowdin-body-color: rgba(var(--crowdin-typeface-color), 0.87); - --crowdin-primary: rgba(var(--crowdin-primaries-color), 1); - --crowdin-primary-btn-color: var(--crowdin-white); - --crowdin-gray-01: rgba(var(--crowdin-cards-color), 0.1); - --crowdin-gray-005: rgba(var(--crowdin-cards-color), 0.05); - --crowdin-gray-012: rgba(var(--crowdin-cards-color), 0.12); - --crowdin-text-muted: rgba(var(--crowdin-typeface-color), 0.54); - --crowdin-danger: #dc5242; - --crowdin-border-color: var(--crowdin-gray-012); -} - -[data-crowdin-host="enterprise"].dark { - --crowdin-primaries-color: 78, 183, 83; - --crowdin-typeface-color: 255, 255, 255; - --crowdin-cards-color: 255, 255, 255; - --crowdin-level-0-bg: #16191d; - --crowdin-level-1-bg: #21252b; - --crowdin-level-2-bg: #2c3139; - --crowdin-level-3-bg: #373e48; - --crowdin-danger: #fa644a; - --crowdin-border-color: var(--crowdin-gray-01); -} diff --git a/site/src/index.css b/site/src/index.css index 466bbbe..8d6b828 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -3,6 +3,6 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; @import "./generated/theme.css"; -@import "./host-sim.css"; +@import "./generated/host-sim.css"; @custom-variant dark (&:is(.dark *)); diff --git a/tests/hosts/crowdin-com.json b/tests/hosts/crowdin-com.json new file mode 100644 index 0000000..b3ddad9 --- /dev/null +++ b/tests/hosts/crowdin-com.json @@ -0,0 +1,31 @@ +{ + "host": "crowdin-com", + "source": "window.cssVariables on crowdin.com - the map AP.getCssVariables delivers to app iframes", + "capturedAt": "2026-10-14", + "light": { + "--crowdin-body-color": "rgba(38, 38, 38, 0.87)", + "--crowdin-border-color": "rgba(38, 38, 38, 0.1)", + "--crowdin-danger": "#dc5242", + "--crowdin-gray-005": "rgba(38, 38, 38, 0.05)", + "--crowdin-gray-01": "rgba(38, 38, 38, 0.1)", + "--crowdin-level-0-bg": "#eeeeee", + "--crowdin-level-2-bg": "#ffffff", + "--crowdin-level-3-bg": "#ffffff", + "--crowdin-primary": "#262626", + "--crowdin-primary-foreground": "#ffffff", + "--crowdin-text-muted": "rgba(38, 38, 38, 0.67)" + }, + "dark": { + "--crowdin-body-color": "rgba(244, 244, 244, 0.87)", + "--crowdin-border-color": "rgba(244, 244, 244, 0.1)", + "--crowdin-danger": "#fd413c", + "--crowdin-gray-005": "rgba(244, 244, 244, 0.05)", + "--crowdin-gray-01": "rgba(244, 244, 244, 0.1)", + "--crowdin-level-0-bg": "#141414", + "--crowdin-level-2-bg": "#1c1c1c", + "--crowdin-level-3-bg": "#262626", + "--crowdin-primary": "#f4f4f4", + "--crowdin-primary-foreground": "#262626", + "--crowdin-text-muted": "rgba(244, 244, 244, 0.54)" + } +} diff --git a/tests/hosts/enterprise.json b/tests/hosts/enterprise.json new file mode 100644 index 0000000..14357bb --- /dev/null +++ b/tests/hosts/enterprise.json @@ -0,0 +1,36 @@ +{ + "host": "enterprise", + "source": "window.cssVariables on Crowdin Enterprise (redesign v2) - the map AP.getCssVariables delivers to app iframes", + "capturedAt": "2026-10-14", + "light": { + "--color-gray-800": "#262626", + "--crowdin-body-color": "var(--crowdin-gray-087)", + "--crowdin-border-color": "var(--crowdin-gray-012)", + "--crowdin-danger": "#dc5242", + "--crowdin-gray-005": "rgba(38, 38, 38, 0.05)", + "--crowdin-gray-01": "rgba(38, 38, 38, 0.1)", + "--crowdin-gray-012": "rgba(38, 38, 38, 0.12)", + "--crowdin-gray-087": "rgba(38, 38, 38, 0.87)", + "--crowdin-level-0-bg": "#eeeeee", + "--crowdin-level-2-bg": "#ffffff", + "--crowdin-level-3-bg": "#ffffff", + "--crowdin-primary": "var(--color-gray-800)", + "--crowdin-primary-foreground": "#ffffff", + "--crowdin-text-muted": "rgba(38, 38, 38, 0.67)" + }, + "dark": { + "--color-gray-100": "#f4f4f4", + "--crowdin-body-color": "var(--crowdin-gray-087)", + "--crowdin-border-color": "var(--crowdin-gray-01)", + "--crowdin-danger": "#fd413c", + "--crowdin-gray-005": "rgba(244, 244, 244, 0.05)", + "--crowdin-gray-01": "rgba(244, 244, 244, 0.1)", + "--crowdin-gray-087": "rgba(244, 244, 244, 0.87)", + "--crowdin-level-0-bg": "#141414", + "--crowdin-level-2-bg": "#1c1c1c", + "--crowdin-level-3-bg": "#262626", + "--crowdin-primary": "var(--color-gray-100)", + "--crowdin-primary-foreground": "#262626", + "--crowdin-text-muted": "rgba(244, 244, 244, 0.54)" + } +}