@krapwoo/ds-viewer 0.14.2 → 0.15.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.
package/README.md CHANGED
@@ -230,7 +230,8 @@ Each catalog shows **one page at a time**: a persistent, searchable sidebar and
230
230
  On web the page is kept in the URL fragment (`#Button`), so refresh, deep links, and back/forward
231
231
  work. Desktop and laptop screens only. `SectionBlock` renders one page: breadcrumb, title,
232
232
  description, previous/next, then the specimens, then always-visible
233
- **Guidance** and **Quick reference** (source path, accessibility), then **Props** in their own box
233
+ **Guidance** and **Quick reference** (source path, accessibility, and — when the page has any —
234
+ **Composition** and the derived **"also used by"** inverse of it), then **Props** in their own box
234
235
  (two columns, filled across first, when there are 4+ props and room). Token pages show only
235
236
  Quick reference with the source path. Guidance, Quick reference, and — only for a page with
236
237
  confirmed `composedOf` entries — Composition lay out from the reference card's own measured inner
@@ -238,7 +239,12 @@ width, not viewport width alone: three columns side by side only when there's co
238
239
  *and* the card is wide enough for all three (Composition gets its own column only then; otherwise
239
240
  it stays nested under Quick reference, as it always has), two when there's room for Guidance and
240
241
  Quick reference side by side, or one stacked column — in full document order, nothing dropped —
241
- when the card is too narrow even for two. The specimens use one of four layouts:
242
+ when the card is too narrow even for two. A Composition name becomes a real link whenever it
243
+ resolves to a real catalog address (a page, a family member, or either one's alias — the same
244
+ resolution a family member's own "Uses" line already uses); an unresolvable name stays honest plain
245
+ text. "Also used by" is derived, never authored, the ordinary-page counterpart of a family member's
246
+ own line of the same name (see "Family pages" below): nothing renders when nothing else uses the
247
+ page. The specimens use one of four layouts:
242
248
  - **Grid** — two props that combine freely, from an explicit `comparison` (e.g. Button's
243
249
  Variant × State).
244
250
  - **Grouped rows** — one row per variant holding that variant's own configurations (e.g.
@@ -260,6 +266,44 @@ card narrower than its minimum scrolls inside itself rather than squeezing its c
260
266
  (`itemsFill`) slots default to `wide`. The catalog's own look comes from `native/catalog/tokens.ts`,
261
267
  never from the host app's tokens.
262
268
 
269
+ ### Source reference
270
+
271
+ A page's `navigation` field is `'browse'` (the default — omit it) or `'reference'`:
272
+
273
+ ```tsx
274
+ export default defineCatalogPage({
275
+ id: 'ButtonSource', description: 'The native declaration behind Button.',
276
+ navigation: 'reference',
277
+ // ...
278
+ });
279
+ ```
280
+
281
+ A `'reference'` page keeps every one of its addresses — its own id, its aliases, and (on a family
282
+ page) every member's own id/alias — fully routable; `navigation` only ever removes it from the
283
+ sidebar, the pager, and the hash fallback's own browse sequence. A family page's members inherit
284
+ their own page's `navigation`; there's no separate per-member field. The moment any page in the
285
+ catalog opts in, the sidebar grows a quiet **Source reference** entry: a catalog-wide search over
286
+ every registered page's and member's own id, title, detected component export, alias, and source
287
+ path — never a page-local member search, and never a second, hand-maintained registry (it's
288
+ derived from the same `SectionDef[]`/`FamilyDef` every other part of the viewer already reads). A
289
+ legacy catalog with no `'reference'` page keeps its present sidebar and page set, unchanged.
290
+ Selecting a result opens its real page (and, for a member, focuses it directly) and offers a real
291
+ `Back to <origin>` return link to wherever that selection actually started from — any page, not
292
+ only another reference destination. Every other already-true reference fact (a Composition name,
293
+ a derived "also used by" user, a family member's own `related`/anatomy name) becomes the same kind
294
+ of real, canonical, focused-return link once it resolves. Only loading a reference page's own URL
295
+ directly, with no recorded origin, offers `Browse catalog` to the first ordinary browsing page
296
+ instead of inventing one that was never visited; a catalog with no browsable page at all offers
297
+ neither.
298
+
299
+ Resolution for the Source reference picker, and for every Composition/"also used by"/`related`/
300
+ anatomy name across the whole catalog (see "Browsing the catalog" above and "Family pages" below),
301
+ shares one rule: an exact registered page id, member id, or alias always wins; only when none of
302
+ those claim the name does a unique, detected component export resolve it instead — letting a name
303
+ find an ordinary page whose own id differs from its component (e.g. a `PillButton` page whose
304
+ component export is `Button`). A component name shared by more than one page or member is
305
+ ambiguous and is never guessed; an unknown name stays plain, honest text rather than a dead link.
306
+
263
307
  ## Adding a component (the recipe)
264
308
 
265
309
  Copy the shape of an existing pair, e.g. `starter-kit/components/Button/` or `starter-kit/components/Badge/`:
@@ -273,6 +317,8 @@ a11y, and its content — `id`/`path`/`props` are derived, never authored), and
273
317
  `src/ds/components/index.ts` the same way its siblings are. `SectionBlock` shows the
274
318
  specimens first, then Guidance, Quick reference, and Props. Give it whichever of these fields
275
319
  actually apply:
320
+ - `notes: string[]` — page-level notes, listed as a "Notes" entry in the page's Quick reference
321
+ (for facts about the source that aren't part of the description, guidance or accessibility).
276
322
  - `comparison: { rowLabel, columnLabel, rows, columns, cells, size? }` — when two props genuinely
277
323
  combine. Author every row × column cell with a real instance, or an `unavailableReason` when that
278
324
  combination does not exist. Never multiply `variants` by `states` to fill it. Keep the column axis
@@ -486,6 +532,9 @@ export default defineCatalogPage({
486
532
  });
487
533
  ```
488
534
 
535
+ Every family page shows a one-line "Family page." explainer under its description, so a reader
536
+ knows its parts are separate components, unlike variants.
537
+
489
538
  - **`whole`** is a member id, and must equal the page's own `id`. It renders first, with no header
490
539
  of its own (the page title already names it) and no number.
491
540
  - **`sections`** (`{ label, kind: 'blocks' | 'list', inWhole, members }`) group the rest: `blocks`
@@ -557,6 +606,10 @@ export default defineCatalogPage({
557
606
  for behavior a web catalog can't demonstrate (a timer, a native gesture). A member's own
558
607
  `notShown` works the same way, under its specimens. Like `aliases`, the page-level field only
559
608
  takes effect on a page with `members`.
609
+ - **Notes and API.** A member's notes, "Uses …" line, guidance, accessibility, props and
610
+ reference excerpt open in one shared side panel (a modal dialog on the right, full width at
611
+ 768px and narrower) from a "Notes and API" link under the member — Esc, the × button or a click outside closes
612
+ it and returns focus to the link; a member with nothing to show has no link.
560
613
  - **`apiLabel`** (default `'API'`) renames the disclosure's reference-excerpt part for a host app
561
614
  that generates one (e.g. `'Swift API'`) — never hardcode a specific language's wording. Also
562
615
  only takes effect on a page with `members`.
@@ -564,6 +617,74 @@ export default defineCatalogPage({
564
617
  and at least one specimen, the same as a normal page's component — `doctor` checks each one.
565
618
  See `viewer-host/viewer-pages/FamilyPage.catalog.tsx` in this repo for a complete, worked example.
566
619
 
620
+ ### Group pages
621
+
622
+ A page can also document several unrelated-but-adjacent components on one page — e.g. two SwiftUI
623
+ button styles used together on one screen, or the handful of request-card bodies a switch statement
624
+ picks between — with no single "whole" that owns the others. Give it `members` with **no `whole`
625
+ and no `sections`** (both omitted, not just empty):
626
+
627
+ ```tsx
628
+ export default defineCatalogPage({
629
+ id: 'OnboardingButtons', group: 'Components', description: 'Two separate button styles used together on onboarding screens.',
630
+ members: [
631
+ { id: 'OnboardingPrimaryButtonStyle', source: 'Onboarding.swift:172', fidelity: 'replicated', relationship: 'Style', description: '...', whenToUse: '...', a11y: '...', variants: { items: [/* ... */] } },
632
+ { id: 'OnboardingSecondaryButtonStyle', source: 'Onboarding.swift:298', fidelity: 'replicated', relationship: 'Style', description: '...', whenToUse: '...', a11y: '...', variants: { items: [/* ... */] } },
633
+ ],
634
+ });
635
+ ```
636
+
637
+ Every group page shows a one-line "Group page." explainer under its description, so a reader knows
638
+ the members are separate components with their own APIs, unlike variants.
639
+
640
+ **Group vs. family:** reach for a family when the members genuinely compose one thing (a view and
641
+ its own subviews) — the page then opens with that thing's own specimens, before its parts. Reach
642
+ for a group when the members are independently meaningful and would otherwise each get their own
643
+ tiny page with nothing to compare them against; a group page shows every member's own section with
644
+ no shared "whole" and no Compare block (layout B) — just the page head, a jump row, then each
645
+ top-level member's own section in authored order.
646
+
647
+ - **Shape.** `sections` and page-level `anatomy` are never used on a group page — `doctor`'s
648
+ `family-invalid` rule reports either one if present. Every member keeps its full `CatalogMember`
649
+ shape (the same fields a family member has).
650
+ - **Top-level vs. nested.** A member whose `parent` names another member *on the same page* is one
651
+ of that member's own parts, nested underneath it; a member with no `parent`, or whose `parent`
652
+ names a member defined on a *different* page (see "External parts" below), is **top-level**. The
653
+ jump row lists exactly the top-level members, in authored order, each as a chip linking to its
654
+ own address; its muted qualifier is "Part N of `<Family>`" when the member is an external part,
655
+ "+k parts" when it has its own nested parts on this page, or nothing.
656
+ - **Embedded families.** A top-level member with parts of its own on the page may carry its own
657
+ `anatomy` (`CatalogMember.anatomy` — the same `{ picture, groups }` shape a family's page-level
658
+ `anatomy` has, but scoped to that one member's own descendants). Its parts number independently of
659
+ every other top-level member's: `1`, `2`, … among siblings, then `1.1`, `1.2`, … for children —
660
+ the same rule a family uses under its own whole, just re-anchored per top-level member instead of
661
+ once per page. `doctor`'s `group-anatomy-member` rule reports an embedded anatomy item naming
662
+ anything other than that member's own descendant.
663
+ - **External parts.** A family's own `inWhole` section may list a member id that is defined on a
664
+ group page instead of its own `members`, as long as that member's own `parent` equals the
665
+ family's whole — an *external part*. The family's outline numbers it exactly like an own part (the
666
+ same number shows on both pages); on the family page its row shows that number, its name, and a
667
+ "Details on `<group title>`" link to the member's own address, with no specimens or duplicated
668
+ description. On the group page, that member's own section heading shows "Part N of `<Family>`",
669
+ linking back to the family page. An id that no page defines at all is reported by
670
+ `family-invalid` ("not a member of this page"); an id that is defined but doesn't resolve to
671
+ exactly one group-page member with a matching parent (defined on several pages, on a page that
672
+ isn't a group page, or with a different `parent`) is reported by `doctor`'s
673
+ `family-external-part` rule. Listing the same external id in two sections, or twice in one, is a
674
+ `family-invalid` "in more than one section" error.
675
+ - **Routing, search, aliases.** A group page's own id and `aliases` resolve to the page, exactly
676
+ like a family's; every member id and alias on a group page resolves to that page with that member
677
+ focused, exactly like a family member's does (a member id or alias equal to its own page's id
678
+ could never be addressed, so `family-invalid` reports it). The sidebar, pager and search list the group page
679
+ once (its own title) — members are reachable by address and search, never as separate sidebar
680
+ rows, the same convention a family's own members already follow.
681
+ - No whole, no Compare block, no `relatedPages`/`composedOf`-at-the-page-level concept of its own
682
+ (a non-empty `relatedPages` on a group page is reported by `family-invalid` as unused) —
683
+ everything else (fidelity badges, `whenToUse`/`a11y`, `composedOf`'s own "Uses …" line, derived
684
+ "Also used by", `notShown`, `apiLabel`) works exactly the same as it does for a family member, see
685
+ above. See `viewer-host/viewer-pages/GroupPage.catalog.tsx` in this repo for a worked example,
686
+ including an embedded family and an external part of a small fixture family page.
687
+
567
688
  ### Token pages
568
689
 
569
690
  A token page shows each token at its real value with its role. Pick the layout by the token's
@@ -614,6 +735,107 @@ shape:
614
735
  <MotionSpecimen kind="spring" spring={UNIT_SPRING_CONFIG} valueRange="unit" />
615
736
  ```
616
737
 
738
+ ### `tokenDefinition`
739
+
740
+ An opt-in, structured alternative to `render`/`tokenGallery`/`tokenSections` for a token page's
741
+ *real entries* — name, value, provenance, source — instead of a hand-built preview. Omitting it
742
+ leaves every existing page exactly as it was; nothing here changes unless a page opts in.
743
+
744
+ ```tsx
745
+ import { defineCatalogPage, type TokenDefinition } from '@krapwoo/ds-viewer';
746
+
747
+ const COLORS: TokenDefinition = {
748
+ kind: 'colors',
749
+ sections: [
750
+ {
751
+ id: 'semantic', title: 'Semantic',
752
+ entries: [
753
+ { id: 'brand', name: 'Brand', kind: 'colors', value: { provenance: 'named', resolution: { status: 'resolved', value: { color: DS_SEMANTIC.brand } } } },
754
+ ],
755
+ },
756
+ ],
757
+ };
758
+
759
+ export default defineCatalogPage({ group: 'Tokens', description: 'Semantic and palette colors.', tokenDefinition: COLORS });
760
+ ```
761
+
762
+ - **Eight kinds**, each with one fixed canonical title: `colors` → Colors, `typography` →
763
+ Typography, `spacing` → Spacing, `radius` → Radius, `dimensions` → Dimensions, `shadows` →
764
+ Shadows, `opacity` → Opacity, `motion` → Motion. Omit the page's own `title` to get it for free;
765
+ an explicit `title` must match (`doctor`'s `token-definition-title-conflict`, below).
766
+ - **The runtime import is the value authority, not `tokens.json`.** An entry's `value` is whatever
767
+ real literal/import you give it at the call site — this is not a reader of your build's
768
+ `tokens.json`/token-scan output. That's also why a static check (`doctor`) can only ever read an
769
+ entry's literal metadata, never its real value — see "Static honesty" below.
770
+ - **Provenance, resolution and `named` are three independent things**, and every entry shows its
771
+ own provenance as a plain muted caption (never a badge/pill): `'named'` ("Named token") is a real
772
+ exported design-system token; `'inline'` ("Inline value") is a one-off literal with no shared
773
+ name; `'platform'` ("Platform-provided") means the real value comes from the OS at runtime, not a
774
+ literal this catalog can read; `'unresolved-finding'` ("Unresolved finding") is a real,
775
+ source-backed literal/expression found in code with no shipped design token behind it yet.
776
+ `resolution` is separate: `{ status: 'resolved', value }` when there's a real sample to draw, or
777
+ `{ status: 'unresolved', reason, expression? }` with the real reason (and, when one exists, the
778
+ real source expression) a human would read at the call site — never a fabricated sample or an
779
+ invented classification. `named: true` says a `'platform'`/`'unresolved-finding'` value is *also*
780
+ exposed through a real named token (shown as "Platform-provided · named token"); omit it for
781
+ `'named'`/`'inline'` provenance, which already say so by themselves. When every entry in a
782
+ section shares the same provenance/named pair, it's shown once for the whole section instead of
783
+ repeating on every entry.
784
+ - **Appearances and scoped ids.** A color entry's `appearance: 'light' | 'dark'` renders an
785
+ independent sample — not a global color-scheme context; omit it for a color with one appearance.
786
+ An entry's `id` only needs to be unique *within its own section* — the same id may recur in a
787
+ different section of the same page (e.g. two scoped "Palette" cards), which is the same
788
+ `TokenSection.id` precedent token galleries already use.
789
+ - **Groups inside a section.** A section may add `groups: [{ id, title, desc?, entries }]` — each
790
+ renders under its own sub-heading, after the section's own entries, in the same grid the section
791
+ itself uses. A group id is unique within its own section; an entry id is unique within its own
792
+ group, a separate scope from the section's own entries and every other group. The section-level
793
+ provenance consolidation (above) considers the section's own entries *and* every one of its
794
+ groups together, so a shared provenance/named pair is still shown only once for the whole section.
795
+ - **The category jump row.** A page with more than one section opens with one chip per section —
796
+ its title plus how many entries it holds (its own, plus every group's) — so "Attention" might
797
+ read "Attention, 3 values". Each chip scrolls that section into view; a single-section page shows
798
+ no jump row, since there's nothing to jump between.
799
+ - **Light/dark pairing.** Within one entries list (a section's own, or one group's), two `colors`
800
+ entries sharing the same `name` — one `appearance: 'light'`, the other `'dark'` — render as a
801
+ single swatch: a split chip (light half left, dark half right), the bare shared name once, and
802
+ `<light value> · <dark value>` as the value line. An entry with no matching opposite appearance
803
+ (or a third same-named entry once both appearances are already paired) renders exactly as any
804
+ other unpaired entry.
805
+ - **Motion's `native-expression` kind is a real limit, not a gap:** a SwiftUI
806
+ `.spring(duration:bounce:)`/`.spring(response:dampingFraction:)` (or any other conditional/
807
+ symbolic value) is shown as its own real text, never converted into a guessed
808
+ stiffness/damping/mass spring — that conversion genuinely isn't supported.
809
+ - **Conflicts.** `tokenDefinition` replaces, and so conflicts with, an explicit `render`,
810
+ `tokenGallery`, `tokenSections`, or `members` (a family page) on the same page — remove one of
811
+ them. These, and an explicit `title` that disagrees with the kind's canonical title, are always
812
+ `doctor` errors (`token-definition-conflict`, `token-definition-title-conflict`), independent of
813
+ `doctor.strict`. A duplicate section id, a duplicate group id within a section, or a duplicate
814
+ entry id within one section or one group, is `token-definition-duplicate-id` (also always an
815
+ error) — the same id may still recur in a different section/group/the section's own entries.
816
+ - **One page per kind.** Two or more pages declaring the same literal `tokenDefinition.kind` each
817
+ get a `token-definition-duplicate-kind` error naming the other page(s) — a page with no
818
+ `tokenDefinition` is never involved. Give one of them its own kind, or merge them into one page.
819
+ - **Aliases.** A `tokenDefinition` page's own `aliases` resolve to the page, the same way a family
820
+ page's `aliases` already do. A section's own `aliases` resolve to the page too, with that section
821
+ scrolled into view. Every one of these joins the catalog's existing alias-collision check
822
+ (`family-invalid`) — an alias must still name exactly one thing, catalog-wide.
823
+ - **Static honesty.** `npx ds-viewer doctor` reads a `tokenDefinition`'s `kind` and each section's
824
+ `id`/`title`/`desc`/`wide`/entry `id`s — and, the same way, each group's own `id`/`title`/entry
825
+ `id`s — the same literal-only way it reads every other page field — it never imports or executes a
826
+ page file, so it can never see a real `value`. When a section's (or a group's) own `entries` isn't
827
+ a literal array (an imported/computed list), or contains an entry whose own `id` isn't a literal,
828
+ that list's ids are read on a best-effort basis and the summary lists it as a section with
829
+ runtime-only entries (not statically validated). That is the normal case when values are imported
830
+ from your own token modules, so it is informational: it never fails `--ci` and `doctor.strict`
831
+ does not promote it. Literal sections still get every check. A `tokenDefinition` whose own
832
+ `kind`/`sections` metadata isn't literal at all makes the whole page not statically checkable —
833
+ reported only as the existing `page-not-checkable` warning, never a separate
834
+ `tokenDefinition`-specific one.
835
+ - **Migration.** A legacy `render`/`tokenGallery`/`tokenSections` token page keeps working
836
+ unchanged — `tokenDefinition` is additive, never required. To convert one, remove `render`/
837
+ `tokenSections` and author the same data as `tokenDefinition`'s entries instead.
838
+
617
839
  ## Keeping the catalog current
618
840
 
619
841
  ### Binding examples to props