@krapwoo/ds-viewer 0.4.2 → 0.4.4

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 CHANGED
@@ -96,6 +96,12 @@ npm run check:catalog # the same headless check CI runs — every page, zero
96
96
  documenting the viewer framework's own pieces (`CatalogShell`, `SectionBlock`, `ComparisonGrid`,
97
97
  …), shown under a "Viewer" group alongside the starter kit's own pages.
98
98
 
99
+ ## Contributing
100
+
101
+ Found a bug or an improvement while using ds-viewer in your app? Open an issue or a pull request:
102
+ [CONTRIBUTING.md](CONTRIBUTING.md) explains how to try a change in your own app before opening
103
+ one, and what every pull request needs.
104
+
99
105
  ## Picking the right component
100
106
 
101
107
  Several components look alike but solve different problems (InputField vs. SearchField vs.
@@ -184,8 +190,28 @@ actually apply:
184
190
  within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
185
191
  key matches a row or column key are not repeated below the grid.
186
192
  - `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
193
+ - `specimenSurface: 'neutral' | 'white' | 'dark' | 'transparent'` — the catalog-owned background
194
+ behind every live specimen on this page (list/grouped/grid cells, and a non-token `render()`
195
+ preview). Defaults to `'neutral'` (a light gray stage, visibly distinct from the surrounding
196
+ white card) — reach for `'dark'` to check a low-contrast component against a dark background, or
197
+ `'transparent'` when the surface itself would be misleading (e.g. a component whose own
198
+ elevation/shadow needs to read against the page, not a chip). Token galleries and sections always
199
+ keep their current, unstaged presentation regardless of this setting.
200
+ - `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
201
+ ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
202
+ more just because a laptop is wide). A narrow window still drops below the cap when it has to —
203
+ `maxColumns` only ever lowers the natural, width-based column count, never raises it.
187
204
  - `group` on a `states` item — the `variants` key it belongs to (a size that only exists for one
188
205
  variant). Grouped states render one row per variant; ungrouped ones go to "Other configurations".
206
+ - `composedOf: [{ component, role, relationship }]` — what this component is built from or
207
+ composed with, shown as a "Composition" subsection in Quick reference. `relationship` is
208
+ `'built-in'` for a component always present inside this one (e.g. Toast is built from a Banner),
209
+ `'slot'` for an optional caller-supplied child (e.g. a `leadingIcon` prop), or `'related'` for a
210
+ component commonly used alongside this one without either containing the other. `component` should be
211
+ a real component or page id: while you browse the catalog in development, an unknown name logs a
212
+ `[Catalog]` warning in the browser console. `doctor` doesn't check it, and it never fails a
213
+ build — e.g.
214
+ `composedOf: [{ component: 'Icon', role: 'Leading glyph', relationship: 'slot' }]`.
189
215
  - `previewWidths: [402, 320]` — extra preview widths for a `render()` page (each capped at 402).
190
216
  `CatalogShell`'s `defaultPreviewWidths="full"` keeps a catalog's previews full width.
191
217
  - `variants: { desc?, align?, itemsFill?, items: [{ key, name, node }] }` — one item per prop enum
@@ -204,7 +230,91 @@ renders in a Preview card (phone width for components). For a token-gallery sect
204
230
  data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGallery`), set
205
231
  `tokenGallery: true` instead of `props`/`a11y`/`states` — `SectionBlock` then renders `render()`'s
206
232
  output full width under a "Tokens" label and shows only a Quick reference card with the source
207
- path.
233
+ path. For anything beyond a single list of tokens, use the token-page layouts below.
234
+
235
+ ### Organising the catalog
236
+
237
+ - **`group`/`groupOrder` accept any semantic category you want** — not just `Tokens` and
238
+ `Components`. A page's `group` is a plain string shown as its sidebar section label;
239
+ `groupOrder` (e.g. `['Tokens', 'Components', 'Patterns', 'Recipes', 'Experiences']`) only
240
+ controls which order those sections appear in, top to bottom — any group not listed sorts
241
+ alphabetically after the ones that are. This starter kit's own two groups (`Tokens`, then
242
+ `Components`) are one valid shape, not the only one: a larger catalog might add `Patterns`
243
+ (reusable combinations like a settings row), `Recipes` (worked end-to-end screens), or
244
+ `Experiences` (full flows) alongside them. Pages sort A–Z inside each group regardless of how
245
+ many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
246
+ "Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
247
+ already find it. A distinct semantic group (Tokens vs. Components vs. Patterns) is a different
248
+ decision from that, since it changes what the reader expects to find there.
249
+ - **Wide components at 3 columns.** Give block-level components (Banner, Card, Toast,
250
+ InputField) `specimenSize: 'regular'`, so Variants and States sit 3 across on a laptop
251
+ (about 316px each). `'wide'` (2 columns, phone width) is only for pages where the full phone
252
+ width is what you're checking, such as truncation or full-screen sheets. 4 columns is too narrow
253
+ for these components.
254
+ - **Mark OS components.** When a component is, or contains, a native platform control rather than
255
+ a custom one (a native date picker, iOS Liquid Glass), set `osComponent: 'full'` or
256
+ `'partial'`. The page shows an "OS component" or "Partly OS component" badge under its title;
257
+ say in `description` which part is native and how it differs on web, where the native part
258
+ usually can't render. Custom replacements styled after a platform control (a custom switch or
259
+ spinner) are not OS components.
260
+ - **Readable titles.** The page id is its address (`#ControlHeights`) and comes from the file
261
+ name; set `title: 'Control heights'` for the heading, sidebar and search. Pages still sort by id.
262
+ - **Point token pages at their token file.** A token page has no component, so its Source would be
263
+ the page file; set `source: 'src/design-system/tokens.ts'` (project-relative) instead.
264
+ - **Coverage only counts the component's own options.** `doctor`'s `option-not-covered` checks
265
+ string-literal unions declared in the component's own folder or a token folder. A union
266
+ declared in another component's folder, such as a shared `IconName`, is that component's
267
+ options, so a Banner page never needs one example per icon.
268
+
269
+ ### Token pages
270
+
271
+ A token page shows each token at its real value with its role. Pick the layout by the token's
272
+ shape:
273
+
274
+ | Token shape | Layout | Example |
275
+ |---|---|---|
276
+ | Small, self-contained preview (radius, shadow, control height, a single colour) | `TokenGrid` of `TokenTile`s: equal tiles in as many columns as fit | Radius, Shadow, ControlHeights |
277
+ | Narrow sample with a long note (icon size, spacing) | `TokenRow` with `notePlacement="right"`: one line per token | IconSizes |
278
+ | Several kinds of token on one page | `tokenSections`: one titled card per kind | Colors (semantic vs palette) |
279
+ | A scale with families (type) | `tokenSections` with `tokenColumns: 2` or `3`, one section per family | Typography (label, body, emphasis…) |
280
+ | A duration/easing or spring value | `MotionSpecimen` next to the token's value | Motion (duration, easing, spring) |
281
+
282
+ - **`tokenSections: [{ title, desc?, wide?, render }]`** replaces `render`. Each section is its
283
+ own titled card. Keep different kinds of token in different sections, never mixed: semantic roles
284
+ apart from raw palette ramps, each type family apart.
285
+ - **`tokenColumns: 1 | 2 | 3`** places sections side by side. Use 2–3 for narrow sections (type
286
+ families); the page drops a column rather than squeeze a section under 280px. `wide: true`
287
+ gives a section its own full row, for wide content such as palette ramps.
288
+ - **Show each token's role, not its category.** The note says where or why the token is used, in
289
+ that order of preference: the token file's own comments, then a usage scan of the code (name the
290
+ main components and how many files use it, and say so when a token is unused). Never invent
291
+ advice, and never write a generic category such as "Regular reading text" when the name and
292
+ value already say it. If there's nothing grounded to say, leave the note out.
293
+ - **Label each sample with its name and real value** (`bodyMd · 16/22 · 400`, `medium · 12px`).
294
+ Use realistic sample text from the product, short enough to fit its column at the largest size.
295
+ - **`MotionSpecimen`** demonstrates a duration/easing or spring token as a real, bounded
296
+ start-to-end slide — a dot travels a fixed track once when it mounts, with a named Replay button
297
+ to play it again. Give it `kind: 'timing'` with `duration` (and optionally a real `Easing`
298
+ function, e.g. `Easing.bezier(...DS_MOTION_EASING.standard)`) for a duration/easing token, or
299
+ `kind: 'spring'` with a `spring` config shaped for `Animated.spring` (e.g. `DS_MOTION_SPRING`) for
300
+ a spring token. It respects the platform's reduce-motion preference automatically — with it on,
301
+ every play (including the initial one) shows the end state immediately instead of animating, so
302
+ it never needs its own per-page reduce-motion handling:
303
+ ```tsx
304
+ <MotionSpecimen kind="timing" duration={DS_MOTION_DURATION.base} easing={Easing.bezier(...DS_MOTION_EASING.standard)} />
305
+ <MotionSpecimen kind="spring" spring={DS_MOTION_SPRING} />
306
+ ```
307
+ A spring's `valueRange` says which domain its `spring` config's rest thresholds are scaled for,
308
+ so the specimen animates the same domain the real config does rather than always normalizing to
309
+ 0–1. `'distance'` (default) is for a config tuned for pixel-space points, e.g. a `BottomSheet`'s
310
+ snap-point spring — the `Animated.Value` animates directly from `0` to the track's pixel
311
+ distance. `'unit'` is for a config whose production value is a normalized 0-to-1 progress or
312
+ blend factor — the `Animated.Value` animates `0` to `1` and is interpolated to the pixel
313
+ distance; `'distance'`'s pixel-scale thresholds would otherwise end a unit-valued spring before
314
+ it ever moves:
315
+ ```tsx
316
+ <MotionSpecimen kind="spring" spring={UNIT_SPRING_CONFIG} valueRange="unit" />
317
+ ```
208
318
 
209
319
  ## Keeping the catalog current
210
320
 
@@ -55,7 +55,7 @@ function checkComponentExportRemoved(pages, components) {
55
55
  * `description` — in which case the viewer shows the exact same "No examples documented." an
56
56
  * undocumented component would. */
57
57
  function pageHasExamples(page) {
58
- return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
58
+ return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true || Boolean(page.tokenSectionTitles?.length);
59
59
  }
60
60
  function checkComponentNoExamples(pages, components) {
61
61
  const byComponent = new Map();
@@ -4,16 +4,15 @@ import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
4
4
  import { resolveGlob } from "./glob.js";
5
5
  import { axisItems, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
6
6
  import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
+ import { sectionColumns } from "../native/catalog/tokenLayout.js";
7
8
  function placeholderItem(item) {
8
9
  return { key: item.key, name: item.name, node: null, group: item.group };
9
10
  }
10
11
  /** A throwaway `SectionDef` built from a `StaticPage` (Task 3) — every specimen's `node` is `null`
11
12
  * (design §4: specimen nodes are never evaluated), since none of the layout functions below ever
12
13
  * look at it; only the counts/keys/sizes they do look at are carried over. Every field Task 3
13
- * reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill` —
14
- * an earlier draft dropped these four, so `explain` reported different geometry than the real
15
- * viewer for any page that set one of them (25 kit pages set `itemsFill`; every `kit-host`
16
- * Viewer page sets `hide`). */
14
+ * reads is carried through here, including `previewWidths`/`fullWidthLabel`/`hide`/`itemsFill`/
15
+ * `maxColumns` — dropping any of them makes `explain` report different geometry than the viewer. */
17
16
  function toSectionDef(page) {
18
17
  const comparison = page.comparison && {
19
18
  rowLabel: page.comparison.rowLabel ?? '',
@@ -31,10 +30,12 @@ function toSectionDef(page) {
31
30
  fullWidthLabel: page.fullWidthLabel,
32
31
  specimenSize: page.specimenSize,
33
32
  previewWidths: page.previewWidths,
34
- variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, items: page.variantsItems.map(placeholderItem) } : undefined,
35
- states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
33
+ variants: page.variantsItems ? { itemsFill: page.variantsItemsFill, maxColumns: page.variantsMaxColumns, items: page.variantsItems.map(placeholderItem) } : undefined,
34
+ states: page.statesItems ? { itemsFill: page.statesItemsFill, maxColumns: page.statesMaxColumns, items: page.statesItems.map(placeholderItem) } : undefined,
36
35
  comparison,
37
36
  render: page.hasRender ? () => null : undefined,
37
+ tokenSections: page.tokenSectionTitles?.map((title, i) => ({ title, wide: page.tokenSectionWide?.[i] || undefined, render: () => null })),
38
+ tokenColumns: page.tokenColumns === 2 || page.tokenColumns === 3 ? page.tokenColumns : undefined,
38
39
  hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
39
40
  };
40
41
  }
@@ -50,7 +51,7 @@ function explainBlock(block) {
50
51
  };
51
52
  }
52
53
  if (block.kind === 'list') {
53
- const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size);
54
+ const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size, block.maxColumns);
54
55
  return {
55
56
  kind: 'list', title: block.title,
56
57
  reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
@@ -64,6 +65,19 @@ function explainBlock(block) {
64
65
  const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
65
66
  return { kind: 'preview', title: block.title, reason: `render() content at ${widths}.` };
66
67
  }
68
+ if (block.kind === 'tokenSections') {
69
+ const n = block.sections.length;
70
+ const titles = block.sections.every((s) => s.title) ? ` (${block.sections.map((s) => s.title).join(', ')})` : '';
71
+ const wide = block.sections.filter((s) => s.wide).length;
72
+ const narrow = n - wide;
73
+ const fit = sectionColumns(MATRIX_LAYOUT.laptopContentWidth, block.columns);
74
+ const parts = [];
75
+ if (narrow > 0)
76
+ parts.push(narrow > 1 && fit > 1 ? `${Math.min(fit, narrow)} side by side on a 1280px laptop` : 'one per row');
77
+ if (wide > 0)
78
+ parts.push(`${wide} full width`);
79
+ return { kind: 'tokenSections', title: 'Token sections', reason: `${n} section${n === 1 ? '' : 's'}${titles}: ${parts.join('; ')}.` };
80
+ }
67
81
  return { kind: 'empty', title: block.title, reason: block.message };
68
82
  }
69
83
  /** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
@@ -73,7 +87,14 @@ function explainBlock(block) {
73
87
  * computed uniformly here for either role, since setting it on `second` is simply unused, not wrong. */
74
88
  function toPlacementBlock(block, height, available) {
75
89
  if (block.kind === 'list') {
76
- return { kind: 'list', height, itemCount: block.items.length, size: block.size, width: listGeometry(block.items.length, available, block.size).containerWidth };
90
+ return {
91
+ kind: 'list',
92
+ height,
93
+ itemCount: block.items.length,
94
+ size: block.size,
95
+ maxColumns: block.maxColumns,
96
+ width: listGeometry(block.items.length, available, block.size, block.maxColumns).containerWidth,
97
+ };
77
98
  }
78
99
  return { kind: block.kind, height };
79
100
  }
package/dist/cli/props.js CHANGED
@@ -97,7 +97,7 @@ export function createComponentReader(entryFiles, options = {}) {
97
97
  const type = checker.getTypeOfSymbolAtLocation(resolved, declaration);
98
98
  if (!isComponentType(checker, type))
99
99
  continue;
100
- records.push(readComponentRecord(checker, exportSymbol.getName(), type, declaration, options.optionRoots ?? []));
100
+ records.push(readComponentRecord(checker, exportSymbol.getName(), type, declaration, options.optionRoots ?? [], options.componentRoots ?? []));
101
101
  }
102
102
  return records.sort((a, b) => a.name.localeCompare(b.name));
103
103
  };
@@ -151,7 +151,7 @@ function isComponentType(checker, type) {
151
151
  return true;
152
152
  return COMPONENT_TYPE_HINT.test(checker.typeToString(type));
153
153
  }
154
- function readComponentRecord(checker, name, type, declaration, optionRoots) {
154
+ function readComponentRecord(checker, name, type, declaration, optionRoots, componentRoots = []) {
155
155
  const sourceFile = declaration.getSourceFile();
156
156
  const signature = type.getCallSignatures()[0];
157
157
  const propsType = signature && signature.parameters.length > 0 ? checker.getTypeOfSymbolAtLocation(signature.parameters[0], declaration) : undefined;
@@ -180,7 +180,7 @@ function readComponentRecord(checker, name, type, declaration, optionRoots) {
180
180
  required: !isPartial && merged.requiredInEvery,
181
181
  default: implementationDefaults.get(prop.getName()) ?? (jsDocDefault ? ts.displayPartsToString(jsDocDefault.text) : undefined),
182
182
  desc: isPartial ? `${baseDesc} Only with some variants of this prop's type.`.trim() : baseDesc,
183
- options: readOptions(checker, propType, propFile, optionRoots),
183
+ options: readOptions(checker, propType, propFile, optionRoots, componentRoots),
184
184
  });
185
185
  }
186
186
  }
@@ -383,9 +383,10 @@ function mergeUnionProps(checker, branches) {
383
383
  * 2 or more string literals, AND that union is declared inside one of `optionRoots` — checked
384
384
  * against the union's alias symbol's declaration file (a named type like `Variant`), or the prop's
385
385
  * own declaration file for an inline union with no alias. A single string literal is never
386
- * options; an out-of-root union (e.g. a 44-member `IconName` from `icons/`) is skipped.
386
+ * options; an out-of-root union (e.g. a 44-member `IconName` from `icons/`) is skipped, and so is a
387
+ * union declared in a different component's folder under a component root (a sibling `Icon/`).
387
388
  */
388
- function readOptions(checker, propType, propDeclarationFile, optionRoots) {
389
+ function readOptions(checker, propType, propDeclarationFile, optionRoots, componentRoots = []) {
389
390
  if (optionRoots.length === 0)
390
391
  return undefined;
391
392
  const nonNullable = checker.getNonNullableType(propType);
@@ -397,8 +398,22 @@ function readOptions(checker, propType, propDeclarationFile, optionRoots) {
397
398
  const isLocal = optionRoots.some((root) => declarationFile === root || declarationFile.startsWith(`${root}${path.sep}`));
398
399
  if (!isLocal)
399
400
  return undefined;
401
+ if (declarationFile !== propDeclarationFile && isInOtherComponent(declarationFile, propDeclarationFile, componentRoots))
402
+ return undefined;
400
403
  return nonNullable.types.map((t) => t.value);
401
404
  }
405
+ /** True when `file` sits in a different component subfolder than `ownFile` under the same root. */
406
+ function isInOtherComponent(file, ownFile, componentRoots) {
407
+ const componentOf = (target, root) => {
408
+ const rel = path.relative(root, target);
409
+ return rel.startsWith('..') || path.isAbsolute(rel) ? undefined : rel.split(path.sep)[0];
410
+ };
411
+ return componentRoots.some((root) => {
412
+ const theirs = componentOf(file, root);
413
+ const ours = componentOf(ownFile, root);
414
+ return theirs !== undefined && ours !== undefined && theirs !== ours && path.extname(theirs) === '';
415
+ });
416
+ }
402
417
  /** Finds the real implementation function behind a `React.memo(Impl)` / `forwardRef(function
403
418
  * Name(...) {})` wrapper and reads each destructured parameter's default initializer as source
404
419
  * text (design §3: "shown as source text"). Resolving the *value* of a non-literal default
@@ -71,6 +71,28 @@ function readStaticPage(file) {
71
71
  page.specimenSize = stringLiteral(resolve(specimenSizeProp.initializer, consts, new Set()));
72
72
  if (tokenGalleryProp)
73
73
  page.tokenGallery = resolve(tokenGalleryProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword;
74
+ const tokenSectionsProp = findProp(pageObject, 'tokenSections');
75
+ if (tokenSectionsProp) {
76
+ const list = resolve(tokenSectionsProp.initializer, consts, new Set());
77
+ const items = ts.isArrayLiteralExpression(list) ? list.elements.map((element) => resolve(element, consts, new Set())) : [list];
78
+ const field = (item, name) => (ts.isObjectLiteralExpression(item) ? findProp(item, name) : undefined);
79
+ page.tokenSectionTitles = items.map((item) => {
80
+ const title = field(item, 'title');
81
+ return (title && stringLiteral(resolve(title.initializer, consts, new Set()))) ?? '';
82
+ });
83
+ page.tokenSectionWide = items.map((item) => {
84
+ const wide = field(item, 'wide');
85
+ return wide ? resolve(wide.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : false;
86
+ });
87
+ if (page.tokenSectionTitles.length > 0)
88
+ page.tokenGallery = true;
89
+ }
90
+ const tokenColumnsProp = findProp(pageObject, 'tokenColumns');
91
+ if (tokenColumnsProp) {
92
+ const value = resolve(tokenColumnsProp.initializer, consts, new Set());
93
+ if (ts.isNumericLiteral(value))
94
+ page.tokenColumns = Number(value.text);
95
+ }
74
96
  page.hasWhenToUse = findProp(pageObject, 'whenToUse') !== undefined;
75
97
  page.hasA11y = findProp(pageObject, 'a11y') !== undefined;
76
98
  page.hasRender = findProp(pageObject, 'render') !== undefined;
@@ -124,6 +146,7 @@ function readStaticPage(file) {
124
146
  else {
125
147
  page.variantsItems = variants.items;
126
148
  page.variantsItemsFill = variants.itemsFill;
149
+ page.variantsMaxColumns = variants.maxColumns;
127
150
  }
128
151
  const states = readSlotItems(findProp(pageObject, 'states'), consts);
129
152
  if (!states.checkable)
@@ -131,6 +154,7 @@ function readStaticPage(file) {
131
154
  else {
132
155
  page.statesItems = states.items;
133
156
  page.statesItemsFill = states.itemsFill;
157
+ page.statesMaxColumns = states.maxColumns;
134
158
  }
135
159
  return page;
136
160
  }
@@ -335,11 +359,12 @@ function readLiteralProps(node, consts) {
335
359
  }
336
360
  return result;
337
361
  }
338
- /** A `variants`/`states` field: `{ itemsFill?, items: [...] }`, `items` read by `readListItems`.
339
- * Distinguishes "field absent" (always checkable, everything undefined) from "field present but
340
- * not literal" (not checkable). `itemsFill` is read only if present and literal — like
341
- * `previewWidths`/`fullWidthLabel`, it isn't in design §4's literal-checkable list, so a
342
- * non-literal `itemsFill` is simply left unset, never a reason to reject the whole slot. */
362
+ /** A `variants`/`states` field: `{ itemsFill?, maxColumns?, items: [...] }`, `items` read by
363
+ * `readListItems`. Distinguishes "field absent" (always checkable, everything undefined) from
364
+ * "field present but not literal" (not checkable). `itemsFill` is read only if present and
365
+ * literal — like `previewWidths`/`fullWidthLabel`, it isn't in the original literal-checkable
366
+ * list, so a non-literal `itemsFill` is simply left unset. `maxColumns` does affect the geometry
367
+ * `explain` reports, so it must be a literal integer from 1–5 or the page is not checkable. */
343
368
  function readSlotItems(prop, consts) {
344
369
  if (!prop)
345
370
  return { checkable: true };
@@ -352,5 +377,16 @@ function readSlotItems(prop, consts) {
352
377
  return { checkable: false };
353
378
  const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
354
379
  const itemsFill = itemsFillProp ? resolve(itemsFillProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : undefined;
355
- return { items, itemsFill, checkable: true };
380
+ const maxColumnsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'maxColumns') : undefined;
381
+ let maxColumns;
382
+ if (maxColumnsProp) {
383
+ const value = resolve(maxColumnsProp.initializer, consts, new Set());
384
+ if (!ts.isNumericLiteral(value))
385
+ return { checkable: false };
386
+ const parsed = Number(value.text);
387
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 5)
388
+ return { checkable: false };
389
+ maxColumns = parsed;
390
+ }
391
+ return { items, itemsFill, maxColumns, checkable: true };
356
392
  }
package/dist/cli/sync.js CHANGED
@@ -49,7 +49,8 @@ export function sync(config, resolveOptions) {
49
49
  const fallbackPaths = hasEntryOutsideProjectRoot
50
50
  ? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
51
51
  : undefined;
52
- const readComponent = createComponentReader(componentEntryFiles, { ...resolveOptions, optionRoots, fallbackPaths });
52
+ const componentRoots = [...new Set(config.components.map((pattern) => path.join(config.projectRoot, globBaseFolder(pattern))))];
53
+ const readComponent = createComponentReader(componentEntryFiles, { ...resolveOptions, optionRoots, componentRoots, fallbackPaths });
53
54
  const warnings = [];
54
55
  for (const entryFile of componentEntryFiles) {
55
56
  try {
@@ -34,12 +34,14 @@ export function neighbors(order, id) {
34
34
  };
35
35
  }
36
36
  /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
37
- export function filterGroups(groups, query) {
37
+ export function filterGroups(groups, query,
38
+ /** Display titles by id; a page matches on its id or its title. */
39
+ labels) {
38
40
  const q = query.trim().toLowerCase();
39
41
  return groups
40
42
  .map((group) => ({
41
43
  label: group.label,
42
- ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
44
+ ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
43
45
  }))
44
46
  .filter((group) => group.ids.length > 0);
45
47
  }
@@ -75,13 +75,18 @@ export function gridWidthBounds(columnCount, size) {
75
75
  /** Balanced wrapping for a one-axis list inside a single bordered card (1px outer border). Each
76
76
  * cell's 1px divider sits inside its own width, so no cell is ever wider than 402px. Uses as few
77
77
  * rows as fit, then spreads items evenly across them so a short last row leaves as little empty
78
- * space as possible. `fillers` blank cells complete the last row. */
79
- export function listGeometry(itemCount, availableWidth, size) {
78
+ * space as possible. `fillers` blank cells complete the last row. `columnCap` (from
79
+ * `VariantSlot.maxColumns`) only ever lowers the natural, width-based column count — a narrow
80
+ * container still drops below it when it has to, so the cap can never widen a row past what the
81
+ * available width actually fits. */
82
+ export function listGeometry(itemCount, availableWidth, size, columnCap) {
80
83
  if (itemCount <= 0)
81
84
  return { columns: 0, rows: 0, fillers: 0, cellWidth: 0, containerWidth: 0 };
82
85
  const inner = Math.max(0, availableWidth - 2);
83
86
  const min = COLUMN_MIN_WIDTH[size];
84
- const maxColumns = Math.max(1, Math.min(itemCount, Math.floor(inner / min)));
87
+ const widthLimit = Math.floor(inner / min);
88
+ const limit = columnCap !== undefined ? Math.min(widthLimit, columnCap) : widthLimit;
89
+ const maxColumns = Math.max(1, Math.min(itemCount, limit));
85
90
  const rows = Math.ceil(itemCount / maxColumns);
86
91
  const columns = Math.ceil(itemCount / rows);
87
92
  const fitted = Math.floor(inner / columns);
@@ -213,6 +218,10 @@ function groupStates(variants, states) {
213
218
  * never shows an empty block next to a real one; with nothing documented at all it shows one
214
219
  * "No examples documented." block. */
215
220
  export function presentationBlocks(def, options = {}) {
221
+ if (def.tokenSections && def.tokenSections.length > 0) {
222
+ // Each section carries its own label, so the block itself has none.
223
+ return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
224
+ }
216
225
  if (def.tokenGallery) {
217
226
  const title = def.fullWidthLabel ?? 'Tokens';
218
227
  return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
@@ -237,7 +246,13 @@ export function presentationBlocks(def, options = {}) {
237
246
  states = grouped.leftovers;
238
247
  }
239
248
  else if (def.variants) {
240
- blocks.push({ kind: 'list', title: 'Variants', size: slotSize(def, def.variants), items: slotItems(def.variants) });
249
+ blocks.push({
250
+ kind: 'list',
251
+ title: 'Variants',
252
+ size: slotSize(def, def.variants),
253
+ items: slotItems(def.variants),
254
+ ...(def.variants.maxColumns !== undefined ? { maxColumns: def.variants.maxColumns } : {}),
255
+ });
241
256
  }
242
257
  else if (def.render) {
243
258
  blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
@@ -250,6 +265,7 @@ export function presentationBlocks(def, options = {}) {
250
265
  title: secondary ? 'Other configurations' : 'States / configurations',
251
266
  size: slotSize(def, states),
252
267
  items: slotItems(states),
268
+ ...(states.maxColumns !== undefined ? { maxColumns: states.maxColumns } : {}),
253
269
  });
254
270
  }
255
271
  if (blocks.length === 0 && !hide.variants && !hide.states) {
@@ -270,8 +286,8 @@ export function choosePlacement({ available, gap, first, second }) {
270
286
  const minCell = second.size === 'wide' ? MATRIX_LAYOUT.columnMaxWidth : COLUMN_MIN_WIDTH[second.size];
271
287
  if (room < minCell + 2)
272
288
  return 'stacked';
273
- const stackedRows = listGeometry(second.itemCount, available, second.size).rows;
274
- const sideRows = listGeometry(second.itemCount, room, second.size).rows;
289
+ const stackedRows = listGeometry(second.itemCount, available, second.size, second.maxColumns).rows;
290
+ const sideRows = listGeometry(second.itemCount, room, second.size, second.maxColumns).rows;
275
291
  const rowHeight = second.height / Math.max(1, stackedRows);
276
292
  const sideHeight = Math.max(first.height, sideRows * rowHeight);
277
293
  const stackedHeight = first.height + gap + second.height;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure layout maths for token pages — no React or React Native runtime imports, so it is unit
3
+ * tested directly (`__tests__/tokenLayout.test.ts`).
4
+ *
5
+ * Token pages lay out in two levels:
6
+ * - sections (one titled card per kind of token, e.g. "Semantic" vs "Palette") flow in up to
7
+ * `tokenColumns` columns, dropping to fewer rather than squeezing a section under its minimum;
8
+ * - inside a section, `TokenGrid` tiles as many equal tiles per row as keep their minimum width.
9
+ */
10
+ /** Narrowest a side-by-side token section may get before the page drops a column. */
11
+ export const TOKEN_SECTION_MIN_WIDTH = 280;
12
+ /** Gap between side-by-side sections and between tiles. */
13
+ export const TOKEN_LAYOUT_GAP = 20;
14
+ /** How many section columns fit in `available` px, at most `requested`, never below 1. */
15
+ export function sectionColumns(available, requested) {
16
+ const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (TOKEN_SECTION_MIN_WIDTH + TOKEN_LAYOUT_GAP));
17
+ return Math.max(1, Math.min(requested, fit));
18
+ }
19
+ /** Equal tiles per row for a `TokenGrid`: as many as keep `minTileWidth` (capped by `maxColumns`),
20
+ * sized so the row is filled exactly. */
21
+ export function tileGeometry(available, minTileWidth, maxColumns = Infinity) {
22
+ const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (minTileWidth + TOKEN_LAYOUT_GAP));
23
+ const columns = Math.max(1, Math.min(maxColumns, fit));
24
+ const tileWidth = columns === 1 ? available : (available - TOKEN_LAYOUT_GAP * (columns - 1)) / columns;
25
+ return { columns, tileWidth };
26
+ }
@@ -136,6 +136,7 @@ export function CatalogShell<TId extends string>({
136
136
  updateEndpoint?: { baseUrl: string; secret: string };
137
137
  }) {
138
138
  const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
139
+ const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
139
140
  const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
140
141
  const [active, setActive] = useState<ShellPageId<TId> | undefined>(() =>
141
142
  isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
@@ -215,7 +216,7 @@ export function CatalogShell<TId extends string>({
215
216
 
216
217
  return (
217
218
  <View style={styles.root}>
218
- <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} active={active} onPress={select} footer={footer} />
219
+ <CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} labels={labels} active={active} onPress={select} footer={footer} />
219
220
  <ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
220
221
  {active === UPDATE_PAGE_ID ? (
221
222
  <UpdatePanel update={update ?? null} endpoint={updateEndpoint} appName={appName} headingRef={headingRef} />
@@ -233,6 +234,7 @@ export function CatalogShell<TId extends string>({
233
234
  previousId: neighbors(order, activeDef.id).previous,
234
235
  nextId: neighbors(order, activeDef.id).next,
235
236
  onNavigate: select,
237
+ labelOf: (id) => labels.get(id) ?? id,
236
238
  }}
237
239
  headingRef={headingRef}
238
240
  defaultPreviewWidths={defaultPreviewWidths}
@@ -13,7 +13,7 @@ function webLinkProps(id: string, active: boolean) {
13
13
 
14
14
  /** One nav link. Hover via onHoverIn/onHoverOut (react-native-web fires them; native never does);
15
15
  * keyboard focus draws the catalog focus ring because react-native-web removes the browser's. */
16
- function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active: boolean; onPress: () => void }) {
16
+ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId; label: string; active: boolean; onPress: () => void }) {
17
17
  const [hovered, setHovered] = useState(false);
18
18
  const [focused, setFocused] = useState(false);
19
19
  return (
@@ -38,7 +38,7 @@ function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active:
38
38
  focused && styles.focusRing,
39
39
  ]}
40
40
  >
41
- <Text style={[styles.label, active && styles.labelActive]}>{id}</Text>
41
+ <Text style={[styles.label, active && styles.labelActive]}>{label}</Text>
42
42
  </Pressable>
43
43
  );
44
44
  }
@@ -79,6 +79,7 @@ export function CatalogSidebar<TId extends string>({
79
79
  logoImageSource,
80
80
  caption,
81
81
  groups,
82
+ labels,
82
83
  active,
83
84
  onPress,
84
85
  footer,
@@ -91,6 +92,8 @@ export function CatalogSidebar<TId extends string>({
91
92
  logoImageSource?: ImageSourcePropType;
92
93
  caption: string;
93
94
  groups: NavGroup<TId>[];
95
+ /** Display titles by page id (`SectionDef.title`); pages without one show their id. */
96
+ labels?: ReadonlyMap<string, string>;
94
97
  active: string | undefined;
95
98
  onPress: (id: TId) => void;
96
99
  /** The quiet update-notice link below the nav list (design §5's footer line), or `undefined`
@@ -112,7 +115,7 @@ export function CatalogSidebar<TId extends string>({
112
115
  const [loadedAspect, setLoadedAspect] = useState<number | undefined>(undefined);
113
116
  const [logoFailed, setLogoFailed] = useState(false);
114
117
  const [nameWrapped, setNameWrapped] = useState(false);
115
- const filtered = filterGroups(groups, query);
118
+ const filtered = filterGroups(groups, query, labels);
116
119
 
117
120
  const logoAspect = staticAspect ?? loadedAspect;
118
121
  const attemptingLogo = Boolean(logoImageSource) && !logoFailed;
@@ -191,7 +194,7 @@ export function CatalogSidebar<TId extends string>({
191
194
  <View key={group.label}>
192
195
  <Text style={styles.groupLabel}>{group.label}</Text>
193
196
  {group.ids.map((id) => (
194
- <NavItem key={id} id={id} active={active === id} onPress={() => onPress(id)} />
197
+ <NavItem key={id} id={id} label={labels?.get(id) ?? id} active={active === id} onPress={() => onPress(id)} />
195
198
  ))}
196
199
  </View>
197
200
  ))}
@@ -2,14 +2,26 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
5
- import type { ComparisonDef, SpecimenSize } from './types';
5
+ import { SpecimenSurface } from './SpecimenSurface';
6
+ import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
6
7
 
7
8
  /**
8
9
  * A grid of two props that combine freely, rendered as an accessible table inside one card.
9
10
  * Columns grow with the window up to 402px and never shrink below the specimen size's minimum;
10
- * a grid wider than its container scrolls horizontally inside the card, never the page.
11
+ * a grid wider than its container scrolls horizontally inside the card, never the page. `surface`
12
+ * is the page's `specimenSurface` behind each cell's example.
11
13
  */
12
- export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; size: SpecimenSize; sectionId: string }) {
14
+ export function ComparisonGrid({
15
+ def,
16
+ size,
17
+ sectionId,
18
+ surface = 'neutral',
19
+ }: {
20
+ def: ComparisonDef;
21
+ size: SpecimenSize;
22
+ sectionId: string;
23
+ surface?: SpecimenSurfaceKind;
24
+ }) {
13
25
  if (__DEV__) {
14
26
  for (const issue of validateComparison({ ...def, size })) console.warn(`[Catalog] ${sectionId}: ${issue}`);
15
27
  }
@@ -53,7 +65,9 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
53
65
  return (
54
66
  <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
55
67
  {cell?.node !== undefined ? (
56
- <View style={cell.fill ? styles.fill : styles.center}>{cell.node}</View>
68
+ <SpecimenSurface surface={surface} style={cell.fill && styles.fill}>
69
+ {cell.node}
70
+ </SpecimenSurface>
57
71
  ) : (
58
72
  <Text style={styles.unavailable}>
59
73
  {cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
@@ -88,7 +102,6 @@ const styles = StyleSheet.create({
88
102
  headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
89
103
  bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
90
104
  specimenCell: { alignItems: 'center', justifyContent: 'center' },
91
- center: { alignItems: 'center' },
92
105
  fill: { alignSelf: 'stretch' },
93
106
  headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
94
107
  rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },