@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,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
|
-
|
|
24
|
-
|
|
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
|
});
|