@krapwoo/ds-viewer 0.4.6 → 0.4.8

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 (65) hide show
  1. package/README.md +121 -21
  2. package/dist/cli/doctor.js +141 -2
  3. package/dist/cli/explain.js +44 -6
  4. package/dist/cli/init.js +5 -8
  5. package/dist/cli/props.js +80 -1
  6. package/dist/cli/staticPage.js +228 -6
  7. package/dist/native/catalog/comparison.js +43 -9
  8. package/native/catalog/BoundedOverlayViewport.tsx +112 -0
  9. package/native/catalog/CatalogShell.tsx +26 -0
  10. package/native/catalog/ComparisonGrid.tsx +6 -2
  11. package/native/catalog/ComparisonGroups.tsx +8 -2
  12. package/native/catalog/ComparisonList.tsx +8 -2
  13. package/native/catalog/PhoneFrame.tsx +109 -19
  14. package/native/catalog/ReferenceDetails.tsx +83 -20
  15. package/native/catalog/SectionBlock.tsx +75 -7
  16. package/native/catalog/SpecimenAddress.tsx +15 -0
  17. package/native/catalog/SpecimenSurface.tsx +80 -10
  18. package/native/catalog/comparison.ts +57 -10
  19. package/native/catalog/deviceFrame.ts +14 -0
  20. package/native/catalog/index.ts +4 -0
  21. package/native/catalog/overlayViewport.ts +158 -0
  22. package/native/catalog/pageApi.ts +6 -0
  23. package/native/catalog/specimenSurfaceStyle.ts +124 -1
  24. package/native/catalog/types.ts +42 -12
  25. package/package.json +5 -4
  26. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
  27. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
  28. package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
  29. package/starter-kit/components/Banner/Banner.catalog.tsx +5 -1
  30. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
  31. package/starter-kit/components/Button/Button.catalog.tsx +4 -1
  32. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
  33. package/starter-kit/components/Card/Card.catalog.tsx +2 -1
  34. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
  35. package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
  36. package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
  37. package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
  38. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +7 -1
  39. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +7 -1
  40. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
  41. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
  42. package/starter-kit/components/InputField/InputField.catalog.tsx +6 -1
  43. package/starter-kit/components/List/List.catalog.tsx +5 -1
  44. package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
  45. package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
  46. package/starter-kit/components/Pill/Pill.catalog.tsx +4 -1
  47. package/starter-kit/components/PillRow/PillRow.catalog.tsx +4 -1
  48. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
  49. package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
  50. package/starter-kit/components/SearchField/SearchField.catalog.tsx +6 -1
  51. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +5 -1
  52. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
  53. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
  54. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
  55. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
  56. package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
  57. package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
  58. package/starter-kit/components/TextArea/TextArea.catalog.tsx +5 -1
  59. package/starter-kit/components/Toast/Toast.catalog.tsx +9 -1
  60. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
  61. package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
  62. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
  63. package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
  64. package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
  65. package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
package/README.md CHANGED
@@ -92,9 +92,15 @@ npm run kit:dev # builds the CLI, then opens the starter kit's own cata
92
92
  npm run check:catalog # the same headless check CI runs — every page, zero console errors
93
93
  ```
94
94
 
95
- `kit-host/` also hosts `viewer-pages/` — standalone catalog pages (not shipped in the package)
96
- documenting the viewer framework's own pieces (`CatalogShell`, `SectionBlock`, `ComparisonGrid`,
97
- …), shown under a "Viewer" group alongside the starter kit's own pages.
95
+ The viewer's own building blocks (`CatalogShell`, `SectionBlock`, `ComparisonGrid`, the catalog's
96
+ own colours and type, …) are documented in a separate catalog for people working on ds-viewer
97
+ itself, so the starter kit's catalog shows only what a project using the kit gets:
98
+
99
+ ```bash
100
+ npm run viewer:dev # opens viewer-host/, the DS Viewer internals catalog (not shipped)
101
+ ```
102
+
103
+ `check:catalog`, `check:doctor` and `check:types` cover both catalogs.
98
104
 
99
105
  ## Contributing
100
106
 
@@ -154,7 +160,13 @@ work. Desktop and laptop screens only. `SectionBlock` renders one page: breadcru
154
160
  description, previous/next, then the specimens, then always-visible
155
161
  **Guidance** and **Quick reference** (source path, accessibility), then **Props** in their own box
156
162
  (two columns, filled across first, when there are 4+ props and room). Token pages show only
157
- Quick reference with the source path. The specimens use one of four layouts:
163
+ Quick reference with the source path. Guidance, Quick reference, and — only for a page with
164
+ confirmed `composedOf` entries — Composition lay out from the reference card's own measured inner
165
+ width, not viewport width alone: three columns side by side only when there's confirmed composition
166
+ *and* the card is wide enough for all three (Composition gets its own column only then; otherwise
167
+ it stays nested under Quick reference, as it always has), two when there's room for Guidance and
168
+ Quick reference side by side, or one stacked column — in full document order, nothing dropped —
169
+ when the card is too narrow even for two. The specimens use one of four layouts:
158
170
  - **Grid** — two props that combine freely, from an explicit `comparison` (e.g. Button's
159
171
  Variant × State).
160
172
  - **Grouped rows** — one row per variant holding that variant's own configurations (e.g.
@@ -199,6 +211,17 @@ actually apply:
199
211
  cell gray, `'white'` or `'transparent'` keeps them all white, and `'dark'` checks a light
200
212
  component on a dark fill. Detection runs in the browser viewer; elsewhere cells stay white.
201
213
  Token galleries and sections are never filled.
214
+ - A `variants`/`states` item's own `fill`/`surface`/`align` — override the page's `specimenSurface`
215
+ (above), stretch just *this* item's wrapper to the row's full width, or change how its content
216
+ aligns inside that wrapper, for one instance that needs any of the three independently of its
217
+ siblings (e.g. a single `fullWidth` Button among otherwise compact, centered ones, or one
218
+ white-on-white example that needs its own gray backing even though the page's guess is right for
219
+ every other item). Omit any of them to inherit the slot's/page's own default: `fill` inherits the
220
+ slot's `itemsFill` (an explicit `fill: false` still overrides an inherited `itemsFill: true`), and
221
+ `align` defaults to centered, or to a stretched, full-width fill specimen's own content when
222
+ `fill` is set — a capped-width `fill` specimen that still wants centering (e.g. a phone-frame
223
+ preview) sets `align: 'center'` explicitly. The same `fill`/`surface`/`align` fields exist on each
224
+ authored `comparison` cell too, independently per cell, for exactly the same reasons.
202
225
  - `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
203
226
  ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
204
227
  more just because a laptop is wide). A narrow window still drops below the cap when it has to —
@@ -206,29 +229,89 @@ actually apply:
206
229
  - `group` on a `states` item — the `variants` key it belongs to (a size that only exists for one
207
230
  variant). Grouped states render one row per variant; ungrouped ones go to "Other configurations".
208
231
  - `composedOf: [{ component, role, relationship }]` — what this component is built from or
209
- composed with, shown as a "Composition" subsection in Quick reference. `relationship` is
210
- `'built-in'` for a component always present inside this one (e.g. Toast is built from a Banner),
211
- `'slot'` for an optional caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a
212
- component commonly used alongside this one without either containing the other. `component` should be
213
- a real component or page id: while you browse the catalog in development, an unknown name logs a
214
- `[Catalog]` warning in the browser console. `doctor` doesn't check it, and it never fails a
215
- build — e.g.
232
+ composed with, shown in Composition (its own column when space permits, otherwise under Quick reference). `relationship` is
233
+ `'built-in'` for a component that's part of this one's own implementation — always rendered, or
234
+ rendered only for some of its variants/states (e.g. Toast is built from a Banner),
235
+ `'slot'` for an optional caller-supplied child (e.g. a `leadingIcon`
236
+ prop), or `'related'` for a component commonly used alongside this one without either containing
237
+ the other. `component` should be a real component or page id: while you browse the catalog in
238
+ development, an unknown name logs a `[Catalog]` warning in the browser console. `doctor` doesn't
239
+ check it, and it never fails a build — e.g.
216
240
  `composedOf: [{ component: 'Icon', role: 'Leading glyph', relationship: 'slot' }]`.
241
+ - `BoundedOverlayViewport` — wrap one demo whose own modal/sheet/portal would otherwise escape its
242
+ card and cover the whole catalog page (React Native Web's `Modal` portals into `document.body`;
243
+ no wrapper or `overflow: hidden` gives it a different boundary). Opt in per item, never per page:
244
+ ```tsx
245
+ import { BoundedOverlayViewport, comparisonCellItemKey } from '@krapwoo/ds-viewer';
246
+
247
+ { key: 'full-height', name: 'Full height', node: (
248
+ <BoundedOverlayViewport pageId="Sheet" slot="states" itemKey="full-height" width={360} height={600}>
249
+ <MyOverlayDemo />
250
+ </BoundedOverlayViewport>
251
+ ) }
252
+ ```
253
+ Here `MyOverlayDemo` is your existing stateful wrapper around the real component, not a viewer
254
+ export. `itemKey` is that item's own `VariantExample.key` for a `variants`/`states` item, or
255
+ `comparisonCellItemKey(rowKey, columnKey)` (import it — never join the two keys yourself; a plain
256
+ `${rowKey}:${columnKey}` is ambiguous whenever either key already contains a colon) for a
257
+ `comparison` cell. `width` is only a maximum — the stage still shrinks to whatever its real
258
+ owning cell actually has at a narrower width — and `height` is the child document's exact usable
259
+ height, never renegotiated; both must be finite and strictly positive or the specimen shows an
260
+ honest "This example is unavailable right now." instead of an iframe sized to a meaningless
261
+ bound. Browser-only: it opens a same-origin child document that boots this catalog's own app
262
+ entry/module/provider tree, so the wrapped demo's overlay opens inside that document's own
263
+ viewport instead of the outer page's `document.body`; the two documents stay independently
264
+ owned, with no shared memory or provider identity between them. On a native runtime, and inside
265
+ the one child document that the address itself selects, `children` render inline with no
266
+ boundary — never read either case as native containment parity.
217
267
  - `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
218
- `CatalogShell`'s `defaultPreviewWidths="full"` keeps a catalog's previews full width.
268
+ Add a second width only when the component looks different there (labels truncate, a row
269
+ wraps); otherwise it's the same picture twice. Leave it out by default.
270
+ - **`PhoneFrame`** — wrap a demo that is a whole screen or opens an overlay (a sheet, a dialog, a
271
+ toast sliding in, a dropdown's picker) in `<PhoneFrame>`. It is a real device: the demo lays out
272
+ in an iPhone SE viewport (375 × 667 points), scaled down only when its cell is narrower. In the
273
+ browser viewer it opens that example in its own device-sized document, so overlays that portal
274
+ to the page (React Native's `Modal`) open inside the phone, not over the catalog.
275
+ - Full-width (`itemsFill`) variants and states wrap at most **3** across; set `maxColumns` to
276
+ change that. A filled example that keeps its own fixed width is centred in its cell.
277
+ Default to one preview; add a second width only when a narrower (or wider) viewport actually
278
+ changes something worth comparing — wrapping, truncation, or reflow — not just to show the same,
279
+ identically-laid-out content twice. `CatalogShell`'s `defaultPreviewWidths="full"` keeps a
280
+ catalog's previews full width.
281
+ - `previewLayout: 'table'` — opt in to one shared, bordered card for a `render()` page's widths,
282
+ instead of the default `'frames'` (one bordered frame per width, side by side; unchanged unless
283
+ you set this). Each width keeps its own caption and stays an independent, stateful live instance
284
+ either way — `'table'` only changes how they're grouped visually. Widths that don't fit the
285
+ available space side by side wrap to stacked columns, each still captioned, rather than scrolling
286
+ or shrinking below their requested size. Has no effect on a `'full'` preview width (catalog
287
+ chrome) or a token gallery — those always render as `'frames'`.
219
288
  - `variants: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per prop enum
220
289
  value (e.g. every `variant`). If the component has no `variant`-like prop at all, still include one
221
- item named `"Default"` showing its plain look — the Variants column should never be empty.
290
+ item named `"Default"` showing its plain look — a page that documents its component through a
291
+ `variants` list should never leave that column empty. This doesn't mean every page must use
292
+ `variants`: a page that reaches for `render()` instead (see below) has no Variants column to fill
293
+ in the first place.
222
294
  - `states: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per meaningfully
223
295
  distinct boolean state (`loading`, `disabled`, icon-only, …). Fine to omit if there are none.
224
296
 
225
297
  Every item's `name` is shown as its cell caption (e.g. `"Primary"`, `"Icon-only"`) — use the
226
298
  actual variant/state value, not a generic label. Omit `states` when there are none — the page simply
227
- shows no States block; don't invent items just to fill it. Set `itemsFill: true` on a slot whose
228
- items are wide, block-level components (Banner, Card, Toast, InputField) rather than small ones meant
229
- to sit centered (Button, Badge, Pill). Reach for `render()` instead of `variants` only when the
230
- content isn't a simple list of instances (a live demo with local state, a wrapping grid); its output
231
- renders in a Preview card (phone width for components). For a token-gallery section with no component API at all (raw token
299
+ shows no States block; don't invent items just to fill it. Avoid a static prop matrix (a `states`
300
+ item, or a `comparison` cell, for every combination) when two configurations only actually look
301
+ different mid-transition and render identically at rest, or when the "closed" side of a toggle is
302
+ just a redundant blank cell next to its "open" counterpart — show that behavior with one interactive
303
+ example instead, with its own working toggle, rather than several now-identical static ones. Set
304
+ `itemsFill: true` on a slot whose items are wide, block-level components (Banner, Card, Toast,
305
+ InputField) rather than small ones meant to sit centered (Button, Badge, Pill).
306
+
307
+ A `variants`/`states` item's `node` can be a plain instance, or a small stateful wrapper component
308
+ (its own `useState` and all) that renders the real component live — a toggle demo for a
309
+ `Collapsible`, say. That local state belongs to the item's own node, not to the page; it doesn't by
310
+ itself mean the page needs `render()`. Reach for `render()` instead of `variants`/`states` only for a
311
+ genuine custom composition that doesn't fit a list of independent instances at all — several demos
312
+ sharing one measured layout, a wrapping grid, a token gallery — not merely because an item has local
313
+ state. `render()`'s output renders in a Preview card (phone width for components). For a
314
+ token-gallery section with no component API at all (raw token
232
315
  data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGallery`), set
233
316
  `tokenGallery: true` instead of `props`/`a11y`/`states` — `SectionBlock` then renders `render()`'s
234
317
  output full width under a "Tokens" label and shows only a Quick reference card with the source
@@ -240,9 +323,9 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
240
323
  `Components`. A page's `group` is a plain string shown as its sidebar section label;
241
324
  `groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
242
325
  controls which order those sections appear in, top to bottom — any group not listed sorts
243
- alphabetically after the ones that are. This starter kit's own two groups (`Tokens`, then
244
- `Components`) are one valid shape, not the only one: a larger catalog might add `Patterns`
245
- (reusable combinations like a settings row), `Recipes` (worked end-to-end screens), or
326
+ alphabetically after the ones that are. The starter kit uses `Tokens`, then every
327
+ component under `Components`, then `Patterns` (whole screens built from the kit, such as
328
+ SavedTrips) and `Reference` (the generated manifest). A larger catalog might add `Recipes` or
246
329
  `Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
247
330
  many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
248
331
  "Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
@@ -363,6 +446,23 @@ Button
363
446
  - `--ci` exits 1 only when an error was found — a warning alone never fails CI, and `--ci` never makes a network call.
364
447
  - `doctor.strict: true` in `ds-viewer.config.ts` promotes every warning to an error, for both the summary counts and `--ci`'s exit code.
365
448
  - A page whose layout data (`specimenSize`, `group`, grid `rows`/`columns`, list items' `key`/`name`/`props`/`group`, `propNotes` keys) isn't a literal — written as something other than a literal value, array, object, or a same-file `const` reference — gets one "page not statically checkable" warning. A page file that fails to parse *or typecheck* is `page-parse-error` instead: every page file is also run through the TypeScript type checker (the same way a component file already is), so a genuine syntax or type error in a page is reported with its line.
449
+ - Three further rules are conservative source-backed *suggestions*, warnings by default
450
+ (the existing `doctor.strict` option still promotes them), never authoritative over what a page authors:
451
+ - `missing-composition-suggestion` — a component's own real JSX source evidence names another
452
+ known component its page's `composedOf` doesn't. The message states only that candidate's
453
+ `component`/`file`/`line`, never an invented role or relationship; add it to `composedOf`
454
+ yourself if it reflects real composition, or leave it as-is if it's incidental.
455
+ - `degenerate-grid-axis` — an authored `comparison` grid's row or column axis has exactly one
456
+ item, so it reads as a one-axis list rather than a real two-axis matrix. Never rewrites the
457
+ grid; an intentional single-value axis is left exactly as authored.
458
+ - `missing-working-preview` — a component has a controlled value/change pair (`value`/`onChange`,
459
+ or `x`/`onXChange`) but every example that renders it passes a fixed value with no working
460
+ callback. Add `intentionalStaticPreview: { reason: '...' }` to the page to suppress it when the
461
+ static examples are deliberate.
462
+ The analyzer follows supported aliases and resolved exports, but uncertain wrappers or
463
+ dynamically-constructed references remain uncheckable rather than being guessed at. A
464
+ declared `intentionalStaticPreview` is taken as the author's own call, not independently
465
+ verified; only a real browser check confirms actual behavior.
366
466
 
367
467
  ## Known gaps
368
468
 
@@ -145,6 +145,140 @@ function checkGridColumnLimit(pages) {
145
145
  }
146
146
  return issues;
147
147
  }
148
+ /** Guided intelligence design §2 / plan Task 3 — `degenerate-grid-axis`: warns (never errors) when
149
+ * a grid's row or column axis has exactly one item — it then reads as a one-axis list, not a real
150
+ * two-axis comparison. Deliberately its own, separate advisory from the existing hard grid-validity
151
+ * errors above (`grid-axis-key-invalid`, `grid-column-limit`): authors keep full authority to leave
152
+ * it as authored; this never silently rewrites the matrix and never promotes to an error. */
153
+ function checkDegenerateGridAxis(pages) {
154
+ const issues = [];
155
+ for (const page of pages) {
156
+ if (!page.checkable || !page.comparison)
157
+ continue; // never a partial read — see checkGridAxisKeys's own note.
158
+ const { rows, columns } = page.comparison;
159
+ for (const [axisName, axis] of [['row', rows], ['column', columns]]) {
160
+ if (axis.items.length === 1) {
161
+ issues.push(issue({
162
+ id: 'degenerate-grid-axis', severity: 'warning', ...pageRef(page),
163
+ message: `This grid's ${axisName} axis has only 1 item; it reads as a one-axis list, not a two-axis comparison.`,
164
+ fix: `If this axis genuinely only ever has one value, consider a Variants/States list instead; otherwise add more items to it.`,
165
+ }));
166
+ }
167
+ }
168
+ }
169
+ return issues;
170
+ }
171
+ /** Guided intelligence design §4 / plan Task 3 — `missing-composition-suggestion`: compares each
172
+ * documented component's optional, additive `composedOfCandidates` (`cli/props.ts`, real JSX
173
+ * source evidence) against the page's own authored `composedOf` names, and suggests — never
174
+ * confirms — a candidate the author hasn't named. Conservative by construction: a candidate's own
175
+ * `component`/`file`/`line` is all the message ever states, never an invented role or relationship
176
+ * (that stays the author's own call via `composedOf`). Never fires when `composedOf` was authored
177
+ * but isn't itself literally readable (`page.hasComposedOf && page.composedOf === undefined`) —
178
+ * the candidate might already be named there; this rule must never risk a false "missing" claim.
179
+ * Only suggests a name the viewer itself would accept in `composedOf` — a detected component or a
180
+ * page id (`buildCatalogSections`' own check); anything else (a plain helper such as a starter
181
+ * kit's `Icon`) would only trade this warning for an "unknown component" one. One issue per
182
+ * component per page, citing its first call site. */
183
+ function checkMissingComposition(pages, components) {
184
+ const componentsByName = new Map(components.map((c) => [c.name, c]));
185
+ const knownNames = new Set([...componentsByName.keys(), ...pages.map((p) => p.id).filter((id) => Boolean(id))]);
186
+ const issues = [];
187
+ for (const page of pages) {
188
+ if (!page.component || !page.checkable)
189
+ continue;
190
+ if (page.hasComposedOf && page.composedOf === undefined)
191
+ continue;
192
+ const component = componentsByName.get(page.component);
193
+ const candidates = component?.composedOfCandidates;
194
+ if (!candidates || candidates.length === 0)
195
+ continue;
196
+ const authored = new Set((page.composedOf ?? []).map((entry) => entry.component));
197
+ const seen = new Set();
198
+ for (const candidate of candidates) {
199
+ if (authored.has(candidate.component) || !knownNames.has(candidate.component) || seen.has(candidate.component))
200
+ continue;
201
+ seen.add(candidate.component);
202
+ issues.push(issue({
203
+ id: 'missing-composition-suggestion', severity: 'warning', ...pageRef(page),
204
+ message: `"${component.name}" renders "${candidate.component}" (${candidate.file}:${candidate.line}), which composedOf doesn't name.`,
205
+ fix: `Add { component: '${candidate.component}', ... } to composedOf with the role/relationship you choose, if this reflects real composition — or leave it if it's incidental.`,
206
+ }));
207
+ }
208
+ }
209
+ return issues;
210
+ }
211
+ /** A function-typed prop's printed type always contains `=>` (`cli/props.ts`'s own displayed type
212
+ * strings — "(value: string) => void", "() => void", …); a plain data prop's never does. */
213
+ function isFunctionPropType(type) {
214
+ return type.includes('=>');
215
+ }
216
+ /** Guided intelligence design §3 / plan Task 3 — finds a component's controlled state/event pair:
217
+ * a non-function prop `x` paired with a function-typed prop named `onXChange` (PascalCase), or the
218
+ * universal `value`/`onChange` pair. "Confidently identifiable" by naming convention alone — never
219
+ * a claim about actual behavior (that's `checkMissingWorkingPreview`'s own job, from the page's
220
+ * examples). Undefined when no such pair exists. */
221
+ function findControlledPair(component) {
222
+ for (const prop of component.props) {
223
+ if (isFunctionPropType(prop.type))
224
+ continue;
225
+ const expectedChangeProp = prop.name === 'value' ? 'onChange' : `on${prop.name.charAt(0).toUpperCase()}${prop.name.slice(1)}Change`;
226
+ const changeProp = component.props.find((p) => p.name === expectedChangeProp);
227
+ if (changeProp && isFunctionPropType(changeProp.type))
228
+ return { valueProp: prop.name, changeProp: changeProp.name };
229
+ }
230
+ return undefined;
231
+ }
232
+ /** Guided intelligence design §3 / plan Task 3 — `missing-working-preview`: recommends a real,
233
+ * stateful preview only when BOTH hold: the documented component has a controlled state/event pair
234
+ * (`findControlledPair`), AND every example that directly renders it (`nodeDirectComponent`)
235
+ * confidently demonstrates a fixed value with a no-op/omitted callback. Conservative by
236
+ * construction — never fires when:
237
+ * - the page already has its own `render()` (design's own escape hatch for live demos, never
238
+ * statically inspectable here);
239
+ * - the author declared `intentionalStaticPreview` with a reason (tolerated, not overridden);
240
+ * - any example is a confidently-identified genuine stateful wrapper (`nodeStatefulWrapper`) —
241
+ * positive evidence the page is already interactive;
242
+ * - no example directly renders the component at all, or any direct example's value/callback is
243
+ * `'unknown'` — genuinely uncheckable, and "uncheckable" must never be reported as "inert".
244
+ * Never a newly-fatal gate: always a warning, same as every other advisory in this file. */
245
+ function checkMissingWorkingPreview(pages, components) {
246
+ const componentsByName = new Map(components.map((c) => [c.name, c]));
247
+ const issues = [];
248
+ for (const page of pages) {
249
+ if (!page.component || !page.checkable || page.hasRender)
250
+ continue;
251
+ if (page.intentionalStaticPreviewReason)
252
+ continue;
253
+ const component = componentsByName.get(page.component);
254
+ if (!component)
255
+ continue;
256
+ const pair = findControlledPair(component);
257
+ if (!pair)
258
+ continue;
259
+ const items = [...(page.variantsItems ?? []), ...(page.statesItems ?? [])];
260
+ if (items.some((i) => i.nodeStatefulWrapper))
261
+ continue;
262
+ const directItems = items.filter((i) => i.nodeDirectComponent === component.name);
263
+ if (directItems.length === 0)
264
+ continue;
265
+ const allStaticInert = directItems.every((i) => {
266
+ const valueAttr = i.nodeAttributes?.[pair.valueProp];
267
+ const changeAttr = i.nodeAttributes?.[pair.changeProp];
268
+ return valueAttr?.kind === 'literal' && (changeAttr === undefined || changeAttr.kind === 'no-op-callback');
269
+ });
270
+ if (!allStaticInert)
271
+ continue;
272
+ issues.push(issue({
273
+ id: 'missing-working-preview', severity: 'warning', ...pageRef(page),
274
+ message: `"${component.name}" has a controlled "${pair.valueProp}"/"${pair.changeProp}" pair, but every example passes a fixed "${pair.valueProp}" ` +
275
+ 'with no working callback.',
276
+ fix: `Add a working example using local state (useState) that wires "${pair.valueProp}" to "${pair.changeProp}", ` +
277
+ 'or declare intentionalStaticPreview with a reason if the examples are deliberately static.',
278
+ }));
279
+ }
280
+ return issues;
281
+ }
148
282
  /** Everything that needs a page's own matching `ComponentRecord` to check: `propNotes`, bound
149
283
  * axes, tagged list items, coverage, grouped-state matching, and guidance/a11y presence. One
150
284
  * function because every one of these checks shares the same per-page `component`/`propsByName`
@@ -305,7 +439,10 @@ export function collectIssues(pages, components) {
305
439
  ...checkDuplicatePageId(pages),
306
440
  ...checkGridAxisKeys(pages),
307
441
  ...checkGridColumnLimit(pages),
442
+ ...checkDegenerateGridAxis(pages),
308
443
  ...checkComponentPages(pages, components),
444
+ ...checkMissingComposition(pages, components),
445
+ ...checkMissingWorkingPreview(pages, components),
309
446
  ];
310
447
  }
311
448
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -343,7 +480,9 @@ export function checkPageTypeErrors(pages, options = {}) {
343
480
  // `createComponentReader` (where a mismatch only degrades prop reading silently; here it would
344
481
  // produce CI-failing errors the host's own `tsc` would never raise). Only `noEmit`,
345
482
  // `skipLibCheck`, and the merged `paths` are forced on top.
346
- const host = readHostTsconfigOptions(pageFiles[0]);
483
+ // `readHostTsconfigOptions` searches upward from the given file's folder; a file name inside
484
+ // `hostRoot` starts that search at the host's own root.
485
+ const host = readHostTsconfigOptions(options.hostRoot ? path.join(options.hostRoot, 'ds-viewer.config.ts') : pageFiles[0]);
347
486
  const compilerOptions = {
348
487
  ...host.options,
349
488
  noEmit: true,
@@ -432,7 +571,7 @@ export function runDoctor(config) {
432
571
  : undefined;
433
572
  let issues = [
434
573
  ...collectIssues(pages, components),
435
- ...checkPageTypeErrors(pages, { fallbackPaths }),
574
+ ...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
436
575
  ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
437
576
  ];
438
577
  // Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
@@ -6,7 +6,29 @@ import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentation
6
6
  import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
7
  import { sectionColumns } from "../native/catalog/tokenLayout.js";
8
8
  function placeholderItem(item) {
9
- return { key: item.key, name: item.name, node: null, group: item.group };
9
+ return {
10
+ key: item.key, name: item.name, node: null, group: item.group,
11
+ fill: item.fill, surface: item.surface, align: item.align,
12
+ };
13
+ }
14
+ /** How many of a block's entries (list/grouped items, or grid cells, named by `noun`) override a
15
+ * given `SpecimenPresentation` field, as a trailing sentence fragment — empty when none do, so an
16
+ * unremarkable block's reason stays unchanged. Shared by every one of the four override-note
17
+ * functions below so a list item's and a grid cell's own surface/alignment override are reported
18
+ * identically. */
19
+ function overrideNote(entries, field, label, noun) {
20
+ const overridden = entries.filter((entry) => entry[field] !== undefined).length;
21
+ return overridden > 0 ? ` ${overridden} ${noun}${overridden === 1 ? '' : 's'} override${overridden === 1 ? 's' : ''} the ${label}.` : '';
22
+ }
23
+ function surfaceOverrideNote(items) {
24
+ return overrideNote(items, 'surface', 'surface', 'item');
25
+ }
26
+ function alignOverrideNote(items) {
27
+ return overrideNote(items, 'align', 'alignment', 'item');
28
+ }
29
+ /** Same pair of override notes, worded for a grid's cells rather than a list's items. */
30
+ function cellOverrideNote(cells) {
31
+ return overrideNote(cells, 'surface', 'surface', 'cell') + overrideNote(cells, 'align', 'alignment', 'cell');
10
32
  }
11
33
  /** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
12
34
  * (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
@@ -19,7 +41,13 @@ function toSectionDef(page) {
19
41
  columnLabel: page.comparison.columnLabel ?? '',
20
42
  rows: page.comparison.rows.prop !== undefined ? { prop: page.comparison.rows.prop, items: page.comparison.rows.items } : page.comparison.rows.items,
21
43
  columns: page.comparison.columns.prop !== undefined ? { prop: page.comparison.columns.prop, items: page.comparison.columns.items } : page.comparison.columns.items,
22
- cells: [],
44
+ // Present only for a direct object-literal `comparison` (never a `grid()` call, whose `cell`
45
+ // callback is unevaluated) whose `cells` array was itself literal — see `StaticComparison.cells`.
46
+ // `node: null` the same way every other placeholder specimen here is never evaluated.
47
+ cells: (page.comparison.cells ?? []).map((cell) => ({
48
+ rowKey: cell.rowKey, columnKey: cell.columnKey, node: null,
49
+ fill: cell.fill, surface: cell.surface, align: cell.align,
50
+ })),
23
51
  size: page.comparison.size,
24
52
  };
25
53
  return {
@@ -47,7 +75,7 @@ function explainBlock(block) {
47
75
  return {
48
76
  kind: 'grid', title: block.title,
49
77
  reason: `${rows} rows × ${columns} columns (${block.size}); ${columns} of ${limit} max ${block.size} columns used — ` +
50
- `${columns <= limit ? 'fits' : 'does NOT fit'} a 1280px laptop.`,
78
+ `${columns <= limit ? 'fits' : 'does NOT fit'} a 1280px laptop.` + cellOverrideNote(block.comparison.cells),
51
79
  };
52
80
  }
53
81
  if (block.kind === 'list') {
@@ -55,11 +83,16 @@ function explainBlock(block) {
55
83
  return {
56
84
  kind: 'list', title: block.title,
57
85
  reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
58
- (geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.',
86
+ (geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.' +
87
+ surfaceOverrideNote(block.items) + alignOverrideNote(block.items),
59
88
  };
60
89
  }
61
90
  if (block.kind === 'grouped') {
62
- return { kind: 'grouped', title: block.title, reason: `${block.groups.length} variant row(s), each with its own configurations.` };
91
+ const items = block.groups.flatMap((group) => group.items);
92
+ return {
93
+ kind: 'grouped', title: block.title,
94
+ reason: `${block.groups.length} variant row(s), each with its own configurations.${surfaceOverrideNote(items)}${alignOverrideNote(items)}`,
95
+ };
63
96
  }
64
97
  if (block.kind === 'preview') {
65
98
  const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
@@ -145,7 +178,7 @@ export function explainPage(config, pageId, options = {}) {
145
178
  };
146
179
  }
147
180
  }
148
- return { pageId, blocks: explained, placement };
181
+ return { pageId, blocks: explained, placement, intentionalStaticPreviewReason: page.intentionalStaticPreviewReason };
149
182
  }
150
183
  export function formatExplain(result) {
151
184
  if (result.notCheckable) {
@@ -160,6 +193,11 @@ export function formatExplain(result) {
160
193
  if (result.placement) {
161
194
  lines.push('Placement');
162
195
  lines.push(` ${result.placement.reason}`);
196
+ lines.push('');
197
+ }
198
+ if (result.intentionalStaticPreviewReason) {
199
+ lines.push('Intentionally static preview');
200
+ lines.push(` ${result.intentionalStaticPreviewReason}`);
163
201
  }
164
202
  return lines.join('\n').replace(/\n+$/, '');
165
203
  }
package/dist/cli/init.js CHANGED
@@ -141,14 +141,11 @@ function ensureSharedProjectFiles(projectRoot) {
141
141
  return written;
142
142
  }
143
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
- ];
144
+ /** The starter kit's own sidebar order (the same list kit-host/ds-viewer.config.ts declares),
145
+ * written into a fresh project's config. Follows the README's "Organising the catalog": Tokens,
146
+ * then every component in one A–Z group, then the non-component kinds of page (Patterns: whole
147
+ * screens built from the kit; Reference: the generated manifest). */
148
+ const KIT_GROUP_ORDER = ['Tokens', 'Components', 'Patterns', 'Reference'];
152
149
  /** Default `installer` — a real `npx expo install`, run only for whichever of
153
150
  * `KIT_REQUIRED_PACKAGES` the project doesn't already depend on. Test-injected in every unit test
154
151
  * (this plan's Global Constraints: never run a real `expo install` in tests). */
package/dist/cli/props.js CHANGED
@@ -185,7 +185,86 @@ function readComponentRecord(checker, name, type, declaration, optionRoots, comp
185
185
  }
186
186
  }
187
187
  const inheritedFrom = propsType ? nodeModulesHeritageNames(checker, propsType.isUnion() ? propsType.types : [propsType]) : [];
188
- return { name, file: path.relative(process.cwd(), sourceFile.fileName), props, inheritedFrom };
188
+ const composedOfCandidates = readComposedOfCandidates(checker, declaration);
189
+ return {
190
+ name,
191
+ file: path.relative(process.cwd(), sourceFile.fileName),
192
+ props,
193
+ inheritedFrom,
194
+ ...(composedOfCandidates.length > 0 ? { composedOfCandidates } : {}),
195
+ };
196
+ }
197
+ /** Optional, additive source evidence (guided-intelligence design §4, plan Task 3): every other
198
+ * known, project-local component this component's own implementation renders as a literal JSX
199
+ * tag. Scoped to exactly the implementation function's own body (the same function
200
+ * `defaultsFromImplementation` already unwraps through `React.memo`/`forwardRef`) — a same-file
201
+ * helper's own JSX, or a different export's JSX, is never walked at all, since traversal never
202
+ * starts there. Only a JSX tag that is a bare identifier resolving (through import aliases and
203
+ * barrel re-exports) to an exported symbol satisfying `isComponentType` becomes a candidate; a
204
+ * type-only import can never reach here (it cannot be used as a JSX tag at all), an import that is
205
+ * never written as a JSX tag is never visited, and a locally-computed/conditional tag identifier
206
+ * (not itself an import alias) is left uncertain rather than guessed at. A `node_modules`
207
+ * declaration (a framework primitive, not a project component) is excluded — "known canonical
208
+ * component export" means part of this project's own component surface. */
209
+ function readComposedOfCandidates(checker, declaration) {
210
+ const startNode = ts.isVariableDeclaration(declaration) && declaration.initializer ? declaration.initializer : declaration;
211
+ const fn = resolveFunctionLike(startNode, new Set());
212
+ if (!fn?.body)
213
+ return [];
214
+ const candidates = [];
215
+ const visit = (node) => {
216
+ // A nested named helper (a local `function foo() {}` declaration, or a `const foo = () => {}`/
217
+ // `function expression` assignment) defines its own scope: its JSX belongs to it, not to this
218
+ // component, even when this component calls it as an ordinary function — so its body is never
219
+ // walked at all, matching the top-level sibling-helper behavior this already preserved.
220
+ if (isNestedNamedHelper(node))
221
+ return;
222
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName)) {
223
+ const candidate = resolveComposedOfCandidate(checker, node.tagName);
224
+ if (candidate)
225
+ candidates.push(candidate);
226
+ }
227
+ ts.forEachChild(node, visit);
228
+ };
229
+ visit(fn.body);
230
+ return candidates;
231
+ }
232
+ function isNestedNamedHelper(node) {
233
+ if (ts.isFunctionDeclaration(node) && node.name)
234
+ return true;
235
+ return (ts.isVariableDeclaration(node) &&
236
+ ts.isIdentifier(node.name) &&
237
+ node.initializer !== undefined &&
238
+ (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)));
239
+ }
240
+ /** Resolves one JSX tag identifier to a composedOfCandidate, or undefined when it isn't a known,
241
+ * project-local component export reached through an import alias (directly or through one or more
242
+ * barrel re-exports). */
243
+ function resolveComposedOfCandidate(checker, tagName) {
244
+ let symbol = checker.getSymbolAtLocation(tagName);
245
+ if (!symbol)
246
+ return undefined;
247
+ // Only a tag identifier that is itself bound to an import (an Alias symbol) counts — a locally
248
+ // computed/conditional identifier (e.g. `const Comp = cond ? A : B`) is left uncertain even if its
249
+ // inferred type happens to satisfy `isComponentType`.
250
+ if (!(symbol.flags & ts.SymbolFlags.Alias))
251
+ return undefined;
252
+ const seen = new Set();
253
+ while (symbol.flags & ts.SymbolFlags.Alias && !seen.has(symbol)) {
254
+ seen.add(symbol);
255
+ symbol = checker.getAliasedSymbol(symbol);
256
+ }
257
+ const resolvedDeclaration = symbol.valueDeclaration;
258
+ if (!resolvedDeclaration)
259
+ return undefined;
260
+ const declarationFile = resolvedDeclaration.getSourceFile().fileName;
261
+ if (declarationFile.includes('node_modules'))
262
+ return undefined;
263
+ const type = checker.getTypeOfSymbolAtLocation(symbol, resolvedDeclaration);
264
+ if (!isComponentType(checker, type))
265
+ return undefined;
266
+ const { line } = tagName.getSourceFile().getLineAndCharacterOfPosition(tagName.getStart());
267
+ return { component: symbol.getName(), file: path.relative(process.cwd(), tagName.getSourceFile().fileName), line: line + 1 };
189
268
  }
190
269
  /** Design §3 — "Type column: keeps alias names, not expanded unions." An optional prop's type
191
270
  * (as read via its symbol) includes `undefined` — printed as `"T | undefined"` when `T` has no