@krapwoo/ds-viewer 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +249 -3
  2. package/config/index.ts +3 -0
  3. package/dist/cli/config.js +9 -1
  4. package/dist/cli/doctor.js +452 -19
  5. package/dist/cli/explain.js +26 -0
  6. package/dist/cli/staticPage.js +99 -10
  7. package/dist/cli/tokenDefinition.js +125 -0
  8. package/dist/native/catalog/catalogNavigation.js +27 -11
  9. package/dist/native/catalog/pageApi.js +557 -26
  10. package/dist/native/catalog/tokenDefinition.js +205 -0
  11. package/native/catalog/CatalogSearchInput.tsx +18 -3
  12. package/native/catalog/CatalogShell.tsx +318 -46
  13. package/native/catalog/CatalogSidebar.tsx +37 -1
  14. package/native/catalog/FamilyPage.tsx +567 -137
  15. package/native/catalog/GroupPage.tsx +230 -0
  16. package/native/catalog/JumpRow.tsx +81 -0
  17. package/native/catalog/ReferenceDetails.tsx +144 -16
  18. package/native/catalog/SectionBlock.tsx +49 -5
  19. package/native/catalog/SourceReferencePicker.tsx +180 -0
  20. package/native/catalog/StructuredTokenPage.tsx +592 -0
  21. package/native/catalog/Swatch.tsx +29 -2
  22. package/native/catalog/TokenLayouts.tsx +30 -9
  23. package/native/catalog/catalogNavigation.ts +30 -10
  24. package/native/catalog/catalogReferences.ts +212 -0
  25. package/native/catalog/familyModel.ts +10 -4
  26. package/native/catalog/index.ts +22 -0
  27. package/native/catalog/pageApi.ts +598 -23
  28. package/native/catalog/tokenDefinition.ts +430 -0
  29. package/native/catalog/types.ts +77 -5
  30. package/package.json +1 -1
  31. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
  32. package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
  33. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +1 -0
  34. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
  35. package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
  36. package/starter-kit/components/Pill/Pill.catalog.tsx +1 -0
  37. package/starter-kit/components/TopNav/TopNav.catalog.tsx +1 -0
@@ -7,7 +7,8 @@ import { importedNames, readStaticPages } from "./staticPage.js";
7
7
  import { resolveGlob } from "./glob.js";
8
8
  import { sync } from "./sync.js";
9
9
  import { gridColumnLimit } from "../native/catalog/comparison.js";
10
- import { validateComposedOf, validateFamily } from "../native/catalog/pageApi.js";
10
+ import { resolveExternalPart, validateComposedOf, validateFamily, validateGroupIssues, } from "../native/catalog/pageApi.js";
11
+ import { canonicalTokenTitle, findDuplicateIds, isTokenKind } from "../native/catalog/tokenDefinition.js";
11
12
  function issue(fields) {
12
13
  return fields;
13
14
  }
@@ -156,24 +157,22 @@ function checkFamilyMembers(pages, components) {
156
157
  }
157
158
  return issues;
158
159
  }
159
- /** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
160
- * the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
161
- * checkable family page, with the same catalog-wide "every other page/member id" contract that
162
- * function already takes. N3: `StaticPage.sections` already carries each section's real
163
- * `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
164
- * and its N6 list-section rule can tell a `list` section from a `blocks` one. */
165
- function checkFamilyValidity(pages) {
166
- // V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
167
- // a family page.
168
- const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
169
- const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
170
- const familyIdsByPageId = new Map();
160
+ /** #64 B5: every id/alias one page itself registers — a family's member ids/aliases plus its own
161
+ * page-level `aliases` (the existing universe `checkFamilyValidity` already built inline), now
162
+ * also including a tokenDefinition page's own `aliases` and each of its sections' own `aliases`
163
+ * (extending routing/uniqueness to tokenDefinition pages — #64 B5's own requirement). Keyed by
164
+ * each page's own resolved id; a page contributing nothing is simply absent, same as before. */
165
+ function idsRegisteredByPage(pages) {
166
+ const byId = new Map();
171
167
  // I-B (independent review, confirmation pass): built from every page with members, not only
172
168
  // checkable ones — the same precedent `checkMemberComposedOf` above already follows for
173
169
  // `knownNames` (no `checkable` filter). A non-checkable family page still contributes a working
174
170
  // link at runtime (`pageApi.ts`'s `otherMemberIds` has no `checkable` concept either), so
175
171
  // excluding it here turned a valid `relatedPages` entry into a false CI-failing error.
176
- for (const page of pages.filter((p) => p.members && p.members.length > 0)) {
172
+ for (const page of pages) {
173
+ const hasMembers = Boolean(page.members && page.members.length > 0);
174
+ if (!hasMembers && !page.tokenDefinition)
175
+ continue;
177
176
  const id = resolvePageId(page, page.file);
178
177
  const ids = new Set();
179
178
  for (const member of page.members ?? []) {
@@ -183,9 +182,87 @@ function checkFamilyValidity(pages) {
183
182
  }
184
183
  for (const alias of page.aliases ?? [])
185
184
  ids.add(alias);
186
- familyIdsByPageId.set(id, ids);
185
+ for (const section of page.tokenDefinition?.sections ?? [])
186
+ for (const alias of section.aliases ?? [])
187
+ ids.add(alias);
188
+ byId.set(id, ids);
189
+ }
190
+ return byId;
191
+ }
192
+ /** Review F9: a page authored with neither `whole` nor `sections` — by presence (`hasWhole` /
193
+ * `hasSections`), never by value, so a family whose `whole`/`sections` isn't a literal (and is
194
+ * therefore left unset, non-checkable) is never mistaken for a group page. A hand-built
195
+ * `StaticPage` without the flags falls back to whether the value is set. */
196
+ function isGroupShape(p) {
197
+ return !(p.hasWhole ?? p.whole !== undefined) && !(p.hasSections ?? p.sections !== undefined);
198
+ }
199
+ /** #63 A5/A6: the static-side equivalent of `buildCatalogSections`'s own external-part pre-pass —
200
+ * built from `StaticPage[]` instead of the runtime catalog, but the same `resolveExternalPart`
201
+ * rule (imported, never reimplemented). Returns, per family page id, the external part ids it
202
+ * resolved (fed into `validateFamily`'s own `externalPartIds` exemption below) and every
203
+ * resolution problem as its own `family-external-part` finding. */
204
+ function collectExternalParts(pages) {
205
+ const memberLocations = new Map();
206
+ for (const p of pages) {
207
+ const pHasMembers = Boolean(p.members && p.members.length > 0);
208
+ const pIsGroup = pHasMembers && isGroupShape(p);
209
+ for (const member of p.members ?? []) {
210
+ memberLocations.set(member.id, [...(memberLocations.get(member.id) ?? []), { pageId: resolvePageId(p, p.file), isGroupPage: pIsGroup, parent: member.parent }]);
211
+ }
187
212
  }
213
+ const externalPartIdsByPage = new Map();
188
214
  const issues = [];
215
+ for (const page of pages) {
216
+ const hasMembers = Boolean(page.members && page.members.length > 0);
217
+ if (!hasMembers || page.whole === undefined || page.sections === undefined || !page.checkable)
218
+ continue; // not a valid family.
219
+ const id = resolvePageId(page, page.file);
220
+ const byIdThisPage = new Set(page.members.map((m) => m.id));
221
+ const external = new Set();
222
+ for (const section of page.sections) {
223
+ if (!section.inWhole)
224
+ continue;
225
+ for (const memberId of section.members) {
226
+ if (byIdThisPage.has(memberId) || external.has(memberId))
227
+ continue;
228
+ const owners = memberLocations.get(memberId);
229
+ if (!owners)
230
+ continue; // genuinely unknown — `family-invalid`'s own "not a member" already covers it.
231
+ const resolution = resolveExternalPart(memberId, owners, page.whole);
232
+ if ('issue' in resolution) {
233
+ issues.push(issue({
234
+ id: 'family-external-part', severity: 'error', ...pageRef(page),
235
+ message: `Family "${id}": ${resolution.issue}`,
236
+ fix: 'Make the id resolve to exactly one group page\'s member, with that member\'s own "parent" equal to this family\'s "whole".',
237
+ }));
238
+ }
239
+ else {
240
+ external.add(memberId);
241
+ }
242
+ }
243
+ }
244
+ if (external.size > 0)
245
+ externalPartIdsByPage.set(id, external);
246
+ }
247
+ return { externalPartIdsByPage, issues };
248
+ }
249
+ /** Plan V3's `family-invalid`: reuses `native/catalog/pageApi.ts`'s own `validateFamily` —
250
+ * the exact same rule `pageApi.ts`'s `buildCatalogSections` runs at runtime — against every
251
+ * checkable family page, with the same catalog-wide "every other page/member id" contract that
252
+ * function already takes. N3: `StaticPage.sections` already carries each section's real
253
+ * `label`/`kind`, so `validateFamily`'s messages name the real section (never an empty label),
254
+ * and its N6 list-section rule can tell a `list` section from a `blocks` one. #63 A5: a page with
255
+ * neither `whole` nor `sections` (a group) is still included in `familyPages` below, but
256
+ * `validateFamily` is now a no-op for it (see its own doc comment) — `checkGroupValidity` is its
257
+ * real validator. */
258
+ function checkFamilyValidity(pages) {
259
+ // V2c item 6: `members.length > 0`, the same predicate the runtime uses — an empty array is not
260
+ // a family page.
261
+ const familyPages = pages.filter((p) => p.members && p.members.length > 0 && p.checkable);
262
+ const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
263
+ const familyIdsByPageId = idsRegisteredByPage(pages);
264
+ const { externalPartIdsByPage, issues: externalIssues } = collectExternalParts(pages);
265
+ const issues = [...externalIssues];
189
266
  for (const page of familyPages) {
190
267
  const id = resolvePageId(page, page.file);
191
268
  const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
@@ -211,12 +288,56 @@ function checkFamilyValidity(pages) {
211
288
  // not only ever silently warned in a browser console (`npm run check:catalog`).
212
289
  relatedPages: page.relatedPages,
213
290
  };
214
- for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds)) {
291
+ const externalPartIds = externalPartIdsByPage.get(id) ?? new Set();
292
+ for (const message of validateFamily(pageLike, otherPageIds, otherMemberIds, externalPartIds)) {
215
293
  issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message, fix: 'Fix the family\'s members/whole/sections to satisfy the rule above.' }));
216
294
  }
217
295
  }
218
296
  return issues;
219
297
  }
298
+ /** #63 A1/A3/A4/A6: the static-side equivalent of `buildCatalogSections`'s own `validateGroup`
299
+ * call, against every checkable group page (`members` with no `whole`/`sections`). Reuses the
300
+ * `family-invalid` rule id for every shape error (same class the family-shaped version already
301
+ * reports under), and the new `group-anatomy-member` rule id only for an embedded anatomy
302
+ * problem (A4) — per the controller's own decision. */
303
+ function checkGroupValidity(pages) {
304
+ const groupPages = pages.filter((p) => p.members && p.members.length > 0 && isGroupShape(p) && p.checkable);
305
+ const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
306
+ const familyIdsByPageId = idsRegisteredByPage(pages);
307
+ const issues = [];
308
+ for (const page of groupPages) {
309
+ const id = resolvePageId(page, page.file);
310
+ const otherPageIds = new Set([...allPageIds].filter((pageId) => pageId !== id));
311
+ const otherMemberIds = new Set();
312
+ for (const [pageId, ids] of familyIdsByPageId)
313
+ if (pageId !== id)
314
+ for (const memberId of ids)
315
+ otherMemberIds.add(memberId);
316
+ const pageLike = {
317
+ id,
318
+ members: (page.members ?? []).map((m) => ({
319
+ id: m.id,
320
+ parent: m.parent,
321
+ aliases: m.aliases,
322
+ anatomy: m.anatomy ? { groups: [{ label: '', items: m.anatomy.memberIds.map((memberId) => ({ member: memberId, description: '' })) }] } : undefined,
323
+ })),
324
+ aliases: page.aliases,
325
+ anatomy: page.hasAnatomy ? {} : undefined,
326
+ relatedPages: page.relatedPages,
327
+ };
328
+ for (const { rule, message } of validateGroupIssues(pageLike, otherPageIds, otherMemberIds)) {
329
+ const isAnatomyRule = rule === 'group-anatomy-member';
330
+ issues.push(issue({
331
+ id: rule,
332
+ severity: 'error', ...pageRef(page), message,
333
+ fix: isAnatomyRule
334
+ ? 'Name only this member\'s own descendants on this page, and set "anatomy" only on a top-level member.'
335
+ : 'Fix the group\'s members/aliases/anatomy to satisfy the rule above.',
336
+ }));
337
+ }
338
+ }
339
+ return issues;
340
+ }
220
341
  /** Deprecation (`relatedPages`): a family page still using one or more non-`inWhole` sections to
221
342
  * document a merely-related component inline, instead of giving it its own page and naming it in
222
343
  * the page's `relatedPages` list. One finding per page (never per section), naming every member
@@ -409,6 +530,146 @@ function checkMissingComposition(pages, components) {
409
530
  }
410
531
  return issues;
411
532
  }
533
+ /** #66: a page is a family or group (it has `members`) — presence, so a page whose `members` could
534
+ * not be read is still never treated as a plain page. */
535
+ function hasMembers(page) {
536
+ return page.hasMembers === true || Boolean(page.members && page.members.length > 0);
537
+ }
538
+ /** #66: a token page — a `tokenDefinition`, a token gallery, or token sections, by presence. */
539
+ function isTokenPage(page) {
540
+ return page.hasTokenDefinition === true || page.tokenDefinition !== undefined || page.tokenGallery === true
541
+ || page.hasExplicitTokenGallery === true || page.hasExplicitTokenSections === true || Boolean(page.tokenSectionTitles?.length);
542
+ }
543
+ /** #66: a non-blank `standalone` reason opts a page out of `part-own-page` and `nothing-to-show`. */
544
+ function hasStandaloneReason(page) {
545
+ return page.standalone !== undefined && page.standalone.trim() !== '';
546
+ }
547
+ /** #66: the pages the two structure rules consider — readable, plain (no members, not a token page),
548
+ * browse-listed, and without a recorded `standalone` reason. */
549
+ function isStructureCandidate(page) {
550
+ return page.checkable && !hasMembers(page) && !isTokenPage(page) && page.navigation !== 'reference' && !hasStandaloneReason(page);
551
+ }
552
+ /** #66: the default `patternGroups` — the starter kit's own group for example screens. */
553
+ export const DEFAULT_PATTERN_GROUPS = ['Patterns'];
554
+ /** #66: whether a page sits in a pattern group (case-insensitive). A pattern page uses components; it
555
+ * is never the whole they are parts of. */
556
+ function isPatternPage(page, patternGroups) {
557
+ const group = page.group?.trim().toLowerCase();
558
+ return group !== undefined && patternGroups.some((name) => name.trim().toLowerCase() === group);
559
+ }
560
+ /** #66 `standalone-reason-empty`: a `standalone` that is blank or whitespace records no reason, so it
561
+ * is not an opt-out. */
562
+ function checkStandaloneReason(pages) {
563
+ return pages
564
+ .filter((page) => page.standalone !== undefined && page.standalone.trim() === '')
565
+ .map((page) => issue({
566
+ id: 'standalone-reason-empty', severity: 'warning', ...pageRef(page),
567
+ message: '`standalone` is empty, so it does not opt this page out of the structure checks.',
568
+ fix: "Write the reason, e.g. standalone: 'Not a part of GitFileCard; it draws its own counts.', or remove the field.",
569
+ }));
570
+ }
571
+ /** #66: every page that uses one of `names`, excluding `self`, deduplicated by host page (a page
572
+ * counts once however many of its entries name the component). A built-in entry anywhere on the
573
+ * host wins over a non-built-in one. */
574
+ function usesOf(pages, self, names) {
575
+ const byHost = new Map();
576
+ for (const host of pages) {
577
+ if (host === self)
578
+ continue;
579
+ const entries = [...(host.composedOf ?? []), ...(host.members ?? []).flatMap((member) => member.composedOf ?? [])];
580
+ for (const entry of entries) {
581
+ if (!names.has(entry.component))
582
+ continue;
583
+ const known = byHost.get(host);
584
+ if (!known || (known.relationship !== 'built-in' && entry.relationship === 'built-in'))
585
+ byHost.set(host, { host, relationship: entry.relationship });
586
+ }
587
+ }
588
+ return [...byHost.values()];
589
+ }
590
+ /** #66: the names a page answers to — its id and its component. */
591
+ function pageNames(page) {
592
+ return new Set([resolvePageId(page, page.file), ...(page.component ? [page.component] : [])]);
593
+ }
594
+ /** #66: whether some other page has composition this reader couldn't read — a page-level or
595
+ * member-level `composedOf`, or a `members` list that wasn't read at all — so "only" and "nothing
596
+ * uses it" can't be claimed outright. */
597
+ function hasUnreadableComposition(pages, self) {
598
+ return pages.some((other) => other !== self && ((other.hasComposedOf === true && other.composedOf === undefined)
599
+ || (other.hasMembers === true && other.members === undefined)
600
+ || Boolean(other.members?.some((member) => member.hasComposedOf === true && member.composedOf === undefined))));
601
+ }
602
+ const pageList = (pages) => [...new Set(pages.map((p) => `\`${resolvePageId(p, p.file)}\``))].join(', ');
603
+ /** #66 `part-own-page`: a candidate page whose component is declared `{ relationship: 'built-in' }`
604
+ * (at page or member level) by exactly one other non-pattern page, which doesn't already list it
605
+ * as a member (by member id, component or alias). Two or more users can't give it one owner, so it
606
+ * is exempt; `slot`/`related`/missing/non-literal relationships never count. Reports; never infers
607
+ * an owner or a relationship. */
608
+ function checkPartOwnPage(pages, patternGroups) {
609
+ const issues = [];
610
+ for (const page of pages) {
611
+ if (!isStructureCandidate(page))
612
+ continue;
613
+ const names = pageNames(page);
614
+ const owners = usesOf(pages, page, names)
615
+ .filter((use) => use.relationship === 'built-in' && !isPatternPage(use.host, patternGroups))
616
+ .map((use) => use.host);
617
+ if (owners.length !== 1)
618
+ continue;
619
+ const owner = owners[0];
620
+ if (owner.members?.some((member) => names.has(member.id) || (member.component !== undefined && names.has(member.component))
621
+ || member.aliases?.some((alias) => names.has(alias))))
622
+ continue;
623
+ const ownerId = resolvePageId(owner, owner.file);
624
+ const id = resolvePageId(page, page.file);
625
+ const caveat = hasUnreadableComposition(pages, page) ? ' (Some pages\' `composedOf` could not be read, so another user may exist.)' : '';
626
+ issues.push(issue({
627
+ id: 'part-own-page', severity: 'warning', ...pageRef(page),
628
+ message: `\`${id}\` is a built-in part of \`${ownerId}\` only, but has its own page.${caveat}`,
629
+ fix: `List it as a part in ${ownerId}'s family (\`members\`/\`sections\`), or set \`standalone: '<reason>'\` if it is not really part of ${ownerId}.`,
630
+ }));
631
+ }
632
+ return issues;
633
+ }
634
+ /** #66 `nothing-to-show`: a candidate page with no variants, states, comparison or `render`. A page
635
+ * this reader could not fully read is never a candidate (`checkable`), so "nothing" is only claimed
636
+ * about a page that was read in full. `previewWidths` alone is not content: it only sizes `render`. */
637
+ function checkNothingToShow(pages, patternGroups) {
638
+ const issues = [];
639
+ for (const page of pages) {
640
+ if (!isStructureCandidate(page))
641
+ continue;
642
+ const hasSpecimens = Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
643
+ if (hasSpecimens)
644
+ continue;
645
+ const id = resolvePageId(page, page.file);
646
+ const hosts = usesOf(pages, page, pageNames(page)).map((use) => use.host);
647
+ const users = hosts.filter((host) => !isPatternPage(host, patternGroups));
648
+ const patternUsers = hosts.filter((host) => isPatternPage(host, patternGroups));
649
+ const unreadable = hasUnreadableComposition(pages, page);
650
+ const base = { id: 'nothing-to-show', severity: 'warning', ...pageRef(page) };
651
+ const otherFix = "Add a specimen, set `navigation: 'reference'`, or `standalone: '<reason>'`.";
652
+ if (users.length > 0) {
653
+ const alsoPatterns = patternUsers.length > 0 ? ` Pattern pages also use it (${pageList(patternUsers)}).` : '';
654
+ issues.push(issue({ ...base,
655
+ message: `\`${id}\` shows no specimens. It is used by ${pageList(users)}.${alsoPatterns}`,
656
+ fix: "List it on that page as a documentation-only part, or set `navigation: 'reference'` to keep it reachable through Source reference search without a browse entry, or `standalone: '<reason>'`.",
657
+ }));
658
+ }
659
+ else if (patternUsers.length > 0) {
660
+ issues.push(issue({ ...base, message: `\`${id}\` shows no specimens. Only pattern pages use it (${pageList(patternUsers)}).`, fix: otherFix }));
661
+ }
662
+ else {
663
+ issues.push(issue({ ...base,
664
+ message: unreadable
665
+ ? `\`${id}\` shows no specimens, and no page whose \`composedOf\` doctor could read uses it.`
666
+ : `\`${id}\` shows no specimens and no other page uses it.`,
667
+ fix: otherFix,
668
+ }));
669
+ }
670
+ }
671
+ return issues;
672
+ }
412
673
  /** The viewer exports that frame an example as a phone (or another bounded document), so an
413
674
  * overlay that portals to the document body opens inside it instead of over the catalog. */
414
675
  const DEVICE_FRAME_EXPORTS = ['PhoneFrame', 'OverlayDemo', 'PhoneScreen', 'BoundedOverlayViewport'];
@@ -719,8 +980,165 @@ function checkComponentPages(pages, components) {
719
980
  }
720
981
  return issues;
721
982
  }
983
+ /** #61: errors for an opted-in `tokenDefinition` that is provably malformed, conflicts with an
984
+ * explicit legacy `render`/`tokenGallery`/`tokenSections`/`members` recipe on the same page, has a
985
+ * duplicate section id or a duplicate entry id within one section, or has an explicit page `title`
986
+ * that conflicts with its kind's canonical title (design R4's own severity list). A `tokenDefinition`
987
+ * that isn't itself statically readable (non-literal `kind`/`sections`) already made the whole page
988
+ * `checkable: false` in `cli/staticPage.ts` — `checkNotCheckable` reports that case as the established
989
+ * `page-not-checkable` warning; this rule only ever runs against a definition that WAS readable. */
990
+ /** Opted-in token pages whose sections take their entries from runtime imports — the intended
991
+ * authoring path, since values come from the app's own token modules. Doctor never executes them,
992
+ * so those sections are listed as not statically validated. Informational only, like the
993
+ * "unbound examples" count: it never becomes an issue, never changes the exit code, and is not
994
+ * promoted by `doctor.strict`. `undefined` when there are none, so legacy output is unchanged. */
995
+ function runtimeOnlyTokenSections(pages) {
996
+ // #64 B1: a section counts as runtime-only when its own entries are opaque, OR its own `groups`
997
+ // couldn't be read, OR any one group's own entries are opaque — groups are read the same
998
+ // lenient way as a section's own entries (`cli/tokenDefinition.ts`'s own `readStaticTokenDefinition`).
999
+ const isOpaque = (section) => section.entriesOpaque || Boolean(section.groupsOpaque) || (section.groups ?? []).some((g) => g.entriesOpaque);
1000
+ const found = pages.flatMap((page) => {
1001
+ const sections = page.tokenDefinition?.sections.filter(isOpaque).map((section) => section.id) ?? [];
1002
+ return sections.length > 0 ? [{ page: resolvePageId(page, page.file), sections }] : [];
1003
+ });
1004
+ return found.length > 0 ? found : undefined;
1005
+ }
1006
+ function checkTokenDefinition(pages) {
1007
+ const issues = [];
1008
+ for (const page of pages) {
1009
+ if (!page.hasTokenDefinition || !page.tokenDefinition)
1010
+ continue;
1011
+ const ref = pageRef(page);
1012
+ const def = page.tokenDefinition;
1013
+ if (!isTokenKind(def.kind)) {
1014
+ issues.push(issue({
1015
+ id: 'token-definition-invalid-kind', severity: 'error', ...ref,
1016
+ message: `tokenDefinition has an unknown kind "${def.kind}".`,
1017
+ fix: 'Use one of: colors, typography, spacing, radius, dimensions, shadows, opacity, motion.',
1018
+ }));
1019
+ continue; // The canonical-title check below needs a valid kind to compare against.
1020
+ }
1021
+ if (page.hasRender) {
1022
+ issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "render".', fix: 'Remove one of them.' }));
1023
+ }
1024
+ if (page.hasExplicitTokenGallery) {
1025
+ issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "tokenGallery".', fix: 'Remove one of them.' }));
1026
+ }
1027
+ if (page.hasExplicitTokenSections) {
1028
+ issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "tokenSections".', fix: 'Remove one of them.' }));
1029
+ }
1030
+ if (page.hasMembers) {
1031
+ issues.push(issue({ id: 'token-definition-conflict', severity: 'error', ...ref, message: 'tokenDefinition conflicts with this page\'s own "members" (a family page).', fix: 'Remove one of them.' }));
1032
+ }
1033
+ if (page.title !== undefined && page.title !== canonicalTokenTitle(def.kind)) {
1034
+ issues.push(issue({
1035
+ id: 'token-definition-title-conflict', severity: 'error', ...ref,
1036
+ message: `tokenDefinition: title "${page.title}" conflicts with kind "${def.kind}" (expected "${canonicalTokenTitle(def.kind)}").`,
1037
+ fix: 'Omit "title" to use the kind\'s canonical title automatically, or change it to match.',
1038
+ }));
1039
+ }
1040
+ for (const id of findDuplicateIds(def.sections.map((s) => s.id))) {
1041
+ issues.push(issue({ id: 'token-definition-duplicate-id', severity: 'error', ...ref, message: `tokenDefinition has more than one section with id "${id}".`, fix: 'Give each section its own id.' }));
1042
+ }
1043
+ for (const section of def.sections) {
1044
+ for (const id of findDuplicateIds(section.entryIds)) {
1045
+ issues.push(issue({
1046
+ id: 'token-definition-duplicate-id', severity: 'error', ...ref,
1047
+ message: `tokenDefinition section "${section.id}" has more than one entry with id "${id}".`,
1048
+ fix: 'Give each entry within this section its own id (the same id may still be reused in a different section).',
1049
+ }));
1050
+ }
1051
+ // #64 B1: a section's own groups — group ids unique within the section, entry ids unique
1052
+ // within their own group (the same scoping `native/catalog/tokenDefinition.ts`'s own
1053
+ // `validateTokenDefinition` enforces at runtime).
1054
+ for (const id of findDuplicateIds((section.groups ?? []).map((g) => g.id))) {
1055
+ issues.push(issue({
1056
+ id: 'token-definition-duplicate-id', severity: 'error', ...ref,
1057
+ message: `tokenDefinition section "${section.id}" has more than one group with id "${id}".`,
1058
+ fix: 'Give each group within this section its own id.',
1059
+ }));
1060
+ }
1061
+ for (const group of section.groups ?? []) {
1062
+ for (const id of findDuplicateIds(group.entryIds)) {
1063
+ issues.push(issue({
1064
+ id: 'token-definition-duplicate-id', severity: 'error', ...ref,
1065
+ message: `tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`,
1066
+ fix: 'Give each entry within this group its own id (the same id may still be reused in a different group or in the section\'s own entries).',
1067
+ }));
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ return issues;
1073
+ }
1074
+ /** #64 B4: a literal `tokenDefinition.kind` shared by two or more pages — one issue per page in
1075
+ * the group (every one of them is reported, each naming the other page(s)). A page with no
1076
+ * `tokenDefinition` at all is never involved. */
1077
+ function checkTokenDefinitionDuplicateKind(pages) {
1078
+ const byKind = new Map();
1079
+ for (const page of pages) {
1080
+ if (!page.tokenDefinition)
1081
+ continue;
1082
+ const kind = page.tokenDefinition.kind;
1083
+ byKind.set(kind, [...(byKind.get(kind) ?? []), page]);
1084
+ }
1085
+ const issues = [];
1086
+ for (const [kind, group] of byKind) {
1087
+ if (group.length < 2)
1088
+ continue;
1089
+ for (const page of group) {
1090
+ const others = group.filter((p) => p !== page).map((p) => resolvePageId(p, p.file)).join(', ');
1091
+ issues.push(issue({
1092
+ id: 'token-definition-duplicate-kind', severity: 'error', ...pageRef(page),
1093
+ message: `tokenDefinition kind "${kind}" is also used by ${others}.`,
1094
+ fix: 'Give one of these pages its own kind, or merge them into one page.',
1095
+ }));
1096
+ }
1097
+ }
1098
+ return issues;
1099
+ }
1100
+ /** #64 B5: a tokenDefinition page's own page-level `aliases`, and each of its sections' own
1101
+ * `aliases`, join the exact same catalog-wide "one owner" pool `family-invalid` already checks
1102
+ * family ids/aliases against (`idsRegisteredByPage`) — reusing that rule id rather than adding a
1103
+ * second alias-collision rule. */
1104
+ function checkTokenDefinitionAliases(pages) {
1105
+ const allPageIds = new Set(pages.map((p) => resolvePageId(p, p.file)));
1106
+ const idsByPage = idsRegisteredByPage(pages);
1107
+ const issues = [];
1108
+ for (const page of pages) {
1109
+ if (!page.tokenDefinition)
1110
+ continue;
1111
+ const id = resolvePageId(page, page.file);
1112
+ const otherPageIds = new Set([...allPageIds].filter((pid) => pid !== id));
1113
+ const otherIds = new Set();
1114
+ for (const [pid, ids] of idsByPage)
1115
+ if (pid !== id)
1116
+ for (const x of ids)
1117
+ otherIds.add(x);
1118
+ const seen = new Set();
1119
+ const checkAlias = (alias) => {
1120
+ if (seen.has(alias)) {
1121
+ issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message: `Page "${id}": id "${alias}" is used more than once on this page.`, fix: 'Give each alias its own name.' }));
1122
+ }
1123
+ seen.add(alias);
1124
+ if (otherPageIds.has(alias)) {
1125
+ issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message: `Page "${id}": id "${alias}" collides with another page's id.`, fix: 'Choose a different alias.' }));
1126
+ }
1127
+ if (otherIds.has(alias)) {
1128
+ issues.push(issue({ id: 'family-invalid', severity: 'error', ...pageRef(page), message: `Page "${id}": id "${alias}" collides with another member or alias elsewhere in the catalog.`, fix: 'Choose a different alias.' }));
1129
+ }
1130
+ };
1131
+ for (const alias of page.aliases ?? [])
1132
+ checkAlias(alias);
1133
+ for (const section of page.tokenDefinition.sections)
1134
+ for (const alias of section.aliases ?? [])
1135
+ checkAlias(alias);
1136
+ }
1137
+ return issues;
1138
+ }
722
1139
  /** Every page-scoped issue, from every rule above. */
723
- export function collectIssues(pages, components) {
1140
+ export function collectIssues(pages, components, options = {}) {
1141
+ const patternGroups = options.patternGroups ?? DEFAULT_PATTERN_GROUPS;
724
1142
  return [
725
1143
  ...checkParseErrors(pages),
726
1144
  ...checkNotCheckable(pages),
@@ -732,12 +1150,19 @@ export function collectIssues(pages, components) {
732
1150
  ...checkDegenerateGridAxis(pages),
733
1151
  ...checkComponentPages(pages, components),
734
1152
  ...checkMissingComposition(pages, components),
1153
+ ...checkStandaloneReason(pages),
1154
+ ...checkPartOwnPage(pages, patternGroups),
1155
+ ...checkNothingToShow(pages, patternGroups),
735
1156
  ...checkMissingWorkingPreview(pages, components),
736
1157
  ...checkPreviewOnlyPages(pages),
737
1158
  ...checkFamilyMembers(pages, components),
738
1159
  ...checkFamilyValidity(pages),
1160
+ ...checkGroupValidity(pages),
739
1161
  ...checkMemberComposedOf(pages, components),
740
1162
  ...checkDeprecatedRelatedSections(pages),
1163
+ ...checkTokenDefinition(pages),
1164
+ ...checkTokenDefinitionDuplicateKind(pages),
1165
+ ...checkTokenDefinitionAliases(pages),
741
1166
  ];
742
1167
  }
743
1168
  /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
@@ -874,7 +1299,7 @@ export function runDoctor(config) {
874
1299
  ? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
875
1300
  : undefined;
876
1301
  let issues = [
877
- ...collectIssues(pages, components),
1302
+ ...collectIssues(pages, components, { patternGroups: config.patternGroups }),
878
1303
  ...checkPageTypeErrors(pages, { fallbackPaths, hostRoot: config.projectRoot }),
879
1304
  ...checkOverlaysFramed(pages, components, config.projectRoot),
880
1305
  ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
@@ -893,6 +1318,9 @@ export function runDoctor(config) {
893
1318
  withExamples: countWithExamples(pages, components),
894
1319
  unboundExamples: countUnboundExamples(pages),
895
1320
  };
1321
+ const runtimeOnly = runtimeOnlyTokenSections(pages);
1322
+ if (runtimeOnly)
1323
+ summary.runtimeOnlyTokenSections = runtimeOnly;
896
1324
  const pageFileById = new Map(pages.map((p) => [resolvePageId(p, p.file), path.relative(config.projectRoot, p.file)]));
897
1325
  return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0, pageFiles: pageFileById };
898
1326
  }
@@ -930,7 +1358,12 @@ export function formatHuman(result) {
930
1358
  renderIssue(issue);
931
1359
  lines.push('');
932
1360
  }
933
- const { errors, warnings, components, withExamples, unboundExamples } = result.summary;
1361
+ const { errors, warnings, components, withExamples, unboundExamples, runtimeOnlyTokenSections: runtimeOnly } = result.summary;
1362
+ if (runtimeOnly && runtimeOnly.length > 0) {
1363
+ const count = runtimeOnly.reduce((sum, entry) => sum + entry.sections.length, 0);
1364
+ const where = runtimeOnly.map((entry) => `${entry.page} (${entry.sections.join(', ')})`).join('; ');
1365
+ lines.push(`${count} token section${count === 1 ? '' : 's'} with runtime-only entries (not statically validated): ${where}.`, '');
1366
+ }
934
1367
  lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} — ` +
935
1368
  `${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
936
1369
  return lines.join('\n');
@@ -5,6 +5,7 @@ import { resolveGlob } from "./glob.js";
5
5
  import { axisItems, blockMinWidth, choosePlacement, gridColumnLimit, listGeometry, presentationBlocks, MATRIX_LAYOUT, } from "../native/catalog/comparison.js";
6
6
  import { CATALOG_LAYOUT } from "../native/catalog/tokens.js";
7
7
  import { sectionColumns } from "../native/catalog/tokenLayout.js";
8
+ import { numberForGroupMember } from "../native/catalog/pageApi.js";
8
9
  function placeholderItem(item) {
9
10
  return {
10
11
  key: item.key, name: item.name, node: null, group: item.group,
@@ -152,6 +153,20 @@ export function explainPage(config, pageId, options = {}) {
152
153
  // to never drift from what the viewer actually renders.
153
154
  if (!page.checkable)
154
155
  return { pageId, notCheckable: true, blocks: [] };
156
+ // #63 A10: a group page (`members` with no `whole`/`sections`) — its real content lives entirely
157
+ // on its members, which `toSectionDef`/`presentationBlocks` below never read, so it gets its own
158
+ // outline instead of an (otherwise empty) block list.
159
+ if (page.members !== undefined && page.members.length > 0 && page.whole === undefined && page.sections === undefined) {
160
+ const members = page.members;
161
+ const byId = new Set(members.map((m) => m.id));
162
+ const topLevelMembers = members.filter((m) => m.parent === undefined || !byId.has(m.parent));
163
+ const topLevel = topLevelMembers.map((m) => ({
164
+ id: m.id,
165
+ qualifier: m.component ? m.component : 'documentation-only',
166
+ parts: [...numberForGroupMember(m.id, members).entries()].map(([id, entry]) => ({ number: entry.number, id })),
167
+ }));
168
+ return { pageId, blocks: [], group: { topLevel } };
169
+ }
155
170
  const def = toSectionDef(page);
156
171
  const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
157
172
  // Two blocks placed side by side get half the row each, which changes how many columns fit.
@@ -194,6 +209,17 @@ export function formatExplain(result) {
194
209
  if (result.notCheckable) {
195
210
  return `${result.pageId}\n\nPage not statically checkable — some of its layout data is not a literal doctor can read.`;
196
211
  }
212
+ // #63 A10: a group page prints its own outline — title, each top-level member with its
213
+ // qualifier, nested parts with numbers — instead of the block-based report below.
214
+ if (result.group) {
215
+ const lines = [result.pageId, ''];
216
+ for (const top of result.group.topLevel) {
217
+ lines.push(`${top.id} (${top.qualifier})`);
218
+ for (const part of top.parts)
219
+ lines.push(` ${part.number}. ${part.id}`);
220
+ }
221
+ return lines.join('\n').replace(/\n+$/, '');
222
+ }
197
223
  const lines = [result.pageId, ''];
198
224
  for (const block of result.blocks) {
199
225
  lines.push(block.title);