@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.
@@ -0,0 +1,592 @@
1
+ import React, { useCallback, useEffect, useRef } from 'react';
2
+ import { View, Text, Platform, StyleSheet, type TextStyle } from 'react-native';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
+ import { TokenGrid, TokenTile, TokenSections } from './TokenLayouts';
5
+ import { TokenRow, TokenRowGroup } from './TokenRow';
6
+ import { Swatch } from './Swatch';
7
+ import { MotionSpecimen } from './MotionSpecimen';
8
+ import { JumpRow, type JumpItem } from './JumpRow';
9
+ import {
10
+ consolidatedProvenanceCaption,
11
+ isTokenKind,
12
+ filteredKindEntriesFromList,
13
+ pairLightDarkColorEntries,
14
+ pairRendersAsOneSwatch,
15
+ sectionEntryCount,
16
+ tokenProvenanceCaption,
17
+ validateTokenDefinition,
18
+ type ColorTokenEntry,
19
+ type DimensionsTokenEntry,
20
+ type MotionTokenEntry,
21
+ type OpacityTokenEntry,
22
+ type RadiusTokenEntry,
23
+ type ShadowTokenEntry,
24
+ type SpacingTokenEntry,
25
+ type TokenDefinition,
26
+ type TokenDefinitionSection,
27
+ type TokenEntry,
28
+ type TokenEntryBase,
29
+ type TokenKind,
30
+ type TokenValueInfo,
31
+ type TypographyTokenEntry,
32
+ } from './tokenDefinition';
33
+ import type { TokenSection } from './types';
34
+
35
+ /** `role`/`note`/`source` consolidated into one plain-text caption — the same single-line
36
+ * "usage · citation" convention `token-contract.md`'s own consumer references already use
37
+ * (`describeUsage(rows)` + a compact citation). Never a status badge (design R2: "no repetitive
38
+ * new status badges"); `undefined` when the entry authored none of the three. */
39
+ function entryNote(entry: Pick<TokenEntryBase, 'role' | 'note' | 'source'>): string | undefined {
40
+ const parts = [entry.note ?? entry.role, entry.source].filter((part): part is string => Boolean(part));
41
+ return parts.length > 0 ? parts.join(' · ') : undefined;
42
+ }
43
+
44
+ /** Correction C1: a value's own provenance/named line, plain muted caption text — never truncated
45
+ * (no `numberOfLines`), so a long expression/reason always wraps. */
46
+ function ProvenanceLine({ value }: { value: Pick<TokenValueInfo<unknown>, 'provenance' | 'named'> }) {
47
+ return <Text style={styles.provenanceLine}>{tokenProvenanceCaption(value)}</Text>;
48
+ }
49
+
50
+ /** Correction C1: an unresolved value's real `reason`, and its real `expression` when one exists
51
+ * (monospace — `CATALOG_TEXT.codeMicro` — since that mono role already exists in this catalog).
52
+ * Never a fabricated classification; never truncated. */
53
+ function UnresolvedCaption({ resolution }: { resolution: { reason: string; expression?: string } }) {
54
+ return (
55
+ <>
56
+ <Text style={styles.fallbackDetail}>{resolution.reason}</Text>
57
+ {resolution.expression ? <Text style={styles.monoNote}>{resolution.expression}</Text> : null}
58
+ </>
59
+ );
60
+ }
61
+
62
+ /** Correction C1 for the four kinds built on `TokenTile` (spacing/radius/dimensions/opacity/
63
+ * shadows): `TokenTile` only has one `note` slot, so this composes `role`/`note`/`source`, the
64
+ * provenance/named line (when not already shown once at the section level) and — for an
65
+ * unresolved value — its real `expression` (monospace) into that one slot, newline-separated.
66
+ * `undefined` (never an empty node) when there is nothing to show, so `TokenTile`'s own `{note ?
67
+ * ... : null}` check still works unchanged. */
68
+ function tileProvenanceNote(entry: TokenEntryBase, value: TokenValueInfo<unknown>, showProvenance: boolean): React.ReactNode {
69
+ const parts: string[] = [];
70
+ const base = entryNote(entry);
71
+ if (base) parts.push(base);
72
+ if (showProvenance) parts.push(tokenProvenanceCaption(value));
73
+ const expression = value.resolution.status === 'unresolved' ? value.resolution.expression : undefined;
74
+ if (parts.length === 0 && !expression) return undefined;
75
+ return (
76
+ <>
77
+ {parts.join('\n')}
78
+ {expression ? <Text style={styles.monoNote}>{parts.length > 0 ? `\n${expression}` : expression}</Text> : null}
79
+ </>
80
+ );
81
+ }
82
+
83
+ function filteredKindEntries(kind: TokenKind, section: TokenDefinitionSection): TokenEntry[] {
84
+ return filteredKindEntriesFromList(kind, section.entries);
85
+ }
86
+
87
+ /** Visible diagnostic for invalid opted-in data (design R3: "never a plausible fake specimen or
88
+ * silently render nothing") — a real, legible error, styled the same as the update page's own
89
+ * failure note-boxes (`UpdatePanel.tsx`'s `err`/`resultNoteErr`). */
90
+ function IssuesBanner({ issues }: { issues: readonly string[] }) {
91
+ return (
92
+ <View style={styles.issuesBox}>
93
+ <Text style={styles.issuesTitle}>Invalid token data</Text>
94
+ {issues.map((issueText, i) => (
95
+ <Text key={i} style={styles.issuesText}>{issueText}</Text>
96
+ ))}
97
+ </View>
98
+ );
99
+ }
100
+
101
+ /** One width for drawable and "No sample" color cells, so a section's row reads as one grid. */
102
+ const COLOR_CELL_WIDTH = 120;
103
+
104
+ function ColorEntryCell({ entry, showProvenance }: { entry: ColorTokenEntry; showProvenance: boolean }) {
105
+ const note = entryNote(entry);
106
+ const name = entry.appearance ? `${entry.name} (${entry.appearance})` : entry.name;
107
+ if (entry.value.resolution.status === 'unresolved') {
108
+ return (
109
+ <View style={styles.colorFallback}>
110
+ <View style={styles.noSamplePreview}><Text style={styles.noSampleText}>No sample</Text></View>
111
+ <Text style={styles.fallbackName}>{name}</Text>
112
+ {showProvenance ? <ProvenanceLine value={entry.value} /> : null}
113
+ <UnresolvedCaption resolution={entry.value.resolution} />
114
+ {note ? <Text style={styles.fallbackNote}>{note}</Text> : null}
115
+ </View>
116
+ );
117
+ }
118
+ return (
119
+ <View style={styles.colorCell}>
120
+ <Swatch name={name} value={entry.value.resolution.value.color} width={COLOR_CELL_WIDTH} wrapName />
121
+ {showProvenance ? <ProvenanceLine value={entry.value} /> : null}
122
+ {note ? <Text style={styles.swatchNote}>{note}</Text> : null}
123
+ </View>
124
+ );
125
+ }
126
+
127
+ /** #64 B3: a light/dark pair, one swatch — a split chip (light half left, dark half right), the
128
+ * bare shared `name`, and `<light value> · <dark value>` as the value line. Only ever called with
129
+ * a pair whose both sides are resolved (`ColorEntries` falls back to two plain cells otherwise —
130
+ * a split chip with no real colour to show on one side would misrepresent the data). */
131
+ function PairedColorCell({ name, light, dark, showProvenance }: { name: string; light: ColorTokenEntry; dark: ColorTokenEntry; showProvenance: boolean }) {
132
+ const lightColor = (light.value.resolution as { status: 'resolved'; value: { color: string } }).value.color;
133
+ const darkColor = (dark.value.resolution as { status: 'resolved'; value: { color: string } }).value.color;
134
+ const note = entryNote(light) ?? entryNote(dark);
135
+ return (
136
+ <View style={styles.colorCell}>
137
+ <Swatch name={name} value={lightColor} splitValue={darkColor} valueLabel={`${lightColor} · ${darkColor}`} width={COLOR_CELL_WIDTH} wrapName />
138
+ {showProvenance ? <ProvenanceLine value={light.value} /> : null}
139
+ {note ? <Text style={styles.swatchNote}>{note}</Text> : null}
140
+ </View>
141
+ );
142
+ }
143
+
144
+ function ColorEntries({ entries, showProvenance }: { entries: ColorTokenEntry[]; showProvenance: boolean }) {
145
+ const paired = pairLightDarkColorEntries(entries);
146
+ return (
147
+ <View style={styles.wrapRow}>
148
+ {paired.map((item) => {
149
+ if (item.kind === 'single') return <ColorEntryCell key={item.entry.id} entry={item.entry} showProvenance={showProvenance} />;
150
+ const { light, dark } = item;
151
+ // A pair whose light or dark side has no real drawable value (unresolved) can't render a
152
+ // meaningful split chip — fall back to each entry's own normal (unpaired-style) cell.
153
+ if (!pairRendersAsOneSwatch(light, dark)) {
154
+ return (
155
+ <React.Fragment key={`${light.id}-${dark.id}`}>
156
+ <ColorEntryCell entry={light} showProvenance={showProvenance} />
157
+ <ColorEntryCell entry={dark} showProvenance={showProvenance} />
158
+ </React.Fragment>
159
+ );
160
+ }
161
+ return <PairedColorCell key={`${light.id}-${dark.id}`} name={item.name} light={light} dark={dark} showProvenance={showProvenance} />;
162
+ })}
163
+ </View>
164
+ );
165
+ }
166
+
167
+ function TypographyRow({ entry, last, showProvenance }: { entry: TypographyTokenEntry; last: boolean; showProvenance: boolean }) {
168
+ const base = entryNote(entry) ?? entry.name;
169
+ const provenanceSuffix = showProvenance ? ` · ${tokenProvenanceCaption(entry.value)}` : '';
170
+ if (entry.value.resolution.status === 'unresolved') {
171
+ return (
172
+ <TokenRow use={`${base}${provenanceSuffix}`} last={last} notePlacement="right">
173
+ <View style={styles.unresolvedStack}>
174
+ <Text style={styles.unresolvedText}>{entry.value.resolution.reason}</Text>
175
+ {entry.value.resolution.expression ? <Text style={styles.unresolvedExpression}>{entry.value.resolution.expression}</Text> : null}
176
+ </View>
177
+ </TokenRow>
178
+ );
179
+ }
180
+ const value = entry.value.resolution.value;
181
+ const meta = `${value.fontSize}${value.fontSizeUnit}${value.fontWeight !== undefined ? `/${value.fontWeight}` : ''}`;
182
+ // The sample is drawn with the token's own authored text-style values, passed through unchanged
183
+ // like TypeScaleGallery's author-supplied `sampleStyle` — data, not catalog chrome. The unit label
184
+ // stays out of the style. The cast covers React Native's closed `fontWeight` union for a real
185
+ // authored weight such as a numeric 600.
186
+ const { fontSizeUnit: _unit, ...authoredTextStyle } = value;
187
+ return (
188
+ <TokenRow use={`${base} · ${meta}${provenanceSuffix}`} last={last} notePlacement="right">
189
+ <Text
190
+ style={[styles.typographySample, authoredTextStyle as TextStyle]}
191
+ numberOfLines={1}
192
+ >
193
+ {entry.name}
194
+ </Text>
195
+ </TokenRow>
196
+ );
197
+ }
198
+
199
+ function TypographyEntries({ entries, showProvenance }: { entries: TypographyTokenEntry[]; showProvenance: boolean }) {
200
+ return (
201
+ <TokenRowGroup>
202
+ <View style={styles.rowStack}>
203
+ {entries.map((entry, i) => <TypographyRow key={entry.id} entry={entry} last={i === entries.length - 1} showProvenance={showProvenance} />)}
204
+ </View>
205
+ </TokenRowGroup>
206
+ );
207
+ }
208
+
209
+ type DimensionLikeEntry = SpacingTokenEntry | RadiusTokenEntry | DimensionsTokenEntry;
210
+
211
+ function DimensionTile({ kind, entry, showProvenance }: { kind: 'spacing' | 'radius' | 'dimensions'; entry: DimensionLikeEntry; showProvenance: boolean }) {
212
+ const note = tileProvenanceNote(entry, entry.value, showProvenance);
213
+ if (entry.value.resolution.status === 'unresolved') {
214
+ return (
215
+ <TokenTile name={entry.name} value={entry.value.resolution.reason} note={note}>
216
+ <Text style={styles.dash}>—</Text>
217
+ </TokenTile>
218
+ );
219
+ }
220
+ const { amount, unit } = entry.value.resolution.value;
221
+ if (kind === 'radius') {
222
+ return (
223
+ <TokenTile name={entry.name} value={`${amount}${unit}`} note={note}>
224
+ <View style={[styles.radiusSample, { borderRadius: Math.min(Math.max(amount, 0), 28) }]} />
225
+ </TokenTile>
226
+ );
227
+ }
228
+ return (
229
+ <TokenTile name={entry.name} value={`${amount}${unit}`} note={note}>
230
+ <View style={[styles.spacingBar, { width: Math.min(Math.max(amount, 1), 120) }]} />
231
+ </TokenTile>
232
+ );
233
+ }
234
+
235
+ function DimensionEntries({ kind, entries, showProvenance }: { kind: 'spacing' | 'radius' | 'dimensions'; entries: DimensionLikeEntry[]; showProvenance: boolean }) {
236
+ return <TokenGrid minTileWidth={140}>{entries.map((entry) => <DimensionTile key={entry.id} kind={kind} entry={entry} showProvenance={showProvenance} />)}</TokenGrid>;
237
+ }
238
+
239
+ function OpacityTile({ entry, showProvenance }: { entry: OpacityTokenEntry; showProvenance: boolean }) {
240
+ const note = tileProvenanceNote(entry, entry.value, showProvenance);
241
+ if (entry.value.resolution.status === 'unresolved') {
242
+ return (
243
+ <TokenTile name={entry.name} value={entry.value.resolution.reason} note={note}>
244
+ <Text style={styles.dash}>—</Text>
245
+ </TokenTile>
246
+ );
247
+ }
248
+ const { amount } = entry.value.resolution.value;
249
+ return (
250
+ <TokenTile name={entry.name} value={`${Math.round(amount * 100)}%`} note={note}>
251
+ <View style={styles.opacityBase}>
252
+ <View style={[styles.opacityFill, { opacity: amount }]} />
253
+ </View>
254
+ </TokenTile>
255
+ );
256
+ }
257
+
258
+ function OpacityEntries({ entries, showProvenance }: { entries: OpacityTokenEntry[]; showProvenance: boolean }) {
259
+ return <TokenGrid minTileWidth={120}>{entries.map((entry) => <OpacityTile key={entry.id} entry={entry} showProvenance={showProvenance} />)}</TokenGrid>;
260
+ }
261
+
262
+ /** Same literal `.shadow(color:radius:x:y:)` preview `token-contract.md`'s own Shadow consumer
263
+ * reference already draws — a real box at the entry's own radius/offset/opacity. */
264
+ function ShadowTile({ entry, showProvenance }: { entry: ShadowTokenEntry; showProvenance: boolean }) {
265
+ const note = tileProvenanceNote(entry, entry.value, showProvenance);
266
+ if (entry.value.resolution.status === 'unresolved') {
267
+ return (
268
+ <TokenTile name={entry.name} value={entry.value.resolution.reason} note={note}>
269
+ <Text style={styles.dash}>—</Text>
270
+ </TokenTile>
271
+ );
272
+ }
273
+ const { color, opacity, radius, x, y } = entry.value.resolution.value;
274
+ return (
275
+ <TokenTile
276
+ name={entry.name}
277
+ value={`y ${y}${x !== 0 ? ` · x ${x}` : ''} · ${Math.round(opacity * 100)}% ${color}`}
278
+ note={note}
279
+ >
280
+ <View
281
+ style={[
282
+ styles.shadowSample,
283
+ { shadowColor: color, shadowOpacity: opacity, shadowRadius: radius, shadowOffset: { width: x, height: y }, elevation: Math.max(1, Math.round(radius / 2)) },
284
+ ]}
285
+ />
286
+ </TokenTile>
287
+ );
288
+ }
289
+
290
+ function ShadowEntries({ entries, showProvenance }: { entries: ShadowTokenEntry[]; showProvenance: boolean }) {
291
+ return <TokenGrid minTileWidth={160}>{entries.map((entry) => <ShadowTile key={entry.id} entry={entry} showProvenance={showProvenance} />)}</TokenGrid>;
292
+ }
293
+
294
+ /** A real timing/spring value is replayed with `MotionSpecimen`; a real native expression that
295
+ * isn't replayable as that physics model (SwiftUI duration/bounce, response/dampingFraction) is
296
+ * shown as its own text, never a guessed conversion (`MotionNativeExpressionValue`'s own doc
297
+ * comment, `tokenDefinition.ts`) — that label names a true property of the type, unlike the
298
+ * removed "symbolic / conditional" classification below (correction C2: an unresolved value shows
299
+ * only its real provenance/reason/expression, never an invented category). */
300
+ function MotionCell({ entry, showProvenance }: { entry: MotionTokenEntry; showProvenance: boolean }) {
301
+ const note = entryNote(entry);
302
+ if (entry.value.resolution.status === 'unresolved') {
303
+ return (
304
+ <View style={styles.motionCell}>
305
+ {showProvenance ? <ProvenanceLine value={entry.value} /> : null}
306
+ <UnresolvedCaption resolution={entry.value.resolution} />
307
+ {note ? <Text style={styles.motionNote}>{note}</Text> : null}
308
+ </View>
309
+ );
310
+ }
311
+ const value = entry.value.resolution.value;
312
+ if (value.kind === 'timing') {
313
+ return (
314
+ <View style={styles.motionCell}>
315
+ <MotionSpecimen kind="timing" duration={value.durationMs} label={`${value.sourceValue}${value.sourceUnit}`} />
316
+ {showProvenance ? <ProvenanceLine value={entry.value} /> : null}
317
+ {note ? <Text style={styles.motionNote}>{note}</Text> : null}
318
+ </View>
319
+ );
320
+ }
321
+ if (value.kind === 'spring') {
322
+ return (
323
+ <View style={styles.motionCell}>
324
+ <MotionSpecimen
325
+ kind="spring"
326
+ spring={{ stiffness: value.stiffness, damping: value.damping, mass: value.mass }}
327
+ valueRange={value.valueRange}
328
+ label={entry.name}
329
+ />
330
+ {showProvenance ? <ProvenanceLine value={entry.value} /> : null}
331
+ {note ? <Text style={styles.motionNote}>{note}</Text> : null}
332
+ </View>
333
+ );
334
+ }
335
+ return (
336
+ <View style={styles.motionCell}>
337
+ <Text style={styles.motionLabel}>native expression (not replayable as physics)</Text>
338
+ <Text style={styles.motionDetail}>{value.expression}</Text>
339
+ {value.detail ? <Text style={styles.motionDetail}>{value.detail}</Text> : null}
340
+ {showProvenance ? <ProvenanceLine value={entry.value} /> : null}
341
+ {note ? <Text style={styles.motionNote}>{note}</Text> : null}
342
+ </View>
343
+ );
344
+ }
345
+
346
+ function MotionEntries({ entries, showProvenance }: { entries: MotionTokenEntry[]; showProvenance: boolean }) {
347
+ return <View style={styles.wrapRow}>{entries.map((entry) => <MotionCell key={entry.id} entry={entry} showProvenance={showProvenance} />)}</View>;
348
+ }
349
+
350
+ /** One entries list's real body, dispatched by the page's own `kind` — shared by a section's own
351
+ * entries and, #64 B1, each of its groups' own entries, so both render through the exact same
352
+ * per-kind grammar. */
353
+ function renderKindEntries(kind: TokenKind, entries: TokenEntry[], showProvenance: boolean): React.ReactNode {
354
+ switch (kind) {
355
+ case 'colors':
356
+ return <ColorEntries entries={entries as ColorTokenEntry[]} showProvenance={showProvenance} />;
357
+ case 'typography':
358
+ return <TypographyEntries entries={entries as TypographyTokenEntry[]} showProvenance={showProvenance} />;
359
+ case 'spacing':
360
+ case 'radius':
361
+ case 'dimensions':
362
+ return <DimensionEntries kind={kind} entries={entries as DimensionLikeEntry[]} showProvenance={showProvenance} />;
363
+ case 'opacity':
364
+ return <OpacityEntries entries={entries as OpacityTokenEntry[]} showProvenance={showProvenance} />;
365
+ case 'shadows':
366
+ return <ShadowEntries entries={entries as ShadowTokenEntry[]} showProvenance={showProvenance} />;
367
+ case 'motion':
368
+ return <MotionEntries entries={entries as MotionTokenEntry[]} showProvenance={showProvenance} />;
369
+ default:
370
+ return null;
371
+ }
372
+ }
373
+
374
+ /** One section's real entries, dispatched by the page's own `kind` — an entry whose own `kind`
375
+ * doesn't match (already reported by `validateTokenDefinition`) or whose `id` repeats another
376
+ * entry already rendered in this section (same) is skipped here rather than rendered twice or
377
+ * rendered as if it were a different kind. Correction C1: when every rendered entry shares the
378
+ * same provenance+named pair, it is shown once as a leading section-level caption instead of
379
+ * repeating it on each entry below — #64 B1 extends that same consolidation across the section's
380
+ * own entries AND every one of its groups, which then each render under their own sub-heading,
381
+ * after the section's own entries, in the same grid grammar. */
382
+ function StructuredTokenSectionBody({ kind, section }: { kind: TokenKind; section: TokenDefinitionSection }) {
383
+ const ownEntries = filteredKindEntries(kind, section);
384
+ const groups = (section.groups ?? []).map((group) => ({ group, entries: filteredKindEntriesFromList(kind, group.entries) }));
385
+ const consolidated = consolidatedProvenanceCaption([...ownEntries, ...groups.flatMap((g) => g.entries)]);
386
+ const showProvenance = consolidated === undefined;
387
+ return (
388
+ <>
389
+ {consolidated ? <Text style={styles.sectionProvenance}>{consolidated}</Text> : null}
390
+ {ownEntries.length > 0 ? renderKindEntries(kind, ownEntries, showProvenance) : null}
391
+ {groups.map(({ group, entries }) => (
392
+ <View key={group.id} style={styles.groupBlock}>
393
+ <Text style={styles.groupTitle}>{group.title}</Text>
394
+ {group.desc ? <Text style={styles.groupDesc}>{group.desc}</Text> : null}
395
+ {renderKindEntries(kind, entries, showProvenance)}
396
+ </View>
397
+ ))}
398
+ </>
399
+ );
400
+ }
401
+
402
+ /** The real `TokenSection[]` for `tokenDefinition` — called only from `SectionBlock.tsx`'s own
403
+ * `tokenSections` block case (the existing JSX rendering boundary), never from `pageApi.ts` (a
404
+ * plain-TS module the Node `--experimental-strip-types` test runner must keep importing without a
405
+ * JSX transform). An unknown `kind` renders the clearly-labeled diagnostic card alone, since no
406
+ * renderer can dispatch on it. Any issue `validateTokenDefinition` finds for a valid kind (a
407
+ * duplicate section/entry id, a mismatched entry kind) renders that card plus every section's
408
+ * real, valid entries — never a fake specimen or nothing. */
409
+ export function tokenDefinitionSections(definition: TokenDefinition): TokenSection[] {
410
+ if (!isTokenKind(definition.kind)) {
411
+ return [{ id: '__token-definition-issues__', title: 'Invalid token data', wide: true, render: () => <IssuesBanner issues={[`tokenDefinition: unknown kind "${definition.kind}".`]} /> }];
412
+ }
413
+ const issues = validateTokenDefinition(definition);
414
+ const sections: TokenSection[] = definition.sections.map((section) => ({
415
+ id: section.id,
416
+ title: section.title,
417
+ desc: section.desc,
418
+ wide: section.wide,
419
+ render: () => <StructuredTokenSectionBody kind={definition.kind} section={section} />,
420
+ }));
421
+ if (issues.length === 0) return sections;
422
+ return [{ id: '__token-definition-issues__', title: 'Invalid token data', wide: true, render: () => <IssuesBanner issues={issues} /> }, ...sections];
423
+ }
424
+
425
+ /** #64 B2/B5: scrolls a section's own node into view — the same instant, web-only
426
+ * `scrollIntoView` convention `FamilyPage.tsx`'s own `scrollAndFocusMember` already established
427
+ * for a member address; this is its section-level counterpart (no focus steal — a chip press or a
428
+ * section-alias arrival moves the viewport, not keyboard focus, since nothing here is itself a
429
+ * landable heading). Native has no scroll-into-view of its own; this is a no-op there, same as
430
+ * that function's own web-only half. */
431
+ function scrollSectionIntoView(node: unknown): void {
432
+ if (Platform.OS !== 'web' || !node) return;
433
+ (node as { scrollIntoView?: (options: { behavior: 'auto'; block: 'start' }) => void }).scrollIntoView?.({ behavior: 'auto', block: 'start' });
434
+ }
435
+
436
+ /** #64 B2 / #63 R1: one `JumpItem` per section — its title, its own entry count
437
+ * (`sectionEntryCount`, own entries plus every group's) as the chip's muted qualifier, and an
438
+ * accessible name like "Attention, 3 values" — the shared `JumpRow` (`JumpRow.tsx`) renders the
439
+ * actual chip/pill visual. `undefined` for a single-section page (nothing to jump between). */
440
+ function tokenJumpItems(definition: TokenDefinition, onJump: (id: string) => void): JumpItem[] | undefined {
441
+ if (definition.sections.length <= 1) return undefined;
442
+ return definition.sections.map((section) => {
443
+ const count = sectionEntryCount(definition.kind, section);
444
+ return {
445
+ id: section.id,
446
+ title: section.title,
447
+ qualifier: String(count),
448
+ accessibilityLabel: `${section.title}, ${count} value${count === 1 ? '' : 's'}`,
449
+ onPress: () => onJump(section.id),
450
+ };
451
+ });
452
+ }
453
+
454
+ /** #64 B2/B5: the real per-page wrapper `SectionBlock.tsx`'s own `tokenSections` block case
455
+ * renders for an opted-in `tokenDefinition` — the jump row (above the first section, B2) plus the
456
+ * existing `TokenSections` grammar, unchanged, now also given each section's own mounted node so
457
+ * a chip press, or a section-alias arrival (`focusSectionId`/`focusNonce`, set by `CatalogShell`
458
+ * from `resolveRoute`'s own `sectionId`), can scroll that section into view. */
459
+ export function TokenDefinitionBlock({
460
+ definition, columns, label, focusSectionId, focusNonce,
461
+ }: {
462
+ definition: TokenDefinition;
463
+ columns: 1 | 2 | 3;
464
+ label?: string;
465
+ focusSectionId?: string;
466
+ focusNonce?: number;
467
+ }) {
468
+ const sections = tokenDefinitionSections(definition);
469
+ const nodesRef = useRef<Map<string, unknown>>(new Map());
470
+ // A section arrival can land before `TokenSections` has measured its width and mounted any
471
+ // section, so the requested id waits here and is scrolled to the moment its node registers.
472
+ const pendingRef = useRef<string | undefined>(undefined);
473
+ const registerNode = useCallback((id: string, node: unknown) => {
474
+ if (node) {
475
+ nodesRef.current.set(id, node);
476
+ if (pendingRef.current === id) {
477
+ pendingRef.current = undefined;
478
+ scrollSectionIntoView(node);
479
+ }
480
+ } else nodesRef.current.delete(id);
481
+ }, []);
482
+ const scrollToSection = useCallback((id: string) => {
483
+ scrollSectionIntoView(nodesRef.current.get(id));
484
+ }, []);
485
+ useEffect(() => {
486
+ if (!focusSectionId) return;
487
+ if (nodesRef.current.has(focusSectionId)) scrollToSection(focusSectionId);
488
+ else pendingRef.current = focusSectionId;
489
+ // eslint-disable-next-line react-hooks/exhaustive-deps
490
+ }, [focusSectionId, focusNonce]);
491
+ const jumpItems = tokenJumpItems(definition, scrollToSection);
492
+ return (
493
+ <>
494
+ {jumpItems ? <JumpRow items={jumpItems} /> : null}
495
+ <TokenSections sections={sections} columns={columns} label={label} onSectionNode={registerNode} />
496
+ </>
497
+ );
498
+ }
499
+
500
+ /** A standalone preview of a whole `tokenDefinition` — the same jump row `TokenDefinitionBlock`
501
+ * shows (B2) when there's more than one section, then every section, stacked, each with its own
502
+ * title/desc and real entries. `SectionBlock.tsx`'s own `tokenSections` block case is the real
503
+ * per-page rendering path (`tokenDefinitionSections`, above); this component exists for a direct
504
+ * preview outside that path — e.g. this framework's own self-catalog demonstration. */
505
+ export function StructuredTokenPage({ definition }: { definition: TokenDefinition }) {
506
+ const sections = tokenDefinitionSections(definition);
507
+ const nodesRef = useRef<Map<string, unknown>>(new Map());
508
+ const scrollToSection = useCallback((id: string) => {
509
+ scrollSectionIntoView(nodesRef.current.get(id));
510
+ }, []);
511
+ const jumpItems = tokenJumpItems(definition, scrollToSection);
512
+ return (
513
+ <View style={styles.pageStack}>
514
+ {jumpItems ? <JumpRow items={jumpItems} /> : null}
515
+ {sections.map((section) => {
516
+ const key = section.id ?? section.title;
517
+ return (
518
+ <View key={key} ref={(node: unknown) => { if (node) nodesRef.current.set(key, node); else nodesRef.current.delete(key); }}>
519
+ <Text style={styles.pageSectionTitle}>{section.title}</Text>
520
+ {section.desc ? <Text style={styles.pageSectionDesc}>{section.desc}</Text> : null}
521
+ <View style={styles.pageSectionBody}>{section.render()}</View>
522
+ </View>
523
+ );
524
+ })}
525
+ </View>
526
+ );
527
+ }
528
+
529
+ const styles = StyleSheet.create({
530
+ wrapRow: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.lg },
531
+ rowStack: { gap: CATALOG_SPACE.lg },
532
+
533
+ // #63 R1: the category jump row moved to the shared `JumpRow.tsx` (used by group pages too).
534
+
535
+ // #64 B1: a sub-group's own sub-heading — `microLabel`, a smaller step than the section's own
536
+ // `label` role (`TokenLayouts.tsx`'s `sectionLabel`), the same role already used for a
537
+ // variant-group name.
538
+ groupBlock: { marginTop: CATALOG_SPACE.lg, gap: CATALOG_SPACE.sm },
539
+ groupTitle: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
540
+ groupDesc: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
541
+
542
+ colorCell: { gap: CATALOG_SPACE.xs, width: COLOR_CELL_WIDTH },
543
+ swatchNote: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted, width: COLOR_CELL_WIDTH },
544
+ colorFallback: { width: COLOR_CELL_WIDTH, gap: CATALOG_SPACE.xs },
545
+ noSamplePreview: { height: 40, borderRadius: CATALOG_RADIUS.sm, borderWidth: 1, borderColor: CATALOG_COLOR.border, backgroundColor: CATALOG_COLOR.surfaceMuted, alignItems: 'center', justifyContent: 'center' },
546
+ noSampleText: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
547
+ fallbackName: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.text },
548
+ fallbackDetail: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
549
+ fallbackNote: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
550
+
551
+ // Correction C1: a value's own provenance/named line — plain muted caption text, never a badge.
552
+ provenanceLine: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
553
+ // Correction C1: the section-level consolidated provenance line, shown once above every entry's
554
+ // own body when every entry in the section shares the same provenance+named pair.
555
+ sectionProvenance: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, marginBottom: CATALOG_SPACE.sm },
556
+ // Correction C1: an unresolved value's real source `expression`, monospace — the same
557
+ // `CATALOG_TEXT.codeMicro` mono role `ReferenceDetails.tsx`'s own `mono` style already uses.
558
+ monoNote: { ...CATALOG_TEXT.codeMicro, color: CATALOG_COLOR.textMuted },
559
+
560
+ typographySample: { color: CATALOG_COLOR.text },
561
+ unresolvedStack: { gap: CATALOG_SPACE.xs },
562
+ unresolvedText: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted },
563
+ unresolvedExpression: { ...CATALOG_TEXT.codeMicro, color: CATALOG_COLOR.textMuted },
564
+
565
+ dash: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.textMuted },
566
+ spacingBar: { height: 12, borderRadius: CATALOG_RADIUS.sm, backgroundColor: CATALOG_COLOR.accent },
567
+ radiusSample: { width: 56, height: 36, backgroundColor: CATALOG_COLOR.accentSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.accent },
568
+ opacityBase: { width: 56, height: 36, borderRadius: CATALOG_RADIUS.sm, backgroundColor: CATALOG_COLOR.surfaceMuted, borderWidth: 1, borderColor: CATALOG_COLOR.border, overflow: 'hidden' },
569
+ opacityFill: { flex: 1, backgroundColor: CATALOG_COLOR.accent },
570
+ shadowSample: { width: 64, height: 36, backgroundColor: '#ffffff', borderRadius: 6 },
571
+
572
+ motionCell: { gap: CATALOG_SPACE.xs, width: 220 },
573
+ motionLabel: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
574
+ motionDetail: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.text },
575
+ motionNote: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
576
+
577
+ issuesBox: {
578
+ backgroundColor: CATALOG_COLOR.dangerSubtle,
579
+ borderWidth: 1,
580
+ borderColor: CATALOG_COLOR.dangerBorder,
581
+ borderRadius: CATALOG_RADIUS.md,
582
+ padding: CATALOG_SPACE.md,
583
+ gap: CATALOG_SPACE.xs,
584
+ },
585
+ issuesTitle: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.danger },
586
+ issuesText: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.danger },
587
+
588
+ pageStack: { gap: CATALOG_SPACE.xl },
589
+ pageSectionTitle: { ...CATALOG_TEXT.label, color: CATALOG_COLOR.text },
590
+ pageSectionDesc: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, marginTop: CATALOG_SPACE.xs },
591
+ pageSectionBody: { marginTop: CATALOG_SPACE.md },
592
+ });
@@ -12,16 +12,32 @@ export function Swatch({
12
12
  value,
13
13
  valueLabel,
14
14
  width = 84,
15
+ wrapName = false,
16
+ splitValue,
15
17
  }: {
16
18
  name: string;
17
19
  value: string;
18
20
  valueLabel?: string;
19
21
  width?: number;
22
+ /** Lets a long name wrap instead of truncating to one line. Off by default, so every existing
23
+ * gallery keeps its compact single-line labels. */
24
+ wrapName?: boolean;
25
+ /** #64 B3: a second colour, rendered as the right half of the chip (`value`'s own colour stays
26
+ * the left half) — for a light/dark pair rendered as one swatch. Omit for every existing
27
+ * single-appearance swatch, whose chip renders exactly as before (one solid fill). */
28
+ splitValue?: string;
20
29
  }) {
21
30
  return (
22
31
  <View style={[styles.swatch, { width }]}>
23
- <View style={[styles.chip, { backgroundColor: value }]} />
24
- <Text style={styles.name} numberOfLines={1}>{name}</Text>
32
+ {splitValue ? (
33
+ <View style={styles.splitChip}>
34
+ <View style={[styles.splitHalf, { backgroundColor: value }]} />
35
+ <View style={[styles.splitHalf, { backgroundColor: splitValue }]} />
36
+ </View>
37
+ ) : (
38
+ <View style={[styles.chip, { backgroundColor: value }]} />
39
+ )}
40
+ <Text style={styles.name} numberOfLines={wrapName ? undefined : 1}>{name}</Text>
25
41
  <Text style={styles.value} numberOfLines={1}>{valueLabel ?? value}</Text>
26
42
  </View>
27
43
  );
@@ -35,6 +51,17 @@ const styles = StyleSheet.create({
35
51
  borderWidth: StyleSheet.hairlineWidth,
36
52
  borderColor: CATALOG_COLOR.border,
37
53
  },
54
+ // #64 B3: the same chip geometry as `chip` above, split into two equal halves for a light/dark
55
+ // pair — `overflow: 'hidden'` keeps both halves inside the chip's own rounded corners.
56
+ splitChip: {
57
+ height: 40,
58
+ flexDirection: 'row',
59
+ overflow: 'hidden',
60
+ borderRadius: CATALOG_RADIUS.sm,
61
+ borderWidth: StyleSheet.hairlineWidth,
62
+ borderColor: CATALOG_COLOR.border,
63
+ },
64
+ splitHalf: { flex: 1 },
38
65
  name: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
39
66
  value: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
40
67
  });