@nebutra/theme 0.1.1 → 0.2.1

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 CHANGED
@@ -1,46 +1,92 @@
1
1
  # @nebutra/theme
2
2
 
3
- CSS-only theme engine for Nebutra — Tailwind v4 `@theme` tokens, `data-theme` selectors, and a `next-themes` re-export.
3
+ **Design-language catalog** for global product chrome swap (Create Center / multi-tenant SaaS).
4
4
 
5
- ## Design Intent
5
+ ## What this is
6
6
 
7
- All visual theming in Nebutra is expressed as CSS custom properties scoped to `[data-theme="<id>"]` selectors defined in `themes.css`. There is no JavaScript theme object or runtime style computation. The `data-theme` attribute is toggled by `next-themes` (re-exported here as `ThemeProvider` and `useTheme`), making the theme system framework-agnostic at the CSS layer while remaining ergonomic in React apps.
7
+ | Layer | Package | Role |
8
+ |-------|---------|------|
9
+ | Product SSOT | `@nebutra/tokens` | `styles.css` + `recipe.css` |
10
+ | **Language swap** | **`@nebutra/theme`** | Brand Packages + `applyLanguage` + `skins.css` |
11
+ | Components | `@nebutra/ui` | `--primary`, `--brand-mark`, `--elevation-*` |
8
12
 
9
- Apps import a single CSS file and a single provider — no per-component theme wiring required.
13
+ A **design language** is a full Brand Package (roles + recipe + free elevation + zones + fonts).
10
14
 
11
- ## Registry Contract
15
+ > **Removed (2026.07):** 78 oklch “mood” presets under `[data-theme]`. They dual-wrote product chrome, looked generic, and fought the carrier model. Do not reintroduce them.
12
16
 
13
- Theme metadata is governed by `src/registry.json` and exported from `@nebutra/theme/registry`.
14
- Do not add new hard-coded theme arrays in apps, presets, docs, or CLI commands.
17
+ ## Quick start
15
18
 
16
- The registry is consumed by:
19
+ ```css
20
+ @import "@nebutra/tokens/styles.css";
21
+ @import "@nebutra/tokens/recipe.css";
22
+ @import "@nebutra/theme/skins.css"; /* multi-language, scoped to html[data-brand] */
23
+ ```
17
24
 
18
- - `@nebutra/design-tokens` build output (`themes.css`)
19
- - `@nebutra/preset` config validation
20
- - `nebutra theme list` and `nebutra theme inspect <id>`
21
- - future Figma/theme-playground publishing surfaces
25
+ ```ts
26
+ // Client-safe root (or `@nebutra/theme/client`)
27
+ import { applyLanguage, clearLanguage, LANGUAGE_REGISTRY } from "@nebutra/theme";
22
28
 
23
- Each built-in theme must declare token path, install command, compatibility flags, WCAG target, required token coverage, and visual regression suites.
29
+ applyLanguage("vanta", { persist: true }); // built-in Brand Package
30
+ clearLanguage(); // factory
24
31
 
25
- ## Usage
32
+ // Compile / Create Center tooling — not on package root:
33
+ // import { compileReferoTokens } from "@nebutra/tokens/brand-package";
34
+ // import { compileReferoTokens } from "@nebutra/theme/brand-package";
35
+ ```
26
36
 
27
- ```tsx
28
- // In your root layout
29
- import { ThemeProvider } from "@nebutra/theme";
30
- import "@nebutra/theme/themes.css";
37
+ ```bash
38
+ nebutra theme list
39
+ nebutra theme inspect vanta
40
+ ```
31
41
 
32
- <ThemeProvider attribute="data-theme" defaultTheme="neon">
33
- {children}
34
- </ThemeProvider>
42
+ ## Catalog generation
43
+
44
+ | File | Role |
45
+ |------|------|
46
+ | `tokens/brands/<id>/brand.json` | Package SSOT (roles, recipe, zones) |
47
+ | `src/languages.meta.json` | Catalog copy: description + proves |
48
+ | `src/languages.json` | **Generated** by `pnpm sync:languages` |
49
+ | `src/built-in-packages.generated.ts` | **Generated** runtime map for `applyLanguage(id)` |
50
+ | `skins.css` | **Generated** by `pnpm sync:skins` |
51
+ | `keyframes.css` | Shared animations (prefer over deprecated `themes.css`) |
52
+
53
+ ```bash
54
+ pnpm --filter @nebutra/theme sync:languages
55
+ pnpm --filter @nebutra/tokens emit-skins
56
+ pnpm --filter @nebutra/theme sync:skins
35
57
  ```
36
58
 
37
- ## Available Themes
59
+ ## Catalog
38
60
 
39
- `neon` | `gradient` | `dark-dense` | `minimal` | `vibrant` | `ocean`
61
+ | id | Proves |
62
+ |----|--------|
63
+ | factory | Default tokens SSOT |
64
+ | linear | Chromatic solid CTA + dual-mode dark/light |
65
+ | gsap | gradient-stroke / outline + zones |
66
+ | raycast | action ≠ brand-mark + elev=key |
67
+ | vercel | Light mono + elev=hairline + dual-mode palettes |
68
+ | vanta | Chromatic action≠brand + elev=none + pills |
69
+ | stripe | Indigo action ≠ midnight brand + elev=none + 4px |
70
+ | notion | Blue action ≠ ink brand + paper canvas + 8/12 radii |
40
71
 
41
- The `THEME_IDS` constant and `ThemeId` type are exported for use in theme switcher components.
72
+ ## Dual-mode stress fixtures
42
73
 
43
- ```bash
44
- nebutra theme list --format json
45
- nebutra theme inspect neon --format json
74
+ Brand packages with `modes.light` + `modes.dark` (today: **linear**, **vercel**)
75
+ emit separate selector blocks so light paint never lands under bare `.dark`:
76
+
77
+ - light → `:root, html[data-brand="…"]`
78
+ - dark → `.dark, html.dark[data-brand="…"]`
79
+
80
+ Coverage: `src/__tests__/apply-language.test.ts` (runtime apply) and
81
+ `@nebutra/tokens` `emit-css.test.ts` (compiler). When adding a dual-mode
82
+ language, extend both fixtures.
83
+
84
+ ## `keyframes.css`
85
+
86
+ Shared keyframe animations only. Prefer:
87
+
88
+ ```css
89
+ @import "@nebutra/theme/keyframes.css";
46
90
  ```
91
+
92
+ `themes.css` remains a **deprecated alias** that re-exports keyframes (no color moods).
package/keyframes.css ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @nebutra/theme — keyframes.css (via themes.generated.css)
3
+ * AUTO-GENERATED — multi-mood oklch catalog removed.
4
+ * Product chrome SSOT: @nebutra/tokens. Design languages: @nebutra/theme/skins.css.
5
+ * This file: shared keyframes only.
6
+ */
7
+
8
+ /* ─── Shared Keyframe Animations ─── */
9
+ /*
10
+ * These keyframes are shared across all apps that import this file.
11
+ * App-specific keyframes (marquee, shimmer-slide, accordion, etc.) stay in each app's globals.css.
12
+ */
13
+
14
+ @theme inline {
15
+ /* Loader / status animations */
16
+ @keyframes typing {
17
+ 0%,
18
+ 100% {
19
+ transform: translateY(0);
20
+ opacity: 0.5;
21
+ }
22
+ 50% {
23
+ transform: translateY(-2px);
24
+ opacity: 1;
25
+ }
26
+ }
27
+ @keyframes loading-dots {
28
+ 0%,
29
+ 100% {
30
+ opacity: 0;
31
+ }
32
+ 50% {
33
+ opacity: 1;
34
+ }
35
+ }
36
+ @keyframes wave {
37
+ 0%,
38
+ 100% {
39
+ transform: scaleY(1);
40
+ }
41
+ 50% {
42
+ transform: scaleY(0.6);
43
+ }
44
+ }
45
+ @keyframes blink {
46
+ 0%,
47
+ 100% {
48
+ opacity: 1;
49
+ }
50
+ 50% {
51
+ opacity: 0;
52
+ }
53
+ }
54
+ @keyframes text-blink {
55
+ 0%,
56
+ 100% {
57
+ color: var(--color-primary);
58
+ }
59
+ 50% {
60
+ color: var(--color-muted-foreground);
61
+ }
62
+ }
63
+ @keyframes bounce-dots {
64
+ 0%,
65
+ 100% {
66
+ transform: scale(0.8);
67
+ opacity: 0.5;
68
+ }
69
+ 50% {
70
+ transform: scale(1.2);
71
+ opacity: 1;
72
+ }
73
+ }
74
+ @keyframes thin-pulse {
75
+ 0%,
76
+ 100% {
77
+ transform: scale(0.95);
78
+ opacity: 0.8;
79
+ }
80
+ 50% {
81
+ transform: scale(1.05);
82
+ opacity: 0.4;
83
+ }
84
+ }
85
+ @keyframes pulse-dot {
86
+ 0%,
87
+ 100% {
88
+ transform: scale(1);
89
+ opacity: 0.8;
90
+ }
91
+ 50% {
92
+ transform: scale(1.5);
93
+ opacity: 1;
94
+ }
95
+ }
96
+ @keyframes wave-bars {
97
+ 0%,
98
+ 100% {
99
+ transform: scaleY(1);
100
+ opacity: 0.5;
101
+ }
102
+ 50% {
103
+ transform: scaleY(0.6);
104
+ opacity: 1;
105
+ }
106
+ }
107
+ @keyframes shimmer {
108
+ 0% {
109
+ background-position: 200% 50%;
110
+ }
111
+ 100% {
112
+ background-position: -200% 50%;
113
+ }
114
+ }
115
+ @keyframes spinner-fade {
116
+ 0% {
117
+ opacity: 0;
118
+ }
119
+ 100% {
120
+ opacity: 1;
121
+ }
122
+ }
123
+
124
+ /* SnippetStatus copy animations */
125
+ @keyframes snippet-fade-in {
126
+ from {
127
+ opacity: 0;
128
+ transform: scale(0.5);
129
+ }
130
+ to {
131
+ opacity: 1;
132
+ transform: scale(1);
133
+ }
134
+ }
135
+ @keyframes snippet-fade-out {
136
+ from {
137
+ opacity: 1;
138
+ transform: scale(1);
139
+ }
140
+ to {
141
+ opacity: 0;
142
+ transform: scale(0.5);
143
+ }
144
+ }
145
+ --animate-snippet-fade-in: snippet-fade-in 0.2s ease-out forwards;
146
+ --animate-snippet-fade-out: snippet-fade-out 0.2s ease-out forwards;
147
+ }
package/package.json CHANGED
@@ -1,23 +1,33 @@
1
1
  {
2
2
  "name": "@nebutra/theme",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
- "description": "CSS-only theme engine — Tailwind v4 @theme tokens + data-theme selectors + next-themes re-export",
5
+ "description": "Design-language catalog for global product chrome swap (Brand Packages). Multi-mood oklch catalog removed.",
6
6
  "private": false,
7
7
  "license": "MIT",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./src/index.ts",
10
+ "sideEffects": [
11
+ "./keyframes.css",
12
+ "./themes.css",
13
+ "./skins.css"
14
+ ],
10
15
  "exports": {
11
16
  ".": "./src/index.ts",
12
- "./registry": "./src/registry.ts",
13
- "./registry.json": "./src/registry.json",
14
- "./themes.css": "./themes.css"
17
+ "./client": "./src/client.ts",
18
+ "./languages": "./src/languages.ts",
19
+ "./languages.json": "./src/languages.json",
20
+ "./brand-package": "./src/brand-package-surface.ts",
21
+ "./keyframes.css": "./keyframes.css",
22
+ "./themes.css": "./themes.css",
23
+ "./skins.css": "./skins.css"
15
24
  },
16
25
  "dependencies": {
17
- "next-themes": "^0.4.6"
26
+ "@nebutra/tokens": "0.1.2"
18
27
  },
19
28
  "devDependencies": {
20
29
  "@types/react": "^19.2.14",
30
+ "tsx": "^4.21.0",
21
31
  "typescript": "^5.9.3",
22
32
  "vitest": "^4.1.4"
23
33
  },
@@ -37,9 +47,10 @@
37
47
  "access": "public"
38
48
  },
39
49
  "scripts": {
40
- "prebuild": "corepack pnpm --filter @nebutra/design-tokens build",
41
- "build": "node -e \"require('node:fs').copyFileSync('../design-tokens/build/css/themes.generated.css', './themes.css'); console.log('themes.css regenerated from @nebutra/design-tokens')\"",
42
- "sync": "corepack pnpm --filter @nebutra/design-tokens build && node -e \"require('node:fs').copyFileSync('../design-tokens/build/css/themes.generated.css', './themes.css')\"",
50
+ "build": "node scripts/sync-languages.mjs && node ../tokens/scripts/emit-skins.mjs && node scripts/sync-skins.mjs",
51
+ "sync": "node scripts/sync-languages.mjs && node scripts/sync-skins.mjs",
52
+ "sync:languages": "node scripts/sync-languages.mjs",
53
+ "sync:skins": "node scripts/sync-skins.mjs",
43
54
  "test": "vitest run",
44
55
  "typecheck": "tsc --noEmit"
45
56
  }
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate design-language catalog artifacts from brands + meta.
4
+ *
5
+ * SSOT package data: packages/design/tokens/brands/<id>/brand.json
6
+ * SSOT catalog copy: packages/design/theme/src/languages.meta.json
7
+ *
8
+ * Generated (DO NOT hand-edit):
9
+ * - src/languages.json
10
+ * - src/built-in-packages.generated.ts
11
+ *
12
+ * Usage: node scripts/sync-languages.mjs
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+ const repoRoot = resolve(packageRoot, "../../..");
21
+ const tokensRoot = resolve(packageRoot, "..", "tokens");
22
+ const brandsDir = join(tokensRoot, "brands");
23
+ const metaPath = join(packageRoot, "src", "languages.meta.json");
24
+ const languagesOut = join(packageRoot, "src", "languages.json");
25
+ const builtInOut = join(packageRoot, "src", "built-in-packages.generated.ts");
26
+ const biomeBin = join(repoRoot, "node_modules/.bin/biome");
27
+
28
+ if (!existsSync(metaPath)) {
29
+ throw new Error(`Missing ${metaPath}`);
30
+ }
31
+ if (!existsSync(brandsDir)) {
32
+ throw new Error(`Missing brands dir ${brandsDir}`);
33
+ }
34
+
35
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
36
+ const compatibility = {
37
+ tailwind: "4",
38
+ cssVariables: true,
39
+ brandPackage: true,
40
+ dataBrand: true,
41
+ };
42
+
43
+ const factoryMeta = meta.factory ?? {};
44
+ const languages = [
45
+ {
46
+ id: "factory",
47
+ name: factoryMeta.name ?? "Nebutra Factory",
48
+ kind: "design-language",
49
+ description: factoryMeta.description ?? "Default product chrome (no skin).",
50
+ proves: factoryMeta.proves ?? [],
51
+ darkDefault: factoryMeta.darkDefault ?? true,
52
+ brandPath: null,
53
+ skinPath: null,
54
+ install: {
55
+ command: "nebutra theme use factory",
56
+ cssImport: null,
57
+ },
58
+ compatibility,
59
+ },
60
+ ];
61
+
62
+ const brandIds = readdirSync(brandsDir, { withFileTypes: true })
63
+ .filter((d) => d.isDirectory())
64
+ .map((d) => d.name)
65
+ .sort();
66
+
67
+ const langMeta = meta.languages ?? {};
68
+ const missingMeta = [];
69
+
70
+ for (const id of brandIds) {
71
+ const brandPath = join(brandsDir, id, "brand.json");
72
+ if (!existsSync(brandPath)) {
73
+ process.stderr.write(`warn: brands/${id} has no brand.json — skip\n`);
74
+ continue;
75
+ }
76
+ const brand = JSON.parse(readFileSync(brandPath, "utf8"));
77
+ const entryMeta = langMeta[id];
78
+ if (!entryMeta) {
79
+ missingMeta.push(id);
80
+ }
81
+ languages.push({
82
+ id: brand.id || id,
83
+ name: brand.name || id,
84
+ kind: "design-language",
85
+ description: entryMeta?.description ?? `${brand.name || id} design language.`,
86
+ proves: entryMeta?.proves ?? [],
87
+ darkDefault: Boolean(brand.darkDefault),
88
+ brandPath: `brands/${id}/brand.json`,
89
+ skinPath: `skins/${id}.css`,
90
+ install: {
91
+ command: `nebutra theme use ${id}`,
92
+ cssImport: `@nebutra/tokens/skins/${id}.css`,
93
+ },
94
+ compatibility,
95
+ });
96
+ }
97
+
98
+ if (missingMeta.length > 0) {
99
+ process.stderr.write(
100
+ `warn: languages.meta.json missing entries for: ${missingMeta.join(", ")}\n` +
101
+ ` (generated with fallback description; add proves/description to meta)\n`,
102
+ );
103
+ }
104
+
105
+ const orphanMeta = Object.keys(langMeta).filter((id) => !brandIds.includes(id));
106
+ if (orphanMeta.length > 0) {
107
+ process.stderr.write(
108
+ `warn: languages.meta.json has unused keys (no brands/<id>): ${orphanMeta.join(", ")}\n`,
109
+ );
110
+ }
111
+
112
+ const registry = {
113
+ $schema: "https://nebutra.com/schemas/design-language-registry.json",
114
+ version: meta.version ?? "2026.07",
115
+ defaultLanguage: meta.defaultLanguage ?? "factory",
116
+ description:
117
+ meta.description ?? "Design-language catalog for global product chrome swap (Brand Packages).",
118
+ languages,
119
+ };
120
+
121
+ writeFileSync(languagesOut, `${JSON.stringify(registry, null, 2)}\n`);
122
+
123
+ // ── built-in-packages.generated.ts ───────────────────────────────────────────
124
+ const importLines = brandIds.map(
125
+ (id) =>
126
+ `import ${safeIdent(id)} from "@nebutra/tokens/brands/${id}/brand.json" with { type: "json" };`,
127
+ );
128
+ const mapEntries = brandIds.map(
129
+ (id) => ` ${JSON.stringify(id)}: ${safeIdent(id)} as BrandPackage,`,
130
+ );
131
+
132
+ const builtInTs = `/**
133
+ * AUTO-GENERATED by scripts/sync-languages.mjs — DO NOT EDIT.
134
+ * Built-in Brand Packages for applyLanguage(id) without manual JSON import.
135
+ * Source: packages/design/tokens/brands/<id>/brand.json
136
+ */
137
+
138
+ import {
139
+ type BrandPackage,
140
+ normalizeBrandPackage,
141
+ } from "@nebutra/tokens/brand-package";
142
+ ${importLines.join("\n")}
143
+
144
+ const RAW_BUILT_IN: Record<string, BrandPackage> = {
145
+ ${mapEntries.join("\n")}
146
+ };
147
+
148
+ /** Normalized package for a shipped design-language id, or undefined. */
149
+ export function getBuiltInBrandPackage(id: string): BrandPackage | undefined {
150
+ const raw = RAW_BUILT_IN[id];
151
+ if (!raw) return undefined;
152
+ return normalizeBrandPackage({ ...raw, id });
153
+ }
154
+
155
+ export function listBuiltInBrandIds(): string[] {
156
+ return Object.keys(RAW_BUILT_IN);
157
+ }
158
+
159
+ export function hasBuiltInBrandPackage(id: string): boolean {
160
+ return id in RAW_BUILT_IN;
161
+ }
162
+ `;
163
+
164
+ writeFileSync(builtInOut, builtInTs);
165
+
166
+ // Biome format so CI governance (full-tree biome check after typecheck/build)
167
+ // does not fail on JSON.stringify / multi-line import formatting drift.
168
+ if (existsSync(biomeBin)) {
169
+ const fmt = spawnSync(biomeBin, ["format", "--write", languagesOut, builtInOut], {
170
+ cwd: repoRoot,
171
+ encoding: "utf8",
172
+ });
173
+ if (fmt.status !== 0) {
174
+ process.stderr.write(fmt.stderr || fmt.stdout || "biome format failed on language artifacts\n");
175
+ process.exit(fmt.status ?? 1);
176
+ }
177
+ }
178
+
179
+ process.stdout.write(
180
+ `sync-languages:\n` +
181
+ ` languages.json: ${languages.length} languages (${brandIds.length} skins + factory)\n` +
182
+ ` built-in-packages.generated.ts: ${brandIds.length} packages\n`,
183
+ );
184
+
185
+ /** Valid JS identifier from brand id (kebab → camel if needed; most ids are single words). */
186
+ function safeIdent(id) {
187
+ if (/^[a-zA-Z_$][\w$]*$/.test(id)) return id;
188
+ return id.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^(\d)/, "_$1");
189
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Build skins.css — multi-language catalog for html[data-brand] switching.
3
+ *
4
+ * Reads tokens/skins/*.css (global emit), rewrites selectors to scoped-only,
5
+ * concatenates into packages/design/theme/skins.css.
6
+ *
7
+ * Product chrome SSOT remains tokens/styles.css; this file never paints :root alone.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
+ const repoRoot = resolve(packageRoot, "../../..");
16
+ const tokensRoot = resolve(packageRoot, "..", "tokens");
17
+ const skinsDir = join(tokensRoot, "skins");
18
+ const outPath = join(packageRoot, "skins.css");
19
+ const biomeBin = join(repoRoot, "node_modules/.bin/biome");
20
+
21
+ if (!existsSync(skinsDir)) {
22
+ throw new Error(`Missing skins dir at ${skinsDir}`);
23
+ }
24
+
25
+ /** Keep only html[data-brand] activation — strip global :root / optional .dark binding. */
26
+ function toScopedCatalogCss(css, id) {
27
+ const scoped = `html[data-brand="${id}"] {`;
28
+ const out = css
29
+ // darkDefault=true global: ":root,\n.dark,\nhtml[data-brand=\"id\"] {"
30
+ .replace(/:root\s*,\s*\n\.dark\s*,\s*\nhtml\[data-brand="[^"]+"\]\s*\{/g, scoped)
31
+ .replace(/:root\s*,\s*\.dark\s*,\s*html\[data-brand="[^"]+"\]\s*\{/g, scoped)
32
+ // darkDefault=false global: ":root,\nhtml[data-brand=\"id\"] {" (no .dark)
33
+ .replace(/:root\s*,\s*\nhtml\[data-brand="[^"]+"\]\s*\{/g, scoped)
34
+ .replace(/:root\s*,\s*html\[data-brand="[^"]+"\]\s*\{/g, scoped);
35
+
36
+ // Safety: never leave a bare :root { color block from a skin
37
+ if (/^:root\s*\{/m.test(out) || /\n:root\s*,/.test(out)) {
38
+ throw new Error(`Skin ${id} still binds :root after scoping — fix emit or regex`);
39
+ }
40
+ return out.trimEnd();
41
+ }
42
+
43
+ const files = readdirSync(skinsDir)
44
+ .filter((f) => f.endsWith(".css") && f !== "README.md")
45
+ .sort();
46
+
47
+ const ids = [];
48
+ const chunks = [
49
+ "/**",
50
+ " * @nebutra/theme — skins.css (design-language catalog)",
51
+ " * AUTO-GENERATED by scripts/sync-skins.mjs — DO NOT EDIT.",
52
+ " *",
53
+ " * Product chrome SSOT: @nebutra/tokens/styles.css + recipe.css",
54
+ " * This file: scoped Brand Package skins under html[data-brand] only.",
55
+ ' * Switch: document.documentElement.dataset.brand = "vanta" | "linear" | ...',
56
+ ' * Factory: delete dataset.brand (or clearLanguage() / applyLanguage("factory")).',
57
+ " *",
58
+ ` * Languages: ${files.map((f) => f.replace(/\.css$/, "")).join(", ")}`,
59
+ " */",
60
+ "",
61
+ ];
62
+
63
+ for (const file of files) {
64
+ const id = file.replace(/\.css$/, "");
65
+ const raw = readFileSync(join(skinsDir, file), "utf8");
66
+ ids.push(id);
67
+ chunks.push(toScopedCatalogCss(raw, id));
68
+ chunks.push("");
69
+ }
70
+
71
+ writeFileSync(outPath, `${chunks.join("\n").trimEnd()}\n`);
72
+ // Match Biome CSS formatter so governance lint after theme build stays green.
73
+ if (existsSync(biomeBin)) {
74
+ const fmt = spawnSync(biomeBin, ["format", "--write", outPath], {
75
+ cwd: repoRoot,
76
+ encoding: "utf8",
77
+ });
78
+ if (fmt.status !== 0) {
79
+ process.stderr.write(fmt.stderr || fmt.stdout || "biome format failed on skins.css\n");
80
+ process.exit(fmt.status ?? 1);
81
+ }
82
+ }
83
+ process.stdout.write(`skins.css refreshed: ${ids.length} languages -> ${outPath}\n`);
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const repoRoot = resolve(packageRoot, "..", "..", "..");
8
+ const designTokensRoot = resolve(packageRoot, "..", "design-tokens");
9
+ const generatedThemesPath = resolve(designTokensRoot, "build", "css", "themes.generated.css");
10
+ const keyframesPath = resolve(packageRoot, "keyframes.css");
11
+ const themesAliasPath = resolve(packageRoot, "themes.css");
12
+ const biomeBin = resolve(
13
+ repoRoot,
14
+ "node_modules",
15
+ ".bin",
16
+ process.platform === "win32" ? "biome.cmd" : "biome",
17
+ );
18
+
19
+ if (!existsSync(generatedThemesPath)) {
20
+ throw new Error(
21
+ `Missing generated theme CSS at ${generatedThemesPath}. Run \`pnpm --filter @nebutra/design-tokens build\` before syncing @nebutra/theme.`,
22
+ );
23
+ }
24
+
25
+ copyFileSync(generatedThemesPath, keyframesPath);
26
+ // Compatibility alias — prefer @nebutra/theme/keyframes.css
27
+ writeFileSync(
28
+ themesAliasPath,
29
+ `/**
30
+ * @deprecated Import \`@nebutra/theme/keyframes.css\` instead.
31
+ * themes.css is a compatibility alias (keyframes only — no color moods).
32
+ */
33
+ @import "./keyframes.css";
34
+ `,
35
+ );
36
+ execFileSync(biomeBin, ["format", "--write", keyframesPath], {
37
+ cwd: repoRoot,
38
+ stdio: "inherit",
39
+ });
40
+ process.stdout.write("keyframes.css refreshed from @nebutra/design-tokens (+ themes.css alias)\n");