@krapwoo/ds-viewer 0.4.2 → 0.4.3

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
@@ -204,7 +204,58 @@ renders in a Preview card (phone width for components). For a token-gallery sect
204
204
  data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGallery`), set
205
205
  `tokenGallery: true` instead of `props`/`a11y`/`states` — `SectionBlock` then renders `render()`'s
206
206
  output full width under a "Tokens" label and shows only a Quick reference card with the source
207
- path.
207
+ path. For anything beyond a single list of tokens, use the token-page layouts below.
208
+
209
+ ### Organising the catalog
210
+
211
+ - **Two sidebar groups: `Tokens`, then `Components`** (`groupOrder: ['Tokens', 'Components']`).
212
+ Pages sort A–Z inside each group. Category groups ("Actions", "Status") make readers guess
213
+ where a component lives; search and A–Z already find it.
214
+ - **Wide components at 3 columns.** Give block-level components (Banner, Card, Toast,
215
+ InputField) `specimenSize: 'regular'`, so Variants and States sit 3 across on a laptop
216
+ (about 316px each). `'wide'` (2 columns, phone width) is only for pages where the full phone
217
+ width is what you're checking, such as truncation or full-screen sheets. 4 columns is too narrow
218
+ for these components.
219
+ - **Mark OS components.** When a component is, or contains, a native platform control rather than
220
+ a custom one (a native date picker, iOS Liquid Glass), set `osComponent: 'full'` or
221
+ `'partial'`. The page shows an "OS component" or "Partly OS component" badge under its title;
222
+ say in `description` which part is native and how it differs on web, where the native part
223
+ usually can't render. Custom replacements styled after a platform control (a custom switch or
224
+ spinner) are not OS components.
225
+ - **Readable titles.** The page id is its address (`#ControlHeights`) and comes from the file
226
+ name; set `title: 'Control heights'` for the heading, sidebar and search. Pages still sort by id.
227
+ - **Point token pages at their token file.** A token page has no component, so its Source would be
228
+ the page file; set `source: 'src/design-system/tokens.ts'` (project-relative) instead.
229
+ - **Coverage only counts the component's own options.** `doctor`'s `option-not-covered` checks
230
+ string-literal unions declared in the component's own folder or a token folder. A union
231
+ declared in another component's folder, such as a shared `IconName`, is that component's
232
+ options, so a Banner page never needs one example per icon.
233
+
234
+ ### Token pages
235
+
236
+ A token page shows each token at its real value with its role. Pick the layout by the token's
237
+ shape:
238
+
239
+ | Token shape | Layout | Example |
240
+ |---|---|---|
241
+ | 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 |
242
+ | Narrow sample with a long note (icon size, spacing) | `TokenRow` with `notePlacement="right"`: one line per token | IconSizes |
243
+ | Several kinds of token on one page | `tokenSections`: one titled card per kind | Colors (semantic vs palette) |
244
+ | A scale with families (type) | `tokenSections` with `tokenColumns: 2` or `3`, one section per family | Typography (label, body, emphasis…) |
245
+
246
+ - **`tokenSections: [{ title, desc?, wide?, render }]`** replaces `render`. Each section is its
247
+ own titled card. Keep different kinds of token in different sections, never mixed: semantic roles
248
+ apart from raw palette ramps, each type family apart.
249
+ - **`tokenColumns: 1 | 2 | 3`** places sections side by side. Use 2–3 for narrow sections (type
250
+ families); the page drops a column rather than squeeze a section under 280px. `wide: true`
251
+ gives a section its own full row, for wide content such as palette ramps.
252
+ - **Show each token's role, not its category.** The note says where or why the token is used, in
253
+ that order of preference: the token file's own comments, then a usage scan of the code (name the
254
+ main components and how many files use it, and say so when a token is unused). Never invent
255
+ advice, and never write a generic category such as "Regular reading text" when the name and
256
+ value already say it. If there's nothing grounded to say, leave the note out.
257
+ - **Label each sample with its name and real value** (`bodyMd · 16/22 · 400`, `medium · 12px`).
258
+ Use realistic sample text from the product, short enough to fit its column at the largest size.
208
259
 
209
260
  ## Keeping the catalog current
210
261
 
@@ -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,6 +4,7 @@ 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
  }
@@ -35,6 +36,8 @@ function toSectionDef(page) {
35
36
  states: page.statesItems ? { itemsFill: page.statesItemsFill, items: page.statesItems.map(placeholderItem) } : undefined,
36
37
  comparison,
37
38
  render: page.hasRender ? () => null : undefined,
39
+ tokenSections: page.tokenSectionTitles?.map((title, i) => ({ title, wide: page.tokenSectionWide?.[i] || undefined, render: () => null })),
40
+ tokenColumns: page.tokenColumns === 2 || page.tokenColumns === 3 ? page.tokenColumns : undefined,
38
41
  hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
39
42
  };
40
43
  }
@@ -64,6 +67,19 @@ function explainBlock(block) {
64
67
  const widths = block.widths === 'full' ? 'full width' : `${block.widths.join('/')}px`;
65
68
  return { kind: 'preview', title: block.title, reason: `render() content at ${widths}.` };
66
69
  }
70
+ if (block.kind === 'tokenSections') {
71
+ const n = block.sections.length;
72
+ const titles = block.sections.every((s) => s.title) ? ` (${block.sections.map((s) => s.title).join(', ')})` : '';
73
+ const wide = block.sections.filter((s) => s.wide).length;
74
+ const narrow = n - wide;
75
+ const fit = sectionColumns(MATRIX_LAYOUT.laptopContentWidth, block.columns);
76
+ const parts = [];
77
+ if (narrow > 0)
78
+ parts.push(narrow > 1 && fit > 1 ? `${Math.min(fit, narrow)} side by side on a 1280px laptop` : 'one per row');
79
+ if (wide > 0)
80
+ parts.push(`${wide} full width`);
81
+ return { kind: 'tokenSections', title: 'Token sections', reason: `${n} section${n === 1 ? '' : 's'}${titles}: ${parts.join('; ')}.` };
82
+ }
67
83
  return { kind: 'empty', title: block.title, reason: block.message };
68
84
  }
69
85
  /** Mirrors `native/catalog/SectionBlock.tsx`'s own `blockWidth` helper: a list block's width is its
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;
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
  }
@@ -213,6 +213,10 @@ function groupStates(variants, states) {
213
213
  * never shows an empty block next to a real one; with nothing documented at all it shows one
214
214
  * "No examples documented." block. */
215
215
  export function presentationBlocks(def, options = {}) {
216
+ if (def.tokenSections && def.tokenSections.length > 0) {
217
+ // Each section carries its own label, so the block itself has none.
218
+ return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
219
+ }
216
220
  if (def.tokenGallery) {
217
221
  const title = def.fullWidthLabel ?? 'Tokens';
218
222
  return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
@@ -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
  ))}
@@ -5,6 +5,7 @@ import { ComparisonGrid } from './ComparisonGrid';
5
5
  import { ComparisonGroups } from './ComparisonGroups';
6
6
  import { ComparisonList } from './ComparisonList';
7
7
  import { ReferenceDetails } from './ReferenceDetails';
8
+ import { TokenSections } from './TokenLayouts';
8
9
  import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
9
10
  import type { PreviewWidths, SectionDef } from './types';
10
11
 
@@ -45,23 +46,27 @@ export interface SectionPager<TId extends string> {
45
46
  previousId: TId | null;
46
47
  nextId: TId | null;
47
48
  onNavigate: (id: TId) => void;
49
+ /** Display title for a page id, for the buttons' accessible names. */
50
+ labelOf?: (id: TId) => string;
48
51
  }
49
52
 
50
53
  function PagerButton<TId extends string>({
51
54
  direction,
52
55
  targetId,
53
56
  onNavigate,
57
+ labelOf,
54
58
  }: {
55
59
  direction: 'previous' | 'next';
56
60
  targetId: TId | null;
57
61
  onNavigate: (id: TId) => void;
62
+ labelOf?: (id: TId) => string;
58
63
  }) {
59
64
  const [hovered, setHovered] = useState(false);
60
65
  const [focused, setFocused] = useState(false);
61
66
  const disabled = targetId == null;
62
67
  const label = disabled
63
68
  ? direction === 'previous' ? 'No previous page' : 'No next page'
64
- : `${direction === 'previous' ? 'Previous' : 'Next'}: ${targetId}`;
69
+ : `${direction === 'previous' ? 'Previous' : 'Next'}: ${labelOf ? labelOf(targetId) : targetId}`;
65
70
  return (
66
71
  <Pressable
67
72
  onPress={() => {
@@ -127,6 +132,8 @@ function BlockContent<TId extends string>({ block, def }: { block: PresentationB
127
132
  return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} />;
128
133
  case 'preview':
129
134
  return def.render ? <Preview render={def.render} widths={block.widths} /> : null;
135
+ case 'tokenSections':
136
+ return <TokenSections sections={block.sections} columns={block.columns} />;
130
137
  default:
131
138
  return (
132
139
  <View style={styles.previewCard}>
@@ -191,11 +198,11 @@ function Blocks<TId extends string>({ blocks, def }: { blocks: PresentationBlock
191
198
  <View onLayout={onContainerLayout} style={[styles.blocks, side && styles.blocksSide]}>
192
199
  {blocks.map((block, i) => (
193
200
  <View
194
- key={block.title}
201
+ key={block.title || block.kind}
195
202
  onLayout={onBlockLayout(i)}
196
203
  style={side ? (i === 0 ? { width: blockWidth(block, available), flexShrink: 0 } : styles.blockFill) : undefined}
197
204
  >
198
- <Text style={styles.blockLabel}>{block.title}</Text>
205
+ {block.title ? <Text style={styles.blockLabel}>{block.title}</Text> : null}
199
206
  <BlockContent block={block} def={def} />
200
207
  </View>
201
208
  ))}
@@ -247,14 +254,19 @@ export function SectionBlock<TId extends string>({
247
254
  <View style={styles.titleText}>
248
255
  {/* The focus target is the heading itself, so assistive tech announces its role and level. */}
249
256
  <View ref={headingRef} tabIndex={-1} role="heading" {...headingLevelProps} style={styles.headingTarget}>
250
- <Text style={styles.title}>{def.id}</Text>
257
+ <Text style={styles.title}>{def.title ?? def.id}</Text>
251
258
  </View>
259
+ {def.osComponent && (
260
+ <View style={styles.osBadge}>
261
+ <Text style={styles.osBadgeText}>{def.osComponent === 'full' ? 'OS component' : 'Partly OS component'}</Text>
262
+ </View>
263
+ )}
252
264
  <Text style={styles.desc}>{def.description}</Text>
253
265
  </View>
254
266
  {pager && (
255
267
  <View style={styles.pager}>
256
- <PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} />
257
- <PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} />
268
+ <PagerButton direction="previous" targetId={pager.previousId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
269
+ <PagerButton direction="next" targetId={pager.nextId} onNavigate={pager.onNavigate} labelOf={pager.labelOf} />
258
270
  </View>
259
271
  )}
260
272
  </View>
@@ -278,6 +290,17 @@ const styles = StyleSheet.create({
278
290
  },
279
291
  titleText: { flex: 1 },
280
292
  headingTarget: { alignSelf: 'flex-start' },
293
+ osBadge: {
294
+ alignSelf: 'flex-start',
295
+ marginBottom: CATALOG_SPACE.sm,
296
+ paddingHorizontal: CATALOG_SPACE.sm,
297
+ paddingVertical: 3,
298
+ borderRadius: CATALOG_RADIUS.control,
299
+ borderWidth: 1,
300
+ borderColor: CATALOG_COLOR.borderStrong,
301
+ backgroundColor: CATALOG_COLOR.surface,
302
+ },
303
+ osBadgeText: { fontSize: CATALOG_TYPE.xs, fontWeight: '700', letterSpacing: 0.4, color: CATALOG_COLOR.text },
281
304
  title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '700', color: CATALOG_COLOR.text, marginBottom: 5 },
282
305
  desc: { fontSize: CATALOG_TYPE.md, lineHeight: 20, color: CATALOG_COLOR.textMuted, maxWidth: 700 },
283
306
  pager: { flexDirection: 'row', gap: 6, flexShrink: 0 },
@@ -0,0 +1,92 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
+ import { sectionColumns, tileGeometry, TOKEN_LAYOUT_GAP } from './tokenLayout';
5
+ import type { TokenSection } from './types';
6
+
7
+ /** Measures its own width; renders nothing inside until the width is known. */
8
+ function useWidth(): [number, (event: LayoutChangeEvent) => void] {
9
+ const [width, setWidth] = useState(0);
10
+ return [width, (event) => {
11
+ const next = Math.round(event.nativeEvent.layout.width);
12
+ if (next > 0 && next !== width) setWidth(next);
13
+ }];
14
+ }
15
+
16
+ /**
17
+ * A token page's sections (`SectionDef.tokenSections`): one titled card per kind of token, flowing
18
+ * in up to `columns` columns. A `wide` section takes a full row; the page drops to fewer columns
19
+ * rather than squeeze a section under its minimum width.
20
+ */
21
+ export function TokenSections({ sections, columns }: { sections: TokenSection[]; columns: 1 | 2 | 3 }) {
22
+ const [width, onLayout] = useWidth();
23
+ const perRow = sectionColumns(width, columns);
24
+ const columnWidth = perRow === 1 ? width : (width - TOKEN_LAYOUT_GAP * (perRow - 1)) / perRow;
25
+ return (
26
+ <View onLayout={onLayout} style={styles.sections}>
27
+ {width > 0 && sections.map((section) => (
28
+ <View key={section.title} style={{ width: section.wide || perRow === 1 ? width : columnWidth }}>
29
+ <Text style={styles.sectionLabel}>{section.title}</Text>
30
+ {section.desc ? <Text style={styles.sectionDesc}>{section.desc}</Text> : null}
31
+ <View style={styles.card}>{section.render()}</View>
32
+ </View>
33
+ ))}
34
+ </View>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Tokens as tiles in columns — for tokens whose preview is small and self-contained (radius,
40
+ * shadow, colour, control height). As many equal tiles per row as keep `minTileWidth`.
41
+ */
42
+ export function TokenGrid({ children, minTileWidth = 140, maxColumns }: { children: React.ReactNode; minTileWidth?: number; maxColumns?: number }) {
43
+ const [width, onLayout] = useWidth();
44
+ const { tileWidth } = tileGeometry(width, minTileWidth, maxColumns);
45
+ return (
46
+ <View onLayout={onLayout} style={styles.grid}>
47
+ {width > 0 && React.Children.map(children, (child) => (child ? <View style={{ width: tileWidth }}>{child}</View> : null))}
48
+ </View>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * One token in a `TokenGrid`: its preview, then its name and value, then its role. `note` says
54
+ * where or why the token is used (from its comments or real usages) — omit it when there is none.
55
+ */
56
+ export function TokenTile({ name, value, note, children }: { name: string; value?: string; note?: string; children: React.ReactNode }) {
57
+ return (
58
+ <View style={styles.tile}>
59
+ <View style={styles.tilePreview}>{children}</View>
60
+ <Text style={styles.tileName}>{name}</Text>
61
+ {value ? <Text style={styles.tileValue}>{value}</Text> : null}
62
+ {note ? <Text style={styles.tileNote}>{note}</Text> : null}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create({
68
+ sections: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP, rowGap: CATALOG_LAYOUT.blockGap, alignItems: 'flex-start' },
69
+ sectionLabel: {
70
+ fontSize: CATALOG_TYPE.sm,
71
+ fontWeight: '700',
72
+ letterSpacing: 0.72,
73
+ textTransform: 'uppercase',
74
+ color: CATALOG_COLOR.text,
75
+ },
76
+ sectionDesc: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: CATALOG_SPACE.xs },
77
+ card: {
78
+ marginTop: CATALOG_LAYOUT.blockLabelGap,
79
+ backgroundColor: CATALOG_COLOR.surface,
80
+ borderWidth: 1,
81
+ borderColor: CATALOG_COLOR.borderStrong,
82
+ borderRadius: CATALOG_RADIUS.card,
83
+ padding: CATALOG_SPACE.xl,
84
+ gap: CATALOG_SPACE.md,
85
+ },
86
+ grid: { flexDirection: 'row', flexWrap: 'wrap', gap: TOKEN_LAYOUT_GAP },
87
+ tile: { gap: CATALOG_SPACE.xs },
88
+ tilePreview: { minHeight: 56, justifyContent: 'center', marginBottom: CATALOG_SPACE.xs },
89
+ tileName: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
90
+ tileValue: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted },
91
+ tileNote: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted, lineHeight: 16 },
92
+ });
@@ -13,17 +13,41 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
13
13
  * reads as a list, not a loose pile of paragraphs. Pass `last` on the final row in a stack to drop
14
14
  * the divider, matching PropsTable's own `rowLast` convention.
15
15
  */
16
- export function TokenRow({ children, use, last = false }: { children: React.ReactNode; use: string; last?: boolean }) {
16
+ export function TokenRow({
17
+ children,
18
+ use,
19
+ last = false,
20
+ notePlacement = 'below',
21
+ sampleWidth = 280,
22
+ }: {
23
+ children: React.ReactNode;
24
+ use: string;
25
+ last?: boolean;
26
+ /** `'right'` puts the note beside a narrow sample (icon sizes, spacing, control heights) so each
27
+ * token is one line; the sample keeps `sampleWidth` and the note takes the rest. */
28
+ notePlacement?: 'below' | 'right';
29
+ sampleWidth?: number;
30
+ }) {
31
+ if (notePlacement === 'right') {
32
+ return (
33
+ <View style={[styles.itemRight, !last && styles.itemDivider]}>
34
+ <View style={{ width: sampleWidth, flexShrink: 0 }}>{children}</View>
35
+ {use ? <Text style={[styles.use, styles.useRight]}>{use}</Text> : null}
36
+ </View>
37
+ );
38
+ }
17
39
  return (
18
40
  <View style={[styles.item, !last && styles.itemDivider]}>
19
41
  {children}
20
- <Text style={styles.use}>{use}</Text>
42
+ {use ? <Text style={styles.use}>{use}</Text> : null}
21
43
  </View>
22
44
  );
23
45
  }
24
46
 
25
47
  const styles = StyleSheet.create({
26
48
  item: { gap: CATALOG_SPACE.xs },
49
+ itemRight: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.xl },
50
+ useRight: { flex: 1 },
27
51
  itemDivider: {
28
52
  paddingBottom: CATALOG_SPACE.md,
29
53
  borderBottomWidth: 1,
@@ -48,12 +48,17 @@ export function neighbors<TId extends string>(
48
48
  }
49
49
 
50
50
  /** Sidebar filter: case-insensitive substring match, sorted ids, empty groups removed. */
51
- export function filterGroups<TId extends string>(groups: readonly NavGroup<TId>[], query: string): NavGroup<TId>[] {
51
+ export function filterGroups<TId extends string>(
52
+ groups: readonly NavGroup<TId>[],
53
+ query: string,
54
+ /** Display titles by id; a page matches on its id or its title. */
55
+ labels?: ReadonlyMap<string, string>,
56
+ ): NavGroup<TId>[] {
52
57
  const q = query.trim().toLowerCase();
53
58
  return groups
54
59
  .map((group) => ({
55
60
  label: group.label,
56
- ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)) : group.ids),
61
+ ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q) || (labels?.get(id)?.toLowerCase().includes(q) ?? false)) : group.ids),
57
62
  }))
58
63
  .filter((group) => group.ids.length > 0);
59
64
  }
@@ -8,7 +8,7 @@
8
8
  * It also decides whether two blocks sit side by side (choosePlacement) and how many columns the
9
9
  * Props box uses (propsColumns). ComparisonGrid, ComparisonGroups, and ComparisonList render it.
10
10
  */
11
- import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, VariantSlot } from './types.ts';
11
+ import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, TokenSection, VariantSlot } from './types.ts';
12
12
  import type React from 'react';
13
13
 
14
14
  /** Approved geometry. `cellPadding` equals CATALOG_SPACE.lg (asserted in tests). The laptop
@@ -230,6 +230,7 @@ export type PresentationBlock =
230
230
  | { kind: 'grouped'; title: string; size: SpecimenSize; groups: ListGroup[] }
231
231
  | { kind: 'list'; title: string; size: SpecimenSize; items: ListItem[] }
232
232
  | { kind: 'preview'; title: string; widths: PreviewWidths }
233
+ | { kind: 'tokenSections'; title: string; sections: TokenSection[]; columns: 1 | 2 | 3 }
233
234
  | { kind: 'empty'; title: string; message: string };
234
235
 
235
236
  export interface PresentationOptions {
@@ -280,6 +281,10 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
280
281
  * never shows an empty block next to a real one; with nothing documented at all it shows one
281
282
  * "No examples documented." block. */
282
283
  export function presentationBlocks<TId extends string>(def: SectionDef<TId>, options: PresentationOptions = {}): PresentationBlock[] {
284
+ if (def.tokenSections && def.tokenSections.length > 0) {
285
+ // Each section carries its own label, so the block itself has none.
286
+ return [{ kind: 'tokenSections', title: '', sections: def.tokenSections, columns: def.tokenColumns ?? 1 }];
287
+ }
283
288
  if (def.tokenGallery) {
284
289
  const title = def.fullWidthLabel ?? 'Tokens';
285
290
  return [def.render ? { kind: 'preview', title, widths: 'full' } : { kind: 'empty', title, message: 'Nothing to preview.' }];
@@ -24,6 +24,7 @@ export { ReferenceDetails } from './ReferenceDetails';
24
24
  export { PropsTable } from './PropsTable';
25
25
  export { VariantGroup } from './VariantGroup';
26
26
  export { TokenRow } from './TokenRow';
27
+ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
27
28
  export { DividedStack } from './DividedStack';
28
29
  export { Swatch } from './Swatch';
29
30
  export { PhoneFrame } from './PhoneFrame';
@@ -33,7 +34,7 @@ export { buildComponentManifest } from './manifest';
33
34
  export type { ComponentManifestEntry, ManifestExample } from './manifest';
34
35
  export { defineCatalogPage, buildCatalogSections } from './pageApi';
35
36
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
36
- export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths } from './types';
37
+ export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths, TokenSection } from './types';
37
38
  export { UpdatePanel } from './UpdatePanel';
38
39
  export { UPDATE_PAGE_ID } from './catalogNavigation';
39
40
  export type { UpdateNotice } from './types';
@@ -1,5 +1,5 @@
1
1
  import type React from 'react';
2
- import type { ComparisonDef, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, VariantSlot } from './types';
2
+ import type { ComparisonDef, NavGroup, PreviewWidths, PropDef, SectionDef, SpecimenSize, TokenSection, VariantSlot } from './types';
3
3
 
4
4
  /** `SectionDef` without the derived fields (`id`/`path`/`props` come from detection and
5
5
  * generated props), plus the three fields design §1 "Page shape" adds. */
@@ -9,6 +9,14 @@ export interface CatalogPageInput<TId extends string = string> {
9
9
  * default when writing the generated page index, so an authored page never has to repeat a name
10
10
  * already implied by its file. */
11
11
  id?: TId;
12
+ /** Display name; see `SectionDef.title`. */
13
+ title?: string;
14
+ /** Project-relative file shown as the page's Source when the page documents something other
15
+ * than a component, e.g. a token page pointing at `src/design-system/tokens.ts`. A component's
16
+ * own file always wins. */
17
+ source?: string;
18
+ /** See `SectionDef.osComponent`. */
19
+ osComponent?: 'full' | 'partial';
12
20
  description: string;
13
21
  whenToUse?: string;
14
22
  a11y?: string;
@@ -16,6 +24,8 @@ export interface CatalogPageInput<TId extends string = string> {
16
24
  states?: VariantSlot;
17
25
  render?: () => React.ReactNode;
18
26
  tokenGallery?: boolean;
27
+ tokenSections?: TokenSection[];
28
+ tokenColumns?: 1 | 2 | 3;
19
29
  fullWidthLabel?: string;
20
30
  comparison?: ComparisonDef;
21
31
  specimenSize?: SpecimenSize;
@@ -121,15 +131,20 @@ export function buildCatalogSections<TId extends string>(
121
131
  // to the page's own file as its Source instead of an empty path.
122
132
  return {
123
133
  id: page.id,
134
+ title: page.title,
135
+ osComponent: page.osComponent,
124
136
  description: page.description,
125
- path: component?.file ?? page.file,
137
+ path: component?.file ?? page.source ?? page.file,
126
138
  whenToUse: page.whenToUse,
127
139
  props: component ? mergePropNotes(component, page.propNotes) : undefined,
128
140
  a11y: page.a11y,
129
141
  variants: page.variants,
130
142
  states: page.states,
131
143
  render: page.render,
132
- tokenGallery: page.tokenGallery,
144
+ // `tokenSections` makes a page a token gallery by itself.
145
+ tokenGallery: page.tokenGallery || (page.tokenSections !== undefined && page.tokenSections.length > 0) || undefined,
146
+ tokenSections: page.tokenSections,
147
+ tokenColumns: page.tokenColumns,
133
148
  fullWidthLabel: page.fullWidthLabel,
134
149
  comparison: page.comparison,
135
150
  specimenSize: page.specimenSize,
@@ -0,0 +1,29 @@
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
+
11
+ /** Narrowest a side-by-side token section may get before the page drops a column. */
12
+ export const TOKEN_SECTION_MIN_WIDTH = 280;
13
+ /** Gap between side-by-side sections and between tiles. */
14
+ export const TOKEN_LAYOUT_GAP = 20;
15
+
16
+ /** How many section columns fit in `available` px, at most `requested`, never below 1. */
17
+ export function sectionColumns(available: number, requested: number): number {
18
+ const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (TOKEN_SECTION_MIN_WIDTH + TOKEN_LAYOUT_GAP));
19
+ return Math.max(1, Math.min(requested, fit));
20
+ }
21
+
22
+ /** Equal tiles per row for a `TokenGrid`: as many as keep `minTileWidth` (capped by `maxColumns`),
23
+ * sized so the row is filled exactly. */
24
+ export function tileGeometry(available: number, minTileWidth: number, maxColumns = Infinity): { columns: number; tileWidth: number } {
25
+ const fit = Math.floor((available + TOKEN_LAYOUT_GAP) / (minTileWidth + TOKEN_LAYOUT_GAP));
26
+ const columns = Math.max(1, Math.min(maxColumns, fit));
27
+ const tileWidth = columns === 1 ? available : (available - TOKEN_LAYOUT_GAP * (columns - 1)) / columns;
28
+ return { columns, tileWidth };
29
+ }
@@ -102,6 +102,13 @@ export interface ComparisonDef {
102
102
  * typed to the app's real section list without this file needing to know what they are. */
103
103
  export interface SectionDef<TId extends string = string> {
104
104
  id: TId;
105
+ /** Display name for the heading, sidebar and search (e.g. "Control heights"). Defaults to `id`,
106
+ * which stays the page's address (`#ControlHeights`) and its name in `explain`. */
107
+ title?: string;
108
+ /** Marks a component that is, or contains, a native platform control rather than a custom one:
109
+ * `'full'` shows an "OS component" badge under the title, `'partial'` "Partly OS component".
110
+ * Say in `description` which part is native and how it differs on web. */
111
+ osComponent?: 'full' | 'partial';
105
112
  description: string;
106
113
  path: string;
107
114
  /** One sentence disambiguating this component from its closest look-alike(s) — the deciding
@@ -163,6 +170,14 @@ export interface SectionDef<TId extends string = string> {
163
170
  * configurations list, and shows only a Quick reference card with the source path (there's no
164
171
  * component behavior to document). */
165
172
  tokenGallery?: boolean;
173
+ /** Token pages with more than one kind of token: one titled card per section (e.g. "Semantic"
174
+ * and "Palette", or "Body" and "Title"), instead of a single "Tokens" card. When set, `render`
175
+ * is not used and the page counts as a token gallery. See `TokenSection`. */
176
+ tokenSections?: TokenSection[];
177
+ /** How many sections sit side by side (1–3, default 1). Narrow sections such as type families
178
+ * use 2–3; the page drops columns rather than squeeze a section under 280px. A section with
179
+ * `wide: true` always takes a full row. */
180
+ tokenColumns?: 1 | 2 | 3;
166
181
  /** Overrides the `tokenGallery` block's label (default `'Tokens'`) — e.g. `'Preview'` for a page
167
182
  * that's a composed, realistic usage example rather than a list of raw token values. Ignored
168
183
  * unless `tokenGallery` is also set. */
@@ -206,3 +221,14 @@ export interface UpdateNotice {
206
221
  summary: string[];
207
222
  releasedAt?: string;
208
223
  }
224
+
225
+ /** One titled card on a token page (`SectionDef.tokenSections`). */
226
+ export interface TokenSection {
227
+ /** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
228
+ title: string;
229
+ /** One line under the title: what this kind of token is for. */
230
+ desc?: string;
231
+ /** Take a full row even when the page sets `tokenColumns` (e.g. palette ramps). */
232
+ wide?: boolean;
233
+ render: () => React.ReactNode;
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",