@zentauri-ui/zentauri-components 2.1.4 → 2.1.6

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 (70) hide show
  1. package/README.md +9 -6
  2. package/cli/cli.integration.test.ts +44 -2
  3. package/cli/index.mjs +134 -28
  4. package/cli/index.test.ts +180 -0
  5. package/cli/props.json +15180 -0
  6. package/cli/props.test.ts +80 -0
  7. package/cli/registry.json +2 -0
  8. package/dist/chunk-3W2UUKWP.js +19 -0
  9. package/dist/{chunk-D2GISTDL.js.map → chunk-3W2UUKWP.js.map} +1 -1
  10. package/dist/{chunk-BL6UVCV7.mjs → chunk-A4IB3C23.mjs} +16 -7
  11. package/dist/chunk-A4IB3C23.mjs.map +1 -0
  12. package/dist/{chunk-WBZKMSXW.mjs → chunk-CHI6MBTI.mjs} +3 -3
  13. package/dist/{chunk-WBZKMSXW.mjs.map → chunk-CHI6MBTI.mjs.map} +1 -1
  14. package/dist/chunk-COCPCZMR.mjs +77 -0
  15. package/dist/chunk-COCPCZMR.mjs.map +1 -0
  16. package/dist/chunk-PG7LQVU6.js +86 -0
  17. package/dist/chunk-PG7LQVU6.js.map +1 -0
  18. package/dist/{chunk-RENXBUZY.js → chunk-QE7OJW4J.js} +6 -6
  19. package/dist/{chunk-RENXBUZY.js.map → chunk-QE7OJW4J.js.map} +1 -1
  20. package/dist/{chunk-NZSZE36T.js → chunk-VA6SB6NN.js} +16 -7
  21. package/dist/{chunk-BL6UVCV7.mjs.map → chunk-VA6SB6NN.js.map} +1 -1
  22. package/dist/{chunk-PAG5CTLN.mjs → chunk-WWKAJHIV.mjs} +3 -3
  23. package/dist/{chunk-PAG5CTLN.mjs.map → chunk-WWKAJHIV.mjs.map} +1 -1
  24. package/dist/design-system/audio-player.d.ts +61 -0
  25. package/dist/design-system/audio-player.d.ts.map +1 -0
  26. package/dist/design-system/facade.js +8 -7
  27. package/dist/design-system/facade.js.map +1 -1
  28. package/dist/design-system/facade.mjs +7 -6
  29. package/dist/design-system/facade.mjs.map +1 -1
  30. package/dist/design-system/index.d.ts +1 -0
  31. package/dist/design-system/index.d.ts.map +1 -1
  32. package/dist/ui/audio-player/audio-player-base.d.ts +20 -0
  33. package/dist/ui/audio-player/audio-player-base.d.ts.map +1 -0
  34. package/dist/ui/audio-player/audio-player.d.ts +6 -0
  35. package/dist/ui/audio-player/audio-player.d.ts.map +1 -0
  36. package/dist/ui/audio-player/index.d.ts +5 -0
  37. package/dist/ui/audio-player/index.d.ts.map +1 -0
  38. package/dist/ui/audio-player/types.d.ts +44 -0
  39. package/dist/ui/audio-player/types.d.ts.map +1 -0
  40. package/dist/ui/audio-player/variants.d.ts +12 -0
  41. package/dist/ui/audio-player/variants.d.ts.map +1 -0
  42. package/dist/ui/audio-player.js +556 -0
  43. package/dist/ui/audio-player.js.map +1 -0
  44. package/dist/ui/audio-player.mjs +545 -0
  45. package/dist/ui/audio-player.mjs.map +1 -0
  46. package/dist/ui/buttons/animated.js +10 -9
  47. package/dist/ui/buttons/animated.js.map +1 -1
  48. package/dist/ui/buttons/animated.mjs +8 -7
  49. package/dist/ui/buttons/animated.mjs.map +1 -1
  50. package/dist/ui/buttons.js +11 -10
  51. package/dist/ui/buttons.mjs +9 -8
  52. package/dist/ui/dynamic-stepper.js +20 -19
  53. package/dist/ui/dynamic-stepper.js.map +1 -1
  54. package/dist/ui/dynamic-stepper.mjs +9 -8
  55. package/dist/ui/dynamic-stepper.mjs.map +1 -1
  56. package/dist/ui/pagination.js +16 -15
  57. package/dist/ui/pagination.js.map +1 -1
  58. package/dist/ui/pagination.mjs +8 -7
  59. package/dist/ui/pagination.mjs.map +1 -1
  60. package/package.json +5 -2
  61. package/src/design-system/audio-player.ts +109 -0
  62. package/src/design-system/index.ts +1 -0
  63. package/src/ui/audio-player/audio-player-base.tsx +557 -0
  64. package/src/ui/audio-player/audio-player.test.tsx +485 -0
  65. package/src/ui/audio-player/audio-player.tsx +8 -0
  66. package/src/ui/audio-player/index.ts +24 -0
  67. package/src/ui/audio-player/types.ts +57 -0
  68. package/src/ui/audio-player/variants.ts +43 -0
  69. package/dist/chunk-D2GISTDL.js +0 -19
  70. package/dist/chunk-NZSZE36T.js.map +0 -1
package/README.md CHANGED
@@ -29,16 +29,16 @@ Generated from the component package Vitest JSON report via `pnpm --filter @zent
29
29
 
30
30
  | Metric | Result |
31
31
  | ---------- | ---------------- |
32
- | Test files | 93 passed (93) |
33
- | Tests | 752 passed (752) |
32
+ | Test files | 96 passed (96) |
33
+ | Tests | 792 passed (792) |
34
34
 
35
35
  | Area | Test files | Tests |
36
36
  | ------------------------------ | ---------- | ----- |
37
- | Components and UI utilities | 46 | 456 |
37
+ | Components and UI utilities | 47 | 490 |
38
38
  | Standalone animations | 1 | 45 |
39
39
  | React hooks | 41 | 174 |
40
40
  | Design system facade | 1 | 11 |
41
- | CLI and import rewriting | 2 | 24 |
41
+ | CLI and import rewriting | 4 | 30 |
42
42
  | Accessibility (axe + keyboard) | 2 | 42 |
43
43
 
44
44
  ### Per-suite snapshot
@@ -48,10 +48,11 @@ Generated from the component package Vitest JSON report via `pnpm --filter @zent
48
48
  | `src/animations/animations.test.tsx` | 45 |
49
49
  | `src/ui/buttons/button.test.tsx` | 44 |
50
50
  | `src/ui/inputs/input.test.tsx` | 40 |
51
+ | `src/ui/audio-player/audio-player.test.tsx` | 34 |
51
52
  | `src/ui/peer-isolation.test.ts` | 29 |
52
53
  | `src/accessibility/axe-core.test.tsx` | 24 |
53
54
  | `src/ui/combobox/combobox.test.tsx` | 24 |
54
- | `cli/cli.integration.test.ts` | 19 |
55
+ | `cli/cli.integration.test.ts` | 20 |
55
56
  | `src/accessibility/keyboard-interaction.test.tsx` | 18 |
56
57
  | `src/ui/pagination/pagination.test.tsx` | 15 |
57
58
  | `src/ui/timeline/timeline.test.tsx` | 14 |
@@ -109,6 +110,7 @@ Generated from the component package Vitest JSON report via `pnpm --filter @zent
109
110
  | `src/ui/popover/popover.test.tsx` | 5 |
110
111
  | `src/ui/radio-group/radio-group.test.tsx` | 5 |
111
112
  | `src/ui/toggle/toggle.test.tsx` | 5 |
113
+ | `cli/index.test.ts` | 4 |
112
114
  | `src/hooks/useBodyScrollLock/useBodyScrollLock.test.ts` | 4 |
113
115
  | `src/hooks/useControllableState/useControllableState.test.ts` | 4 |
114
116
  | `src/hooks/useDebouncedValue/useDebouncedValue.test.ts` | 4 |
@@ -131,6 +133,7 @@ Generated from the component package Vitest JSON report via `pnpm --filter @zent
131
133
  | `src/hooks/useMediaQuery/useMediaQuery.test.ts` | 2 |
132
134
  | `src/hooks/useNetworkStatus/useNetworkStatus.test.ts` | 2 |
133
135
  | `src/hooks/useResizeObserver/useResizeObserver.test.ts` | 2 |
136
+ | `cli/props.test.ts` | 1 |
134
137
  | `src/hooks/useInView/useInView.test.ts` | 1 |
135
138
  | `src/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.test.ts` | 1 |
136
139
  | `src/hooks/usePageVisibility/usePageVisibility.test.ts` | 1 |
@@ -847,7 +850,7 @@ From this package directory in the monorepo:
847
850
 
848
851
  - `pnpm build` (or `npm run build`) — production bundle via `tsup` (Rollup treeshake + `scripts/prepend-use-client.mjs` via `onSuccess` so each UI entry under `dist/ui/`, animation entry under `dist/animations/`, chart entry under `dist/charts/`, and `dist/ui/<name>/animated.*` starts with `"use client"` where needed)
849
852
  - `pnpm dev` — `tsup` watch mode (same `onSuccess` hook after each rebuild)
850
- - `pnpm test` / `pnpm test:watch` — **Vitest** and **Testing Library** unit tests // currently covered 752 test cases in total
853
+ - `pnpm test` / `pnpm test:watch` — **Vitest** and **Testing Library** unit tests // currently covered 792 test cases in total
851
854
  - `pnpm test:a11y` — focused accessibility coverage for package-level UI primitives and compound components: **axe-core** audits for every interactive component plus **keyboard-interaction** tests (focus order, arrow-key nav, Home/End, Escape/Enter) for the compound components
852
855
  - `pnpm check:tokens` — enforce the `--zui-*` token contract across design-system, variant, and local custom-property usage without generating a large checked-in token catalog
853
856
  - **`pnpm run generate:registry`** — runs `scripts/generate-registry.mjs`, which reads **`uiComponentNames`**, **`uiAnimatedComponentNames`**, **`animationEntryNames`**, **`chartEntryNames`**, and **`hooksEntryNames`** from `tsup.config.ts`, applies fixed **`nameAliases`**, scans each component/chart source to build **`peerHints`**, and writes **`cli/registry.json`** (`components` + `animations` + `hooks` + `peerHints`). Run this after adding or renaming UI, animation, chart, or hook entries so the CLI stays in sync (the script prints counts).
@@ -1,5 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
3
10
  import { tmpdir } from "node:os";
4
11
  import { dirname, join } from "node:path";
5
12
  import { fileURLToPath } from "node:url";
@@ -106,6 +113,35 @@ describe("zentauri-ui CLI", () => {
106
113
  }
107
114
  });
108
115
 
116
+ it("should not use a monorepo root config when add runs inside a package", () => {
117
+ const dir = mkdtempSync(join(tmpdir(), "zentauri-cli-monorepo-"));
118
+ try {
119
+ const appDir = join(dir, "apps/web");
120
+ execFileSync(process.execPath, [
121
+ "-e",
122
+ `require("node:fs").mkdirSync(${JSON.stringify(appDir)}, { recursive: true })`,
123
+ ]);
124
+ writeFileSync(
125
+ join(dir, "package.json"),
126
+ JSON.stringify({ private: true, workspaces: ["apps/*"] }),
127
+ );
128
+ writeFileSync(join(appDir, "package.json"), JSON.stringify({}));
129
+
130
+ runCli(dir, ["init"]);
131
+ const stderr = runCliError(appDir, ["add", "button"]);
132
+
133
+ expect(stderr).toContain("No components.json found");
134
+ expect(
135
+ existsSync(join(dir, "src/components/ui/buttons/button.tsx")),
136
+ ).toBe(false);
137
+ expect(
138
+ existsSync(join(appDir, "src/components/ui/buttons/button.tsx")),
139
+ ).toBe(false);
140
+ } finally {
141
+ rmSync(dir, { recursive: true, force: true });
142
+ }
143
+ });
144
+
109
145
  it("should add an animated component explicitly and report missing peers", () => {
110
146
  const dir = mkdtempSync(join(tmpdir(), "zentauri-cli-add-animated-"));
111
147
  try {
@@ -162,11 +198,17 @@ describe("zentauri-ui CLI", () => {
162
198
  expect(
163
199
  existsSync(join(dir, "src/components/design-system/button.ts")),
164
200
  ).toBe(true);
201
+ expect(
202
+ existsSync(join(dir, "src/components/design-system/tokens.ts")),
203
+ ).toBe(true);
204
+ expect(
205
+ readdirSync(join(dir, "src/components/design-system")).sort(),
206
+ ).toEqual(["button.ts", "tokens.ts"]);
165
207
  const variants = readFileSync(
166
208
  join(dir, "src/components/ui/buttons/variants.ts"),
167
209
  "utf8",
168
210
  );
169
- expect(variants).toContain("../../design-system");
211
+ expect(variants).toContain("../../design-system/button");
170
212
  } finally {
171
213
  rmSync(dir, { recursive: true, force: true });
172
214
  }
package/cli/index.mjs CHANGED
@@ -84,7 +84,7 @@
84
84
  * command, or refused `init` overwrite. Successful runs leave default exit 0.
85
85
  */
86
86
 
87
- import { existsSync, readFileSync } from "node:fs";
87
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
88
88
  import {
89
89
  readFile,
90
90
  writeFile,
@@ -238,8 +238,9 @@ async function walkFiles(dir) {
238
238
  }
239
239
 
240
240
  /**
241
- * Walks upward from `startDir` toward the filesystem root until `components.json`
242
- * exists, or returns `undefined` if none found (e.g. user forgot `init`).
241
+ * Walks upward from `startDir` until `components.json` exists. If the command
242
+ * starts inside a package, discovery stops at that package's `package.json`
243
+ * boundary so monorepo root config cannot capture nested package installs.
243
244
  *
244
245
  * @param {string} startDir — typically `process.cwd()` or `--cwd` resolved path
245
246
  * @returns {string | undefined} — absolute path to `components.json` if found
@@ -253,11 +254,17 @@ async function walkFiles(dir) {
253
254
  */
254
255
  async function findComponentsJson(startDir) {
255
256
  let d = startDir;
257
+ const packagePath = findPackageJson(startDir);
258
+ const packageBoundary = packagePath ? dirname(packagePath) : undefined;
259
+
256
260
  for (;;) {
257
261
  const p = join(d, "components.json");
258
262
  if (existsSync(p)) {
259
263
  return p;
260
264
  }
265
+ if (packageBoundary && d === packageBoundary) {
266
+ return undefined;
267
+ }
261
268
  const parent = dirname(d);
262
269
  if (parent === d) {
263
270
  return undefined;
@@ -957,6 +964,7 @@ async function copyUiComponent(componentName, config, configDir, packageRoot) {
957
964
  return true;
958
965
  });
959
966
  const usedHooks = new Set();
967
+ const designSystemImportTarget = getDesignSystemEntryName(componentName);
960
968
 
961
969
  for (const absSrc of files) {
962
970
  const rel = relative(srcRoot, absSrc);
@@ -972,10 +980,13 @@ async function copyUiComponent(componentName, config, configDir, packageRoot) {
972
980
  hooksAlias: config.aliases.hooks,
973
981
  uiAlias: config.aliases.ui,
974
982
  });
983
+ const rewrittenCode = designSystemImportTarget
984
+ ? rewriteDesignSystemBarrelImports(code, designSystemImportTarget)
985
+ : code;
975
986
  for (const h of uh) {
976
987
  usedHooks.add(h);
977
988
  }
978
- await writeFile(absDest, code, "utf8");
989
+ await writeFile(absDest, rewrittenCode, "utf8");
979
990
  } else {
980
991
  await copyFile(absSrc, absDest);
981
992
  }
@@ -1022,11 +1033,57 @@ async function copyHookFolder(hookName, config, configDir, packageRoot) {
1022
1033
  }
1023
1034
 
1024
1035
  /**
1025
- * Copies shared component design tokens/variant maps beside the consumer's UI
1026
- * folder so vendored components can keep relative `../../design-system/*`
1027
- * imports without adding a new public alias to `components.json`.
1036
+ * Maps registry component names to their matching design-system token file.
1037
+ */
1038
+ function getDesignSystemEntryName(componentName) {
1039
+ if (
1040
+ componentName.startsWith("charts/") ||
1041
+ componentName.startsWith("animations/")
1042
+ ) {
1043
+ return undefined;
1044
+ }
1045
+ if (componentName === "buttons") {
1046
+ return "button";
1047
+ }
1048
+ return componentName;
1049
+ }
1050
+
1051
+ /**
1052
+ * Narrows vendored imports from the design-system barrel to the selected token
1053
+ * file. This lets a component like `buttons` vendor `design-system/button.ts`
1054
+ * without also requiring `design-system/index.ts`.
1055
+ */
1056
+ function rewriteDesignSystemBarrelImports(source, designSystemEntryName) {
1057
+ return source.replace(
1058
+ /from\s+(["'])((?:\.\.\/)+)design-system\1/g,
1059
+ (_, quote, rel) =>
1060
+ `from ${quote}${rel}design-system/${designSystemEntryName}${quote}`,
1061
+ );
1062
+ }
1063
+
1064
+ /**
1065
+ * Extracts local design-system dependencies from a design token file.
1066
+ */
1067
+ function extractDesignSystemDependencies(source) {
1068
+ const deps = new Set();
1069
+ const re = /from\s+["']\.\/([^"']+)["']/g;
1070
+ let match;
1071
+ while ((match = re.exec(source)) !== null) {
1072
+ deps.add(match[1].replace(/\.(tsx?|jsx?)$/, ""));
1073
+ }
1074
+ return [...deps];
1075
+ }
1076
+
1077
+ /**
1078
+ * Copies only the design-system token files required by selected components,
1079
+ * plus local token dependencies such as `tokens.ts`.
1028
1080
  */
1029
- async function copyDesignSystemFolder(config, configDir, packageRoot) {
1081
+ async function copyDesignSystemFiles(
1082
+ componentNames,
1083
+ config,
1084
+ configDir,
1085
+ packageRoot,
1086
+ ) {
1030
1087
  const srcRoot = join(packageRoot, "src", "design-system");
1031
1088
  if (!existsSync(srcRoot)) {
1032
1089
  return;
@@ -1039,28 +1096,34 @@ async function copyDesignSystemFolder(config, configDir, packageRoot) {
1039
1096
  dirname(config.resolvedPaths.ui),
1040
1097
  "design-system",
1041
1098
  );
1042
- const files = await walkFiles(srcRoot);
1043
- for (const absSrc of files) {
1044
- const rel = relative(srcRoot, absSrc);
1045
- if (isTestFile(rel)) {
1099
+ const pending = componentNames
1100
+ .map((name) => getDesignSystemEntryName(name))
1101
+ .filter(Boolean);
1102
+ const copied = new Set();
1103
+
1104
+ while (pending.length > 0) {
1105
+ const entryName = pending.shift();
1106
+ if (copied.has(entryName)) {
1107
+ continue;
1108
+ }
1109
+ const absSrc = join(srcRoot, `${entryName}.ts`);
1110
+ if (!existsSync(absSrc)) {
1046
1111
  continue;
1047
1112
  }
1113
+ copied.add(entryName);
1114
+ const rel = relative(srcRoot, absSrc);
1048
1115
  const absDest = join(destRoot, rel);
1116
+ const raw = await readFile(absSrc, "utf8");
1117
+ for (const dep of extractDesignSystemDependencies(raw)) {
1118
+ if (!copied.has(dep)) {
1119
+ pending.push(dep);
1120
+ }
1121
+ }
1049
1122
  if (existsSync(absDest)) {
1050
1123
  continue;
1051
1124
  }
1052
1125
  await mkdir(dirname(absDest), { recursive: true });
1053
- if (/\.(tsx?|jsx?)$/.test(absSrc)) {
1054
- const raw = await readFile(absSrc, "utf8");
1055
- const { code } = rewriteImports(raw, {
1056
- utilsAlias: config.aliases.utils,
1057
- hooksAlias: config.aliases.hooks,
1058
- uiAlias: config.aliases.ui,
1059
- });
1060
- await writeFile(absDest, code, "utf8");
1061
- } else {
1062
- await copyFile(absSrc, absDest);
1063
- }
1126
+ await writeFile(absDest, raw, "utf8");
1064
1127
  }
1065
1128
  }
1066
1129
 
@@ -1258,7 +1321,7 @@ async function cmdAdd(names, cwd, options = {}) {
1258
1321
  }
1259
1322
 
1260
1323
  await ensureUtilsFile(config, configDir, packageRoot);
1261
- await copyDesignSystemFolder(config, configDir, packageRoot);
1324
+ await copyDesignSystemFiles(resolvedNames, config, configDir, packageRoot);
1262
1325
 
1263
1326
  const allHooks = new Set();
1264
1327
  for (const name of resolvedNames) {
@@ -1401,7 +1464,50 @@ async function main() {
1401
1464
  process.exitCode = 1;
1402
1465
  }
1403
1466
 
1404
- main().catch((err) => {
1405
- console.error(err instanceof Error ? err.message : err);
1406
- process.exitCode = 1;
1407
- });
1467
+ export {
1468
+ buildCompactThemeCss,
1469
+ cmdAdd,
1470
+ cmdInit,
1471
+ cmdTheme,
1472
+ collectHookTransitiveClosure,
1473
+ copyDesignSystemFiles,
1474
+ copyHookFolder,
1475
+ copyUiComponent,
1476
+ defaultConfig,
1477
+ detectFramework,
1478
+ findComponentsJson,
1479
+ getMissingDependencies,
1480
+ importPathFor,
1481
+ isTestFile,
1482
+ loadRegistry,
1483
+ main,
1484
+ normalizeHexColor,
1485
+ printAdoptionHints,
1486
+ printInfo,
1487
+ printList,
1488
+ resolveComponentName,
1489
+ resolveHookName,
1490
+ validateConfig,
1491
+ walkFiles,
1492
+ };
1493
+
1494
+ function isDirectCliRun() {
1495
+ if (!process.argv[1]) {
1496
+ return false;
1497
+ }
1498
+ try {
1499
+ return (
1500
+ realpathSync(process.argv[1]) ===
1501
+ realpathSync(fileURLToPath(import.meta.url))
1502
+ );
1503
+ } catch {
1504
+ return resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1505
+ }
1506
+ }
1507
+
1508
+ if (isDirectCliRun()) {
1509
+ main().catch((err) => {
1510
+ console.error(err instanceof Error ? err.message : err);
1511
+ process.exitCode = 1;
1512
+ });
1513
+ }
@@ -0,0 +1,180 @@
1
+ import {
2
+ existsSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import {
14
+ cmdAdd,
15
+ cmdInit,
16
+ cmdTheme,
17
+ defaultConfig,
18
+ importPathFor,
19
+ normalizeHexColor,
20
+ resolveComponentName,
21
+ resolveHookName,
22
+ validateConfig,
23
+ } from "./index.mjs";
24
+
25
+ type TestRegistry = Parameters<typeof resolveComponentName>[1] &
26
+ Parameters<typeof resolveHookName>[1];
27
+
28
+ function makeTempDir(prefix: string) {
29
+ return mkdtempSync(join(tmpdir(), prefix));
30
+ }
31
+
32
+ function silenceConsole() {
33
+ const logs: string[] = [];
34
+ const errors: string[] = [];
35
+
36
+ vi.spyOn(console, "log").mockImplementation((...args) => {
37
+ logs.push(args.join(" "));
38
+ });
39
+ vi.spyOn(console, "error").mockImplementation((...args) => {
40
+ errors.push(args.join(" "));
41
+ });
42
+
43
+ return { errors, logs };
44
+ }
45
+
46
+ describe("CLI module commands", () => {
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ process.exitCode = undefined;
50
+ });
51
+
52
+ it("resolves aliases, hooks, imports, hex colors, and config validation", () => {
53
+ const registry: TestRegistry = {
54
+ components: ["buttons", "card", "charts/line"],
55
+ hooks: ["useWindowSize"],
56
+ nameAliases: { button: "buttons" },
57
+ animatedComponents: ["buttons", "spinner"],
58
+ uiComponents: ["buttons", "card"],
59
+ };
60
+
61
+ expect(resolveComponentName("button", registry)).toBe("buttons");
62
+ expect(resolveComponentName("CARD", registry)).toBe("card");
63
+ expect(resolveHookName("usewindowsize", registry)).toBe("useWindowSize");
64
+ expect(importPathFor("useWindowSize", "hook", registry)).toBe(
65
+ "@zentauri-ui/zentauri-components/hooks/useWindowSize",
66
+ );
67
+ expect(importPathFor("charts/line", "component", registry)).toBe(
68
+ "@zentauri-ui/zentauri-components/charts/line",
69
+ );
70
+ expect(importPathFor("spinner", "component", registry)).toBe(
71
+ "@zentauri-ui/zentauri-components/ui/spinner/animated",
72
+ );
73
+ expect(normalizeHexColor("38b")).toBe("#3388bb");
74
+ expect(() => validateConfig(defaultConfig())).not.toThrow();
75
+ expect(() => resolveComponentName("missing", registry)).toThrow(
76
+ /Unknown component/,
77
+ );
78
+ expect(() => normalizeHexColor("not-a-color")).toThrow(
79
+ /Invalid brand color/,
80
+ );
81
+ });
82
+
83
+ it("initializes components.json with framework-aware guidance", async () => {
84
+ const dir = makeTempDir("zentauri-cli-module-init-");
85
+ const { errors, logs } = silenceConsole();
86
+
87
+ try {
88
+ writeFileSync(
89
+ join(dir, "package.json"),
90
+ JSON.stringify({ dependencies: { next: "16.0.0" } }),
91
+ );
92
+
93
+ await cmdInit(dir);
94
+
95
+ expect(existsSync(join(dir, "components.json"))).toBe(true);
96
+ expect(
97
+ JSON.parse(readFileSync(join(dir, "components.json"), "utf8")),
98
+ ).toEqual(defaultConfig());
99
+ expect(logs.join("\n")).toContain("Detected framework: Next.js");
100
+ expect(logs.join("\n")).toContain('@source "./src/components/ui";');
101
+
102
+ await cmdInit(dir);
103
+
104
+ expect(process.exitCode).toBe(1);
105
+ expect(errors.join("\n")).toContain("Refusing to overwrite existing");
106
+ } finally {
107
+ rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ it("vendors components, animated entries, design tokens, and transitive hooks", async () => {
112
+ const dir = makeTempDir("zentauri-cli-module-add-");
113
+ const { logs } = silenceConsole();
114
+
115
+ try {
116
+ await cmdInit(dir);
117
+ await cmdAdd(["button"], dir);
118
+ await cmdAdd(["hook", "usePrefersReducedMotion"], dir);
119
+ await cmdAdd(["button"], dir, { animated: true });
120
+
121
+ expect(
122
+ existsSync(join(dir, "src/components/ui/buttons/button.tsx")),
123
+ ).toBe(true);
124
+ expect(
125
+ existsSync(join(dir, "src/components/ui/buttons/animated/index.ts")),
126
+ ).toBe(true);
127
+ expect(
128
+ existsSync(join(dir, "src/components/design-system/button.ts")),
129
+ ).toBe(true);
130
+ expect(
131
+ existsSync(join(dir, "src/components/design-system/tokens.ts")),
132
+ ).toBe(true);
133
+ expect(
134
+ existsSync(
135
+ join(
136
+ dir,
137
+ "src/hooks/usePrefersReducedMotion/usePrefersReducedMotion.ts",
138
+ ),
139
+ ),
140
+ ).toBe(true);
141
+ expect(
142
+ existsSync(join(dir, "src/hooks/useMediaQuery/useMediaQuery.ts")),
143
+ ).toBe(true);
144
+ expect(
145
+ readFileSync(
146
+ join(dir, "src/components/ui/buttons/button-base.tsx"),
147
+ "utf8",
148
+ ),
149
+ ).toContain('from "@/lib/utils"');
150
+ expect(logs.join("\n")).toContain("Including animated entry for buttons");
151
+ expect(logs.join("\n")).toContain("Missing peer dependencies");
152
+ } finally {
153
+ rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ it("generates theme CSS to stdout or a requested file", async () => {
158
+ const dir = makeTempDir("zentauri-cli-module-theme-");
159
+ const { errors, logs } = silenceConsole();
160
+
161
+ try {
162
+ await cmdTheme("#2563eb", { dark: "#60a5fa" }, dir);
163
+ expect(logs.join("\n")).toContain("--zui-brand: #2563eb;");
164
+ expect(logs.join("\n")).toContain("--zui-brand-dark: #60a5fa;");
165
+
166
+ await cmdTheme("38bdf8", { out: "src/styles/zentauri-theme.css" }, dir);
167
+ const themePath = join(dir, "src/styles/zentauri-theme.css");
168
+ expect(existsSync(themePath)).toBe(true);
169
+ expect(readFileSync(themePath, "utf8")).toContain(
170
+ "--zui-brand: #38bdf8;",
171
+ );
172
+
173
+ await cmdTheme("", {}, dir);
174
+ expect(process.exitCode).toBe(1);
175
+ expect(errors.join("\n")).toContain("Usage: zentauri-components theme");
176
+ } finally {
177
+ rmSync(dir, { recursive: true, force: true });
178
+ }
179
+ });
180
+ });