@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.
- package/README.md +100 -15
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/explain.js +44 -6
- package/dist/cli/props.js +80 -1
- package/dist/cli/staticPage.js +228 -6
- package/dist/native/catalog/comparison.js +29 -4
- package/native/catalog/BoundedOverlayViewport.tsx +112 -0
- package/native/catalog/CatalogShell.tsx +22 -0
- package/native/catalog/ComparisonGrid.tsx +2 -2
- package/native/catalog/ComparisonGroups.tsx +2 -2
- package/native/catalog/ComparisonList.tsx +2 -2
- package/native/catalog/PhoneFrame.tsx +6 -0
- package/native/catalog/ReferenceDetails.tsx +75 -20
- package/native/catalog/SectionBlock.tsx +66 -4
- package/native/catalog/SpecimenSurface.tsx +18 -10
- package/native/catalog/comparison.ts +39 -5
- package/native/catalog/index.ts +4 -0
- package/native/catalog/overlayViewport.ts +156 -0
- package/native/catalog/pageApi.ts +6 -0
- package/native/catalog/specimenSurfaceStyle.ts +70 -1
- package/native/catalog/types.ts +42 -12
- package/package.json +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
- package/starter-kit/components/Button/Button.catalog.tsx +3 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
- package/starter-kit/components/List/List.catalog.tsx +3 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
- 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.
|
|
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
|
|
210
|
-
`'built-in'` for a component
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
`
|
|
215
|
-
|
|
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
|
-
|
|
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 —
|
|
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.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
package/dist/cli/explain.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|