@nebutra/tokens 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +5 -2
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/AGENTS.md +5 -2
  4. package/CHANGELOG.md +23 -0
  5. package/DESIGN.md +30 -8
  6. package/LICENSE +21 -676
  7. package/README.md +41 -8
  8. package/brands/gsap/brand.json +180 -0
  9. package/brands/linear/brand.json +202 -0
  10. package/brands/notion/brand.json +189 -0
  11. package/brands/raycast/brand.json +167 -0
  12. package/brands/stripe/brand.json +183 -0
  13. package/brands/vanta/brand.json +198 -0
  14. package/brands/vercel/brand.json +207 -0
  15. package/package.json +17 -8
  16. package/recipe.css +114 -0
  17. package/scripts/compile-brand-run.ts +79 -0
  18. package/scripts/compile-brand.mjs +25 -0
  19. package/scripts/emit-skins-run.ts +62 -0
  20. package/scripts/emit-skins.mjs +31 -0
  21. package/scripts/sync-styles.mjs +29 -0
  22. package/skins/README.md +151 -0
  23. package/skins/gsap.css +247 -0
  24. package/skins/linear.css +282 -0
  25. package/skins/notion.css +248 -0
  26. package/skins/raycast.css +272 -0
  27. package/skins/stripe.css +248 -0
  28. package/skins/vanta.css +255 -0
  29. package/skins/vercel.css +283 -0
  30. package/src/brand-package/__tests__/compile-refero.test.ts +427 -0
  31. package/src/brand-package/__tests__/emit-css.test.ts +130 -0
  32. package/src/brand-package/apply-brand.ts +92 -0
  33. package/src/brand-package/compile-helpers.ts +170 -0
  34. package/src/brand-package/compile-refero.ts +69 -0
  35. package/src/brand-package/emit-css.ts +468 -0
  36. package/src/brand-package/hex-to-hsl.ts +100 -0
  37. package/src/brand-package/index.ts +71 -0
  38. package/src/brand-package/infer-recipe.ts +198 -0
  39. package/src/brand-package/normalize.ts +295 -0
  40. package/src/brand-package/presets/context.ts +17 -0
  41. package/src/brand-package/presets/generic.ts +214 -0
  42. package/src/brand-package/presets/gsap.ts +146 -0
  43. package/src/brand-package/presets/index.ts +31 -0
  44. package/src/brand-package/presets/linear.ts +183 -0
  45. package/src/brand-package/presets/notion.ts +208 -0
  46. package/src/brand-package/presets/raycast.ts +138 -0
  47. package/src/brand-package/presets/recipe.ts +61 -0
  48. package/src/brand-package/presets/stripe.ts +207 -0
  49. package/src/brand-package/presets/vanta.ts +220 -0
  50. package/src/brand-package/presets/vercel.ts +187 -0
  51. package/src/brand-package/types.ts +285 -0
  52. package/src/brand-package/use-brand.ts +207 -0
  53. package/src/brand-package/validate.ts +108 -0
  54. package/src/index.ts +31 -1
  55. package/src/theme-provider.tsx +5 -3
  56. package/styles.css +153 -69
  57. package/turbo.json +15 -0
@@ -0,0 +1,207 @@
1
+ {
2
+ "id": "vercel",
3
+ "name": "Vercel",
4
+ "darkDefault": false,
5
+ "version": "1.0.0",
6
+ "semantic": {
7
+ "background": "0 0% 98%",
8
+ "foreground": "0 0% 9%",
9
+ "card": "0 0% 100%",
10
+ "cardForeground": "0 0% 9%",
11
+ "popover": "0 0% 100%",
12
+ "popoverForeground": "0 0% 9%",
13
+ "primary": "0 0% 9%",
14
+ "primaryForeground": "0 0% 100%",
15
+ "secondary": "0 0% 92%",
16
+ "secondaryForeground": "0 0% 30%",
17
+ "muted": "0 0% 92%",
18
+ "mutedForeground": "0 0% 40%",
19
+ "accent": "0 0% 92%",
20
+ "accentForeground": "0 0% 30%",
21
+ "destructive": "0 72% 51%",
22
+ "destructiveForeground": "0 0% 100%",
23
+ "border": "0 0% 92%",
24
+ "input": "0 0% 100%",
25
+ "ring": "0 0% 9%",
26
+ "success": "133 50% 32%",
27
+ "successForeground": "0 0% 100%",
28
+ "info": "0 0% 30%",
29
+ "infoForeground": "0 0% 100%"
30
+ },
31
+ "modes": {
32
+ "light": {
33
+ "semantic": {
34
+ "background": "0 0% 98%",
35
+ "foreground": "0 0% 9%",
36
+ "card": "0 0% 100%",
37
+ "cardForeground": "0 0% 9%",
38
+ "popover": "0 0% 100%",
39
+ "popoverForeground": "0 0% 9%",
40
+ "primary": "0 0% 9%",
41
+ "primaryForeground": "0 0% 100%",
42
+ "secondary": "0 0% 92%",
43
+ "secondaryForeground": "0 0% 30%",
44
+ "muted": "0 0% 92%",
45
+ "mutedForeground": "0 0% 40%",
46
+ "accent": "0 0% 92%",
47
+ "accentForeground": "0 0% 30%",
48
+ "destructive": "0 72% 51%",
49
+ "destructiveForeground": "0 0% 100%",
50
+ "border": "0 0% 92%",
51
+ "input": "0 0% 100%",
52
+ "ring": "0 0% 9%",
53
+ "success": "133 50% 32%",
54
+ "successForeground": "0 0% 100%",
55
+ "info": "0 0% 30%",
56
+ "infoForeground": "0 0% 100%"
57
+ }
58
+ },
59
+ "dark": {
60
+ "semantic": {
61
+ "background": "0 0% 0%",
62
+ "foreground": "0 0% 93%",
63
+ "card": "0 0% 4%",
64
+ "cardForeground": "0 0% 93%",
65
+ "popover": "0 0% 4%",
66
+ "popoverForeground": "0 0% 93%",
67
+ "primary": "0 0% 100%",
68
+ "primaryForeground": "0 0% 0%",
69
+ "secondary": "0 0% 12%",
70
+ "secondaryForeground": "0 0% 93%",
71
+ "muted": "0 0% 12%",
72
+ "mutedForeground": "0 0% 63%",
73
+ "accent": "0 0% 12%",
74
+ "accentForeground": "0 0% 93%",
75
+ "destructive": "0 72% 51%",
76
+ "destructiveForeground": "0 0% 100%",
77
+ "border": "0 0% 16%",
78
+ "input": "0 0% 4%",
79
+ "ring": "0 0% 100%",
80
+ "success": "133 49% 40%",
81
+ "successForeground": "0 0% 100%",
82
+ "info": "0 0% 70%",
83
+ "infoForeground": "0 0% 0%"
84
+ }
85
+ }
86
+ },
87
+ "recipe": {
88
+ "buttonDefault": "solid",
89
+ "density": "compact",
90
+ "badgeDefault": "muted",
91
+ "radii": {
92
+ "button": "6px",
93
+ "card": "6px",
94
+ "badge": "6px",
95
+ "input": "6px",
96
+ "pill": "9999px"
97
+ },
98
+ "elevationTokens": {
99
+ "card": "rgba(0, 0, 0, 0.08) 0px 0px 0px 1px, rgb(250, 250, 250) 0px 0px 0px 1px",
100
+ "control": "0 0 #0000",
101
+ "raised": "rgba(0, 0, 0, 0.08) 0px 0px 0px 1px, rgb(250, 250, 250) 0px 0px 0px 1px"
102
+ },
103
+ "outlineBorder": "#ebebeb"
104
+ },
105
+ "typography": {
106
+ "fontSans": "'Geist Sans', 'Geist', ui-sans-serif, system-ui, sans-serif",
107
+ "fontMono": "'Geist Mono', ui-monospace, Menlo, monospace",
108
+ "fontDisplay": "'Geist Sans', 'Geist', ui-sans-serif, system-ui, sans-serif",
109
+ "headingWeight": 450,
110
+ "faces": [
111
+ {
112
+ "family": "Geist Sans",
113
+ "src": [
114
+ {
115
+ "url": "https://cdn.jsdelivr.net/fontsource/fonts/geist-sans:vf@latest/latin-wght-normal.woff2",
116
+ "format": "woff2"
117
+ }
118
+ ],
119
+ "weight": "100 900",
120
+ "display": "swap"
121
+ }
122
+ ]
123
+ },
124
+ "zones": {
125
+ "product": {
126
+ "caption": {
127
+ "fontSize": "13px",
128
+ "lineHeight": 1.54,
129
+ "fontWeight": 400
130
+ },
131
+ "bodySm": {
132
+ "fontSize": "14px",
133
+ "lineHeight": 1.43,
134
+ "fontWeight": 400
135
+ },
136
+ "body": {
137
+ "fontSize": "16px",
138
+ "lineHeight": 1.5,
139
+ "fontWeight": 400
140
+ },
141
+ "heading": {
142
+ "fontSize": "30px",
143
+ "lineHeight": 1.1,
144
+ "fontWeight": 500,
145
+ "letterSpacing": "-1.5px"
146
+ }
147
+ },
148
+ "marketing": {
149
+ "body": {
150
+ "fontSize": "16px",
151
+ "lineHeight": 1.5,
152
+ "fontWeight": 400
153
+ },
154
+ "heading": {
155
+ "fontSize": "30px",
156
+ "lineHeight": 1.1,
157
+ "fontWeight": 450,
158
+ "letterSpacing": "-1.5px"
159
+ },
160
+ "headingLg": {
161
+ "fontSize": "56px",
162
+ "lineHeight": 1,
163
+ "fontWeight": 450,
164
+ "letterSpacing": "-3.36px"
165
+ },
166
+ "display": {
167
+ "fontSize": "64px",
168
+ "lineHeight": 1,
169
+ "fontWeight": 450,
170
+ "letterSpacing": "-3.84px"
171
+ }
172
+ }
173
+ },
174
+ "extensions": {
175
+ "categories": {
176
+ "carbon": "#000000",
177
+ "terminal": "#297a3a"
178
+ },
179
+ "sourceUrl": "https://vercel.com",
180
+ "notes": [
181
+ "Dual-mode monochrome — light: Obsidian CTA; dark: white CTA on carbon.",
182
+ "Elevation is hairline double-ring, never drop-shadow.",
183
+ "Spectrum/solar gradients are marketing-only decorative (not product chrome)."
184
+ ]
185
+ },
186
+ "roles": {
187
+ "canvas": "0 0% 98%",
188
+ "canvasForeground": "0 0% 9%",
189
+ "surface": "0 0% 100%",
190
+ "surfaceForeground": "0 0% 9%",
191
+ "action": "0 0% 9%",
192
+ "actionForeground": "0 0% 100%",
193
+ "quiet": "0 0% 92%",
194
+ "quietForeground": "0 0% 30%",
195
+ "muted": "0 0% 92%",
196
+ "mutedForeground": "0 0% 40%",
197
+ "border": "0 0% 92%",
198
+ "input": "0 0% 100%",
199
+ "ring": "0 0% 9%",
200
+ "destructive": "0 72% 51%",
201
+ "destructiveForeground": "0 0% 100%",
202
+ "success": "133 50% 32%",
203
+ "successForeground": "0 0% 100%",
204
+ "info": "0 0% 30%",
205
+ "infoForeground": "0 0% 100%"
206
+ }
207
+ }
package/package.json CHANGED
@@ -1,22 +1,28 @@
1
1
  {
2
2
  "name": "@nebutra/tokens",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Unified theme tokens and theme-provider entry for Nebutra apps",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "sideEffects": [
9
- "./styles.css"
9
+ "./styles.css",
10
+ "./recipe.css",
11
+ "./skins/*.css"
10
12
  ],
11
13
  "main": "./src/index.ts",
12
14
  "types": "./src/index.ts",
13
15
  "exports": {
14
16
  ".": "./src/index.ts",
15
- "./styles.css": "./styles.css"
17
+ "./styles.css": "./styles.css",
18
+ "./recipe.css": "./recipe.css",
19
+ "./skins/*": "./skins/*",
20
+ "./brands/*": "./brands/*",
21
+ "./brand-package": "./src/brand-package/index.ts",
22
+ "./use-brand": "./src/brand-package/use-brand.ts"
16
23
  },
17
24
  "dependencies": {
18
- "next-themes": "^0.4.6",
19
- "@nebutra/design-tokens": "0.1.1"
25
+ "@nebutra/design-tokens": "0.1.2"
20
26
  },
21
27
  "peerDependencies": {
22
28
  "react": "^19"
@@ -38,8 +44,11 @@
38
44
  "access": "public"
39
45
  },
40
46
  "scripts": {
41
- "build": "node -e \"const fs=require('node:fs'),src='../design-tokens/build/css/styles.generated.css';if(fs.existsSync(src)){fs.copyFileSync(src,'./styles.css');console.log('styles.css refreshed from @nebutra/design-tokens')}else{console.log('styles.css kept (committed copy used; design-tokens build/ absent — likely Turbo cache restore without files)')}\"",
42
- "sync": "corepack pnpm --filter @nebutra/design-tokens build && node -e \"require('node:fs').copyFileSync('../design-tokens/build/css/styles.generated.css', './styles.css')\"",
43
- "typecheck": "tsc --noEmit"
47
+ "build": "node scripts/sync-styles.mjs && node scripts/emit-skins.mjs",
48
+ "sync": "node scripts/sync-styles.mjs",
49
+ "emit-skins": "node scripts/emit-skins.mjs",
50
+ "compile-brand": "node scripts/compile-brand.mjs",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "node --experimental-strip-types --test src/brand-package/__tests__/*.test.ts"
44
53
  }
45
54
  }
package/recipe.css ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Product chrome recipe surface — carrier defaults for Nebutra factory brand.
3
+ *
4
+ * Contract:
5
+ * --primary = roles.action (CTA only)
6
+ * --brand-mark = roles.brand (logo / AI badge; never default CTA)
7
+ * --elevation-card|… = free CSS box-shadow from brand package
8
+ * --radius-button|card|… = shape slots
9
+ *
10
+ * VI logo lock (--brand-primary hex) remains separate from product action.
11
+ * Tailwind utilities: bg-brand-mark / text-brand-mark (see @theme below).
12
+ */
13
+
14
+ :root {
15
+ /* ── Color roles (overridden by brand skins) ──────────────────── */
16
+ --role-action: var(--primary);
17
+ --role-action-fg: var(--primary-foreground);
18
+ --brand-mark: var(--primary);
19
+ --brand-mark-foreground: var(--primary-foreground);
20
+
21
+ /* ── Button default (solid | outline | gradient-stroke) ───────── */
22
+ --btn-default-bg: hsl(var(--primary));
23
+ --btn-default-fg: hsl(var(--primary-foreground));
24
+ --btn-default-border: transparent;
25
+ --btn-default-border-width: 0px;
26
+ --btn-default-stroke-gradient: transparent;
27
+ --btn-default-hover-bg: color-mix(in srgb, hsl(var(--primary)) 90%, transparent);
28
+ --btn-default-radius: var(--radius-button, var(--radius-md, 0.375rem));
29
+ --btn-default-shadow: 0 0 #0000;
30
+ --btn-default-padding-y: 0.5rem;
31
+ --btn-default-padding-x: 1rem;
32
+
33
+ /* ── Shape slots ──────────────────────────────────────────────── */
34
+ --radius-button: var(--radius-md, 0.375rem);
35
+ --radius-card: var(--radius-lg, 0.75rem);
36
+ --radius-badge: 9999px;
37
+ --radius-inputs: var(--radius-md, 0.375rem);
38
+ --radius-pill: 9999px;
39
+
40
+ /* ── Control density (factory = Geist ladder; brands may override) ─ */
41
+ --control-height-tiny: 1.5rem; /* 24 */
42
+ --control-height-sm: 2rem; /* 32 */
43
+ --control-height-md: 2.5rem; /* 40 */
44
+ --control-height-lg: 3rem; /* 48 */
45
+ --control-font-size-tiny: 0.6875rem; /* 11 */
46
+ --control-font-size-sm: 0.75rem; /* 12 */
47
+ --control-font-size-md: 0.875rem; /* 14 */
48
+ --control-font-size-lg: 1rem; /* 16 */
49
+ --font-weight-medium: 500;
50
+ --font-weight-heading: 600;
51
+
52
+ /* ── Elevation (free CSS; brand package replaces these) ───────── */
53
+ --elevation-card: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));
54
+ --elevation-control: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.04));
55
+ --elevation-raised: var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1));
56
+
57
+ /* ── Badge default = brand mark (AI / identity chips; not CTA) ─── */
58
+ --badge-default-bg: hsl(var(--brand-mark));
59
+ --badge-default-fg: hsl(var(--brand-mark-foreground));
60
+ --badge-default-border: transparent;
61
+ --badge-default-radius: var(--radius-badge, 9999px);
62
+ --badge-default-hover-bg: color-mix(in srgb, hsl(var(--brand-mark)) 80%, transparent);
63
+ }
64
+
65
+ /* Tailwind v4: brand-mark as first-class color (Logo / BrandMark / badge) */
66
+ @theme inline {
67
+ --color-brand-mark: hsl(var(--brand-mark));
68
+ --color-brand-mark-foreground: hsl(var(--brand-mark-foreground));
69
+ }
70
+
71
+ /* ── Button default ─────────────────────────────────────────────── */
72
+ .btn-brand-default {
73
+ border-width: var(--btn-default-border-width, 0px);
74
+ border-style: solid;
75
+ border-color: transparent;
76
+ border-radius: var(--btn-default-radius, var(--radius-md, 0.375rem));
77
+ background-color: transparent;
78
+ background-image:
79
+ linear-gradient(
80
+ var(--btn-default-bg, hsl(var(--primary))),
81
+ var(--btn-default-bg, hsl(var(--primary)))
82
+ ),
83
+ var(--btn-default-stroke-gradient, linear-gradient(transparent, transparent));
84
+ background-origin: border-box;
85
+ background-clip: padding-box, border-box;
86
+ color: var(--btn-default-fg, hsl(var(--primary-foreground)));
87
+ box-shadow: var(--btn-default-shadow, 0 0 #0000);
88
+ font-weight: var(--font-weight-medium, 500);
89
+ }
90
+
91
+ .btn-brand-default:hover:not(:disabled):not([aria-disabled="true"]) {
92
+ background-image:
93
+ linear-gradient(
94
+ var(--btn-default-hover-bg, color-mix(in srgb, hsl(var(--primary)) 90%, transparent)),
95
+ var(--btn-default-hover-bg, color-mix(in srgb, hsl(var(--primary)) 90%, transparent))
96
+ ),
97
+ var(--btn-default-stroke-gradient, linear-gradient(transparent, transparent));
98
+ }
99
+
100
+ /* ── Badge default (brand-mark; skins may set match-action → primary) ─ */
101
+ .badge-brand-default {
102
+ border-color: var(--badge-default-border, transparent);
103
+ border-radius: var(--badge-default-radius, 9999px);
104
+ background-color: var(--badge-default-bg, hsl(var(--brand-mark)));
105
+ color: var(--badge-default-fg, hsl(var(--brand-mark-foreground)));
106
+ font-weight: var(--font-weight-medium, 500);
107
+ }
108
+
109
+ .badge-brand-default:hover {
110
+ background-color: var(
111
+ --badge-default-hover-bg,
112
+ color-mix(in srgb, hsl(var(--brand-mark)) 80%, transparent)
113
+ );
114
+ }
@@ -0,0 +1,79 @@
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { compileReferoTokens } from "../src/brand-package/index";
4
+
5
+ function parseArgs(argv: string[]) {
6
+ let input: string | null = null;
7
+ let id: string | undefined;
8
+ let name: string | undefined;
9
+ let out: string | undefined;
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a === "--id") id = argv[++i];
13
+ else if (a === "--name") name = argv[++i];
14
+ else if (a === "--out") out = argv[++i];
15
+ else if (!a.startsWith("-")) input = resolve(a);
16
+ }
17
+ return { input, id, name, out };
18
+ }
19
+
20
+ const args = parseArgs(process.argv.slice(2));
21
+ if (!args.input) {
22
+ process.stderr.write(
23
+ "Usage: compile-brand.mjs <dir|tokens.json> [--id id] [--name name] [--out dir]\n",
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ const st = statSync(args.input);
29
+ let tokens: Record<string, unknown>;
30
+ let designMd = "";
31
+ let hintId = "custom";
32
+
33
+ if (st.isDirectory()) {
34
+ const tokensPath = join(args.input, "tokens.json");
35
+ try {
36
+ tokens = JSON.parse(readFileSync(tokensPath, "utf8"));
37
+ } catch {
38
+ throw new Error(`No tokens.json in ${args.input}`);
39
+ }
40
+ const designPath = join(args.input, "DESIGN.md");
41
+ try {
42
+ designMd = readFileSync(designPath, "utf8");
43
+ } catch {
44
+ designMd = "";
45
+ }
46
+ hintId = basename(args.input).toLowerCase();
47
+ } else {
48
+ tokens = JSON.parse(readFileSync(args.input, "utf8"));
49
+ hintId = basename(args.input, ".json").toLowerCase();
50
+ }
51
+
52
+ const result = compileReferoTokens({
53
+ tokens,
54
+ designMd,
55
+ id: args.id ?? hintId,
56
+ name: args.name,
57
+ });
58
+
59
+ const packageRoot = resolve(import.meta.dirname, "..");
60
+ const outDir = args.out ? resolve(args.out) : join(packageRoot, "brands", result.brand.id);
61
+ mkdirSync(outDir, { recursive: true });
62
+ // brand.json is SSOT — skin CSS only under skins/<id>.css (via emit-skins)
63
+ writeFileSync(join(outDir, "brand.json"), `${JSON.stringify(result.brand, null, 2)}\n`);
64
+ mkdirSync(join(packageRoot, "skins"), { recursive: true });
65
+ writeFileSync(join(packageRoot, "skins", `${result.brand.id}.css`), result.css);
66
+
67
+ process.stdout.write(
68
+ `${JSON.stringify(
69
+ {
70
+ out: outDir,
71
+ id: result.brand.id,
72
+ recipe: result.brand.recipe.buttonDefault,
73
+ warnings: result.warnings,
74
+ note: "brand.json is SSOT; re-run emit-skins after hand-edits to brand.json",
75
+ },
76
+ null,
77
+ 2,
78
+ )}\n`,
79
+ );
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Compile Refero export → Brand Package + skin CSS
4
+ *
5
+ * node packages/design/tokens/scripts/compile-brand.mjs ~/Desktop/GSAP --id gsap
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
+ const repoRoot = resolve(packageRoot, "../../..");
14
+ const tsx = join(repoRoot, "node_modules/.bin/tsx");
15
+ const runner = join(packageRoot, "scripts/compile-brand-run.ts");
16
+
17
+ const r = spawnSync(
18
+ existsSync(tsx) ? tsx : "npx",
19
+ existsSync(tsx) ? [runner, ...process.argv.slice(2)] : ["tsx", runner, ...process.argv.slice(2)],
20
+ {
21
+ stdio: "inherit",
22
+ cwd: packageRoot,
23
+ },
24
+ );
25
+ process.exit(r.status ?? 1);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Re-emit all skin CSS files from brands/<id>/brand.json (SSOT).
3
+ */
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ import { emitBrandCss, normalizeBrandPackage } from "../src/brand-package/index.ts";
8
+
9
+ const packageRoot = resolve(import.meta.dirname, "..");
10
+ const repoRoot = resolve(packageRoot, "../../..");
11
+ const brandsDir = join(packageRoot, "brands");
12
+ const skinsDir = join(packageRoot, "skins");
13
+ const biomeBin = join(repoRoot, "node_modules/.bin/biome");
14
+
15
+ if (!existsSync(brandsDir)) {
16
+ throw new Error(`Missing brands dir: ${brandsDir}`);
17
+ }
18
+
19
+ mkdirSync(skinsDir, { recursive: true });
20
+
21
+ const ids = readdirSync(brandsDir, { withFileTypes: true })
22
+ .filter((d) => d.isDirectory())
23
+ .map((d) => d.name)
24
+ .sort();
25
+
26
+ const written: string[] = [];
27
+ const cssPaths: string[] = [];
28
+
29
+ for (const id of ids) {
30
+ const brandPath = join(brandsDir, id, "brand.json");
31
+ if (!existsSync(brandPath)) {
32
+ process.stderr.write(`skip ${id}: no brand.json\n`);
33
+ continue;
34
+ }
35
+ const raw = JSON.parse(readFileSync(brandPath, "utf8"));
36
+ const brand = normalizeBrandPackage({ ...raw, id: raw.id || id });
37
+ const css = emitBrandCss(brand, { mode: "global" });
38
+
39
+ // Single publish path: skins/<id>.css (no brands/<id>/skin.css mirror)
40
+ const outPath = join(skinsDir, `${brand.id}.css`);
41
+ writeFileSync(outPath, css);
42
+ cssPaths.push(outPath);
43
+ written.push(brand.id);
44
+ }
45
+
46
+ // Post-format so CI "governance lint guards" (biome check) stays green after
47
+ // typecheck/build re-emits skins in the same workspace.
48
+ if (cssPaths.length > 0 && existsSync(biomeBin)) {
49
+ const fmt = spawnSync(biomeBin, ["format", "--write", ...cssPaths], {
50
+ cwd: repoRoot,
51
+ encoding: "utf8",
52
+ });
53
+ if (fmt.status !== 0) {
54
+ process.stderr.write(fmt.stderr || fmt.stdout || "biome format failed on skins\n");
55
+ process.exit(fmt.status ?? 1);
56
+ }
57
+ }
58
+
59
+ process.stdout.write(
60
+ `emit-skins: ${written.length} skins from brand.json → skins/*.css\n` +
61
+ ` ${written.join(", ")}\n`,
62
+ );
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Skin CSS SSOT pipeline.
4
+ *
5
+ * Source of truth: brands/<id>/brand.json
6
+ * Generated (do not hand-edit):
7
+ * - skins/<id>.css (single publish path for @import / cssImport)
8
+ *
9
+ * Catalog: packages/design/theme/skins.css via theme scripts/sync-skins.mjs
10
+ *
11
+ * Usage: node scripts/emit-skins.mjs
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const repoRoot = resolve(packageRoot, "../../..");
20
+ const tsx = join(repoRoot, "node_modules/.bin/tsx");
21
+ const runner = join(packageRoot, "scripts/emit-skins-run.ts");
22
+
23
+ const r = spawnSync(
24
+ existsSync(tsx) ? tsx : "npx",
25
+ existsSync(tsx) ? [runner, ...process.argv.slice(2)] : ["tsx", runner, ...process.argv.slice(2)],
26
+ {
27
+ stdio: "inherit",
28
+ cwd: packageRoot,
29
+ },
30
+ );
31
+ process.exit(r.status ?? 1);
@@ -0,0 +1,29 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { copyFileSync, existsSync } 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 generatedStylesPath = resolve(designTokensRoot, "build", "css", "styles.generated.css");
10
+ const runtimeStylesPath = resolve(packageRoot, "styles.css");
11
+ const biomeBin = resolve(
12
+ repoRoot,
13
+ "node_modules",
14
+ ".bin",
15
+ process.platform === "win32" ? "biome.cmd" : "biome",
16
+ );
17
+
18
+ if (!existsSync(generatedStylesPath)) {
19
+ throw new Error(
20
+ `Missing generated runtime token CSS at ${generatedStylesPath}. Run \`pnpm --filter @nebutra/design-tokens build\` before syncing @nebutra/tokens.`,
21
+ );
22
+ }
23
+
24
+ copyFileSync(generatedStylesPath, runtimeStylesPath);
25
+ execFileSync(biomeBin, ["format", "--write", runtimeStylesPath], {
26
+ cwd: repoRoot,
27
+ stdio: "inherit",
28
+ });
29
+ process.stdout.write("styles.css refreshed from @nebutra/design-tokens\n");