@krapwoo/ds-viewer 0.1.0 → 0.2.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 (159) hide show
  1. package/README.md +100 -78
  2. package/dist/cli/config.js +26 -1
  3. package/dist/cli/dev.js +50 -5
  4. package/dist/cli/glob.js +8 -0
  5. package/dist/cli/init.js +148 -18
  6. package/dist/cli/main.js +102 -3
  7. package/dist/cli/props.js +166 -18
  8. package/dist/cli/sync.js +17 -1
  9. package/dist/cli/workspace.js +49 -21
  10. package/native/catalog/CatalogShell.tsx +6 -1
  11. package/native/catalog/CatalogSidebar.tsx +98 -5
  12. package/native/catalog/TokenRow.tsx +3 -2
  13. package/native/catalog/index.ts +6 -5
  14. package/native/catalog/logoLayout.ts +12 -0
  15. package/native/catalog/pageApi.ts +10 -1
  16. package/native/catalog/tokens.ts +5 -0
  17. package/package.json +11 -7
  18. package/starter-kit/WHEN_TO_USE.md +124 -0
  19. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  20. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  21. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  22. package/starter-kit/components/Avatar/Avatar.catalog.tsx +72 -0
  23. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  24. package/starter-kit/components/Avatar/index.ts +2 -0
  25. package/starter-kit/components/Badge/Badge.catalog.tsx +96 -0
  26. package/starter-kit/components/Badge/Badge.tsx +56 -0
  27. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  28. package/starter-kit/components/Badge/index.ts +2 -0
  29. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  30. package/starter-kit/components/Banner/Banner.tsx +257 -0
  31. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  32. package/starter-kit/components/Banner/index.ts +2 -0
  33. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  34. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  35. package/starter-kit/components/BottomSheet/index.ts +2 -0
  36. package/starter-kit/components/Button/Button.catalog.tsx +113 -0
  37. package/starter-kit/components/Button/Button.tsx +173 -0
  38. package/starter-kit/components/Button/Button.types.ts +52 -0
  39. package/starter-kit/components/Button/index.ts +2 -0
  40. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  41. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  42. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  43. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  44. package/starter-kit/components/Card/Card.tsx +133 -0
  45. package/starter-kit/components/Card/index.ts +2 -0
  46. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  47. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  48. package/starter-kit/components/Checkbox/index.ts +2 -0
  49. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  50. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  51. package/starter-kit/components/Dialog/index.ts +2 -0
  52. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  53. package/starter-kit/components/Divider/Divider.tsx +20 -0
  54. package/starter-kit/components/Divider/index.ts +2 -0
  55. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  56. package/starter-kit/components/Dock/Dock.tsx +58 -0
  57. package/starter-kit/components/Dock/index.ts +2 -0
  58. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  59. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  60. package/starter-kit/components/Dropdown/index.ts +2 -0
  61. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  62. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  63. package/starter-kit/components/EmptyState/index.ts +2 -0
  64. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  65. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  66. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  67. package/starter-kit/components/FieldContainer/index.ts +2 -0
  68. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  69. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  70. package/starter-kit/components/InputClearButton/index.ts +2 -0
  71. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  72. package/starter-kit/components/InputField/InputField.tsx +418 -0
  73. package/starter-kit/components/InputField/index.ts +2 -0
  74. package/starter-kit/components/List/List.catalog.tsx +30 -0
  75. package/starter-kit/components/List/List.tsx +36 -0
  76. package/starter-kit/components/List/index.ts +2 -0
  77. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  78. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  79. package/starter-kit/components/ListItem/index.ts +2 -0
  80. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  81. package/starter-kit/components/Loading/Loading.tsx +121 -0
  82. package/starter-kit/components/Loading/index.ts +2 -0
  83. package/starter-kit/components/Pill/Pill.catalog.tsx +93 -0
  84. package/starter-kit/components/Pill/Pill.tsx +181 -0
  85. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  86. package/starter-kit/components/Pill/index.ts +2 -0
  87. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  88. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  89. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  90. package/starter-kit/components/PillRow/index.ts +2 -0
  91. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  92. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  93. package/starter-kit/components/ProgressDots/index.ts +2 -0
  94. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  95. package/starter-kit/components/Radio/Radio.tsx +114 -0
  96. package/starter-kit/components/Radio/index.ts +2 -0
  97. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  98. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  99. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  100. package/starter-kit/components/SearchField/index.ts +2 -0
  101. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  102. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  103. package/starter-kit/components/SectionHeader/index.ts +2 -0
  104. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  105. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  106. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  107. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  108. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  109. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  110. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  111. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  112. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  113. package/starter-kit/components/Shimmer/index.ts +2 -0
  114. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  115. package/starter-kit/components/Surface/Surface.tsx +45 -0
  116. package/starter-kit/components/Surface/index.ts +2 -0
  117. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  118. package/starter-kit/components/Switch/Switch.tsx +129 -0
  119. package/starter-kit/components/Switch/index.ts +2 -0
  120. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  121. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  122. package/starter-kit/components/TextArea/index.ts +2 -0
  123. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  124. package/starter-kit/components/Toast/Toast.tsx +144 -0
  125. package/starter-kit/components/Toast/index.ts +2 -0
  126. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  127. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  128. package/starter-kit/components/Tooltip/index.ts +2 -0
  129. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  130. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  131. package/starter-kit/components/TopNav/index.ts +2 -0
  132. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  133. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  134. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  135. package/starter-kit/components/index.ts +36 -0
  136. package/starter-kit/components/statusColors.ts +27 -0
  137. package/starter-kit/icons/Icon.native.tsx +90 -0
  138. package/starter-kit/icons/index.ts +10 -0
  139. package/starter-kit/icons/paths.ts +623 -0
  140. package/starter-kit/icons/types.ts +69 -0
  141. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  142. package/starter-kit/pages/Font.catalog.tsx +45 -0
  143. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  144. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  145. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  146. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  147. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  148. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  149. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  150. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  151. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  152. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  153. package/starter-kit/tokens/index.ts +50 -0
  154. package/starter-kit/tokens/motion.ts +90 -0
  155. package/starter-kit/tokens/palette.ts +111 -0
  156. package/starter-kit/tokens/scales.ts +106 -0
  157. package/starter-kit/tokens/semantic.ts +98 -0
  158. package/starter-kit/tokens/shadow.ts +36 -0
  159. package/starter-kit/tokens/typography.ts +72 -0
package/dist/cli/main.js CHANGED
@@ -1,14 +1,113 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { resolveConfig } from "./config.js";
4
- import { initExistingProject } from "./init.js";
6
+ import { initExistingProject, initNewProject, promptInitMode } from "./init.js";
5
7
  import { dev } from "./dev.js";
6
8
  import { sync } from "./sync.js";
9
+ const USAGE = `Usage: ds-viewer <command>
10
+
11
+ Commands:
12
+ init Set up the viewer in this project: config, catalog pages, and an npm script
13
+ --new start from the starter kit (new project)
14
+ --existing detect this project's own components/tokens (existing project)
15
+ --kit-root DIR where to copy the starter kit (--new only; default src/ds)
16
+ --yes accept the detected components and tokens without asking (--existing only)
17
+ sync Regenerate the component, props, token, and page data from source
18
+ dev Sync, then serve the catalog on a local web address and keep it current as files change
19
+
20
+ Options:
21
+ -h, --help Show this help
22
+ -v, --version Show the installed version`;
23
+ /** Walks up from this file (cli/ in source, dist/cli/ when installed) to the package's own package.json. */
24
+ function packageVersion() {
25
+ let dir = path.dirname(fileURLToPath(import.meta.url));
26
+ while (true) {
27
+ const candidate = path.join(dir, 'package.json');
28
+ if (existsSync(candidate)) {
29
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
30
+ if (pkg.name === '@krapwoo/ds-viewer' && pkg.version)
31
+ return pkg.version;
32
+ }
33
+ const parent = path.dirname(dir);
34
+ if (parent === dir)
35
+ return 'unknown';
36
+ dir = parent;
37
+ }
38
+ }
39
+ /** `--kit-root`'s raw CLI value, normalized to a project-root-relative, forward-slash path — or an
40
+ * error when the value can't be made into one. Without this: an absolute value was silently
41
+ * relocated *under* the project root by `path.join` (which never does `path.resolve`'s "a later
42
+ * absolute segment replaces everything before it" — it just concatenates), a trailing slash
43
+ * produced a doubled-slash glob (`src/ds//components/*`), and a quote broke the generated
44
+ * config's TypeScript string literal (Minor finding, Fable's implementation review,
45
+ * cli/main.ts:59-60 & cli/init.ts:231-233,257). */
46
+ export function parseKitRoot(projectRoot, rawValue) {
47
+ if (rawValue.includes("'") || rawValue.includes('"')) {
48
+ return { error: `--kit-root cannot contain a quote (got "${rawValue}")` };
49
+ }
50
+ const relative = path.relative(projectRoot, path.resolve(projectRoot, rawValue));
51
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
52
+ return { error: `--kit-root must stay inside the project root (got "${rawValue}")` };
53
+ }
54
+ return { value: relative.split(path.sep).join('/') };
55
+ }
56
+ /** Parses `init`'s flags and dispatches to `initNewProject`/`initExistingProject` — pulled out of
57
+ * `main` so the dispatch (which flags pick which project path, `--yes`'s 0.1-compatible default,
58
+ * `--kit-root`'s normalization) is unit-testable with an injected installer, never a real `expo
59
+ * install` (Minor finding, Fable's implementation review: "No CLI test covers `--yes` defaulting
60
+ * to existing, `--kit-root` parsing, or `--new` dispatch"). */
61
+ export async function runInit(projectRoot, rest, options = {}) {
62
+ const kitRootIndex = rest.indexOf('--kit-root');
63
+ // Validated, not just indexed: a bare trailing `--kit-root` (no value) or `--kit-root --yes`
64
+ // (the next token is itself a flag) would otherwise silently pass `undefined`/`'--yes'` as the
65
+ // kit root. Both are treated as "no value given", the same as omitting the flag entirely.
66
+ const kitRootValue = kitRootIndex !== -1 ? rest[kitRootIndex + 1] : undefined;
67
+ const kitRootRaw = kitRootValue && !kitRootValue.startsWith('--') ? kitRootValue : undefined;
68
+ let kitRoot;
69
+ if (kitRootRaw !== undefined) {
70
+ const parsed = parseKitRoot(projectRoot, kitRootRaw);
71
+ if (parsed.error)
72
+ return { messages: [parsed.error], written: [], exitCode: 1 };
73
+ kitRoot = parsed.value;
74
+ }
75
+ let mode = rest.includes('--new') ? 'new' : rest.includes('--existing') ? 'existing' : undefined;
76
+ // 0.1 had only one `init` path (today's `initExistingProject`) and no mode question at all —
77
+ // `--yes` alone must keep working exactly as it did for a 0.1 user or script, so `--yes` with
78
+ // neither `--new` nor `--existing` defaults to `existing`, the same path 0.1's `--yes` always
79
+ // ran, instead of prompting (which would hang/exit 1 on non-interactive stdin and break them).
80
+ if (!mode && rest.includes('--yes'))
81
+ mode = 'existing';
82
+ if (!mode)
83
+ mode = await (options.promptMode ?? promptInitMode)();
84
+ if (!mode) {
85
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY);
86
+ // A non-interactive stdin (CI, piped npx) never got to ask the question at all — but a TTY
87
+ // whose answer `promptInitMode` simply couldn't parse did ask, so it needs its own message,
88
+ // not the "non-interactive" one (Minor finding, Fable's implementation review).
89
+ const message = isTTY
90
+ ? 'Could not parse that answer. Specify --new or --existing directly.'
91
+ : 'Specify --new or --existing (non-interactive stdin cannot be asked).';
92
+ return { messages: [`${message}\n\n${USAGE}`], written: [], exitCode: 1 };
93
+ }
94
+ return mode === 'new'
95
+ ? initNewProject(projectRoot, { kitRoot, installer: options.installer })
96
+ : initExistingProject(projectRoot, { yes: rest.includes('--yes'), confirm: options.confirm });
97
+ }
7
98
  async function main() {
8
99
  const [command, ...rest] = process.argv.slice(2);
9
100
  const projectRoot = process.cwd();
101
+ if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
102
+ console.log(USAGE);
103
+ return;
104
+ }
105
+ if (command === '--version' || command === '-v') {
106
+ console.log(packageVersion());
107
+ return;
108
+ }
10
109
  if (command === 'init') {
11
- const result = await initExistingProject(projectRoot, { yes: rest.includes('--yes') });
110
+ const result = await runInit(projectRoot, rest);
12
111
  for (const message of result.messages)
13
112
  console.log(message);
14
113
  for (const file of result.written)
@@ -28,7 +127,7 @@ async function main() {
28
127
  await dev(config);
29
128
  return;
30
129
  }
31
- console.error(`Unknown command "${command ?? ''}". Usage: ds-viewer <init|dev|sync>`);
130
+ console.error(`Unknown command "${command}".\n\n${USAGE}`);
32
131
  process.exitCode = 1;
33
132
  }
34
133
  main().catch((error) => {
package/dist/cli/props.js CHANGED
@@ -52,7 +52,7 @@ export function createComponentReader(entryFiles, options = {}) {
52
52
  allowJs: false,
53
53
  noEmit: true,
54
54
  baseUrl: host.baseUrl,
55
- paths: { ...host.paths, ...options.paths },
55
+ paths: { ...options.fallbackPaths, ...host.paths, ...options.paths },
56
56
  // `pathsBasePath` is @internal: it tells the compiler which folder `paths` are relative to when
57
57
  // the host tsconfig has `paths` but no `baseUrl` (the TypeScript 6 idiom).
58
58
  ...(host.pathsBasePath ? { pathsBasePath: host.pathsBasePath } : {}),
@@ -156,33 +156,23 @@ function readComponentRecord(checker, name, type, declaration, optionRoots) {
156
156
  const propsType = signature && signature.parameters.length > 0 ? checker.getTypeOfSymbolAtLocation(signature.parameters[0], declaration) : undefined;
157
157
  const implementationDefaults = defaultsFromImplementation(declaration);
158
158
  const props = [];
159
- const inheritedFrom = new Set();
160
159
  if (propsType) {
161
160
  const branches = propsType.isUnion() ? propsType.types : [propsType];
162
161
  for (const merged of mergeUnionProps(checker, branches)) {
163
162
  const { symbol: prop, declaration: propDeclaration, branchCount } = merged;
164
163
  const propFile = propDeclaration?.getSourceFile().fileName ?? '';
165
- if (propFile.includes('node_modules')) {
166
- // `@types/react` itself (e.g. `ref` from `RefAttributes` on a `forwardRef` component) is
167
- // not a meaningful "inherited from" fact — every component already uses React — so it's
168
- // dropped rather than reported as "plus all react props". A host-type package's own props
169
- // (e.g. react-native's `TextInput`) are still reported as before.
170
- if (!/[/\\]@types[/\\]react[/\\]/.test(propFile) || /[/\\]@types[/\\]react-native[/\\]/.test(propFile)) {
171
- inheritedFrom.add(path.basename(path.dirname(propFile)));
172
- }
164
+ // A prop declared in `node_modules` (e.g. `value` from react-native's `TextInputProps`) is
165
+ // never listed individually — `inheritedFrom` below (design §3) summarises the whole
166
+ // extended type as one row instead.
167
+ if (propFile.includes('node_modules'))
173
168
  continue;
174
- }
175
169
  const propType = propDeclaration ? checker.getTypeOfSymbolAtLocation(prop, propDeclaration) : checker.getAnyType();
176
- // Under `strict`, an optional prop's type includes `| undefined` (that's how TypeScript
177
- // represents `label?: string` when read via a symbol) — print its non-nullable type instead,
178
- // keeping any alias name, so the Props table reads "string" rather than "string | undefined".
179
- const displayType = prop.flags & ts.SymbolFlags.Optional ? checker.getNonNullableType(propType) : propType;
180
170
  const jsDocDefault = prop.getJsDocTags().find((tag) => tag.name === 'default');
181
- const baseDesc = ts.displayPartsToString(prop.getDocumentationComment(checker));
171
+ const baseDesc = joinWrappedLines(ts.displayPartsToString(prop.getDocumentationComment(checker)));
182
172
  const isPartial = branchCount < branches.length;
183
173
  props.push({
184
174
  name: prop.getName(),
185
- type: checker.typeToString(displayType, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope),
175
+ type: displayTypeString(checker, prop, propType),
186
176
  // A prop present in only some branches is never required overall — the caller can always
187
177
  // pick a branch that omits it — regardless of whether it's required within the branch(es)
188
178
  // it does appear in (that's `merged.requiredInEvery`, checked only when `!isPartial`).
@@ -193,7 +183,165 @@ function readComponentRecord(checker, name, type, declaration, optionRoots) {
193
183
  });
194
184
  }
195
185
  }
196
- return { name, file: path.relative(process.cwd(), sourceFile.fileName), props, inheritedFrom: [...inheritedFrom] };
186
+ const inheritedFrom = propsType ? nodeModulesHeritageNames(checker, propsType.isUnion() ? propsType.types : [propsType]) : [];
187
+ return { name, file: path.relative(process.cwd(), sourceFile.fileName), props, inheritedFrom };
188
+ }
189
+ /** Design §3 — "Type column: keeps alias names, not expanded unions." An optional prop's type
190
+ * (as read via its symbol) includes `undefined` — printed as `"T | undefined"` when `T` has no
191
+ * alias of its own (e.g. `label?: string`), but when `T` is itself an alias whose own definition
192
+ * already includes `undefined` (react-native's `StyleProp<T>`, React's `ReactNode`), the type as
193
+ * declared is simply `T` with no added union member, and `checker.typeToString` already prints
194
+ * just the alias name (e.g. `"StyleProp<ViewStyle>"`) — no `| undefined` to strip. Dropping a
195
+ * trailing/leading `undefined` segment as *text*, rather than calling `checker.getNonNullableType`
196
+ * on the type and re-printing that, is what keeps the alias in both cases: `getNonNullableType`
197
+ * rebuilds a fresh, unaliased union once it has to remove a member nested inside an alias's own
198
+ * definition (`StyleProp`/`ReactNode`), even though the printed string never needed that member
199
+ * removed in the first place. */
200
+ function displayTypeString(checker, prop, propType) {
201
+ const printed = checker.typeToString(propType, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
202
+ if (!(prop.flags & ts.SymbolFlags.Optional))
203
+ return printed;
204
+ const kept = splitTopLevelUnion(printed).filter((part) => part !== 'undefined' && part !== 'null');
205
+ if (kept.length === 0)
206
+ return printed;
207
+ // A single remaining part that is itself wrapped in one outer pair of parentheses (e.g. the
208
+ // `(() => void)` TypeScript prints around an arrow type that was a union member) no longer needs
209
+ // those parentheses once it is the only part left — `(t: string) => void`, not `((t: string) =>
210
+ // void)`.
211
+ if (kept.length === 1)
212
+ return unwrapOuterParens(kept[0]);
213
+ return kept.join(' | ');
214
+ }
215
+ /** Splits a printed type's top-level ` | ` union members — skipping any `|` nested inside
216
+ * `<...>`, `(...)`, `{...}`, or `[...]` (e.g. the one inside `RecursiveArray<Falsy | ViewStyle>`)
217
+ * — so `displayTypeString` can drop a top-level `undefined`/`null` member without disturbing a
218
+ * union that appears only as part of a deeper type argument. An arrow type's `=>` prints a `>`
219
+ * that is not a closing bracket, so it must not decrement `depth`: skip it whenever the previous
220
+ * character was `=` (otherwise `(() => void) | undefined` goes to depth -1 at the arrow and the
221
+ * top-level ` | ` before `undefined` is read as nested, never splitting it off). */
222
+ function splitTopLevelUnion(typeString) {
223
+ const parts = [];
224
+ let depth = 0;
225
+ let start = 0;
226
+ for (let i = 0; i < typeString.length; i++) {
227
+ const ch = typeString[i];
228
+ if (ch === '<' || ch === '(' || ch === '{' || ch === '[')
229
+ depth += 1;
230
+ else if (ch === '>' || ch === ')' || ch === '}' || ch === ']') {
231
+ if (ch === '>' && typeString[i - 1] === '=')
232
+ continue;
233
+ depth -= 1;
234
+ }
235
+ else if (depth === 0 && typeString.startsWith(' | ', i)) {
236
+ parts.push(typeString.slice(start, i));
237
+ start = i + 3;
238
+ }
239
+ }
240
+ parts.push(typeString.slice(start));
241
+ return parts;
242
+ }
243
+ /** Strips one outer `(...)` pair from `part` when it wraps the whole string — not just any
244
+ * leading `(`, e.g. `(a) => (b)` has balanced outer parens only around `a`, not around the whole
245
+ * expression, so this walks depth to confirm the first `(` closes at the very last character. */
246
+ function unwrapOuterParens(part) {
247
+ if (part[0] !== '(' || part[part.length - 1] !== ')')
248
+ return part;
249
+ let depth = 0;
250
+ for (let i = 0; i < part.length; i++) {
251
+ const ch = part[i];
252
+ if (ch === '(')
253
+ depth += 1;
254
+ else if (ch === ')') {
255
+ depth -= 1;
256
+ if (depth === 0)
257
+ return i === part.length - 1 ? part.slice(1, -1) : part;
258
+ }
259
+ }
260
+ return part;
261
+ }
262
+ /** Design §3 — "Inherited props declared in `node_modules` ... are summarised as one row: 'plus
263
+ * all TextInput props'." Reads each branch's own `extends` heritage clause(s) — not the flattened
264
+ * property list `mergeUnionProps` builds for the table above — because a `node_modules` interface
265
+ * like react-native's `TextInputProps` itself extends several more `node_modules` interfaces
266
+ * (`ViewProps`, `TouchableWithoutFeedbackProps`, ...), each declared in its own file; grouping by
267
+ * every inherited *property*'s own declaration file (the previous approach) reported one noisy
268
+ * entry per ancestor file instead of the one name the component's own Props type actually names.
269
+ * A `forwardRef` component's props type is `OwnProps & RefAttributes<T>` (an intersection, not an
270
+ * interface) — `heritageInterfaceDeclarations` unwraps that to reach `OwnProps`'s own declaration. */
271
+ function nodeModulesHeritageNames(checker, branches) {
272
+ const names = new Set();
273
+ for (const branch of branches) {
274
+ for (const declaration of heritageInterfaceDeclarations(branch)) {
275
+ const declarationFile = declaration.getSourceFile().fileName;
276
+ if (declarationFile.includes('node_modules')) {
277
+ // `declaration` was pulled in directly by the branch itself (e.g. `type Props =
278
+ // TextInputProps & {...}`), not named in a *local* interface's own `extends` clause —
279
+ // report its own name. Walking its `heritageClauses` below (as the `extends
280
+ // TextInputProps` case does) would instead report TextInputProps's own ancestors, e.g.
281
+ // "View"/"TouchableWithoutFeedback".
282
+ if (!isReactOnlyFile(declarationFile))
283
+ names.add(declaration.name.getText().replace(/Props$/, ''));
284
+ continue;
285
+ }
286
+ for (const clause of declaration.heritageClauses ?? []) {
287
+ for (const typeNode of clause.types) {
288
+ // `getTypeAtLocation` on the whole `ExpressionWithTypeArguments` node (not just its
289
+ // `.expression`) is required for `resolveUtilityTypeSource` below to see `Omit`/`Pick`'s
290
+ // actual instantiated type arguments — on `.expression` alone they come back as the
291
+ // utility type's own unbound generic parameters ("T", "K"), not `TextInputProps`.
292
+ const heritageType = resolveUtilityTypeSource(checker.getTypeAtLocation(typeNode));
293
+ const heritageDeclaration = (heritageType.aliasSymbol ?? heritageType.symbol)?.declarations?.[0];
294
+ if (!heritageDeclaration)
295
+ continue;
296
+ const file = heritageDeclaration.getSourceFile().fileName;
297
+ if (!file.includes('node_modules'))
298
+ continue;
299
+ // `@types/react` itself (e.g. `Attributes`, reached through a `forwardRef` component's
300
+ // own `RefAttributes<T>`) is not a meaningful "inherited from" fact — every component
301
+ // already uses React. A host-type package's own heritage (e.g. react-native's `View`,
302
+ // reached through `TextInputProps`) is still reported as before.
303
+ if (isReactOnlyFile(file))
304
+ continue;
305
+ const rawName = (heritageType.aliasSymbol ?? heritageType.symbol)?.getName() ?? typeNode.expression.getText();
306
+ names.add(rawName.replace(/Props$/, ''));
307
+ }
308
+ }
309
+ }
310
+ }
311
+ return [...names];
312
+ }
313
+ /** True for a declaration file under `@types/react` that is not also under `@types/react-native`
314
+ * (react-native's own `@types` package path contains `react` as a substring). */
315
+ function isReactOnlyFile(file) {
316
+ return /[/\\]@types[/\\]react[/\\]/.test(file) && !/[/\\]@types[/\\]react-native[/\\]/.test(file);
317
+ }
318
+ /** `Omit<T, K>`/`Pick<T, K>` of a node_modules type prints its own alias symbol ("Omit"/"Pick")
319
+ * rather than `T`'s name — resolve to `T` (the type's first alias type argument) instead, so
320
+ * `extends Omit<TextInputProps, 'value'>` reports "TextInput", not "Omit" (Fable's review,
321
+ * cli/props.ts:270-301). */
322
+ function resolveUtilityTypeSource(type) {
323
+ const aliasName = type.aliasSymbol?.getName();
324
+ if ((aliasName === 'Omit' || aliasName === 'Pick') && type.aliasTypeArguments?.[0]) {
325
+ return type.aliasTypeArguments[0];
326
+ }
327
+ return type;
328
+ }
329
+ /** Every `InterfaceDeclaration` backing `type` — itself if `type` is one, or (recursing) each
330
+ * constituent of an intersection, so a `forwardRef` component's `OwnProps & RefAttributes<T>`
331
+ * still reaches `OwnProps`'s own heritage clause. */
332
+ function heritageInterfaceDeclarations(type) {
333
+ if (type.isIntersection())
334
+ return type.types.flatMap(heritageInterfaceDeclarations);
335
+ return (type.symbol?.declarations ?? []).filter(ts.isInterfaceDeclaration);
336
+ }
337
+ /** Design §3, finding 4: a JSDoc paragraph hard-wrapped in source (`getDocumentationComment`
338
+ * preserves each line break verbatim) must read as running text — join lines within a paragraph
339
+ * with a single space, but keep a blank-line paragraph break as its own line break. */
340
+ function joinWrappedLines(text) {
341
+ return text
342
+ .split(/\n{2,}/)
343
+ .map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim())
344
+ .join('\n\n');
197
345
  }
198
346
  /**
199
347
  * Design §3 — "Unions of object types (e.g. `CardProps` as two intersections) are merged into one
package/dist/cli/sync.js CHANGED
@@ -33,7 +33,23 @@ export function sync(config, resolveOptions) {
33
33
  const components = [];
34
34
  const componentRefs = [];
35
35
  const seenNames = new Set();
36
- const readComponent = createComponentReader(componentEntryFiles, { ...resolveOptions, optionRoots });
36
+ // A component entry file can live outside `config.projectRoot` (e.g. kit-host's sibling
37
+ // `../starter-kit/`) — `createComponentReader`'s own host-tsconfig lookup walks up from the
38
+ // entry file, never reaching `config.projectRoot`'s own node_modules. This fallback (lowest
39
+ // priority — a real host `paths`, or `resolveOptions.paths`, still wins) is what lets such a
40
+ // file still resolve `react`/`react-native` against the project that actually declares them.
41
+ // `@types/*` is listed BEFORE the plain package deliberately (confirmed by spike,
42
+ // `spikes/README.md` "sync-fallback-paths"): once `paths` redirects a bare specifier to an
43
+ // on-disk folder, TypeScript resolves it there and never falls through to a second entry just
44
+ // because the first has no types — the reverse order left `react`/`react-native` both
45
+ // resolving to "implicitly any", because the plain package folder matched first. Only computed
46
+ // when at least one entry file actually lies outside `config.projectRoot` — normal projects
47
+ // (every entry file under projectRoot) keep standard TypeScript resolution untouched.
48
+ const hasEntryOutsideProjectRoot = componentEntryFiles.some((file) => !file.startsWith(`${config.projectRoot}${path.sep}`));
49
+ const fallbackPaths = hasEntryOutsideProjectRoot
50
+ ? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
51
+ : undefined;
52
+ const readComponent = createComponentReader(componentEntryFiles, { ...resolveOptions, optionRoots, fallbackPaths });
37
53
  for (const entryFile of componentEntryFiles) {
38
54
  try {
39
55
  for (const record of readComponent(entryFile)) {
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- const METRO_CONFIG = `const path = require('path');
3
+ import { globBaseFolder } from "./glob.js";
4
+ function metroConfigJs(extraWatchFolders) {
5
+ return `const path = require('path');
4
6
  const fs = require('fs');
5
7
  const workspace = __dirname;
6
8
  const projectRoot = path.resolve(workspace, '..');
@@ -16,7 +18,18 @@ if (typeof config !== 'object') {
16
18
  config.projectRoot = workspace;
17
19
  // Expo sets the server root to the folder the config was built for; bundle URLs must resolve from the workspace.
18
20
  config.server = { ...(config.server || {}), unstable_serverRoot: workspace };
19
- config.watchFolders = Array.from(new Set([...(config.watchFolders || []), projectRoot]));
21
+ // Every configured components/tokens/pages glob's base folder — not just projectRoot itself — so a
22
+ // host whose kit lives in a sibling folder (e.g. kit-host/ds-viewer.config.ts reaching
23
+ // ../starter-kit/components) still gets its files watched and bundled. Resolved from projectRoot at
24
+ // runtime, not baked in as an absolute path, so the generated file stays portable across machines.
25
+ const EXTRA_WATCH_FOLDERS = ${JSON.stringify(extraWatchFolders)};
26
+ const extraWatchFolders = EXTRA_WATCH_FOLDERS;
27
+ // A configured components/tokens/pages glob can point at a folder that doesn't exist yet (e.g. a
28
+ // standalone-pages folder before its first file is added) — Metro refuses to watch a nonexistent
29
+ // folder, which otherwise makes every single bundle request fail with HTTP 500. Filtered here, at
30
+ // workspace-generation time, not above at config-resolution time, because a folder created after
31
+ // \`dev\` starts should start being watched on its own next run, same as every other glob folder.
32
+ config.watchFolders = Array.from(new Set([...(config.watchFolders || []), projectRoot, ...extraWatchFolders.map((f) => path.resolve(projectRoot, f)).filter((f) => fs.existsSync(f))]));
20
33
  config.resolver.nodeModulesPaths = [path.join(projectRoot, 'node_modules')];
21
34
  // A standalone page can read generated data with \`import { components, tokens } from '@krapwoo/ds-viewer/generated'\`
22
35
  // (design §3) — redirect that one specifier to this workspace's own generated/index.ts.
@@ -31,17 +44,28 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
31
44
  };
32
45
  module.exports = config;
33
46
  `;
47
+ }
34
48
  /** Decided here, not inside the viewer: Metro resolves `react-native-safe-area-context` statically
35
49
  * at bundle time, so a runtime try/catch `require` cannot skip it when the optional peer is
36
50
  * missing. Importing and wrapping must instead be baked into the generated entry (design §1
37
51
  * "Three parts", Viewer row). */
38
- function entryTsx(name, hasSafeArea, groupOrder) {
52
+ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath) {
39
53
  const safeAreaImport = hasSafeArea ? "import { SafeAreaProvider } from 'react-native-safe-area-context';\n" : '';
40
- const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections} />`;
54
+ // A literal `require(...)` with a path computed at generation time: Metro resolves every import
55
+ // statically, so the logo's actual (arbitrary, user-configured) location must already be baked
56
+ // into this generated file, the same reasoning `hasSafeArea` above already follows for the
57
+ // optional SafeAreaProvider import.
58
+ // `JSON.stringify`, not a plain single-quoted template literal: `logoRequirePath` carries the
59
+ // user's own logo file name, which may contain a `'` (e.g. `assets/it's.png`) — a bare
60
+ // `'${logoRequirePath}'` breaks the generated file's syntax the moment it does (Minor finding,
61
+ // Fable's implementation review, cli/workspace.ts:65).
62
+ const logoImport = logoRequirePath ? `const logoSource = require(${JSON.stringify(logoRequirePath)});\n` : '';
63
+ const logoProp = logoRequirePath ? ' logoImageSource={logoSource}' : '';
64
+ const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} />`;
41
65
  const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
42
66
  return `import { registerRootComponent } from 'expo';
43
67
  import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
44
- ${safeAreaImport}import pages from './generated/pages';
68
+ ${safeAreaImport}${logoImport}import pages from './generated/pages';
45
69
  import components from './generated/components.json';
46
70
 
47
71
  function App() {
@@ -52,25 +76,22 @@ function App() {
52
76
  registerRootComponent(App);
53
77
  `;
54
78
  }
55
- /** The folder a glob pattern varies under — the part of the pattern before its first wildcard
56
- * segment (or, for a pattern with no wildcard, its own containing folder). Same logic as
57
- * `cli/sync.ts`'s `globBaseFolder` (Task 10), kept local here since it's one line and this module
58
- * has no other reason to depend on `sync.ts`. */
59
- function globBaseFolder(pattern) {
60
- const segments = pattern.split('/');
61
- const wildcardIndex = segments.findIndex((segment) => segment.includes('*'));
62
- const baseSegments = wildcardIndex === -1 ? segments.slice(0, -1) : segments.slice(0, wildcardIndex);
63
- return baseSegments.join('/') || '.';
79
+ /** Every configured components/tokens/pages glob's base folder, deduplicated and sorted — shared by
80
+ * `projectIncludeGlobs` (tsconfig `include`, below) and `metroConfigJs`'s extra watch folders
81
+ * (above). Kept as the raw relative folder (e.g. `"../starter-kit/components"`), not yet turned
82
+ * into either a tsconfig glob or an absolute path — each caller does that its own way. */
83
+ function configGlobFolders(config) {
84
+ const folders = new Set();
85
+ for (const pattern of [...config.components, ...(config.tokens ?? []), ...(config.pages ?? [])]) {
86
+ folders.add(globBaseFolder(pattern));
87
+ }
88
+ return [...folders].sort();
64
89
  }
65
90
  /** Folders the workspace's own `tsconfig.json` needs to type-check — derived from the config's own
66
91
  * globs (the spike used `../src/**\/*`), instead of `../**\/*`, which would pull in the whole
67
92
  * project, including `node_modules`. */
68
93
  function projectIncludeGlobs(config) {
69
- const folders = new Set();
70
- for (const pattern of [...config.components, ...(config.tokens ?? []), ...(config.pages ?? [])]) {
71
- folders.add(globBaseFolder(pattern));
72
- }
73
- return [...folders].sort().map((folder) => `../${folder}/**/*`);
94
+ return configGlobFolders(config).map((folder) => `../${folder}/**/*`);
74
95
  }
75
96
  /** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
76
97
  * workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
@@ -88,12 +109,19 @@ export function writeWorkspace(config) {
88
109
  if (existsSync(projectTsconfig))
89
110
  workspaceTsconfig.extends = '../tsconfig.json';
90
111
  writeFileSync(path.join(workspace, 'tsconfig.json'), `${JSON.stringify(workspaceTsconfig, null, 2)}\n`);
91
- writeFileSync(path.join(workspace, 'metro.config.js'), METRO_CONFIG);
112
+ writeFileSync(path.join(workspace, 'metro.config.js'), metroConfigJs(configGlobFolders(config)));
92
113
  const projectBabelConfig = path.join(config.projectRoot, 'babel.config.js');
93
114
  if (existsSync(projectBabelConfig)) {
94
115
  writeFileSync(path.join(workspace, 'babel.config.js'), "module.exports = require('../babel.config.js');\n");
95
116
  }
96
117
  const hasSafeArea = existsSync(path.join(config.projectRoot, 'node_modules', 'react-native-safe-area-context', 'package.json'));
97
- writeFileSync(path.join(workspace, 'entry.tsx'), entryTsx(config.name, hasSafeArea, config.groupOrder ?? []));
118
+ // `config.logo` is already validated by `cli/config.ts`'s `resolveConfig` (Step 3 above) — by the
119
+ // time `writeWorkspace` sees it, it's either a real, existing file or undefined. Computed relative
120
+ // to `workspace` (one level deeper than `projectRoot`), with forward slashes so the generated
121
+ // `require()` string is portable across machines.
122
+ const logoRequirePath = config.logo
123
+ ? path.relative(workspace, path.resolve(config.projectRoot, config.logo)).split(path.sep).join('/')
124
+ : undefined;
125
+ writeFileSync(path.join(workspace, 'entry.tsx'), entryTsx(config.name, hasSafeArea, config.groupOrder ?? [], logoRequirePath));
98
126
  return workspace;
99
127
  }
@@ -1,5 +1,6 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { AccessibilityInfo, Platform, ScrollView, StyleSheet, Text, View, findNodeHandle } from 'react-native';
3
+ import type { ImageSourcePropType } from 'react-native';
3
4
  import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
5
  import { CatalogSidebar } from './CatalogSidebar';
5
6
  import { SectionBlock } from './SectionBlock';
@@ -43,6 +44,7 @@ function resetMainScroll(scrollView: ScrollView | null): void {
43
44
  */
44
45
  export function CatalogShell<TId extends string>({
45
46
  appName,
47
+ logoImageSource,
46
48
  title,
47
49
  groups,
48
50
  sections,
@@ -50,6 +52,9 @@ export function CatalogShell<TId extends string>({
50
52
  }: {
51
53
  /** Short product/app name — the sidebar logo and the breadcrumb root. */
52
54
  appName: string;
55
+ /** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
56
+ * config field. See `CatalogSidebar`'s own doc comment for the approved layout rule. */
57
+ logoImageSource?: ImageSourcePropType;
53
58
  /** What this catalog is (e.g. "Component Catalog") — the sidebar caption. */
54
59
  title: string;
55
60
  groups: NavGroup<TId>[];
@@ -122,7 +127,7 @@ export function CatalogShell<TId extends string>({
122
127
 
123
128
  return (
124
129
  <View style={styles.root}>
125
- <CatalogSidebar logo={appName} caption={title} groups={groups} active={active} onPress={select} />
130
+ <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} active={active} onPress={select} />
126
131
  <ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
127
132
  {activeDef ? (
128
133
  <SectionBlock