@krapwoo/ds-viewer 0.1.1 → 0.3.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.
Files changed (173) hide show
  1. package/README.md +182 -78
  2. package/dist/cli/agentsFile.js +31 -0
  3. package/dist/cli/config.js +26 -1
  4. package/dist/cli/dev.js +50 -5
  5. package/dist/cli/doctor.js +492 -0
  6. package/dist/cli/explain.js +151 -0
  7. package/dist/cli/githubAction.js +68 -0
  8. package/dist/cli/glob.js +8 -0
  9. package/dist/cli/init.js +152 -18
  10. package/dist/cli/main.js +115 -4
  11. package/dist/cli/pageIndex.js +27 -1
  12. package/dist/cli/props.js +170 -21
  13. package/dist/cli/staticPage.js +356 -0
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/workspace.js +49 -21
  16. package/dist/native/catalog/comparison.js +283 -0
  17. package/dist/native/catalog/tokens.js +119 -0
  18. package/dist/native/catalog/types.js +1 -0
  19. package/native/catalog/CatalogShell.tsx +6 -1
  20. package/native/catalog/CatalogSidebar.tsx +98 -5
  21. package/native/catalog/ComparisonGrid.tsx +9 -7
  22. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  23. package/native/catalog/TokenRow.tsx +3 -2
  24. package/native/catalog/catalogNavigation.ts +1 -1
  25. package/native/catalog/comparison.ts +63 -5
  26. package/native/catalog/index.ts +8 -6
  27. package/native/catalog/logoLayout.ts +12 -0
  28. package/native/catalog/pageApi.ts +21 -3
  29. package/native/catalog/tokens.ts +5 -0
  30. package/native/catalog/types.ts +9 -6
  31. package/package.json +12 -6
  32. package/starter-kit/WHEN_TO_USE.md +124 -0
  33. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  34. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  35. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  36. package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
  37. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  38. package/starter-kit/components/Avatar/index.ts +2 -0
  39. package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
  40. package/starter-kit/components/Badge/Badge.tsx +56 -0
  41. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  42. package/starter-kit/components/Badge/index.ts +2 -0
  43. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  44. package/starter-kit/components/Banner/Banner.tsx +257 -0
  45. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  46. package/starter-kit/components/Banner/index.ts +2 -0
  47. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  48. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  49. package/starter-kit/components/BottomSheet/index.ts +2 -0
  50. package/starter-kit/components/Button/Button.catalog.tsx +99 -0
  51. package/starter-kit/components/Button/Button.tsx +173 -0
  52. package/starter-kit/components/Button/Button.types.ts +52 -0
  53. package/starter-kit/components/Button/index.ts +2 -0
  54. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  55. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  56. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  57. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  58. package/starter-kit/components/Card/Card.tsx +133 -0
  59. package/starter-kit/components/Card/index.ts +2 -0
  60. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  61. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  62. package/starter-kit/components/Checkbox/index.ts +2 -0
  63. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  64. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  65. package/starter-kit/components/Dialog/index.ts +2 -0
  66. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  67. package/starter-kit/components/Divider/Divider.tsx +20 -0
  68. package/starter-kit/components/Divider/index.ts +2 -0
  69. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  70. package/starter-kit/components/Dock/Dock.tsx +58 -0
  71. package/starter-kit/components/Dock/index.ts +2 -0
  72. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  73. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  74. package/starter-kit/components/Dropdown/index.ts +2 -0
  75. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  76. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  77. package/starter-kit/components/EmptyState/index.ts +2 -0
  78. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  79. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  80. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  81. package/starter-kit/components/FieldContainer/index.ts +2 -0
  82. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  83. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  84. package/starter-kit/components/InputClearButton/index.ts +2 -0
  85. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  86. package/starter-kit/components/InputField/InputField.tsx +418 -0
  87. package/starter-kit/components/InputField/index.ts +2 -0
  88. package/starter-kit/components/List/List.catalog.tsx +30 -0
  89. package/starter-kit/components/List/List.tsx +36 -0
  90. package/starter-kit/components/List/index.ts +2 -0
  91. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  92. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  93. package/starter-kit/components/ListItem/index.ts +2 -0
  94. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  95. package/starter-kit/components/Loading/Loading.tsx +121 -0
  96. package/starter-kit/components/Loading/index.ts +2 -0
  97. package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
  98. package/starter-kit/components/Pill/Pill.tsx +181 -0
  99. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  100. package/starter-kit/components/Pill/index.ts +2 -0
  101. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  102. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  103. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  104. package/starter-kit/components/PillRow/index.ts +2 -0
  105. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  106. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  107. package/starter-kit/components/ProgressDots/index.ts +2 -0
  108. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  109. package/starter-kit/components/Radio/Radio.tsx +114 -0
  110. package/starter-kit/components/Radio/index.ts +2 -0
  111. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  112. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  113. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  114. package/starter-kit/components/SearchField/index.ts +2 -0
  115. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  116. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  117. package/starter-kit/components/SectionHeader/index.ts +2 -0
  118. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  119. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  120. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  121. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  122. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  123. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  124. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  125. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  126. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  127. package/starter-kit/components/Shimmer/index.ts +2 -0
  128. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  129. package/starter-kit/components/Surface/Surface.tsx +45 -0
  130. package/starter-kit/components/Surface/index.ts +2 -0
  131. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  132. package/starter-kit/components/Switch/Switch.tsx +129 -0
  133. package/starter-kit/components/Switch/index.ts +2 -0
  134. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  135. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  136. package/starter-kit/components/TextArea/index.ts +2 -0
  137. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  138. package/starter-kit/components/Toast/Toast.tsx +144 -0
  139. package/starter-kit/components/Toast/index.ts +2 -0
  140. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  141. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  142. package/starter-kit/components/Tooltip/index.ts +2 -0
  143. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  144. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  145. package/starter-kit/components/TopNav/index.ts +2 -0
  146. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  147. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  148. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  149. package/starter-kit/components/index.ts +36 -0
  150. package/starter-kit/components/statusColors.ts +27 -0
  151. package/starter-kit/icons/Icon.native.tsx +90 -0
  152. package/starter-kit/icons/index.ts +10 -0
  153. package/starter-kit/icons/paths.ts +623 -0
  154. package/starter-kit/icons/types.ts +69 -0
  155. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  156. package/starter-kit/pages/Font.catalog.tsx +45 -0
  157. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  158. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  159. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  160. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  161. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  162. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  163. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  164. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  165. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  166. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  167. package/starter-kit/tokens/index.ts +50 -0
  168. package/starter-kit/tokens/motion.ts +90 -0
  169. package/starter-kit/tokens/palette.ts +111 -0
  170. package/starter-kit/tokens/scales.ts +106 -0
  171. package/starter-kit/tokens/semantic.ts +98 -0
  172. package/starter-kit/tokens/shadow.ts +36 -0
  173. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,68 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ // Design §4 "GitHub Action" + §5's lockfile-detection assumption. `checkout`/`setup-node` pins
4
+ // reused verbatim from this repository's own `.github/workflows/ci.yml` (Global Constraints);
5
+ // `pnpm/action-setup` is the one new SHA this plan resolves (`gh api
6
+ // repos/pnpm/action-setup/git/refs/tags/v6.1.0`, dereferenced to its commit). `node-version: '20'`
7
+ // matches the package's own published `engines` floor (`>=20.19.0`): a user's project only ever
8
+ // runs the compiled `dist/cli/main.js` via `npx ds-viewer`, which needs no type-stripping (unlike
9
+ // this repo's own CI, which type-strips its *source* and therefore needs Node 22).
10
+ // Errata #5: `pnpm/action-setup` needs an explicit pnpm version — it has no lockfile to infer one
11
+ // from. Read the project's own `packageManager` field (`"pnpm@9.1.0"`) when present; otherwise
12
+ // default to 10, matching the pin's own major version line.
13
+ function pnpmVersion(projectRoot) {
14
+ const packageJsonPath = path.join(projectRoot, 'package.json');
15
+ if (!existsSync(packageJsonPath))
16
+ return '10';
17
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
18
+ const match = packageJson.packageManager?.match(/^pnpm@(\d+)/);
19
+ return match ? match[1] : '10';
20
+ }
21
+ // Important finding 6 (Fable's review): Yarn 1 (still the common Expo lockfile) has no
22
+ // `--immutable` flag — its equivalent is `--frozen-lockfile`. Yarn Berry needs `corepack enable`
23
+ // first, since `actions/setup-node` ships Yarn 1 on `ubuntu-latest`. A v1 lockfile's own first line
24
+ // is always exactly `# yarn lockfile v1`; anything else (Berry's own header/`__metadata`) is Berry.
25
+ function yarnInstallStep(projectRoot) {
26
+ const firstLine = readFileSync(path.join(projectRoot, 'yarn.lock'), 'utf8').split('\n', 1)[0];
27
+ if (firstLine === '# yarn lockfile v1')
28
+ return ' - run: yarn install --frozen-lockfile\n';
29
+ return ' - run: corepack enable\n - run: yarn install --immutable\n';
30
+ }
31
+ function installStep(projectRoot) {
32
+ if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) {
33
+ return ` - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0\n with:\n version: ${pnpmVersion(projectRoot)}\n - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n - run: pnpm install --frozen-lockfile\n`;
34
+ }
35
+ const setupNode = ` - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0\n with:\n node-version: '20'\n`;
36
+ if (existsSync(path.join(projectRoot, 'yarn.lock')))
37
+ return `${setupNode}${yarnInstallStep(projectRoot)}`;
38
+ // `package-lock.json`, or no lockfile at all (a fresh project before its first commit) — `npm
39
+ // ci` is npm's own documented behavior for the latter: it installs from `package.json` and
40
+ // writes a fresh lockfile, exactly like a plain `npm install` would.
41
+ return `${setupNode} - run: npm ci\n`;
42
+ }
43
+ function workflowContent(projectRoot) {
44
+ return `name: DS Viewer
45
+
46
+ on:
47
+ pull_request:
48
+
49
+ jobs:
50
+ doctor:
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
54
+ ${installStep(projectRoot)} - run: npx ds-viewer doctor --ci
55
+ `;
56
+ }
57
+ /** Design §4 "GitHub Action", §1 Ownership: written once, never overwritten — a user's own edits
58
+ * to this file (or a differently-named workflow they wrote instead) are never touched again. The
59
+ * install step is picked from whichever lockfile is present *right now*, at `init` time — never
60
+ * re-picked later, since this function never runs again once the file exists. */
61
+ export function ensureGithubAction(projectRoot) {
62
+ const workflowPath = path.join(projectRoot, '.github', 'workflows', 'ds-viewer.yml');
63
+ if (existsSync(workflowPath))
64
+ return [];
65
+ mkdirSync(path.dirname(workflowPath), { recursive: true });
66
+ writeFileSync(workflowPath, workflowContent(projectRoot));
67
+ return [workflowPath];
68
+ }
package/dist/cli/glob.js CHANGED
@@ -30,6 +30,14 @@ function walk(root, segments, consumed) {
30
30
  }
31
31
  }
32
32
  const [segment, ...rest] = segments;
33
+ // `readdirSync` never returns "." or ".." as entries, so without this a config glob that climbs
34
+ // out of its own root (e.g. kit-host's `../starter-kit/components/*/index.ts`, Task 5) would
35
+ // regex-match against real directory entries and silently resolve to nothing. `path.join` below
36
+ // already collapses "." and ".." correctly once they're part of `consumed` — this just lets them
37
+ // reach it instead of being treated as a literal name to search for.
38
+ if (segment === '..' || segment === '.') {
39
+ return walk(root, rest, [...consumed, segment]);
40
+ }
33
41
  const dir = path.join(root, ...consumed);
34
42
  let entries;
35
43
  try {
package/dist/cli/init.js CHANGED
@@ -1,8 +1,11 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createInterface } from 'node:readline/promises';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { preflight } from "./preflight.js";
5
6
  import { detectComponentFolders, detectTokenFiles } from "./detect.js";
7
+ import { ensureAgentsFileSection } from "./agentsFile.js";
8
+ import { ensureGithubAction } from "./githubAction.js";
6
9
  async function promptConfirm(message) {
7
10
  // A closed, non-interactive stdin (CI, piped npx) would reject `question` with an AbortError;
8
11
  // say how to proceed instead.
@@ -15,25 +18,28 @@ async function promptConfirm(message) {
15
18
  rl.close();
16
19
  return /^y(es)?$/i.test(answer.trim());
17
20
  }
18
- /** This package's own version — used as the `^x.y.z` devDependency version written below, since a
19
- * project run via `npx @krapwoo/ds-viewer init` has no local copy to read its version from yet.
20
- * Walks up from this file's own directory looking for the `package.json` named
21
- * `@krapwoo/ds-viewer`, rather than a fixed relative path: this file sits one level under the
22
- * repo root as `cli/init.ts` (how the test below runs it) but two levels under it once compiled,
23
- * as `dist/cli/init.js` — a single `../..` would be wrong for one of the two. */
24
- function ownVersion() {
21
+ /** This package's own installed root — needed both for `ownVersion()` below and (new in 0.2) to
22
+ * find the bundled `starter-kit/` to copy from. Walks up from this file's own directory looking
23
+ * for the `package.json` named `@krapwoo/ds-viewer`, rather than a fixed relative path: this file
24
+ * sits one level under the repo root as `cli/init.ts` (how tests run it) but two levels under it
25
+ * once compiled, as `dist/cli/init.js` — a single `../..` would be wrong for one of the two. */
26
+ function packageRoot() {
25
27
  let dir = import.meta.dirname;
26
28
  for (let depth = 0; depth < 5; depth += 1) {
27
29
  const candidate = path.join(dir, 'package.json');
28
30
  if (existsSync(candidate)) {
29
31
  const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
30
32
  if (pkg.name === '@krapwoo/ds-viewer')
31
- return pkg.version;
33
+ return dir;
32
34
  }
33
35
  dir = path.dirname(dir);
34
36
  }
35
37
  throw new Error("Could not find @krapwoo/ds-viewer's own package.json to read its version.");
36
38
  }
39
+ function ownVersion() {
40
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
41
+ return pkg.version;
42
+ }
37
43
  /** Detects the existing indent of a JSON file's first indented line, so rewriting it (e.g.
38
44
  * `package.json` below) doesn't reformat the user's own indentation style. Falls back to two
39
45
  * spaces when nothing indented is found (e.g. a minified `package.json`). */
@@ -85,6 +91,25 @@ export async function initExistingProject(projectRoot, options = {}) {
85
91
  written.push(draftPath);
86
92
  }
87
93
  }
94
+ written.push(...ensureSharedProjectFiles(projectRoot));
95
+ const routerWarnings = componentFolders
96
+ .filter((folder) => folder.relativePath.split(path.sep)[0] === 'app')
97
+ .map((folder) => `Warning: ${folder.relativePath.split(path.sep).join('/')} is inside an Expo Router "app/" directory; its .catalog.tsx file will become a route.`);
98
+ const messages = [
99
+ ...warnings.map((issue) => `Warning: ${issue.package} not found. Run: ${issue.installCommand}`),
100
+ ...routerWarnings,
101
+ // "with catalog pages", not "with examples": this counts any existing `<Name>.catalog.tsx`,
102
+ // including init's own drafts, which explicitly say "Needs examples" — not real coverage yet.
103
+ `${componentFolders.length} components · ${withExamplesCount} with catalog pages`,
104
+ 'Next: npm install, then npm run ds-viewer dev',
105
+ ];
106
+ return { messages, written };
107
+ }
108
+ /** Design §2, "Both paths then write what is missing": `ds-viewer.config.ts` is written by each
109
+ * path separately (its contents differ), but the package script, the devDependency entry, the
110
+ * `.gitignore` entry, and (from 0.3) the `AGENTS.md` section are identical either way. */
111
+ function ensureSharedProjectFiles(projectRoot) {
112
+ const written = [];
88
113
  const packageJsonPath = path.join(projectRoot, 'package.json');
89
114
  const packageJsonRaw = readFileSync(packageJsonPath, 'utf8');
90
115
  const packageJson = JSON.parse(packageJsonRaw);
@@ -106,26 +131,135 @@ export async function initExistingProject(projectRoot, options = {}) {
106
131
  const gitignorePath = path.join(projectRoot, '.gitignore');
107
132
  const gitignoreEntry = '.ds-viewer/';
108
133
  const existingGitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
109
- // `\r\n` line endings leave a trailing `\r` on each split line, which never equals the entry —
110
- // trim it per line so a CRLF .gitignore is recognized as already having the entry on a rerun.
111
134
  if (!existingGitignore.split('\n').some((line) => line.replace(/\r$/, '') === gitignoreEntry)) {
112
135
  const separator = existingGitignore === '' || existingGitignore.endsWith('\n') ? '' : '\n';
113
136
  writeFileSync(gitignorePath, `${existingGitignore}${separator}${gitignoreEntry}\n`);
114
137
  written.push(gitignorePath);
115
138
  }
116
- const routerWarnings = componentFolders
117
- .filter((folder) => folder.relativePath.split(path.sep)[0] === 'app')
118
- .map((folder) => `Warning: ${folder.relativePath.split(path.sep).join('/')} is inside an Expo Router "app/" directory; its .catalog.tsx file will become a route.`);
139
+ written.push(...ensureAgentsFileSection(projectRoot));
140
+ written.push(...ensureGithubAction(projectRoot));
141
+ return written;
142
+ }
143
+ const KIT_REQUIRED_PACKAGES = ['react-native-svg', 'react-native-safe-area-context'];
144
+ /** The starter kit's own intended sidebar order (the same list kit-host/ds-viewer.config.ts
145
+ * declares, minus "Viewer" — that group only holds kit-host's own framework-docs pages under
146
+ * `viewer-pages/`, which `init --new` never copies). Written into a fresh project's config so its
147
+ * sidebar isn't left alphabetical (controller end-to-end finding E3). */
148
+ const KIT_GROUP_ORDER = [
149
+ 'Actions', 'Surfaces', 'Inputs', 'Controls', 'Selection', 'Feedback', 'Navigation',
150
+ 'Overlays', 'Layout', 'Sub-Parts', 'Recipes', 'Tokens', 'Reference',
151
+ ];
152
+ /** Default `installer` — a real `npx expo install`, run only for whichever of
153
+ * `KIT_REQUIRED_PACKAGES` the project doesn't already depend on. Test-injected in every unit test
154
+ * (this plan's Global Constraints: never run a real `expo install` in tests). */
155
+ function defaultInstaller(projectRoot, missingPackages) {
156
+ if (missingPackages.length === 0)
157
+ return;
158
+ // Same `shell: process.platform === 'win32'` reasoning as `dev.ts`'s existing `spawn('npx', ...)`
159
+ // call: Windows resolves `npx` via `npx.cmd`, which `execFileSync` only finds through a shell.
160
+ execFileSync('npx', ['expo', 'install', ...missingPackages], { cwd: projectRoot, stdio: 'inherit', shell: process.platform === 'win32' });
161
+ }
162
+ /** Copies every file under `srcDir` into `destDir`, skipping any destination file that already
163
+ * exists (design's "never overwrites user files" rule — Global Constraints) and reporting, not
164
+ * throwing on, one unreadable source file (Error-handling rule). Also reports, rather than
165
+ * throwing on, an unreadable nested folder — the same `readdirSync` call recurses, so wrapping it
166
+ * here covers every depth — so files already copied before it are still returned, and skips a
167
+ * symlinked entry (neither a plain file nor a directory `copyKitIfMissing` should recurse into)
168
+ * instead of copying through it (Minor finding, Fable's implementation review,
169
+ * cli/init.ts:205-224). Returns every path it wrote. */
170
+ export function copyKitIfMissing(srcDir, destDir) {
171
+ const written = [];
172
+ mkdirSync(destDir, { recursive: true });
173
+ let entries;
174
+ try {
175
+ entries = readdirSync(srcDir, { withFileTypes: true });
176
+ }
177
+ catch (error) {
178
+ console.warn(`Could not read ${srcDir}: ${error.message}`);
179
+ return written;
180
+ }
181
+ for (const entry of entries) {
182
+ if (entry.isSymbolicLink())
183
+ continue;
184
+ const srcPath = path.join(srcDir, entry.name);
185
+ const destPath = path.join(destDir, entry.name);
186
+ if (entry.isDirectory()) {
187
+ written.push(...copyKitIfMissing(srcPath, destPath));
188
+ continue;
189
+ }
190
+ if (existsSync(destPath))
191
+ continue;
192
+ try {
193
+ copyFileSync(srcPath, destPath);
194
+ written.push(destPath);
195
+ }
196
+ catch (error) {
197
+ console.warn(`Could not copy ${path.relative(srcDir, srcPath)}: ${error.message}`);
198
+ }
199
+ }
200
+ return written;
201
+ }
202
+ function newProjectConfigContents(kitRoot, starterKitVersion) {
203
+ return `import { defineConfig } from '@krapwoo/ds-viewer/config';
204
+
205
+ export default defineConfig({
206
+ name: 'My App',
207
+ components: ['${kitRoot}/components/*/index.ts'],
208
+ tokens: ['${kitRoot}/tokens/index.ts'],
209
+ pages: ['${kitRoot}/pages/*.catalog.tsx'],
210
+ groupOrder: ${JSON.stringify(KIT_GROUP_ORDER)},
211
+ starterKit: { version: '${starterKitVersion}' },
212
+ });
213
+ `;
214
+ }
215
+ /** The new-project half of `init` (design §2's "new project" column) — existing-project `init` is
216
+ * `initExistingProject`, 0.1 scope. No confirmation prompt here: unlike the existing-project path,
217
+ * there is nothing to detect or confirm — the kit root is the only user choice, and it's a flag. */
218
+ export async function initNewProject(projectRoot, options = {}) {
219
+ const { errors, warnings } = preflight(projectRoot);
220
+ if (errors.length > 0) {
221
+ return { messages: errors.map((issue) => `Missing ${issue.package}. Run: ${issue.installCommand}`), written: [], exitCode: 1 };
222
+ }
223
+ const packageJson = JSON.parse(readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
224
+ const installedPackages = { ...packageJson.dependencies, ...packageJson.devDependencies };
225
+ const missingKitPackages = KIT_REQUIRED_PACKAGES.filter((name) => !installedPackages[name]);
226
+ await (options.installer ?? defaultInstaller)(projectRoot, missingKitPackages);
227
+ const kitRoot = options.kitRoot ?? 'src/ds';
228
+ const written = copyKitIfMissing(path.join(packageRoot(), 'starter-kit'), path.join(projectRoot, kitRoot));
229
+ // Read before `ensureSharedProjectFiles`/the config write below add their own entries — this is
230
+ // only true when `copyKitIfMissing` itself wrote nothing, i.e. a rerun found every kit file
231
+ // already present (controller end-to-end finding E2: a second `init --new` claimed to have
232
+ // copied the kit even though nothing changed on disk).
233
+ const copiedKitFiles = written.length > 0;
234
+ const configPath = path.join(projectRoot, 'ds-viewer.config.ts');
235
+ if (!existsSync(configPath)) {
236
+ writeFileSync(configPath, newProjectConfigContents(kitRoot, ownVersion()));
237
+ written.push(configPath);
238
+ }
239
+ written.push(...ensureSharedProjectFiles(projectRoot));
119
240
  const messages = [
120
241
  ...warnings.map((issue) => `Warning: ${issue.package} not found. Run: ${issue.installCommand}`),
121
- ...routerWarnings,
122
- // "with catalog pages", not "with examples": this counts any existing `<Name>.catalog.tsx`,
123
- // including init's own drafts, which explicitly say "Needs examples" — not real coverage yet.
124
- `${componentFolders.length} components · ${withExamplesCount} with catalog pages`,
242
+ ...(missingKitPackages.length > 0 ? [`Installed ${missingKitPackages.join(', ')}.`] : []),
243
+ copiedKitFiles ? `Copied the starter kit into ${kitRoot}/.` : `Starter kit already present in ${kitRoot}/ — nothing copied.`,
125
244
  'Next: npm install, then npm run ds-viewer dev',
126
245
  ];
127
246
  return { messages, written };
128
247
  }
248
+ /** The one new-vs-existing question (design §2's "One question") — `--new`/`--existing` (wired in
249
+ * `cli/main.ts`) skip it entirely; this is only reached with neither flag. */
250
+ export async function promptInitMode() {
251
+ if (!process.stdin.isTTY)
252
+ return undefined;
253
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
254
+ const answer = await rl.question('New or existing project? [new/existing] ');
255
+ rl.close();
256
+ const normalized = answer.trim().toLowerCase();
257
+ if (normalized === 'new' || normalized === 'n')
258
+ return 'new';
259
+ if (normalized === 'existing' || normalized === 'e')
260
+ return 'existing';
261
+ return undefined;
262
+ }
129
263
  function configFileContents(componentFolders, tokenFiles) {
130
264
  // Emits both `index.ts` and `index.tsx` globs for every detected parent folder whenever any
131
265
  // detected folder uses `.tsx` (`detect.ts` accepts either extension — Important: a project using
package/dist/cli/main.js CHANGED
@@ -3,16 +3,28 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { resolveConfig } from "./config.js";
6
- import { initExistingProject } from "./init.js";
6
+ import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
7
7
  import { dev } from "./dev.js";
8
8
  import { sync } from "./sync.js";
9
+ import { formatHuman, runDoctor, toDoctorJson } from "./doctor.js";
10
+ import { explainPage, formatExplain, parseHeights } from "./explain.js";
9
11
  const USAGE = `Usage: ds-viewer <command>
10
12
 
11
13
  Commands:
12
- init Set up the viewer in this project: config, draft catalog pages, and an npm script
13
- --yes accept the detected components and tokens without asking
14
+ init Set up the viewer in this project: config, catalog pages, and an npm script
15
+ --new start from the starter kit (new project)
16
+ --existing detect this project's own components/tokens (existing project)
17
+ --kit-root DIR where to copy the starter kit (--new only; default src/ds)
18
+ --yes accept the detected components and tokens without asking (--existing only)
14
19
  sync Regenerate the component, props, token, and page data from source
15
20
  dev Sync, then serve the catalog on a local web address and keep it current as files change
21
+ doctor Check every page for drift and coverage gaps
22
+ --json print the machine-readable report instead
23
+ --ci exit 1 when any error was found (never on a warning alone)
24
+ explain <Page>
25
+ Print why a page's specimens are laid out the way they are
26
+ --heights <first>,<second> check side-by-side placement with these measured heights
27
+ --json print the machine-readable report instead
16
28
 
17
29
  Options:
18
30
  -h, --help Show this help
@@ -33,6 +45,90 @@ function packageVersion() {
33
45
  dir = parent;
34
46
  }
35
47
  }
48
+ /** `--kit-root`'s raw CLI value, normalized to a project-root-relative, forward-slash path — or an
49
+ * error when the value can't be made into one. Without this: an absolute value was silently
50
+ * relocated *under* the project root by `path.join` (which never does `path.resolve`'s "a later
51
+ * absolute segment replaces everything before it" — it just concatenates), a trailing slash
52
+ * produced a doubled-slash glob (`src/ds//components/*`), and a quote broke the generated
53
+ * config's TypeScript string literal (Minor finding, Fable's implementation review,
54
+ * cli/main.ts:59-60 & cli/init.ts:231-233,257). */
55
+ export function parseKitRoot(projectRoot, rawValue) {
56
+ if (rawValue.includes("'") || rawValue.includes('"')) {
57
+ return { error: `--kit-root cannot contain a quote (got "${rawValue}")` };
58
+ }
59
+ const relative = path.relative(projectRoot, path.resolve(projectRoot, rawValue));
60
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
61
+ return { error: `--kit-root must stay inside the project root (got "${rawValue}")` };
62
+ }
63
+ return { value: relative.split(path.sep).join('/') };
64
+ }
65
+ /** Parses `init`'s flags and dispatches to `initNewProject`/`initExistingProject` — pulled out of
66
+ * `main` so the dispatch (which flags pick which project path, `--yes`'s 0.1-compatible default,
67
+ * `--kit-root`'s normalization) is unit-testable with an injected installer, never a real `expo
68
+ * install` (Minor finding, Fable's implementation review: "No CLI test covers `--yes` defaulting
69
+ * to existing, `--kit-root` parsing, or `--new` dispatch"). */
70
+ export async function runInit(projectRoot, rest, options = {}) {
71
+ const kitRootIndex = rest.indexOf('--kit-root');
72
+ // Validated, not just indexed: a bare trailing `--kit-root` (no value) or `--kit-root --yes`
73
+ // (the next token is itself a flag) would otherwise silently pass `undefined`/`'--yes'` as the
74
+ // kit root. Both are treated as "no value given", the same as omitting the flag entirely.
75
+ const kitRootValue = kitRootIndex !== -1 ? rest[kitRootIndex + 1] : undefined;
76
+ const kitRootRaw = kitRootValue && !kitRootValue.startsWith('--') ? kitRootValue : undefined;
77
+ let kitRoot;
78
+ if (kitRootRaw !== undefined) {
79
+ const parsed = parseKitRoot(projectRoot, kitRootRaw);
80
+ if (parsed.error)
81
+ return { messages: [parsed.error], written: [], exitCode: 1 };
82
+ kitRoot = parsed.value;
83
+ }
84
+ let mode = rest.includes('--new') ? 'new' : rest.includes('--existing') ? 'existing' : undefined;
85
+ // 0.1 had only one `init` path (today's `initExistingProject`) and no mode question at all —
86
+ // `--yes` alone must keep working exactly as it did for a 0.1 user or script, so `--yes` with
87
+ // neither `--new` nor `--existing` defaults to `existing`, the same path 0.1's `--yes` always
88
+ // ran, instead of prompting (which would hang/exit 1 on non-interactive stdin and break them).
89
+ if (!mode && rest.includes('--yes'))
90
+ mode = 'existing';
91
+ if (!mode)
92
+ mode = await (options.promptMode ?? promptInitMode)();
93
+ if (!mode) {
94
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY);
95
+ // A non-interactive stdin (CI, piped npx) never got to ask the question at all — but a TTY
96
+ // whose answer `promptInitMode` simply couldn't parse did ask, so it needs its own message,
97
+ // not the "non-interactive" one (Minor finding, Fable's implementation review).
98
+ const message = isTTY
99
+ ? 'Could not parse that answer. Specify --new or --existing directly.'
100
+ : 'Specify --new or --existing (non-interactive stdin cannot be asked).';
101
+ return { messages: [`${message}\n\n${USAGE}`], written: [], exitCode: 1 };
102
+ }
103
+ return mode === 'new'
104
+ ? initNewProject(projectRoot, { kitRoot, installer: options.installer })
105
+ : initExistingProject(projectRoot, { yes: rest.includes('--yes'), confirm: options.confirm });
106
+ }
107
+ /** Pulled out of `main()` for the same reason `runInit` was in 0.1/0.2 (Fable's review): testable
108
+ * without spawning a process. `--ci`'s exit code is read from `runDoctor`'s own `exitCode` — a
109
+ * plain `doctor` (no `--ci`) always returns 0 here regardless of errors found. */
110
+ export function runDoctorCommand(projectRoot, rest) {
111
+ const config = resolveConfig(projectRoot);
112
+ const result = runDoctor(config);
113
+ const output = rest.includes('--json') ? JSON.stringify(toDoctorJson(result), null, 2) : formatHuman(result);
114
+ return { output, exitCode: rest.includes('--ci') ? result.exitCode : 0 };
115
+ }
116
+ /** Same "testable without spawning" shape as `runDoctorCommand` above. */
117
+ export function runExplainCommand(projectRoot, pageId, rest) {
118
+ if (!pageId)
119
+ return { output: 'Usage: ds-viewer explain <Page> [--heights <first>,<second>] [--json]', exitCode: 1 };
120
+ const heightsIndex = rest.indexOf('--heights');
121
+ const heightsRaw = heightsIndex !== -1 ? rest[heightsIndex + 1] : undefined;
122
+ const heights = heightsRaw ? parseHeights(heightsRaw) : undefined;
123
+ if (heightsRaw && !heights) {
124
+ return { output: `--heights must be two comma-separated numbers, e.g. --heights 420,610 (got "${heightsRaw}")`, exitCode: 1 };
125
+ }
126
+ const config = resolveConfig(projectRoot);
127
+ const result = explainPage(config, pageId, { heights });
128
+ if (!result)
129
+ return { output: `No page named "${pageId}" was found.`, exitCode: 1 };
130
+ return { output: rest.includes('--json') ? JSON.stringify(result, null, 2) : formatExplain(result), exitCode: 0 };
131
+ }
36
132
  async function main() {
37
133
  const [command, ...rest] = process.argv.slice(2);
38
134
  const projectRoot = process.cwd();
@@ -45,7 +141,7 @@ async function main() {
45
141
  return;
46
142
  }
47
143
  if (command === 'init') {
48
- const result = await initExistingProject(projectRoot, { yes: rest.includes('--yes') });
144
+ const result = await runInit(projectRoot, rest);
49
145
  for (const message of result.messages)
50
146
  console.log(message);
51
147
  for (const file of result.written)
@@ -60,6 +156,21 @@ async function main() {
60
156
  console.log(`Synced ${result.componentCount} components, ${result.pageCount} pages.`);
61
157
  return;
62
158
  }
159
+ if (command === 'doctor') {
160
+ const { output, exitCode } = runDoctorCommand(projectRoot, rest);
161
+ console.log(output);
162
+ if (exitCode)
163
+ process.exitCode = exitCode;
164
+ return;
165
+ }
166
+ if (command === 'explain') {
167
+ const [pageId, ...explainRest] = rest;
168
+ const { output, exitCode } = runExplainCommand(projectRoot, pageId, explainRest);
169
+ console.log(output);
170
+ if (exitCode)
171
+ process.exitCode = exitCode;
172
+ return;
173
+ }
63
174
  if (command === 'dev') {
64
175
  const config = resolveConfig(projectRoot);
65
176
  await dev(config);
@@ -1,4 +1,4 @@
1
- import { existsSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveGlob } from "./glob.js";
4
4
  /** Component pages: `<Export>.catalog.tsx` beside the component's entry file (design §2). A
@@ -10,6 +10,25 @@ export function discoverPages(input) {
10
10
  const standalonePages = input.standalonePageGlobs.flatMap((pattern) => resolveGlob(input.projectRoot, pattern));
11
11
  return [...componentPages, ...standalonePages].sort();
12
12
  }
13
+ /** Every `*.catalog.tsx`/`.catalog.ts` file `doctor` should read — unlike `discoverPages`, this
14
+ * also finds a page beside a component whose folder still exists but whose export was renamed or
15
+ * removed (that page's file never changed, so it's still sitting right there). `componentFolders`
16
+ * is every distinct folder containing a currently-resolved component entry file — `cli/doctor.ts`
17
+ * derives it the same way `cli/sync.ts` does, by resolving `config.components` and taking each
18
+ * match's `path.dirname`. A folder that was deleted entirely (not just renamed within) has nothing
19
+ * left to find here, which is correct: there's no stale page file left on disk to warn about. */
20
+ export function discoverAllPageFiles(input) {
21
+ const componentPages = input.componentFolders.flatMap((folder) => {
22
+ try {
23
+ return readdirSync(folder).filter((name) => /\.catalog\.tsx?$/.test(name)).map((name) => path.join(folder, name));
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ });
29
+ const standalonePages = input.standalonePageGlobs.flatMap((pattern) => resolveGlob(input.projectRoot, pattern));
30
+ return [...new Set([...componentPages, ...standalonePages])].sort();
31
+ }
13
32
  /** Writes `.ds-viewer/generated/pages.ts`: imports every discovered page's default export,
14
33
  * resolves each one's id (design §2 "Page id": `id ?? component ?? <file stem>` — a component
15
34
  * page defaults to its export name, a standalone page to its own file's stem) and `file` (the
@@ -25,6 +44,13 @@ export function writePageIndex(generatedDir, pageFiles) {
25
44
  writeFileSync(outFile, `${importLines.join('\n')}\n\n${resolveLines.join('\n')}\n\n${exportLine}\n`);
26
45
  return outFile;
27
46
  }
47
+ /** `page.id ?? page.component ?? <file's own stem>` (design §2 "Page id") — factored out of
48
+ * `writePageIndex`'s own inline logic above so `cli/doctor.ts` resolves a page's id exactly the
49
+ * same way, never a second, drifting copy of this rule. Takes the same two fields `StaticPage`
50
+ * (Task 3) and the generated page index both already carry. */
51
+ export function resolvePageId(page, file) {
52
+ return page.id ?? page.component ?? fileStem(file);
53
+ }
28
54
  function toImportPath(fromDir, toFile) {
29
55
  const withoutExt = toFile.replace(/\.tsx?$/, '');
30
56
  const relativePath = path.relative(fromDir, withoutExt).split(path.sep).join('/');