@krapwoo/ds-viewer 0.4.6 → 0.4.7

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 (34) hide show
  1. package/README.md +100 -15
  2. package/dist/cli/doctor.js +137 -0
  3. package/dist/cli/explain.js +44 -6
  4. package/dist/cli/props.js +80 -1
  5. package/dist/cli/staticPage.js +228 -6
  6. package/dist/native/catalog/comparison.js +29 -4
  7. package/native/catalog/BoundedOverlayViewport.tsx +112 -0
  8. package/native/catalog/CatalogShell.tsx +22 -0
  9. package/native/catalog/ComparisonGrid.tsx +2 -2
  10. package/native/catalog/ComparisonGroups.tsx +2 -2
  11. package/native/catalog/ComparisonList.tsx +2 -2
  12. package/native/catalog/PhoneFrame.tsx +6 -0
  13. package/native/catalog/ReferenceDetails.tsx +75 -20
  14. package/native/catalog/SectionBlock.tsx +66 -4
  15. package/native/catalog/SpecimenSurface.tsx +18 -10
  16. package/native/catalog/comparison.ts +39 -5
  17. package/native/catalog/index.ts +4 -0
  18. package/native/catalog/overlayViewport.ts +156 -0
  19. package/native/catalog/pageApi.ts +6 -0
  20. package/native/catalog/specimenSurfaceStyle.ts +70 -1
  21. package/native/catalog/types.ts +42 -12
  22. package/package.json +1 -1
  23. package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
  24. package/starter-kit/components/Button/Button.catalog.tsx +3 -0
  25. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
  26. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
  27. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
  28. package/starter-kit/components/List/List.catalog.tsx +3 -0
  29. package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
  30. package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
  31. package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
  32. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
  33. package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
  34. package/starter-kit/components/Toast/Toast.catalog.tsx +7 -0
package/README.md CHANGED
@@ -154,7 +154,13 @@ work. Desktop and laptop screens only. `SectionBlock` renders one page: breadcru
154
154
  description, previous/next, then the specimens, then always-visible
155
155
  **Guidance** and **Quick reference** (source path, accessibility), then **Props** in their own box
156
156
  (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:
157
+ Quick reference with the source path. Guidance, Quick reference, and — only for a page with
158
+ confirmed `composedOf` entries — Composition lay out from the reference card's own measured inner
159
+ width, not viewport width alone: three columns side by side only when there's confirmed composition
160
+ *and* the card is wide enough for all three (Composition gets its own column only then; otherwise
161
+ it stays nested under Quick reference, as it always has), two when there's room for Guidance and
162
+ Quick reference side by side, or one stacked column — in full document order, nothing dropped —
163
+ when the card is too narrow even for two. The specimens use one of four layouts:
158
164
  - **Grid** — two props that combine freely, from an explicit `comparison` (e.g. Button's
159
165
  Variant × State).
160
166
  - **Grouped rows** — one row per variant holding that variant's own configurations (e.g.
@@ -199,6 +205,17 @@ actually apply:
199
205
  cell gray, `'white'` or `'transparent'` keeps them all white, and `'dark'` checks a light
200
206
  component on a dark fill. Detection runs in the browser viewer; elsewhere cells stay white.
201
207
  Token galleries and sections are never filled.
208
+ - A `variants`/`states` item's own `fill`/`surface`/`align` — override the page's `specimenSurface`
209
+ (above), stretch just *this* item's wrapper to the row's full width, or change how its content
210
+ aligns inside that wrapper, for one instance that needs any of the three independently of its
211
+ siblings (e.g. a single `fullWidth` Button among otherwise compact, centered ones, or one
212
+ white-on-white example that needs its own gray backing even though the page's guess is right for
213
+ every other item). Omit any of them to inherit the slot's/page's own default: `fill` inherits the
214
+ slot's `itemsFill` (an explicit `fill: false` still overrides an inherited `itemsFill: true`), and
215
+ `align` defaults to centered, or to a stretched, full-width fill specimen's own content when
216
+ `fill` is set — a capped-width `fill` specimen that still wants centering (e.g. a phone-frame
217
+ preview) sets `align: 'center'` explicitly. The same `fill`/`surface`/`align` fields exist on each
218
+ authored `comparison` cell too, independently per cell, for exactly the same reasons.
202
219
  - `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
203
220
  ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
204
221
  more just because a laptop is wide). A narrow window still drops below the cap when it has to —
@@ -206,29 +223,80 @@ actually apply:
206
223
  - `group` on a `states` item — the `variants` key it belongs to (a size that only exists for one
207
224
  variant). Grouped states render one row per variant; ungrouped ones go to "Other configurations".
208
225
  - `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.
226
+ composed with, shown in Composition (its own column when space permits, otherwise under Quick reference). `relationship` is
227
+ `'built-in'` for a component that's part of this one's own implementation — always rendered, or
228
+ rendered only for some of its variants/states (e.g. Toast is built from a Banner),
229
+ `'slot'` for an optional caller-supplied child (e.g. a `leadingIcon`
230
+ prop), or `'related'` for a component commonly used alongside this one without either containing
231
+ the other. `component` should be a real component or page id: while you browse the catalog in
232
+ development, an unknown name logs a `[Catalog]` warning in the browser console. `doctor` doesn't
233
+ check it, and it never fails a build — e.g.
216
234
  `composedOf: [{ component: 'Icon', role: 'Leading glyph', relationship: 'slot' }]`.
235
+ - `BoundedOverlayViewport` — wrap one demo whose own modal/sheet/portal would otherwise escape its
236
+ card and cover the whole catalog page (React Native Web's `Modal` portals into `document.body`;
237
+ no wrapper or `overflow: hidden` gives it a different boundary). Opt in per item, never per page:
238
+ ```tsx
239
+ import { BoundedOverlayViewport, comparisonCellItemKey } from '@krapwoo/ds-viewer';
240
+
241
+ { key: 'full-height', name: 'Full height', node: (
242
+ <BoundedOverlayViewport pageId="Sheet" slot="states" itemKey="full-height" width={360} height={600}>
243
+ <MyOverlayDemo />
244
+ </BoundedOverlayViewport>
245
+ ) }
246
+ ```
247
+ Here `MyOverlayDemo` is your existing stateful wrapper around the real component, not a viewer
248
+ export. `itemKey` is that item's own `VariantExample.key` for a `variants`/`states` item, or
249
+ `comparisonCellItemKey(rowKey, columnKey)` (import it — never join the two keys yourself; a plain
250
+ `${rowKey}:${columnKey}` is ambiguous whenever either key already contains a colon) for a
251
+ `comparison` cell. `width` is only a maximum — the stage still shrinks to whatever its real
252
+ owning cell actually has at a narrower width — and `height` is the child document's exact usable
253
+ height, never renegotiated; both must be finite and strictly positive or the specimen shows an
254
+ honest "This example is unavailable right now." instead of an iframe sized to a meaningless
255
+ bound. Browser-only: it opens a same-origin child document that boots this catalog's own app
256
+ entry/module/provider tree, so the wrapped demo's overlay opens inside that document's own
257
+ viewport instead of the outer page's `document.body`; the two documents stay independently
258
+ owned, with no shared memory or provider identity between them. On a native runtime, and inside
259
+ the one child document that the address itself selects, `children` render inline with no
260
+ boundary — never read either case as native containment parity.
217
261
  - `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.
262
+ Default to one preview; add a second width only when a narrower (or wider) viewport actually
263
+ changes something worth comparing — wrapping, truncation, or reflow — not just to show the same,
264
+ identically-laid-out content twice. `CatalogShell`'s `defaultPreviewWidths="full"` keeps a
265
+ catalog's previews full width.
266
+ - `previewLayout: 'table'` — opt in to one shared, bordered card for a `render()` page's widths,
267
+ instead of the default `'frames'` (one bordered frame per width, side by side; unchanged unless
268
+ you set this). Each width keeps its own caption and stays an independent, stateful live instance
269
+ either way — `'table'` only changes how they're grouped visually. Widths that don't fit the
270
+ available space side by side wrap to stacked columns, each still captioned, rather than scrolling
271
+ or shrinking below their requested size. Has no effect on a `'full'` preview width (catalog
272
+ chrome) or a token gallery — those always render as `'frames'`.
219
273
  - `variants: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per prop enum
220
274
  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.
275
+ item named `"Default"` showing its plain look — a page that documents its component through a
276
+ `variants` list should never leave that column empty. This doesn't mean every page must use
277
+ `variants`: a page that reaches for `render()` instead (see below) has no Variants column to fill
278
+ in the first place.
222
279
  - `states: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per meaningfully
223
280
  distinct boolean state (`loading`, `disabled`, icon-only, …). Fine to omit if there are none.
224
281
 
225
282
  Every item's `name` is shown as its cell caption (e.g. `"Primary"`, `"Icon-only"`) — use the
226
283
  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
284
+ shows no States block; don't invent items just to fill it. Avoid a static prop matrix (a `states`
285
+ item, or a `comparison` cell, for every combination) when two configurations only actually look
286
+ different mid-transition and render identically at rest, or when the "closed" side of a toggle is
287
+ just a redundant blank cell next to its "open" counterpart — show that behavior with one interactive
288
+ example instead, with its own working toggle, rather than several now-identical static ones. Set
289
+ `itemsFill: true` on a slot whose items are wide, block-level components (Banner, Card, Toast,
290
+ InputField) rather than small ones meant to sit centered (Button, Badge, Pill).
291
+
292
+ A `variants`/`states` item's `node` can be a plain instance, or a small stateful wrapper component
293
+ (its own `useState` and all) that renders the real component live — a toggle demo for a
294
+ `Collapsible`, say. That local state belongs to the item's own node, not to the page; it doesn't by
295
+ itself mean the page needs `render()`. Reach for `render()` instead of `variants`/`states` only for a
296
+ genuine custom composition that doesn't fit a list of independent instances at all — several demos
297
+ sharing one measured layout, a wrapping grid, a token gallery — not merely because an item has local
298
+ state. `render()`'s output renders in a Preview card (phone width for components). For a
299
+ token-gallery section with no component API at all (raw token
232
300
  data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGallery`), set
233
301
  `tokenGallery: true` instead of `props`/`a11y`/`states` — `SectionBlock` then renders `render()`'s
234
302
  output full width under a "Tokens" label and shows only a Quick reference card with the source
@@ -363,6 +431,23 @@ Button
363
431
  - `--ci` exits 1 only when an error was found — a warning alone never fails CI, and `--ci` never makes a network call.
364
432
  - `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
433
  - 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.
434
+ - Three further rules are conservative source-backed *suggestions*, warnings by default
435
+ (the existing `doctor.strict` option still promotes them), never authoritative over what a page authors:
436
+ - `missing-composition-suggestion` — a component's own real JSX source evidence names another
437
+ known component its page's `composedOf` doesn't. The message states only that candidate's
438
+ `component`/`file`/`line`, never an invented role or relationship; add it to `composedOf`
439
+ yourself if it reflects real composition, or leave it as-is if it's incidental.
440
+ - `degenerate-grid-axis` — an authored `comparison` grid's row or column axis has exactly one
441
+ item, so it reads as a one-axis list rather than a real two-axis matrix. Never rewrites the
442
+ grid; an intentional single-value axis is left exactly as authored.
443
+ - `missing-working-preview` — a component has a controlled value/change pair (`value`/`onChange`,
444
+ or `x`/`onXChange`) but every example that renders it passes a fixed value with no working
445
+ callback. Add `intentionalStaticPreview: { reason: '...' }` to the page to suppress it when the
446
+ static examples are deliberate.
447
+ The analyzer follows supported aliases and resolved exports, but uncertain wrappers or
448
+ dynamically-constructed references remain uncheckable rather than being guessed at. A
449
+ declared `intentionalStaticPreview` is taken as the author's own call, not independently
450
+ verified; only a real browser check confirms actual behavior.
366
451
 
367
452
  ## Known gaps
368
453
 
@@ -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
@@ -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/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