Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ r/
.DS_Store
site/dist/
site/src/generated/

# JetBrains IDE settings
.idea/
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
208 changes: 104 additions & 104 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -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)"
}
}
}
Expand Down
58 changes: 55 additions & 3 deletions scripts/registry.test.mjs
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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 <html>, 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 ?? "<missing>"));
}
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}`,
);
}
}
});
4 changes: 2 additions & 2 deletions site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading
Loading