@nexub/tokens 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # nexub-migrate
2
+
3
+ Moves app code onto current Nexub DS conventions. Ships with `@nexub/tokens`; no dependencies, Node >= 20.
4
+
5
+ ```sh
6
+ npx nexub-migrate # dry run over ./src: diff + manual-review items
7
+ npx nexub-migrate src app --write # apply fixes
8
+ npx nexub-migrate --check # CI: exit 1 if anything is found
9
+ npx nexub-migrate --json # machine-readable
10
+ npx nexub-migrate --rules logical-classes --ignore 'src/legacy/**'
11
+ ```
12
+
13
+ | Rule | Does |
14
+ | --- | --- |
15
+ | `legacy-tokens` | `var(--color-text-primary)` → `var(--color-on-surface)`, `--color-{leap,bookub,payub,founderub}-500` → `--color-{brand}`, `--color-brand-primary-500` → `--color-primary`. Map is built from the installed `@nexub/themes` (`base.css` + `light.css`), else an embedded snapshot. Color-mix aliases (`--color-*-border`) are reported, not rewritten. `--color-text-disabled` is a role and is never touched; declarations (`--color-x: …`) are left alone. |
16
+ | `logical-classes` | `ml/mr` `pl/pr` `border-l/r` `rounded-l/r/tl/tr/bl/br` `text-left/right` → `ms/me` `ps/pe` `border-s/e` `rounded-s/e/ss/se/es/ee` `text-start/end`, keeping variants, `!` and `-`. `left-*`/`right-*` positions are reported only. |
17
+ | `tw-animate` | Reports tailwindcss-animate classes (`animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-*` …). Use `nx-motion` / `nx-pop` / `nx-dialog` from `@nexub/themes/base`. |
18
+ | `opacity-modifier` | Reports `bg-[var(--x)]/10` (Tailwind 3 emits nothing). `--fix` rewrites to `bg-[color-mix(in_srgb,var(--x)_10%,transparent)]`. |
19
+
20
+ **Where classes are looked for.** `.html`/`.mdx`: `class=`/`className=` attributes. `.css`/`.scss`: `@apply`.
21
+ JS/TS: string and template literals (static parts only) that are either in an explicit class context —
22
+ `className=`, `class=`, the args of `cn/clsx/cva/cx/twMerge/twJoin(…)`, `tw\`…\`` — or where every
23
+ whitespace-separated token looks like a Tailwind class (lowercase start, no quotes/braces). So `"ml-2 pe-4"`
24
+ is rewritten, `"Align text-left please"` and identifiers like `ml` are not. Margin/padding values must be
25
+ spacing-like (`2`, `0.5`, `px`, `auto`, `[…]`), so `"pr-review"` stays.
26
+
27
+ Limits: no regex-literal or JSX-text awareness (a stray `'` can hide the rest of that line from the class
28
+ pass); a single-token string such as a TS union member `'text-left'` is treated as a class. Docs that
29
+ mention legacy token names in prose get rewritten too — `--ignore` them.
30
+
31
+ Skips `node_modules`, `dist`, `build`, `.next`, `out`, `coverage` and files over 1 MB.
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ // nexub-migrate — move app code onto current Nexub DS conventions.
3
+ // Dependency-free (node: built-ins only), Node >= 20. See ./README.md.
4
+ import { readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { parseArgs } from 'node:util';
9
+
10
+ export const RULES = ['legacy-tokens', 'logical-classes', 'tw-animate', 'opacity-modifier'];
11
+ const EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.scss', '.html', '.mdx']);
12
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.next', 'out', 'coverage']);
13
+ const MAX_BYTES = 1024 * 1024;
14
+
15
+ // ─── legacy-tokens: alias map ────────────────────────────────────────────────
16
+ const NEVER = new Set(['--color-text-disabled']); // the disabled-state role, not a legacy alias
17
+ const BRAND = {
18
+ '--color-leap-500': '--color-leap',
19
+ '--color-bookub-500': '--color-bookub',
20
+ '--color-payub-500': '--color-payub',
21
+ '--color-founderub-500': '--color-founderub',
22
+ '--color-brand-primary-500': '--color-primary',
23
+ };
24
+ // Chains that end here are palette/scale tokens, not roles: leave them alone.
25
+ const NOT_ROLE = /^--color-(palette|accent|brand)-/;
26
+
27
+ /** Parse theme CSS (base.css, light.css …) into { aliases: {legacy: role}, mix: {legacy: value} }. */
28
+ export function buildAliasMap(cssTexts) {
29
+ const decl = new Map();
30
+ for (const css of cssTexts) {
31
+ for (const m of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/(--color-[\w-]+)\s*:\s*([^;]+);/g)) decl.set(m[1], m[2].trim());
32
+ }
33
+ const resolve = (name, seen) => {
34
+ if (BRAND[name]) return { to: BRAND[name] };
35
+ const v = decl.get(name);
36
+ if (v === undefined) return NOT_ROLE.test(name) ? null : { to: name };
37
+ if (seen.has(name)) return null; // cycle
38
+ seen.add(name);
39
+ const ref = /^var\((--color-[\w-]+)\)$/.exec(v);
40
+ if (ref) return resolve(ref[1], seen);
41
+ if (v.startsWith('color-mix(')) return { mix: v };
42
+ return null; // literal value: not an alias
43
+ };
44
+ const aliases = { ...BRAND };
45
+ const mix = {};
46
+ for (const name of decl.keys()) {
47
+ if (NEVER.has(name)) continue;
48
+ const r = resolve(name, new Set());
49
+ if (r?.to && r.to !== name) aliases[name] = r.to;
50
+ else if (r?.mix) mix[name] = r.mix;
51
+ }
52
+ return { aliases, mix };
53
+ }
54
+
55
+ // Snapshot of buildAliasMap([@nexub/themes base.css, light.css]) at 1.8.0 — used when
56
+ // @nexub/themes is not installed. bin/nexub-migrate.test.mjs fails if it drifts from source.
57
+ export const SNAPSHOT = /* SNAPSHOT:BEGIN */ {
58
+ "aliases": {
59
+ "--color-leap-500": "--color-leap",
60
+ "--color-bookub-500": "--color-bookub",
61
+ "--color-payub-500": "--color-payub",
62
+ "--color-founderub-500": "--color-founderub",
63
+ "--color-brand-primary-500": "--color-primary",
64
+ "--color-success-500": "--color-success",
65
+ "--color-success-600": "--color-on-success-container",
66
+ "--color-success-bg": "--color-success-container",
67
+ "--color-error-500": "--color-error",
68
+ "--color-error-600": "--color-on-error-container",
69
+ "--color-error-bg": "--color-error-container",
70
+ "--color-danger-500": "--color-error",
71
+ "--color-danger-600": "--color-on-error-container",
72
+ "--color-danger-bg": "--color-error-container",
73
+ "--color-warning-500": "--color-warning",
74
+ "--color-warning-600": "--color-on-warning-container",
75
+ "--color-warning-bg": "--color-warning-container",
76
+ "--color-info-500": "--color-info",
77
+ "--color-info-600": "--color-on-info-container",
78
+ "--color-info-bg": "--color-info-container",
79
+ "--color-border-input": "--color-outline",
80
+ "--color-text-placeholder": "--color-on-surface-muted",
81
+ "--color-text-info": "--color-on-info-container",
82
+ "--color-text-success": "--color-on-success-container",
83
+ "--color-text-warning": "--color-on-warning-container",
84
+ "--color-text-danger": "--color-on-error-container",
85
+ "--color-accent-500": "--color-primary",
86
+ "--color-bg-default": "--color-surface",
87
+ "--color-bg-subtle": "--color-surface-container-low",
88
+ "--color-bg-muted": "--color-surface-container-high",
89
+ "--color-bg-raised": "--color-surface-elevated",
90
+ "--color-bg-inverse": "--color-inverse-surface",
91
+ "--color-text-primary": "--color-on-surface",
92
+ "--color-text-secondary": "--color-on-surface-variant",
93
+ "--color-text-muted": "--color-on-surface-muted",
94
+ "--color-text-subtle": "--color-outline",
95
+ "--color-text-inverse": "--color-inverse-on-surface",
96
+ "--color-text-link": "--color-primary",
97
+ "--color-border-default": "--color-outline-variant",
98
+ "--color-border-subtle": "--color-surface-container-high",
99
+ "--color-border-strong": "--color-outline",
100
+ "--color-border-inverse": "--color-inverse-on-surface",
101
+ "--color-ring-default": "--color-primary",
102
+ "--color-ring-offset": "--color-surface",
103
+ "--color-surface-muted": "--color-surface-container-low",
104
+ "--color-surface-raised": "--color-surface-elevated",
105
+ "--color-surface-sunken": "--color-surface-container-high",
106
+ "--color-surface-inverse": "--color-inverse-surface",
107
+ "--color-semantic-success-default": "--color-success",
108
+ "--color-semantic-success-strong": "--color-on-success-container",
109
+ "--color-semantic-success-bg": "--color-success-container",
110
+ "--color-semantic-error-default": "--color-error",
111
+ "--color-semantic-error-strong": "--color-on-error-container",
112
+ "--color-semantic-error-bg": "--color-error-container",
113
+ "--color-semantic-warning-default": "--color-warning",
114
+ "--color-semantic-warning-strong": "--color-on-warning-container",
115
+ "--color-semantic-warning-bg": "--color-warning-container",
116
+ "--color-semantic-info-default": "--color-info",
117
+ "--color-semantic-info-strong": "--color-on-info-container",
118
+ "--color-semantic-info-bg": "--color-info-container"
119
+ },
120
+ "mix": {
121
+ "--color-success-border": "color-mix(in srgb, var(--color-success) 35%, transparent)",
122
+ "--color-error-border": "color-mix(in srgb, var(--color-error) 35%, transparent)",
123
+ "--color-danger-border": "color-mix(in srgb, var(--color-error) 35%, transparent)",
124
+ "--color-warning-border": "color-mix(in srgb, var(--color-warning) 35%, transparent)",
125
+ "--color-info-border": "color-mix(in srgb, var(--color-info) 35%, transparent)",
126
+ "--color-semantic-success-border": "color-mix(in srgb, var(--color-success) 35%, transparent)",
127
+ "--color-semantic-error-border": "color-mix(in srgb, var(--color-error) 35%, transparent)",
128
+ "--color-semantic-warning-border": "color-mix(in srgb, var(--color-warning) 35%, transparent)",
129
+ "--color-semantic-info-border": "color-mix(in srgb, var(--color-info) 35%, transparent)"
130
+ }
131
+ } /* SNAPSHOT:END */;
132
+
133
+ export function loadAliasMap(cwd = process.cwd()) {
134
+ for (const from of [path.join(cwd, 'package.json'), import.meta.url]) {
135
+ try {
136
+ const req = createRequire(from);
137
+ const css = ['base', 'light'].map((f) => readFileSync(req.resolve(`@nexub/themes/${f}`), 'utf8'));
138
+ return { ...buildAliasMap(css), source: '@nexub/themes' };
139
+ } catch { /* not resolvable from here */ }
140
+ }
141
+ return { ...SNAPSHOT, source: 'snapshot' };
142
+ }
143
+
144
+ // ─── class-string contexts ───────────────────────────────────────────────────
145
+ /**
146
+ * Find string-literal bodies in JS/TS source. Handles '…', "…", `…${…}…` (static chunks
147
+ * only), // and /* comments. ponytail: no regex-literal or JSX-text awareness — a stray
148
+ * quote (/'/ or <p>Don't</p>) can mis-pair until the end of that line.
149
+ */
150
+ export function scanStrings(src) {
151
+ const out = [];
152
+ const stack = []; // brace depth at each open `${`
153
+ let depth = 0;
154
+ let i = 0;
155
+ const n = src.length;
156
+ const readTemplate = (j, tplStart, openLeft) => {
157
+ const start = j;
158
+ while (j < n && src[j] !== '`' && !(src[j] === '$' && src[j + 1] === '{')) j += src[j] === '\\' ? 2 : 1;
159
+ const openRight = src[j] === '$';
160
+ out.push({ start, end: j, quoteAt: tplStart, openLeft, openRight });
161
+ if (openRight) { stack.push({ depth, tplStart }); return j + 2; }
162
+ return j + 1;
163
+ };
164
+ while (i < n) {
165
+ const c = src[i];
166
+ if (c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i); if (i < 0) break; continue; }
167
+ if (c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i + 2); if (i < 0) break; i += 2; continue; }
168
+ if (c === "'" || c === '"') {
169
+ let j = i + 1;
170
+ while (j < n && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1;
171
+ out.push({ start: i + 1, end: Math.min(j, n), quoteAt: i, openLeft: false, openRight: false });
172
+ i = j + 1;
173
+ continue;
174
+ }
175
+ if (c === '`') { i = readTemplate(i + 1, i, false); continue; }
176
+ if (c === '{') depth++;
177
+ else if (c === '}') {
178
+ if (stack.length && stack.at(-1).depth === depth) { i = readTemplate(i + 1, stack.pop().tplStart, true); continue; }
179
+ depth--;
180
+ }
181
+ i++;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // Explicit class contexts: className= / class= attributes, cn/clsx/cva/cx/twMerge/twJoin(…) args, tw`…`.
187
+ const CLASS_CONTEXT = /(?:\bclass(?:Name)?\s*=\s*\{?\s*|\b(?:cn|clsx|cva|cx|twMerge|twJoin)\s*\((?:[^()]*?,)?\s*|\btw)$/;
188
+ // Otherwise a plain string counts only when every token looks like a Tailwind class:
189
+ // starts lowercase/digit/[/@/!/-, and has no quotes, braces or '<'. "Align text-left" is not one.
190
+ const TW_TOKEN = /^[!-]?[a-z0-9@[][^\s"'`<{}]*$/;
191
+ const looksLikeClassList = (text) => {
192
+ const t = text.trim();
193
+ return t !== '' && t.split(/\s+/).every((tok) => TW_TOKEN.test(tok));
194
+ };
195
+
196
+ export function classSegments(src, ext) {
197
+ if (ext === '.css' || ext === '.scss') {
198
+ return [...src.matchAll(/@apply\s+([^;}]+)/dg)].map((m) => ({ start: m.indices[1][0], end: m.indices[1][1] }));
199
+ }
200
+ if (ext === '.html' || ext === '.mdx') {
201
+ return [...src.matchAll(/\bclass(?:Name)?\s*=\s*(["'])([^"']*)\1/dg)].map((m) => ({ start: m.indices[2][0], end: m.indices[2][1] }));
202
+ }
203
+ return scanStrings(src).filter((s) => {
204
+ if (CLASS_CONTEXT.test(src.slice(Math.max(0, s.quoteAt - 120), s.quoteAt))) return true;
205
+ return looksLikeClassList(src.slice(s.start, s.end));
206
+ });
207
+ }
208
+
209
+ // ─── class-token rules ───────────────────────────────────────────────────────
210
+ const SIDE = { l: 's', r: 'e' };
211
+ const CORNER = { l: 's', r: 'e', tl: 'ss', tr: 'se', bl: 'es', br: 'ee' };
212
+ const LOGICAL = [
213
+ // value-constrained so prose like "pr-review" never matches
214
+ [/^([mp])([lr])-(\d+(?:\.\d+)?|px|auto|\[[^\]]+\])$/, (_, a, s, v) => `${a}${SIDE[s]}-${v}`],
215
+ [/^border-([lr])(-.+)?$/, (_, s, r = '') => `border-${SIDE[s]}${r}`],
216
+ [/^rounded-(tl|tr|bl|br|l|r)(-.+)?$/, (_, c, r = '') => `rounded-${CORNER[c]}${r}`],
217
+ [/^text-(left|right)$/, (_, s) => `text-${s === 'left' ? 'start' : 'end'}`],
218
+ ];
219
+ const POSITION = /^(left|right)-/;
220
+ const TW_ANIMATE = /^(animate-(in|out)|(fade|zoom|spin)-(in|out)(-.+)?|slide-(in-from|out-to)-.+)$/;
221
+ const OPACITY = /^([\w-]+)-\[var\((--[\w-]+)\)\]\/(\d+)$/;
222
+
223
+ /** Split `md:hover:!-ml-2` into { prefix: 'md:hover:', imp: '!', neg: '-', base: 'ml-2' }. */
224
+ function splitToken(tok) {
225
+ let depth = 0;
226
+ let cut = 0;
227
+ for (let i = 0; i < tok.length; i++) {
228
+ if (tok[i] === '[') depth++;
229
+ else if (tok[i] === ']') depth--;
230
+ else if (tok[i] === ':' && depth === 0) cut = i + 1;
231
+ }
232
+ let rest = tok.slice(cut);
233
+ const imp = rest.startsWith('!') ? '!' : '';
234
+ rest = rest.slice(imp.length);
235
+ const neg = rest.startsWith('-') ? '-' : '';
236
+ return { prefix: tok.slice(0, cut), imp, neg, base: rest.slice(neg.length) };
237
+ }
238
+
239
+ function rewriteClassText(text, seg, offset, rules, fix, report) {
240
+ return text.replace(/\S+/g, (tok, off) => {
241
+ if ((seg.openLeft && off === 0) || (seg.openRight && off + tok.length === text.length)) return tok; // glued to ${…}
242
+ const at = offset + off;
243
+ const { prefix, imp, neg, base } = splitToken(tok);
244
+ let next = base;
245
+ if (rules.has('logical-classes')) {
246
+ for (const [re, fn] of LOGICAL) if (re.test(next)) { next = next.replace(re, fn); break; }
247
+ if (POSITION.test(base)) report(at, 'logical-classes', `"${tok}" is a physical position; review for start-/end- (not auto-fixed)`);
248
+ }
249
+ if (rules.has('tw-animate') && TW_ANIMATE.test(base)) {
250
+ report(at, 'tw-animate', `"${tok}" is a tailwindcss-animate class; use nx-motion / nx-pop / nx-dialog from @nexub/themes/base`);
251
+ }
252
+ if (rules.has('opacity-modifier')) {
253
+ const m = OPACITY.exec(next);
254
+ if (m) {
255
+ const mixed = `${m[1]}-[color-mix(in_srgb,var(${m[2]})_${m[3]}%,transparent)]`;
256
+ if (fix) next = mixed;
257
+ else report(at, 'opacity-modifier', `"${tok}": Tailwind 3 emits nothing for /${m[3]} on a var(); use ${prefix}${imp}${neg}${mixed} (or --fix)`);
258
+ }
259
+ }
260
+ return next === base ? tok : prefix + imp + neg + next;
261
+ });
262
+ }
263
+
264
+ // ─── per-file migration ──────────────────────────────────────────────────────
265
+ /**
266
+ * Migrate one file's text. Returns { output, manual: [{ line, rule, message }] }.
267
+ * opts: { ext, rules: Set, fix: boolean, aliasMap: { aliases, mix } }
268
+ */
269
+ export function migrateSource(src, { ext, rules = new Set(RULES), fix = false, aliasMap = SNAPSHOT }) {
270
+ const items = [];
271
+ let text = src;
272
+ if (['logical-classes', 'tw-animate', 'opacity-modifier'].some((r) => rules.has(r))) {
273
+ let out = '';
274
+ let last = 0;
275
+ for (const seg of classSegments(src, ext)) {
276
+ out += src.slice(last, seg.start) + rewriteClassText(src.slice(seg.start, seg.end), seg, seg.start, rules, fix,
277
+ (at, rule, message) => items.push({ at, rule, message }));
278
+ last = seg.end;
279
+ }
280
+ text = out + src.slice(last);
281
+ }
282
+ if (rules.has('legacy-tokens')) {
283
+ // Skip definitions (`--color-x:` / `'--color-x':`) — those override the alias on purpose.
284
+ text = text.replace(/(?<![\w-])--color-[a-z0-9-]+(?![\w-])(?!['"]?\s*:)/g, (name, at) => {
285
+ if (aliasMap.aliases[name]) return aliasMap.aliases[name];
286
+ if (aliasMap.mix[name]) items.push({ at, rule: 'legacy-tokens', message: `${name} is a color-mix alias (${aliasMap.mix[name]}); rewrite by hand` });
287
+ return name;
288
+ });
289
+ }
290
+ // Rewrites never add or remove newlines, so offsets in src and text share line numbers.
291
+ const lineOf = (at) => src.slice(0, at).split('\n').length;
292
+ const manual = items.map(({ at, rule, message }) => ({ line: lineOf(at), rule, message })).sort((a, b) => a.line - b.line);
293
+ return { output: text, manual };
294
+ }
295
+
296
+ // ─── file walking ────────────────────────────────────────────────────────────
297
+ export function globToRegExp(glob) {
298
+ const re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\/?|\*|\?/g, (m) => ({ '*': '[^/]*', '?': '[^/]', '**': '.*' })[m] ?? '(?:.*/)?');
299
+ return new RegExp(`^${re}$`);
300
+ }
301
+
302
+ export function collectFiles(paths, { cwd = process.cwd(), ignore = [] } = {}) {
303
+ const res = ignore.map(globToRegExp);
304
+ const ignored = (rel) => res.some((re, i) => re.test(rel) || (!ignore[i].includes('/') && re.test(path.posix.basename(rel))));
305
+ const files = [];
306
+ const walk = (abs) => {
307
+ const rel = path.relative(cwd, abs).split(path.sep).join('/');
308
+ if (rel && ignored(rel)) return;
309
+ let st;
310
+ try { st = statSync(abs); } catch { return; }
311
+ if (st.isDirectory()) {
312
+ if (SKIP_DIRS.has(path.basename(abs)) && rel !== '') return;
313
+ for (const e of readdirSync(abs).sort()) walk(path.join(abs, e));
314
+ } else if (st.isFile() && EXTS.has(path.extname(abs)) && st.size <= MAX_BYTES) {
315
+ files.push(abs);
316
+ }
317
+ };
318
+ for (const p of paths) walk(path.resolve(cwd, p));
319
+ return files;
320
+ }
321
+
322
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
323
+ export const USAGE = `Usage: nexub-migrate [paths…] [options]
324
+
325
+ Move app code onto current Nexub DS conventions. Paths default to ./src.
326
+ Dry run by default: prints what would change and what needs a human.
327
+
328
+ Options:
329
+ --write apply fixes in place
330
+ --check exit 1 if anything fixable or reportable is found (CI)
331
+ --json print machine-readable results
332
+ --fix also rewrite opacity-modifier hits (preview, or apply with --write)
333
+ --rules <a,b> run only these rules (default: all)
334
+ --ignore <glob> skip paths matching glob (repeatable; supports * and **)
335
+ -h, --help show this help
336
+
337
+ Rules:
338
+ legacy-tokens var(--color-text-primary) -> var(--color-on-surface), brand -500 -> role;
339
+ color-mix aliases (e.g. --color-semantic-success-border) reported
340
+ logical-classes ml/mr, pl/pr, border-l/r, rounded-l/r/tl/tr/bl/br, text-left/right
341
+ -> ms/me, ps/pe, border-s/e, rounded-s/e/ss/se/es/ee, text-start/end;
342
+ left-/right- positions reported
343
+ tw-animate report tailwindcss-animate enter/exit classes (use nx-motion/nx-pop/nx-dialog)
344
+ opacity-modifier report [var(--x)]/NN (emits nothing in Tailwind 3); --fix rewrites to color-mix
345
+
346
+ Ignored: node_modules, dist, build, .next, out, coverage, files over 1 MB.
347
+ Exit codes: 0 ok, 1 --check found something, 2 usage error.`;
348
+
349
+ export function run(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s) } = {}) {
350
+ let args;
351
+ try {
352
+ args = parseArgs({
353
+ args: argv,
354
+ allowPositionals: true,
355
+ options: {
356
+ write: { type: 'boolean' }, check: { type: 'boolean' }, json: { type: 'boolean' }, fix: { type: 'boolean' },
357
+ rules: { type: 'string' }, ignore: { type: 'string', multiple: true }, help: { type: 'boolean', short: 'h' },
358
+ },
359
+ });
360
+ } catch (e) {
361
+ stderr(`${e.message}\n\n${USAGE}\n`);
362
+ return 2;
363
+ }
364
+ const { values: o, positionals } = args;
365
+ if (o.help) { stdout(`${USAGE}\n`); return 0; }
366
+ const rules = new Set(o.rules ? o.rules.split(',').map((r) => r.trim()).filter(Boolean) : RULES);
367
+ const unknown = [...rules].filter((r) => !RULES.includes(r));
368
+ if (unknown.length) { stderr(`Unknown rule(s): ${unknown.join(', ')}. Known: ${RULES.join(', ')}\n`); return 2; }
369
+
370
+ const aliasMap = rules.has('legacy-tokens') ? loadAliasMap(cwd) : SNAPSHOT;
371
+ const results = [];
372
+ for (const abs of collectFiles(positionals.length ? positionals : ['src'], { cwd, ignore: o.ignore ?? [] })) {
373
+ const src = readFileSync(abs, 'utf8');
374
+ const { output, manual } = migrateSource(src, { ext: path.extname(abs), rules, fix: !!o.fix, aliasMap });
375
+ const a = src.split('\n');
376
+ const b = output.split('\n');
377
+ const changes = a.flatMap((line, i) => (line === b[i] ? [] : [{ line: i + 1, before: line, after: b[i] }]));
378
+ if (!changes.length && !manual.length) continue;
379
+ if (o.write && changes.length) writeFileSync(abs, output);
380
+ results.push({ file: path.relative(cwd, abs).split(path.sep).join('/'), changes, manual });
381
+ }
382
+ const totals = {
383
+ files: results.length,
384
+ changes: results.reduce((n, r) => n + r.changes.length, 0),
385
+ manual: results.reduce((n, r) => n + r.manual.length, 0),
386
+ };
387
+
388
+ if (o.json) {
389
+ stdout(`${JSON.stringify({ written: !!o.write, aliasSource: aliasMap.source ?? 'snapshot', results, totals }, null, 2)}\n`);
390
+ } else {
391
+ for (const r of results) {
392
+ stdout(`--- ${r.file}\n+++ ${r.file}\n`);
393
+ for (const c of r.changes) stdout(`@@ ${c.line} @@\n-${c.before}\n+${c.after}\n`);
394
+ for (const m of r.manual) stdout(`! ${r.file}:${m.line} [${m.rule}] ${m.message}\n`);
395
+ }
396
+ const verb = o.write ? 'changed' : 'to change';
397
+ stdout(`\n${totals.changes} line(s) ${verb} in ${results.filter((r) => r.changes.length).length} file(s); ${totals.manual} item(s) need manual review.`);
398
+ stdout(!o.write && totals.changes ? ' Run with --write to apply.\n' : '\n');
399
+ }
400
+ return o.check && (totals.changes || totals.manual) ? 1 : 0;
401
+ }
402
+
403
+ const isMain = (() => {
404
+ try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; }
405
+ })();
406
+ if (isMain) process.exitCode = run(process.argv.slice(2));
@@ -0,0 +1,17 @@
1
+ /** Tonal palette from a hex colour: its own hue and chroma (brand fidelity).
2
+ * @param {string} hex */
3
+ export function tonalPalette(hex: string): TonalPalette;
4
+ /** @typedef {[palette: string, light: number, dark: number, highLight: number, highDark: number]} RoleTones */
5
+ /** @typedef {'standard' | 'medium' | 'high'} ContrastLevel */
6
+ export const TONES: number[];
7
+ export function toneHex(pal: TonalPalette, tone: number): string;
8
+ export function accent(pal: string): Record<string, RoleTones>;
9
+ /** @type {Record<string, RoleTones>} */
10
+ export const ROLES: Record<string, RoleTones>;
11
+ export function tertiary(product: string): Record<string, RoleTones>;
12
+ /** @type {ContrastLevel[]} */
13
+ export const LEVELS: ContrastLevel[];
14
+ export function tonesAt([pal, l, d, hl, hd]: RoleTones, level: ContrastLevel): [string, number, number];
15
+ export type RoleTones = [palette: string, light: number, dark: number, highLight: number, highDark: number];
16
+ export type ContrastLevel = "standard" | "medium" | "high";
17
+ import { TonalPalette } from '@material/material-color-utilities';
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ // Role → tone table and HCT palette generation, shared by the build
3
+ // (scripts/generate.mjs → dist/css/roles.css) and the runtime seed theming
4
+ // entry (@nexub/tokens/seed). Plain JS so the build script can import it
5
+ // before tsc runs; tsc (allowJs) emits it and its .d.ts into dist/js.
6
+ //
7
+ // @material/material-color-utilities is pinned to 0.3.0: 0.4.0 ships broken ESM.
8
+ import { Hct, TonalPalette, argbFromHex, hexFromArgb } from '@material/material-color-utilities';
9
+ /** @typedef {[palette: string, light: number, dark: number, highLight: number, highDark: number]} RoleTones */
10
+ /** @typedef {'standard' | 'medium' | 'high'} ContrastLevel */
11
+ // 5/15/25/35/45/65/76/85 exist for data-contrast="medium" (halfway tones).
12
+ export const TONES = [0, 4, 5, 6, 10, 12, 15, 17, 20, 22, 24, 25, 30, 35, 40, 45, 50, 60, 65, 70, 72, 76, 80, 85, 87, 90, 92, 94, 95, 96, 98, 99, 100];
13
+ /** Tonal palette from a hex colour: its own hue and chroma (brand fidelity).
14
+ * @param {string} hex */
15
+ export function tonalPalette(hex) {
16
+ const hct = Hct.fromInt(argbFromHex(hex));
17
+ return TonalPalette.fromHueAndChroma(hct.hue, hct.chroma);
18
+ }
19
+ /** @param {TonalPalette} pal @param {number} tone */
20
+ export const toneHex = (pal, tone) => hexFromArgb(pal.tone(tone));
21
+ // role → [palette, light tone, dark tone, light tone (high), dark tone (high)]
22
+ // High contrast pushes accents/outlines/secondary text further from surfaces.
23
+ /** @param {string} pal @returns {Record<string, RoleTones>} */
24
+ export const accent = (pal) => ({
25
+ [pal]: [pal, 40, 80, 30, 90],
26
+ [`on-${pal}`]: [pal, 100, 20, 100, 10],
27
+ [`${pal}-container`]: [pal, 90, 30, 90, 30],
28
+ [`on-${pal}-container`]: [pal, 10, 90, 0, 100],
29
+ });
30
+ /** @type {Record<string, RoleTones>} */
31
+ export const ROLES = {
32
+ ...accent('primary'),
33
+ ...accent('secondary'),
34
+ ...accent('error'),
35
+ ...accent('success'),
36
+ ...accent('warning'),
37
+ ...accent('info'),
38
+ // Product accents, always available (badges, dots, tags), whatever the
39
+ // data-product. Tertiary is the *current* product; these name each one.
40
+ ...accent('leap'),
41
+ ...accent('bookub'),
42
+ ...accent('payub'),
43
+ ...accent('founderub'),
44
+ surface: ['neutral', 98, 6, 98, 6],
45
+ 'surface-dim': ['neutral', 87, 6, 87, 6],
46
+ 'surface-bright': ['neutral', 98, 24, 98, 24],
47
+ 'surface-container-lowest': ['neutral', 100, 4, 100, 4],
48
+ 'surface-container-low': ['neutral', 96, 10, 96, 10],
49
+ 'surface-container': ['neutral', 94, 12, 94, 12],
50
+ 'surface-container-high': ['neutral', 92, 17, 92, 17],
51
+ 'surface-container-highest': ['neutral', 90, 22, 90, 22],
52
+ // Not an M3 role: floating layers (menus, popovers, dialogs). White in light,
53
+ // lighter than the page in dark (Apple's elevated background).
54
+ 'surface-elevated': ['neutral', 100, 17, 100, 17],
55
+ 'on-surface': ['neutral', 10, 90, 0, 100],
56
+ 'on-surface-variant': ['neutral-variant', 30, 80, 20, 90],
57
+ // Not an M3 role: tertiary text that still clears 4.5:1 on every container.
58
+ 'on-surface-muted': ['neutral-variant', 40, 72, 30, 80],
59
+ outline: ['neutral-variant', 50, 60, 30, 80],
60
+ 'outline-variant': ['neutral-variant', 80, 30, 50, 60],
61
+ 'inverse-surface': ['neutral', 20, 90, 20, 90],
62
+ 'inverse-on-surface': ['neutral', 95, 20, 100, 0],
63
+ 'inverse-primary': ['primary', 80, 40, 80, 40],
64
+ // Behind modals and under image captions; black in both themes.
65
+ scrim: ['neutral', 0, 0, 0, 0],
66
+ };
67
+ // Product brands remap the tertiary family.
68
+ /** @param {string} product @returns {Record<string, RoleTones>} */
69
+ export const tertiary = (product) => Object.fromEntries(Object.entries(accent('tertiary')).map(([role, [, ...tones]]) => [role, [product, ...tones]]));
70
+ // Contrast levels. Medium sits halfway between standard and high, snapped
71
+ // to the nearest generated tone.
72
+ /** @type {ContrastLevel[]} */
73
+ export const LEVELS = ['standard', 'medium', 'high'];
74
+ /** @param {number} t */
75
+ const snap = (t) => TONES.reduce((a, b) => (Math.abs(b - t) < Math.abs(a - t) ? b : a));
76
+ /** @param {RoleTones} entry @param {ContrastLevel} level @returns {[string, number, number]} */
77
+ export const tonesAt = ([pal, l, d, hl, hd], level) => level === 'high' ? [pal, hl, hd] : level === 'medium' ? [pal, snap((l + hl) / 2), snap((d + hd) / 2)] : [pal, l, d];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * CSS custom properties for the accent family derived from `seed`, as
3
+ * `light-dark(<light>, <dark>)` values (same shape as dist/css/roles.css).
4
+ * target 'primary': primary, on-primary, primary-container, on-primary-container, inverse-primary.
5
+ * target 'tertiary': tertiary, on-tertiary, tertiary-container, on-tertiary-container.
6
+ * @param {string} seed Hex colour, `#rgb` or `#rrggbb`.
7
+ * @param {SeedThemeOptions} [options]
8
+ * @returns {Record<string, string>} e.g. `createSeedTheme('#4099f1')['--color-primary'] === 'light-dark(#0061a6, #a0c9ff)'`
9
+ */
10
+ export function createSeedTheme(seed: string, { contrast, target }?: SeedThemeOptions): Record<string, string>;
11
+ /**
12
+ * The same theme as a CSS rule, for SSR / static stylesheets (no flash, no JS):
13
+ * `:root {\n --color-primary: light-dark(…);\n …\n}\n`.
14
+ * @param {string} seed
15
+ * @param {SeedThemeOptions & { selector?: string }} [options] `selector` defaults to ':root'.
16
+ * @returns {string}
17
+ */
18
+ export function createSeedThemeCss(seed: string, { selector, ...options }?: SeedThemeOptions & {
19
+ selector?: string;
20
+ }): string;
21
+ export type SeedThemeOptions = {
22
+ /**
23
+ * Contrast level (matches `data-contrast`). Default 'standard'.
24
+ */
25
+ contrast?: "standard" | "medium" | "high" | undefined;
26
+ /**
27
+ * Accent family to seed. 'tertiary' is the product brand. Default 'primary'.
28
+ */
29
+ target?: "primary" | "tertiary" | undefined;
30
+ };
@@ -0,0 +1,51 @@
1
+ // @ts-check
2
+ // @nexub/tokens/seed — runtime brand theming from one seed colour (white-label).
3
+ // Uses the same HCT palette generation and role → tone table as the build
4
+ // (src/roles.js), so a seeded accent family meets the same WCAG pairs as the
5
+ // built-in ones: roles are HCT tones, and tone gaps fix contrast for any hue.
6
+ import { LEVELS, ROLES, accent, tonalPalette, toneHex, tonesAt } from './roles.js';
7
+ /**
8
+ * @typedef {object} SeedThemeOptions
9
+ * @property {'standard' | 'medium' | 'high'} [contrast] Contrast level (matches `data-contrast`). Default 'standard'.
10
+ * @property {'primary' | 'tertiary'} [target] Accent family to seed. 'tertiary' is the product brand. Default 'primary'.
11
+ */
12
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
13
+ /**
14
+ * CSS custom properties for the accent family derived from `seed`, as
15
+ * `light-dark(<light>, <dark>)` values (same shape as dist/css/roles.css).
16
+ * target 'primary': primary, on-primary, primary-container, on-primary-container, inverse-primary.
17
+ * target 'tertiary': tertiary, on-tertiary, tertiary-container, on-tertiary-container.
18
+ * @param {string} seed Hex colour, `#rgb` or `#rrggbb`.
19
+ * @param {SeedThemeOptions} [options]
20
+ * @returns {Record<string, string>} e.g. `createSeedTheme('#4099f1')['--color-primary'] === 'light-dark(#0061a6, #a0c9ff)'`
21
+ */
22
+ export function createSeedTheme(seed, { contrast = 'standard', target = 'primary' } = {}) {
23
+ if (typeof seed !== 'string' || !HEX.test(seed))
24
+ throw new TypeError(`@nexub/tokens/seed: seed must be a hex colour (#rgb or #rrggbb), got ${JSON.stringify(seed)}`);
25
+ if (!LEVELS.includes(contrast))
26
+ throw new TypeError(`@nexub/tokens/seed: contrast must be one of ${LEVELS.join(', ')}, got ${JSON.stringify(contrast)}`);
27
+ if (target !== 'primary' && target !== 'tertiary')
28
+ throw new TypeError(`@nexub/tokens/seed: target must be 'primary' or 'tertiary', got ${JSON.stringify(target)}`);
29
+ const pal = tonalPalette(seed);
30
+ const roles = { ...accent(target) };
31
+ if (target === 'primary' && ROLES['inverse-primary'])
32
+ roles['inverse-primary'] = ROLES['inverse-primary'];
33
+ /** @type {Record<string, string>} */
34
+ const vars = {};
35
+ for (const [role, entry] of Object.entries(roles)) {
36
+ const [, l, d] = tonesAt(entry, contrast);
37
+ vars[`--color-${role}`] = `light-dark(${toneHex(pal, l)}, ${toneHex(pal, d)})`;
38
+ }
39
+ return vars;
40
+ }
41
+ /**
42
+ * The same theme as a CSS rule, for SSR / static stylesheets (no flash, no JS):
43
+ * `:root {\n --color-primary: light-dark(…);\n …\n}\n`.
44
+ * @param {string} seed
45
+ * @param {SeedThemeOptions & { selector?: string }} [options] `selector` defaults to ':root'.
46
+ * @returns {string}
47
+ */
48
+ export function createSeedThemeCss(seed, { selector = ':root', ...options } = {}) {
49
+ const decls = Object.entries(createSeedTheme(seed, options)).map(([k, v]) => ` ${k}: ${v};`);
50
+ return `${selector} {\n${decls.join('\n')}\n}\n`;
51
+ }