@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,430 @@
|
|
|
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
|
+
|
|
12
|
+
/** The eight kinds a structured token page can declare, each with one fixed canonical title. */
|
|
13
|
+
export type TokenKind = 'colors' | 'typography' | 'spacing' | 'radius' | 'dimensions' | 'shadows' | 'opacity' | 'motion';
|
|
14
|
+
|
|
15
|
+
export const TOKEN_KINDS: readonly TokenKind[] = ['colors', 'typography', 'spacing', 'radius', 'dimensions', 'shadows', 'opacity', 'motion'];
|
|
16
|
+
|
|
17
|
+
const TOKEN_KIND_TITLES: Record<TokenKind, string> = {
|
|
18
|
+
colors: 'Colors',
|
|
19
|
+
typography: 'Typography',
|
|
20
|
+
spacing: 'Spacing',
|
|
21
|
+
radius: 'Radius',
|
|
22
|
+
dimensions: 'Dimensions',
|
|
23
|
+
shadows: 'Shadows',
|
|
24
|
+
opacity: 'Opacity',
|
|
25
|
+
motion: 'Motion',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function isTokenKind(value: string): value is TokenKind {
|
|
29
|
+
return Object.prototype.hasOwnProperty.call(TOKEN_KIND_TITLES, value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The one fixed title a kind renders under — an opted-in page's own explicit `title` must equal
|
|
33
|
+
* this (see `validateTokenDefinitionUsage`), or omit `title` entirely to get it for free. */
|
|
34
|
+
export function canonicalTokenTitle(kind: TokenKind): string {
|
|
35
|
+
return TOKEN_KIND_TITLES[kind];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How a value's identity relates to the design system — independent of whether a concrete sample
|
|
39
|
+
* can be drawn (see `TokenResolution`): `'named'` (a real exported design-system token), `'inline'`
|
|
40
|
+
* (a one-off literal with no shared name — the common case), `'platform'` (the real value comes
|
|
41
|
+
* from the OS/platform at runtime, not a literal this catalog can read), or `'unresolved-finding'`
|
|
42
|
+
* (a real, source-backed literal/expression found in code with no shipped design token behind it
|
|
43
|
+
* yet). `named` on `TokenValueInfo` lets a 'platform'/'unresolved-finding' value still say it is
|
|
44
|
+
* *also* exposed through a real token name — see that field's own doc comment. */
|
|
45
|
+
export type TokenProvenance = 'named' | 'inline' | 'platform' | 'unresolved-finding';
|
|
46
|
+
|
|
47
|
+
/** Whether a value resolves to something drawable. `'unresolved'` never carries a fabricated
|
|
48
|
+
* sample — only the real reason (and, when one exists, the real source expression) a human would
|
|
49
|
+
* read at the call site. */
|
|
50
|
+
export type TokenResolution<V> = { status: 'resolved'; value: V } | { status: 'unresolved'; reason: string; expression?: string };
|
|
51
|
+
|
|
52
|
+
export interface TokenValueInfo<V> {
|
|
53
|
+
provenance: TokenProvenance;
|
|
54
|
+
/** True when this value is *also* exposed through a real named design-system token even though
|
|
55
|
+
* `provenance` is `'platform'` or `'unresolved-finding'` (token-contract.md: "a named exported
|
|
56
|
+
* token may itself have a platform-provided or unresolved value"). Omit for `'named'`/`'inline'`
|
|
57
|
+
* provenance, which already say so by themselves. */
|
|
58
|
+
named?: boolean;
|
|
59
|
+
resolution: TokenResolution<V>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TOKEN_PROVENANCE_LABELS: Record<TokenProvenance, string> = {
|
|
63
|
+
named: 'Named token',
|
|
64
|
+
inline: 'Inline value',
|
|
65
|
+
platform: 'Platform-provided',
|
|
66
|
+
'unresolved-finding': 'Unresolved finding',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The fixed caption vocabulary for a value's own `provenance`/`named` pair — plain muted caption
|
|
70
|
+
* text, never a status badge (correction C1: provenance must be visible, but never as a pill/tag).
|
|
71
|
+
* Appends " · named token" only when `named` is true (`TokenValueInfo.named`'s own doc comment:
|
|
72
|
+
* meaningful only alongside `'platform'`/`'unresolved-finding'`). */
|
|
73
|
+
export function tokenProvenanceCaption(value: Pick<TokenValueInfo<unknown>, 'provenance' | 'named'>): string {
|
|
74
|
+
const label = TOKEN_PROVENANCE_LABELS[value.provenance];
|
|
75
|
+
return value.named ? `${label} · named token` : label;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** When every one of `entries` shares the same `tokenProvenanceCaption`, returns it once — the
|
|
79
|
+
* renderer's own section-level consolidation (correction C1: show a shared provenance+named pair
|
|
80
|
+
* once under the section instead of repeating it on every entry). `undefined` for an empty list,
|
|
81
|
+
* or when the entries disagree — either way the caller shows it per entry instead. */
|
|
82
|
+
export function consolidatedProvenanceCaption(
|
|
83
|
+
entries: readonly { value: Pick<TokenValueInfo<unknown>, 'provenance' | 'named'> }[],
|
|
84
|
+
): string | undefined {
|
|
85
|
+
if (entries.length === 0) return undefined;
|
|
86
|
+
const first = tokenProvenanceCaption(entries[0].value);
|
|
87
|
+
return entries.every((entry) => tokenProvenanceCaption(entry.value) === first) ? first : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fields every token entry carries regardless of kind. `source`/`role`/`note` are prose/pointer
|
|
91
|
+
* strings, the same literal-string lane every other provenance pointer in this codebase already
|
|
92
|
+
* uses (`SectionDef.path`'s own `source: string` precedent) — never evaluated, never required to
|
|
93
|
+
* resolve to anything. */
|
|
94
|
+
export interface TokenEntryBase {
|
|
95
|
+
/** Stable within its own section (see `findDuplicateEntryIds`) — may recur in a different
|
|
96
|
+
* section or a different page's definition; an address needs the page+section too (see
|
|
97
|
+
* `tokenEntryAddress`). */
|
|
98
|
+
id: string;
|
|
99
|
+
name: string;
|
|
100
|
+
/** A literal pointer to where the real value lives (e.g. `'DS_SPACING.800'`, a file:line) — never
|
|
101
|
+
* evaluated, purely for a human/AI to cross-reference. */
|
|
102
|
+
source?: string;
|
|
103
|
+
/** Authored usage role, when one is known — never invented from a category or number. */
|
|
104
|
+
role?: string;
|
|
105
|
+
/** Short "when to use this" prose, when known. */
|
|
106
|
+
note?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ColorTokenValue {
|
|
110
|
+
/** A real, directly renderable CSS/React Native color string (hex, `rgba(...)`, a named color). */
|
|
111
|
+
color: string;
|
|
112
|
+
}
|
|
113
|
+
export interface ColorTokenEntry extends TokenEntryBase {
|
|
114
|
+
kind: 'colors';
|
|
115
|
+
/** Independent light/dark sample — not a global color-scheme context. Omit for a color with one
|
|
116
|
+
* appearance. */
|
|
117
|
+
appearance?: 'light' | 'dark';
|
|
118
|
+
value: TokenValueInfo<ColorTokenValue>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Shared by spacing/radius/dimensions — a bare number has no fixed unit; `unit` is always
|
|
122
|
+
* author-set (never guessed from the number). */
|
|
123
|
+
export interface DimensionTokenValue {
|
|
124
|
+
amount: number;
|
|
125
|
+
unit: string;
|
|
126
|
+
}
|
|
127
|
+
export interface SpacingTokenEntry extends TokenEntryBase {
|
|
128
|
+
kind: 'spacing';
|
|
129
|
+
value: TokenValueInfo<DimensionTokenValue>;
|
|
130
|
+
}
|
|
131
|
+
export interface RadiusTokenEntry extends TokenEntryBase {
|
|
132
|
+
kind: 'radius';
|
|
133
|
+
value: TokenValueInfo<DimensionTokenValue>;
|
|
134
|
+
}
|
|
135
|
+
export interface DimensionsTokenEntry extends TokenEntryBase {
|
|
136
|
+
kind: 'dimensions';
|
|
137
|
+
value: TokenValueInfo<DimensionTokenValue>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Opacity's unit is intrinsic (a 0–1 ratio) — no separate `unit` field to author. */
|
|
141
|
+
export interface OpacityTokenValue {
|
|
142
|
+
amount: number;
|
|
143
|
+
}
|
|
144
|
+
export interface OpacityTokenEntry extends TokenEntryBase {
|
|
145
|
+
kind: 'opacity';
|
|
146
|
+
value: TokenValueInfo<OpacityTokenValue>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface TypographyTokenValue {
|
|
150
|
+
fontSize: number;
|
|
151
|
+
fontSizeUnit: string;
|
|
152
|
+
lineHeight?: number;
|
|
153
|
+
fontWeight?: string | number;
|
|
154
|
+
letterSpacing?: number;
|
|
155
|
+
fontFamily?: string;
|
|
156
|
+
}
|
|
157
|
+
export interface TypographyTokenEntry extends TokenEntryBase {
|
|
158
|
+
kind: 'typography';
|
|
159
|
+
value: TokenValueInfo<TypographyTokenValue>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ShadowTokenValue {
|
|
163
|
+
color: string;
|
|
164
|
+
opacity: number;
|
|
165
|
+
radius: number;
|
|
166
|
+
x: number;
|
|
167
|
+
y: number;
|
|
168
|
+
}
|
|
169
|
+
export interface ShadowTokenEntry extends TokenEntryBase {
|
|
170
|
+
kind: 'shadows';
|
|
171
|
+
value: TokenValueInfo<ShadowTokenValue>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A real, replayable timing value — `durationMs` is what `MotionSpecimen` actually animates;
|
|
175
|
+
* `sourceValue`/`sourceUnit` keep the authored value/unit (e.g. `0.3`/`'s'`) so a seconds source
|
|
176
|
+
* is never silently presented as if it had always been milliseconds. */
|
|
177
|
+
export interface MotionTimingValue {
|
|
178
|
+
kind: 'timing';
|
|
179
|
+
durationMs: number;
|
|
180
|
+
sourceValue: number;
|
|
181
|
+
sourceUnit: string;
|
|
182
|
+
}
|
|
183
|
+
/** A real, replayable spring — the same stiffness/damping/mass physics model `MotionSpecimen`
|
|
184
|
+
* already animates. Never built by converting a native duration/bounce or response/dampingFraction
|
|
185
|
+
* spring (see `MotionNativeExpressionValue`) — that conversion is not supported (token-contract.md,
|
|
186
|
+
* token-seams.reported.md §4). */
|
|
187
|
+
export interface MotionSpringValue {
|
|
188
|
+
kind: 'spring';
|
|
189
|
+
stiffness?: number;
|
|
190
|
+
damping?: number;
|
|
191
|
+
mass?: number;
|
|
192
|
+
valueRange?: 'distance' | 'unit';
|
|
193
|
+
}
|
|
194
|
+
/** A real native expression (SwiftUI `.spring(duration:bounce:)`, `.spring(response:dampingFraction:)`,
|
|
195
|
+
* a conditional/symbolic value, …) that is honestly not replayable as `MotionSpecimen` physics —
|
|
196
|
+
* shown as its real text, never a guessed physics translation. */
|
|
197
|
+
export interface MotionNativeExpressionValue {
|
|
198
|
+
kind: 'native-expression';
|
|
199
|
+
expression: string;
|
|
200
|
+
detail?: string;
|
|
201
|
+
}
|
|
202
|
+
export type MotionTokenValue = MotionTimingValue | MotionSpringValue | MotionNativeExpressionValue;
|
|
203
|
+
export interface MotionTokenEntry extends TokenEntryBase {
|
|
204
|
+
kind: 'motion';
|
|
205
|
+
value: TokenValueInfo<MotionTokenValue>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type TokenEntry =
|
|
209
|
+
| ColorTokenEntry
|
|
210
|
+
| SpacingTokenEntry
|
|
211
|
+
| RadiusTokenEntry
|
|
212
|
+
| DimensionsTokenEntry
|
|
213
|
+
| OpacityTokenEntry
|
|
214
|
+
| TypographyTokenEntry
|
|
215
|
+
| ShadowTokenEntry
|
|
216
|
+
| MotionTokenEntry;
|
|
217
|
+
|
|
218
|
+
/** #64 B1: a titled sub-group of entries inside one section — e.g. a "Brand" group of colors
|
|
219
|
+
* nested under a page's "Palette" section, shown under its own sub-heading but in the same grid
|
|
220
|
+
* grammar the section's own entries use. `id` is unique within its own section (never shown);
|
|
221
|
+
* entry ids are unique within this group's own `entries`, a separate scope from the section's own
|
|
222
|
+
* `entries` and from any other group — the same "scoped uniqueness" `TokenEntryBase.id` already
|
|
223
|
+
* establishes between sections. */
|
|
224
|
+
export interface TokenDefinitionGroup {
|
|
225
|
+
id: string;
|
|
226
|
+
title: string;
|
|
227
|
+
desc?: string;
|
|
228
|
+
entries: TokenEntry[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One titled card of real entries, within a page's single `kind` — e.g. "Attention colors" and
|
|
232
|
+
* "Palette colors" as two scoped sections of one Colors-kind page (token-contract.md's own
|
|
233
|
+
* Hermex finding). `id` is this section's own stable, page-scoped identity (never shown); `title`
|
|
234
|
+
* is the human heading, which may legitimately repeat across sections without colliding (see
|
|
235
|
+
* `TokenSection.id` in `types.ts`, and `TokenLayouts.tsx`'s own key fix). */
|
|
236
|
+
export interface TokenDefinitionSection {
|
|
237
|
+
id: string;
|
|
238
|
+
title: string;
|
|
239
|
+
desc?: string;
|
|
240
|
+
wide?: boolean;
|
|
241
|
+
entries: TokenEntry[];
|
|
242
|
+
/** #64 B1: sub-groups rendered after this section's own `entries`, each under its own
|
|
243
|
+
* sub-heading. Either may be empty/omitted — a section may have only `entries`, only `groups`,
|
|
244
|
+
* or both. */
|
|
245
|
+
groups?: TokenDefinitionGroup[];
|
|
246
|
+
/** #64 B5: extra names that resolve to this page with this section scrolled into view — the
|
|
247
|
+
* section-scoped counterpart of a tokenDefinition page's own (page-level) `aliases`. */
|
|
248
|
+
aliases?: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** #64 B2 / review F5: how many values a section shows — the count the jump row's chip reads. It is
|
|
252
|
+
* computed from the same pipeline the renderer uses (`renderedEntryCount`): ids deduplicated within
|
|
253
|
+
* each list, entries of another `kind` dropped, then light/dark pairing, over the section's own
|
|
254
|
+
* `entries` plus every one of its `groups'` own `entries`. */
|
|
255
|
+
export function sectionEntryCount(kind: TokenKind, section: Pick<TokenDefinitionSection, 'entries' | 'groups'>): number {
|
|
256
|
+
const groupTotal = (section.groups ?? []).reduce((sum, group) => sum + renderedEntryCount(kind, group.entries), 0);
|
|
257
|
+
return renderedEntryCount(kind, section.entries) + groupTotal;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface TokenDefinition {
|
|
261
|
+
kind: TokenKind;
|
|
262
|
+
sections: TokenDefinitionSection[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Every id that appears more than once in `ids`, each listed once. Order-independent; used both for
|
|
266
|
+
* a section's own entry ids (must be unique within that section — ids may still recur in a
|
|
267
|
+
* *different* section, see `TokenEntryBase.id`) and for a definition's own section ids. */
|
|
268
|
+
export function findDuplicateIds(ids: readonly string[]): string[] {
|
|
269
|
+
const seen = new Set<string>();
|
|
270
|
+
const duplicates = new Set<string>();
|
|
271
|
+
for (const id of ids) {
|
|
272
|
+
if (seen.has(id)) duplicates.add(id);
|
|
273
|
+
seen.add(id);
|
|
274
|
+
}
|
|
275
|
+
return [...duplicates];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A collision-safe address for one entry: page, section, and entry id joined so that an escaped
|
|
279
|
+
* separator inside any one of the three can never be mistaken for a real boundary (`/` and `\`
|
|
280
|
+
* are escaped before joining) — never a flattened/concatenated name. Used wherever an entry needs
|
|
281
|
+
* one stable cross-page identity (future search/manifest indexing); not required for a React list
|
|
282
|
+
* key, which only ever needs sibling-level uniqueness. */
|
|
283
|
+
export function tokenEntryAddress(pageId: string, sectionId: string, entryId: string): string {
|
|
284
|
+
const encode = (part: string) => part.replace(/\\/g, '\\\\').replace(/\//g, '\\/');
|
|
285
|
+
return `${encode(pageId)}/${encode(sectionId)}/${encode(entryId)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Structural problems with a `TokenDefinition` itself (design, Global Constraints: "never a
|
|
289
|
+
* partial read/silent precedence loss") — duplicate section ids, a section with duplicate entry
|
|
290
|
+
* ids, or an entry whose own `kind` doesn't match the page's declared `kind`. Empty when the
|
|
291
|
+
* definition is internally consistent. Both `buildCatalogSections` (dev console) and
|
|
292
|
+
* `cli/doctor.ts` (CI-failing errors) call this so the same rule applies at runtime and statically. */
|
|
293
|
+
export function validateTokenDefinition(def: TokenDefinition): string[] {
|
|
294
|
+
const issues: string[] = [];
|
|
295
|
+
for (const id of findDuplicateIds(def.sections.map((s) => s.id))) {
|
|
296
|
+
issues.push(`tokenDefinition has more than one section with id "${id}".`);
|
|
297
|
+
}
|
|
298
|
+
for (const section of def.sections) {
|
|
299
|
+
for (const id of findDuplicateIds(section.entries.map((e) => e.id))) {
|
|
300
|
+
issues.push(`tokenDefinition section "${section.id}" has more than one entry with id "${id}".`);
|
|
301
|
+
}
|
|
302
|
+
for (const entry of section.entries) {
|
|
303
|
+
if (entry.kind !== def.kind) {
|
|
304
|
+
issues.push(`tokenDefinition section "${section.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// #64 B1: a section's own `groups` — group ids unique within the section, entry ids unique
|
|
308
|
+
// within their own group (a separate scope from the section's own entries and every other
|
|
309
|
+
// group), and the same kind-match rule as the section's own entries.
|
|
310
|
+
for (const id of findDuplicateIds((section.groups ?? []).map((g) => g.id))) {
|
|
311
|
+
issues.push(`tokenDefinition section "${section.id}" has more than one group with id "${id}".`);
|
|
312
|
+
}
|
|
313
|
+
for (const group of section.groups ?? []) {
|
|
314
|
+
for (const id of findDuplicateIds(group.entries.map((e) => e.id))) {
|
|
315
|
+
issues.push(`tokenDefinition section "${section.id}" group "${group.id}" has more than one entry with id "${id}".`);
|
|
316
|
+
}
|
|
317
|
+
for (const entry of group.entries) {
|
|
318
|
+
if (entry.kind !== def.kind) {
|
|
319
|
+
issues.push(`tokenDefinition section "${section.id}" group "${group.id}": entry "${entry.id}" has kind "${entry.kind}", but the page's kind is "${def.kind}".`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return issues;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** #64 B3: one `colors`-kind entries list (a section's own `entries`, or one group's), paired so
|
|
328
|
+
* that two entries sharing the same `name` — one `appearance: 'light'`, one `'dark'` — render as a
|
|
329
|
+
* single swatch. Order-preserving: a pair appears at the position of whichever of its two entries
|
|
330
|
+
* comes first; every other entry (no appearance, an unmatched appearance, or a later same-named
|
|
331
|
+
* duplicate once both appearances are already claimed) passes through unpaired, exactly as
|
|
332
|
+
* before. Never alters a value — this only decides which entries belong together. */
|
|
333
|
+
export type PairedColorEntry =
|
|
334
|
+
| { kind: 'pair'; name: string; light: ColorTokenEntry; dark: ColorTokenEntry }
|
|
335
|
+
| { kind: 'single'; entry: ColorTokenEntry };
|
|
336
|
+
|
|
337
|
+
export function pairLightDarkColorEntries(entries: readonly ColorTokenEntry[]): PairedColorEntry[] {
|
|
338
|
+
const lightByName = new Map<string, ColorTokenEntry>();
|
|
339
|
+
const darkByName = new Map<string, ColorTokenEntry>();
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
if (entry.appearance === 'light' && !lightByName.has(entry.name)) lightByName.set(entry.name, entry);
|
|
342
|
+
if (entry.appearance === 'dark' && !darkByName.has(entry.name)) darkByName.set(entry.name, entry);
|
|
343
|
+
}
|
|
344
|
+
const claimed = new Set<string>();
|
|
345
|
+
const result: PairedColorEntry[] = [];
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (claimed.has(entry.id)) continue;
|
|
348
|
+
if (entry.appearance === 'light') {
|
|
349
|
+
const dark = darkByName.get(entry.name);
|
|
350
|
+
if (dark && dark.id !== entry.id && !claimed.has(dark.id)) {
|
|
351
|
+
claimed.add(entry.id);
|
|
352
|
+
claimed.add(dark.id);
|
|
353
|
+
result.push({ kind: 'pair', name: entry.name, light: entry, dark });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (entry.appearance === 'dark') {
|
|
358
|
+
const light = lightByName.get(entry.name);
|
|
359
|
+
if (light && light.id !== entry.id && !claimed.has(light.id)) {
|
|
360
|
+
claimed.add(entry.id);
|
|
361
|
+
claimed.add(light.id);
|
|
362
|
+
result.push({ kind: 'pair', name: entry.name, light, dark: entry });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
result.push({ kind: 'single', entry });
|
|
367
|
+
}
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The real entries of one list, deduplicated by id within this kind — shared by the section's own
|
|
372
|
+
* `entries` and, #64 B1, each of its `groups`' own `entries` (a separate scope per group), so none
|
|
373
|
+
* of them ever double-counts an entry whose own `kind` doesn't match (already reported by
|
|
374
|
+
* `validateTokenDefinition`) or whose `id` repeats another entry in the *same* list. The renderer
|
|
375
|
+
* and the jump row's chip count both read through this. */
|
|
376
|
+
export function filteredKindEntriesFromList(kind: TokenKind, entries: readonly TokenEntry[]): TokenEntry[] {
|
|
377
|
+
const seen = new Set<string>();
|
|
378
|
+
return entries.filter((entry) => {
|
|
379
|
+
if (entry.kind !== kind) return false;
|
|
380
|
+
if (seen.has(entry.id)) return false;
|
|
381
|
+
seen.add(entry.id);
|
|
382
|
+
return true;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Whether a light/dark pair can render as one split swatch. A pair whose light or dark side has no
|
|
387
|
+
* real drawable value (unresolved) can't, and falls back to each entry's own cell. */
|
|
388
|
+
export function pairRendersAsOneSwatch(light: ColorTokenEntry, dark: ColorTokenEntry): boolean {
|
|
389
|
+
return light.value.resolution.status === 'resolved' && dark.value.resolution.status === 'resolved';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** How many cells one entries list renders: the filtered list, with a resolved light/dark pair as
|
|
393
|
+
* one swatch and an unresolved pair as two. */
|
|
394
|
+
export function renderedEntryCount(kind: TokenKind, entries: readonly TokenEntry[]): number {
|
|
395
|
+
const shown = filteredKindEntriesFromList(kind, entries);
|
|
396
|
+
if (kind !== 'colors') return shown.length;
|
|
397
|
+
return pairLightDarkColorEntries(shown as ColorTokenEntry[]).reduce(
|
|
398
|
+
(count, item) => count + (item.kind === 'pair' && !pairRendersAsOneSwatch(item.light, item.dark) ? 2 : 1),
|
|
399
|
+
0,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** What else the hosting page authored, read down to only what a conflict check needs — a plain,
|
|
404
|
+
* presence-only shape so both the runtime (`CatalogPageInput`) and the static reader
|
|
405
|
+
* (`StaticPage`) can satisfy it without either importing the other's full type. */
|
|
406
|
+
export interface TokenDefinitionPageUsage {
|
|
407
|
+
hasRender?: boolean;
|
|
408
|
+
hasTokenGallery?: boolean;
|
|
409
|
+
hasTokenSections?: boolean;
|
|
410
|
+
hasFamily?: boolean;
|
|
411
|
+
/** The page's own explicit `title`, when it set one — compared against the kind's canonical
|
|
412
|
+
* title (`canonicalTokenTitle`). Omitting `title` entirely never conflicts. */
|
|
413
|
+
title?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Actionable conflicts between an opted-in `tokenDefinition` and the rest of the page it sits on
|
|
417
|
+
* (design R1: "never silent precedence/data loss") — an explicit legacy `render`/`tokenGallery`/
|
|
418
|
+
* `tokenSections`, an incompatible family (`members`) recipe, or an explicit `title` that doesn't
|
|
419
|
+
* match the kind's canonical title. Empty when there is nothing to report. */
|
|
420
|
+
export function validateTokenDefinitionUsage(kind: TokenKind, usage: TokenDefinitionPageUsage): string[] {
|
|
421
|
+
const issues: string[] = [];
|
|
422
|
+
if (usage.hasRender) issues.push('tokenDefinition conflicts with this page\'s own "render" — remove one of them.');
|
|
423
|
+
if (usage.hasTokenGallery) issues.push('tokenDefinition conflicts with this page\'s own "tokenGallery" — remove one of them.');
|
|
424
|
+
if (usage.hasTokenSections) issues.push('tokenDefinition conflicts with this page\'s own "tokenSections" — remove one of them.');
|
|
425
|
+
if (usage.hasFamily) issues.push('tokenDefinition conflicts with this page\'s own "members" (a family page) — remove one of them.');
|
|
426
|
+
if (usage.title !== undefined && usage.title !== canonicalTokenTitle(kind)) {
|
|
427
|
+
issues.push(`tokenDefinition: title "${usage.title}" conflicts with kind "${kind}" (expected "${canonicalTokenTitle(kind)}"). Omit "title" to use it automatically.`);
|
|
428
|
+
}
|
|
429
|
+
return issues;
|
|
430
|
+
}
|
package/native/catalog/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
+
import type { TokenDefinition } from './tokenDefinition.ts';
|
|
2
3
|
|
|
3
4
|
/** One documented prop of a component, shown in its Props table. */
|
|
4
5
|
export interface PropDef {
|
|
@@ -184,6 +185,8 @@ export interface SectionDef<TId extends string = string> {
|
|
|
184
185
|
* not a generic disclaimer. Say plainly when a component has no explicit handling beyond the
|
|
185
186
|
* host element's default semantics, rather than inventing coverage that isn't there. */
|
|
186
187
|
a11y?: string;
|
|
188
|
+
/** Page-level notes, listed as a "Notes" entry in the page's Quick reference. */
|
|
189
|
+
notes?: string[];
|
|
187
190
|
/** Every value of the component's primary enum prop (e.g. `variant`), as individual instances —
|
|
188
191
|
* **including whichever value that prop defaults to** (e.g. Button's Variants starts with
|
|
189
192
|
* "Primary" since `variant` defaults to `'primary'`; Card's single instance is named "Default"
|
|
@@ -293,6 +296,40 @@ export interface SectionDef<TId extends string = string> {
|
|
|
293
296
|
/** Set when the authored page has `members`: the viewer renders a `FamilyPage` instead of the
|
|
294
297
|
* normal single-component layout. See `CatalogPageInput.members` and `FamilyDef`. */
|
|
295
298
|
family?: FamilyDef;
|
|
299
|
+
/** #60: which browse layer this page belongs to. Omitted (the default) means `'browse'` —
|
|
300
|
+
* unchanged, present in the sidebar/pager/hash fallback like every page always has been.
|
|
301
|
+
* `'reference'` keeps the page fully routable (its address, aliases, and member addresses all
|
|
302
|
+
* still resolve) but removes it from the browse sequence those three presentation surfaces
|
|
303
|
+
* derive — see `catalogReferences.ts`'s `browsableIds`. A family page's members inherit this
|
|
304
|
+
* same layer from their own page; there is no separate per-member field. See
|
|
305
|
+
* `CatalogPageInput.navigation`. */
|
|
306
|
+
navigation?: 'browse' | 'reference';
|
|
307
|
+
/** The real detected component export this page documents, set only from an actual binding
|
|
308
|
+
* (`GeneratedComponent.name`) — never guessed from the page's own id. Undefined for a family
|
|
309
|
+
* page (each member carries its own `CatalogMember.component` instead) and for a page with no
|
|
310
|
+
* matching component record (a token/standalone page). Lets #60's catalog-wide reference index
|
|
311
|
+
* (`catalogReferences.ts`) search and resolve a normal page by its component export name even
|
|
312
|
+
* when that name differs from the page's own id. */
|
|
313
|
+
component?: string;
|
|
314
|
+
/** #60: every other registered page's or member's own `composedOf` entry naming this page,
|
|
315
|
+
* tagged `'built-in'` — the same derived "also used by" relationship `FamilyDef.members[]`
|
|
316
|
+
* already carries, now also surfaced for an ordinary (non-family) page. Always populated by
|
|
317
|
+
* `buildCatalogSections` (`[]` when nothing else uses this page), the same convention
|
|
318
|
+
* `AlsoUsedByEntry`'s own doc comment already establishes for a family member. Rendered by
|
|
319
|
+
* `ReferenceDetails`'s `AlsoUsedByFact`. */
|
|
320
|
+
alsoUsedBy?: AlsoUsedByEntry[];
|
|
321
|
+
/** #61: passed straight through from `CatalogPageInput.tokenDefinition` when the page opted in —
|
|
322
|
+
* `SectionBlock.tsx`'s own `tokenSections` block case renders this page's real entries
|
|
323
|
+
* (`StructuredTokenPage.tsx`) instead of `tokenSections`' placeholder cards when it is set.
|
|
324
|
+
* `buildCatalogSections` always also derives `tokenSections`/`tokenGallery` from it (so layout
|
|
325
|
+
* geometry/doctor/explain never need to know this field exists at all) — this is only the real
|
|
326
|
+
* entry data the renderer needs. Undefined for every page that doesn't opt in. */
|
|
327
|
+
tokenDefinition?: TokenDefinition;
|
|
328
|
+
/** #64 B5: a tokenDefinition page's own `CatalogPageInput.aliases`, extended to resolve to this
|
|
329
|
+
* page (`catalogNavigation.ts`'s `aliasTargets`) — the same page-level alias a family page's
|
|
330
|
+
* own `aliases` already resolves via `FamilyDef.aliases`. Set only when the page has
|
|
331
|
+
* `tokenDefinition`; every other page's own `aliases` stays accepted-but-unused, unchanged. */
|
|
332
|
+
tokenDefinitionAliases?: string[];
|
|
296
333
|
}
|
|
297
334
|
|
|
298
335
|
/** A labeled group of section ids in the sidebar (e.g. "Components" vs "Tokens"). */
|
|
@@ -379,6 +416,13 @@ export interface CatalogMember {
|
|
|
379
416
|
/** Same meaning as `SectionDef.intentionalStaticPreview` — accepted per member, but not yet
|
|
380
417
|
* consumed by anything (neither the viewer nor `doctor`'s member-level checks read it). */
|
|
381
418
|
intentionalStaticPreview?: { reason: string };
|
|
419
|
+
/** #63 A4: only meaningful on a group page's own top-level member (one with parts of its own on
|
|
420
|
+
* the same page) — the same `CatalogAnatomy` shape `FamilyDef.anatomy` gives a family's whole,
|
|
421
|
+
* scoped to this member's own descendants instead of a whole page. Its `member` items must name
|
|
422
|
+
* this member's own descendants on the page; `validateGroup` reports anything else. Unused (and
|
|
423
|
+
* an issue if set) on any other member, and on every family member — a family's own anatomy
|
|
424
|
+
* stays page-level (`FamilyDef.anatomy`), unchanged. */
|
|
425
|
+
anatomy?: CatalogAnatomy;
|
|
382
426
|
}
|
|
383
427
|
|
|
384
428
|
/** One section of a family page (spec §5.1): `kind` alone decides full blocks versus the folded
|
|
@@ -423,14 +467,28 @@ export interface RelatedPage {
|
|
|
423
467
|
}
|
|
424
468
|
|
|
425
469
|
/** A family page's members with resolved props and paths, the whole, sections, and the computed
|
|
426
|
-
* outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`.
|
|
470
|
+
* outline — set on `SectionDef.family` by `buildCatalogSections` when a page has `members`.
|
|
471
|
+
* #63: also set, with `whole: undefined`, for a *group* page (`members` with no `whole` and no
|
|
472
|
+
* `sections`) — see `kind`. The structural finding behind this reuse: every existing consumer of
|
|
473
|
+
* this type (`catalogNavigation.ts`'s `aliasTargets`, `catalogReferences.ts`'s
|
|
474
|
+
* `buildReferenceIndex`, `familyModel.ts`'s `resolveWhole`) only ever compares a member's id
|
|
475
|
+
* against `whole`, which degrades safely to "no match" when `whole` is `undefined` — so a group
|
|
476
|
+
* page's members route, search, and resolve exactly like a family's non-whole members, with no
|
|
477
|
+
* change to any of those functions. */
|
|
427
478
|
export interface FamilyDef {
|
|
428
|
-
whole: string;
|
|
479
|
+
whole: string | undefined;
|
|
480
|
+
/** #63 A1: distinguishes a group page's own `FamilyDef` from an ordinary family's. Omitted
|
|
481
|
+
* (`undefined`) for every family page, including today's and the "exactly one of whole/sections"
|
|
482
|
+
* invalid shape, which keeps reporting as a family everywhere else already does — this field
|
|
483
|
+
* changes no existing behavior, it only labels the new shape. */
|
|
484
|
+
kind?: 'group';
|
|
429
485
|
/** `alsoUsedBy` is derived — `buildCatalogSections` always populates it (`[]` when nothing else
|
|
430
486
|
* uses this member) — see `AlsoUsedByEntry`. Optional only so a hand-built `FamilyDef` (a demo,
|
|
431
487
|
* a fixture) that predates this field still type-checks; a reader treats an absent value the
|
|
432
|
-
* same as `[]`.
|
|
433
|
-
|
|
488
|
+
* same as `[]`. #63 A5: `partOf` is set only on a group member that is also an external part of
|
|
489
|
+
* some family's outline (`FamilyDef.externalParts`, on that family's own `FamilyDef`) — its own
|
|
490
|
+
* owning family's page id/title and the number that family gave it. */
|
|
491
|
+
members: Array<CatalogMember & { props?: PropDef[]; path: string; alsoUsedBy?: AlsoUsedByEntry[]; partOf?: { pageId: string; title: string; number: string } }>;
|
|
434
492
|
sections: CatalogMemberSection[];
|
|
435
493
|
outline: Map<string, { number: string; level: number }>;
|
|
436
494
|
notShown?: string;
|
|
@@ -440,15 +498,29 @@ export interface FamilyDef {
|
|
|
440
498
|
/** Label for the disclosure's API part (`disclosureLabel`). See `CatalogPageInput.apiLabel`.
|
|
441
499
|
* Defaults to `'API'` when absent. */
|
|
442
500
|
apiLabel?: string;
|
|
443
|
-
/** Slice D1 item 1. See `CatalogPageInput.anatomy`.
|
|
501
|
+
/** Slice D1 item 1. See `CatalogPageInput.anatomy`. Never set for a group page — A1 forbids
|
|
502
|
+
* page-level anatomy there (an embedded family's own anatomy lives on its top-level member
|
|
503
|
+
* instead, see `CatalogMember.anatomy`). */
|
|
444
504
|
anatomy?: CatalogAnatomy;
|
|
445
505
|
/** Other catalog pages named at the bottom of this page, under the "Related" heading, after any
|
|
446
506
|
* deprecated non-`inWhole` sections. See `CatalogPageInput.relatedPages`. */
|
|
447
507
|
relatedPages?: RelatedPage[];
|
|
508
|
+
/** #63 A5: every one of this family's own outline ids that is actually an *external part* — a
|
|
509
|
+
* member defined on a group page rather than this page's own `members` — keyed by that id, with
|
|
510
|
+
* the owning group page's id/title and the owning member's `fidelity` (so its pin is dashed for a
|
|
511
|
+
* documentation-only member, the same as an own part's). Set only on a family's own `FamilyDef`; always `undefined`
|
|
512
|
+
* for a group's own (a group page is never itself a family with external parts). */
|
|
513
|
+
externalParts?: ReadonlyMap<string, { pageId: string; title: string; fidelity?: CatalogMember['fidelity'] }>;
|
|
448
514
|
}
|
|
449
515
|
|
|
450
516
|
/** One titled card on a token page (`SectionDef.tokenSections`). */
|
|
451
517
|
export interface TokenSection {
|
|
518
|
+
/** #61: this section's own stable, page-scoped identity — used as its React key instead of
|
|
519
|
+
* `title` (`TokenLayouts.tsx`'s `TokenSections`) so two sections that legitimately share a human
|
|
520
|
+
* title (e.g. two "Palette" cards in different scopes, `tokenDefinition.ts`'s own scoped-section
|
|
521
|
+
* precedent) never collide. Optional, backward-compatible: omitted (every pre-#61 `TokenSection`)
|
|
522
|
+
* keeps the prior `title`-keyed behavior exactly as it was. */
|
|
523
|
+
id?: string;
|
|
452
524
|
/** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
|
|
453
525
|
title: string;
|
|
454
526
|
/** One line under the title: what this kind of token is for. */
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { DS_SEMANTIC } from '../../tokens';
|
|
|
6
6
|
export default defineCatalogPage({
|
|
7
7
|
component: 'AnimatedChevron',
|
|
8
8
|
group: 'Components',
|
|
9
|
+
standalone: 'A reusable kit component in its own right; Banner is just the only kit component that uses it.',
|
|
9
10
|
whenToUse: 'An expand/collapse affordance that should animate its own state change — it flips vertically in place, so it never points sideways mid-transition. Where a chevron never changes state, a plain icon is enough.',
|
|
10
11
|
description: 'A chevron that morphs between down (collapsed) and up (expanded) — flipping vertically in place instead of rotating through a sideways-pointing angle, and instead of swapping icons. The morph Banner\'s own collapsible header uses for its disclosure indicator. Not commonly used on its own; exported for building a custom disclosure/accordion toggle.',
|
|
11
12
|
a11y: 'Purely decorative — no accessibility role of its own. Wrap it in an accessible parent (the way Banner\'s own header Pressable does) if the toggle needs to be announced.',
|
|
@@ -32,6 +32,7 @@ const AVATAR_COMPARISON = grid(
|
|
|
32
32
|
export default defineCatalogPage({
|
|
33
33
|
component: 'Avatar',
|
|
34
34
|
group: 'Components',
|
|
35
|
+
standalone: 'A reusable kit component in its own right; EmptyState is just the only kit component that uses it.',
|
|
35
36
|
whenToUse: 'A person or place\'s identity in a circle. Pass whatever you have: image, icon, or initials are tried in that order, so a missing image degrades to initials without leaving a gap.',
|
|
36
37
|
specimenSize: 'compact',
|
|
37
38
|
comparison: AVATAR_COMPARISON,
|
|
@@ -65,6 +65,7 @@ const dock = (close: () => void) => (
|
|
|
65
65
|
export default defineCatalogPage({
|
|
66
66
|
component: 'BottomSheet',
|
|
67
67
|
group: 'Components',
|
|
68
|
+
standalone: 'A reusable kit component in its own right; Dropdown is just the only kit component that uses it.',
|
|
68
69
|
// Every example is a phone. At the default size two blocks share the row and each cell falls to
|
|
69
70
|
// 270px, which squeezes the frame until the sheet's own text is barely readable — 'wide' keeps a
|
|
70
71
|
// 402px minimum, the width the sheet is actually used at.
|
|
@@ -6,6 +6,7 @@ import { Button } from '../Button';
|
|
|
6
6
|
export default defineCatalogPage({
|
|
7
7
|
component: 'ButtonGroup',
|
|
8
8
|
group: 'Components',
|
|
9
|
+
standalone: 'A reusable kit component in its own right; EmptyState is just the only kit component that uses it.',
|
|
9
10
|
whenToUse: 'Two or three Buttons that should read as one family — horizontal for a trailing-aligned pair (a dialog\'s Cancel and Confirm), vertical for up to three stretched full width. Keep one variant tier and one size across the group. For a screen\'s bottom action area, use Dock.',
|
|
10
11
|
composedOf: [
|
|
11
12
|
{ component: 'Button', role: "Its children: up to two Buttons side by side, or three stacked.", relationship: 'slot' },
|
|
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
|
|
|
12
12
|
export default defineCatalogPage({
|
|
13
13
|
component: 'Divider',
|
|
14
14
|
group: 'Components',
|
|
15
|
+
standalone: 'A reusable kit component in its own right; List is just the only kit component that uses it.',
|
|
15
16
|
whenToUse: 'A single hairline between two pieces of content you are laying out yourself. List already puts one between each pair of its rows, so adding your own there would double them.',
|
|
16
17
|
specimenSize: 'regular',
|
|
17
18
|
description: 'A 1px hairline separator at the divider token colour.',
|
|
@@ -41,6 +41,7 @@ const PILL_COMPARISON = grid(
|
|
|
41
41
|
export default defineCatalogPage({
|
|
42
42
|
component: 'Pill',
|
|
43
43
|
group: 'Components',
|
|
44
|
+
standalone: 'A reusable kit component in its own right; PillRow is just the only kit component that uses it.',
|
|
44
45
|
composedOf: [
|
|
45
46
|
{ component: 'Loading', role: "The spinner shown instead of the label and icon while loading is true.", relationship: 'built-in' },
|
|
46
47
|
],
|
|
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
|
|
|
12
12
|
export default defineCatalogPage({
|
|
13
13
|
component: 'TopNav',
|
|
14
14
|
group: 'Components',
|
|
15
|
+
standalone: 'A reusable kit component in its own right; Dropdown is just the only kit component that uses it.',
|
|
15
16
|
whenToUse: 'A screen\'s top bar, and BottomSheet\'s header slot. Its leading and trailing slots reserve their width even when empty, so the title stays centred whether one, both, or neither side has content. For actions at the bottom of a screen, use Dock.',
|
|
16
17
|
composedOf: [
|
|
17
18
|
{ component: 'Button', role: "The leading and trailing slots, typically small icon Buttons (back, close, an action).", relationship: 'slot' },
|