@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.
- package/README.md +249 -3
- package/config/index.ts +3 -0
- package/dist/cli/config.js +9 -1
- package/dist/cli/doctor.js +452 -19
- package/dist/cli/explain.js +26 -0
- package/dist/cli/staticPage.js +99 -10
- package/dist/cli/tokenDefinition.js +125 -0
- package/dist/native/catalog/catalogNavigation.js +27 -11
- package/dist/native/catalog/pageApi.js +557 -26
- package/dist/native/catalog/tokenDefinition.js +205 -0
- package/native/catalog/CatalogSearchInput.tsx +18 -3
- package/native/catalog/CatalogShell.tsx +318 -46
- package/native/catalog/CatalogSidebar.tsx +37 -1
- package/native/catalog/FamilyPage.tsx +567 -137
- package/native/catalog/GroupPage.tsx +230 -0
- package/native/catalog/JumpRow.tsx +81 -0
- package/native/catalog/ReferenceDetails.tsx +144 -16
- package/native/catalog/SectionBlock.tsx +49 -5
- package/native/catalog/SourceReferencePicker.tsx +180 -0
- package/native/catalog/StructuredTokenPage.tsx +592 -0
- package/native/catalog/Swatch.tsx +29 -2
- package/native/catalog/TokenLayouts.tsx +30 -9
- package/native/catalog/catalogNavigation.ts +30 -10
- package/native/catalog/catalogReferences.ts +212 -0
- package/native/catalog/familyModel.ts +10 -4
- package/native/catalog/index.ts +22 -0
- package/native/catalog/pageApi.ts +598 -23
- package/native/catalog/tokenDefinition.ts +430 -0
- package/native/catalog/types.ts +77 -5
- package/package.json +1 -1
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +1 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +1 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +1 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +1 -0
- 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
|
|
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=
|
|
52
|
+
accessibilityLabel={accessibilityLabel}
|
|
53
|
+
autoFocus={autoFocus}
|
|
39
54
|
onFocus={() => setFocused(true)}
|
|
40
55
|
onBlur={() => setFocused(false)}
|
|
41
56
|
style={styles.input}
|