@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
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pure data model for `CatalogPageInput.tokenDefinition` — an optional, opt-in structured shape for
3
+ * a token page's real entries (name, value, provenance, source) instead of a hand-built `render()`.
4
+ * No React or React Native import: this module is shared by the runtime (`pageApi.ts`), the real
5
+ * renderer (`StructuredTokenPage.tsx`), and the CLI's static reader (`cli/tokenDefinition.ts`), and
6
+ * is itself tested directly under the plain Node `--experimental-strip-types` runner.
7
+ *
8
+ * Omitting `tokenDefinition` leaves every existing `tokenGallery`/`tokenSections`/`render` page
9
+ * exactly as it was — nothing here changes unless a page opts in.
10
+ */
11
+ export const TOKEN_KINDS = ['colors', 'typography', 'spacing', 'radius', 'dimensions', 'shadows', 'opacity', 'motion'];
12
+ const TOKEN_KIND_TITLES = {
13
+ colors: 'Colors',
14
+ typography: 'Typography',
15
+ spacing: 'Spacing',
16
+ radius: 'Radius',
17
+ dimensions: 'Dimensions',
18
+ shadows: 'Shadows',
19
+ opacity: 'Opacity',
20
+ motion: 'Motion',
21
+ };
22
+ export function isTokenKind(value) {
23
+ return Object.prototype.hasOwnProperty.call(TOKEN_KIND_TITLES, value);
24
+ }
25
+ /** The one fixed title a kind renders under — an opted-in page's own explicit `title` must equal
26
+ * this (see `validateTokenDefinitionUsage`), or omit `title` entirely to get it for free. */
27
+ export function canonicalTokenTitle(kind) {
28
+ return TOKEN_KIND_TITLES[kind];
29
+ }
30
+ const TOKEN_PROVENANCE_LABELS = {
31
+ named: 'Named token',
32
+ inline: 'Inline value',
33
+ platform: 'Platform-provided',
34
+ 'unresolved-finding': 'Unresolved finding',
35
+ };
36
+ /** The fixed caption vocabulary for a value's own `provenance`/`named` pair — plain muted caption
37
+ * text, never a status badge (correction C1: provenance must be visible, but never as a pill/tag).
38
+ * Appends " · named token" only when `named` is true (`TokenValueInfo.named`'s own doc comment:
39
+ * meaningful only alongside `'platform'`/`'unresolved-finding'`). */
40
+ export function tokenProvenanceCaption(value) {
41
+ const label = TOKEN_PROVENANCE_LABELS[value.provenance];
42
+ return value.named ? `${label} · named token` : label;
43
+ }
44
+ /** When every one of `entries` shares the same `tokenProvenanceCaption`, returns it once — the
45
+ * renderer's own section-level consolidation (correction C1: show a shared provenance+named pair
46
+ * once under the section instead of repeating it on every entry). `undefined` for an empty list,
47
+ * or when the entries disagree — either way the caller shows it per entry instead. */
48
+ export function consolidatedProvenanceCaption(entries) {
49
+ if (entries.length === 0)
50
+ return undefined;
51
+ const first = tokenProvenanceCaption(entries[0].value);
52
+ return entries.every((entry) => tokenProvenanceCaption(entry.value) === first) ? first : undefined;
53
+ }
54
+ /** #64 B2 / review F5: how many values a section shows — the count the jump row's chip reads. It is
55
+ * computed from the same pipeline the renderer uses (`renderedEntryCount`): ids deduplicated within
56
+ * each list, entries of another `kind` dropped, then light/dark pairing, over the section's own
57
+ * `entries` plus every one of its `groups'` own `entries`. */
58
+ export function sectionEntryCount(kind, section) {
59
+ const groupTotal = (section.groups ?? []).reduce((sum, group) => sum + renderedEntryCount(kind, group.entries), 0);
60
+ return renderedEntryCount(kind, section.entries) + groupTotal;
61
+ }
62
+ /** Every id that appears more than once in `ids`, each listed once. Order-independent; used both for
63
+ * a section's own entry ids (must be unique within that section — ids may still recur in a
64
+ * *different* section, see `TokenEntryBase.id`) and for a definition's own section ids. */
65
+ export function findDuplicateIds(ids) {
66
+ const seen = new Set();
67
+ const duplicates = new Set();
68
+ for (const id of ids) {
69
+ if (seen.has(id))
70
+ duplicates.add(id);
71
+ seen.add(id);
72
+ }
73
+ return [...duplicates];
74
+ }
75
+ /** A collision-safe address for one entry: page, section, and entry id joined so that an escaped
76
+ * separator inside any one of the three can never be mistaken for a real boundary (`/` and `\`
77
+ * are escaped before joining) — never a flattened/concatenated name. Used wherever an entry needs
78
+ * one stable cross-page identity (future search/manifest indexing); not required for a React list
79
+ * key, which only ever needs sibling-level uniqueness. */
80
+ export function tokenEntryAddress(pageId, sectionId, entryId) {
81
+ const encode = (part) => part.replace(/\\/g, '\\\\').replace(/\//g, '\\/');
82
+ return `${encode(pageId)}/${encode(sectionId)}/${encode(entryId)}`;
83
+ }
84
+ /** Structural problems with a `TokenDefinition` itself (design, Global Constraints: "never a
85
+ * partial read/silent precedence loss") — duplicate section ids, a section with duplicate entry
86
+ * ids, or an entry whose own `kind` doesn't match the page's declared `kind`. Empty when the
87
+ * definition is internally consistent. Both `buildCatalogSections` (dev console) and
88
+ * `cli/doctor.ts` (CI-failing errors) call this so the same rule applies at runtime and statically. */
89
+ export function validateTokenDefinition(def) {
90
+ const issues = [];
91
+ for (const id of findDuplicateIds(def.sections.map((s) => s.id))) {
92
+ issues.push(`tokenDefinition has more than one section with id "${id}".`);
93
+ }
94
+ for (const section of def.sections) {
95
+ for (const id of findDuplicateIds(section.entries.map((e) => e.id))) {
96
+ issues.push(`tokenDefinition section "${section.id}" has more than one entry with id "${id}".`);
97
+ }
98
+ for (const entry of section.entries) {
99
+ if (entry.kind !== def.kind) {
100
+ issues.push(`tokenDefinition section "${section.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
101
+ }
102
+ }
103
+ // #64 B1: a section's own `groups` — group ids unique within the section, entry ids unique
104
+ // within their own group (a separate scope from the section's own entries and every other
105
+ // group), and the same kind-match rule as the section's own entries.
106
+ for (const id of findDuplicateIds((section.groups ?? []).map((g) => g.id))) {
107
+ issues.push(`tokenDefinition section "${section.id}" has more than one group with id "${id}".`);
108
+ }
109
+ for (const group of section.groups ?? []) {
110
+ for (const id of findDuplicateIds(group.entries.map((e) => e.id))) {
111
+ issues.push(`tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`);
112
+ }
113
+ for (const entry of group.entries) {
114
+ if (entry.kind !== def.kind) {
115
+ issues.push(`tokenDefinition section "${section.id}" group "${group.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return issues;
121
+ }
122
+ export function pairLightDarkColorEntries(entries) {
123
+ const lightByName = new Map();
124
+ const darkByName = new Map();
125
+ for (const entry of entries) {
126
+ if (entry.appearance === 'light' && !lightByName.has(entry.name))
127
+ lightByName.set(entry.name, entry);
128
+ if (entry.appearance === 'dark' && !darkByName.has(entry.name))
129
+ darkByName.set(entry.name, entry);
130
+ }
131
+ const claimed = new Set();
132
+ const result = [];
133
+ for (const entry of entries) {
134
+ if (claimed.has(entry.id))
135
+ continue;
136
+ if (entry.appearance === 'light') {
137
+ const dark = darkByName.get(entry.name);
138
+ if (dark && dark.id !== entry.id && !claimed.has(dark.id)) {
139
+ claimed.add(entry.id);
140
+ claimed.add(dark.id);
141
+ result.push({ kind: 'pair', name: entry.name, light: entry, dark });
142
+ continue;
143
+ }
144
+ }
145
+ if (entry.appearance === 'dark') {
146
+ const light = lightByName.get(entry.name);
147
+ if (light && light.id !== entry.id && !claimed.has(light.id)) {
148
+ claimed.add(entry.id);
149
+ claimed.add(light.id);
150
+ result.push({ kind: 'pair', name: entry.name, light, dark: entry });
151
+ continue;
152
+ }
153
+ }
154
+ result.push({ kind: 'single', entry });
155
+ }
156
+ return result;
157
+ }
158
+ /** The real entries of one list, deduplicated by id within this kind — shared by the section's own
159
+ * `entries` and, #64 B1, each of its `groups`' own `entries` (a separate scope per group), so none
160
+ * of them ever double-counts an entry whose own `kind` doesn't match (already reported by
161
+ * `validateTokenDefinition`) or whose `id` repeats another entry in the *same* list. The renderer
162
+ * and the jump row's chip count both read through this. */
163
+ export function filteredKindEntriesFromList(kind, entries) {
164
+ const seen = new Set();
165
+ return entries.filter((entry) => {
166
+ if (entry.kind !== kind)
167
+ return false;
168
+ if (seen.has(entry.id))
169
+ return false;
170
+ seen.add(entry.id);
171
+ return true;
172
+ });
173
+ }
174
+ /** Whether a light/dark pair can render as one split swatch. A pair whose light or dark side has no
175
+ * real drawable value (unresolved) can't, and falls back to each entry's own cell. */
176
+ export function pairRendersAsOneSwatch(light, dark) {
177
+ return light.value.resolution.status === 'resolved' && dark.value.resolution.status === 'resolved';
178
+ }
179
+ /** How many cells one entries list renders: the filtered list, with a resolved light/dark pair as
180
+ * one swatch and an unresolved pair as two. */
181
+ export function renderedEntryCount(kind, entries) {
182
+ const shown = filteredKindEntriesFromList(kind, entries);
183
+ if (kind !== 'colors')
184
+ return shown.length;
185
+ return pairLightDarkColorEntries(shown).reduce((count, item) => count + (item.kind === 'pair' && !pairRendersAsOneSwatch(item.light, item.dark) ? 2 : 1), 0);
186
+ }
187
+ /** Actionable conflicts between an opted-in `tokenDefinition` and the rest of the page it sits on
188
+ * (design R1: "never silent precedence/data loss") — an explicit legacy `render`/`tokenGallery`/
189
+ * `tokenSections`, an incompatible family (`members`) recipe, or an explicit `title` that doesn't
190
+ * match the kind's canonical title. Empty when there is nothing to report. */
191
+ export function validateTokenDefinitionUsage(kind, usage) {
192
+ const issues = [];
193
+ if (usage.hasRender)
194
+ issues.push('tokenDefinition conflicts with this page\'s own "render" — remove one of them.');
195
+ if (usage.hasTokenGallery)
196
+ issues.push('tokenDefinition conflicts with this page\'s own "tokenGallery" — remove one of them.');
197
+ if (usage.hasTokenSections)
198
+ issues.push('tokenDefinition conflicts with this page\'s own "tokenSections" — remove one of them.');
199
+ if (usage.hasFamily)
200
+ issues.push('tokenDefinition conflicts with this page\'s own "members" (a family page) — remove one of them.');
201
+ if (usage.title !== undefined && usage.title !== canonicalTokenTitle(kind)) {
202
+ issues.push(`tokenDefinition: title "${usage.title}" conflicts with kind "${kind}" (expected "${canonicalTokenTitle(kind)}"). Omit "title" to use it automatically.`);
203
+ }
204
+ return issues;
205
+ }
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useRef, useState, type RefObject } from 'react';
2
2
  import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
3
3
  import { CATALOG_TEXT, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS, CATALOG_LAYOUT } from './tokens';
4
4
 
@@ -7,15 +7,28 @@ import { CATALOG_TEXT, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS, CATALOG_LAY
7
7
  * RN primitives rather than the host app's own search/field component — the catalog is a
8
8
  * documentation tool for that component, not a consumer of it, so its own chrome shouldn't depend
9
9
  * on (or accidentally break alongside) whatever that component does.
10
+ *
11
+ * `accessibilityLabel` and `autoFocus` are optional and additive (#60): every existing caller
12
+ * keeps today's default name ("Filter components") and no autofocus, unchanged. A caller naming a
13
+ * genuinely different search surface — e.g. `SourceReferencePicker.tsx`'s catalog-wide picker,
14
+ * which is not the sidebar's own filter — overrides the label so its own durable name stays
15
+ * truthful, rather than three different filters all claiming to be "Filter components".
10
16
  */
11
17
  export function CatalogSearchInput({
12
18
  value,
13
19
  onChangeText,
14
20
  placeholder,
21
+ accessibilityLabel = 'Filter components',
22
+ autoFocus = false,
23
+ inputRef: providedInputRef,
15
24
  }: {
16
25
  value: string;
17
26
  onChangeText: (text: string) => void;
18
27
  placeholder: string;
28
+ accessibilityLabel?: string;
29
+ autoFocus?: boolean;
30
+ /** Focus after a surrounding modal has shown, without racing its trigger-focus capture. */
31
+ inputRef?: RefObject<TextInput | null>;
19
32
  }) {
20
33
  // Focus draws the catalog focus ring — react-native-web resets the browser's default input focus
21
34
  // ring, so the box has to draw its own indicator.
@@ -25,7 +38,8 @@ export function CatalogSearchInput({
25
38
  // project's Pressable types (targeting native) don't expose that field, though RNW fires the events.
26
39
  const [clearHovered, setClearHovered] = useState(false);
27
40
  // Clearing unmounts the clear button that has focus; return focus to the field it cleared.
28
- const inputRef = useRef<TextInput>(null);
41
+ const localInputRef = useRef<TextInput>(null);
42
+ const inputRef = providedInputRef ?? localInputRef;
29
43
  return (
30
44
  <View style={[styles.box, focused && styles.boxFocused]}>
31
45
  <TextInput
@@ -35,7 +49,8 @@ export function CatalogSearchInput({
35
49
  placeholder={placeholder}
36
50
  placeholderTextColor={CATALOG_COLOR.textMuted}
37
51
  // A durable name — the placeholder alone disappears the moment the user types.
38
- accessibilityLabel="Filter components"
52
+ accessibilityLabel={accessibilityLabel}
53
+ autoFocus={autoFocus}
39
54
  onFocus={() => setFocused(true)}
40
55
  onBlur={() => setFocused(false)}
41
56
  style={styles.input}