@krapwoo/ds-viewer 0.9.0 → 0.10.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/dist/cli/dev.js CHANGED
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { resolveConfig } from "./config.js";
8
8
  import { sync } from "./sync.js";
9
- import { clearDevLock, liveDevLock, missingWorkspaceFiles, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
9
+ import { clearDevLock, liveDevLock, missingWorkspaceFiles, readEndpointRecord, workspacePath, writeDevLock, writeEndpointRecord, writeWorkspace } from "./workspace.js";
10
10
  import { findFreePort } from "./port.js";
11
11
  import { globBaseFolder } from "./glob.js";
12
12
  import { buildVersionStatus, createVersionController, defaultPreferencesDir, readPersonalAutoCheck, resolveAutoCheck, runUpdateCheck, statusFromCheck, writeUpdateFile, writeVersionFile, } from "./updateCheck.js";
@@ -362,6 +362,44 @@ export function repairWorkspaceIfMissing(deps) {
362
362
  sync(config);
363
363
  return { missing, repaired: true };
364
364
  }
365
+ function listenOnce(server, port) {
366
+ return new Promise((resolve, reject) => {
367
+ const onError = (error) => {
368
+ server.removeListener('listening', onListening);
369
+ reject(error);
370
+ };
371
+ const onListening = () => {
372
+ server.removeListener('error', onError);
373
+ resolve(server.address().port);
374
+ };
375
+ server.once('error', onError);
376
+ server.once('listening', onListening);
377
+ server.listen(port, '127.0.0.1');
378
+ });
379
+ }
380
+ /** Resolves the update endpoint's real port: the stored record's port when it's actually free, a
381
+ * fresh OS-assigned one otherwise. Tries the real bind directly, once — a separate "is this free?"
382
+ * probe followed by a real bind would leave a window for something else to take the port in
383
+ * between, and could never tell "a foreign process holds it" from "nothing does" anyway, which
384
+ * Part 1 says this must never try to resolve: a bind failure for any reason falls back the same
385
+ * way, never killing, probing, or assuming ownership of whatever holds it. Mutates
386
+ * `secretHolder.secret` to a fresh value only on that fallback path — reusing the stored *port*
387
+ * without the *secret* minted for it would pair a stale secret with an address nobody recorded. */
388
+ export async function bindEndpointServer(server, secretHolder, storedPort) {
389
+ if (storedPort !== undefined) {
390
+ try {
391
+ const port = await listenOnce(server, storedPort);
392
+ return { port, reused: true };
393
+ }
394
+ catch {
395
+ // Taken — by a foreign process, a second catalog, anything. Fall through to a fresh port and
396
+ // secret below; never retry the same port or investigate what's holding it.
397
+ }
398
+ }
399
+ secretHolder.secret = randomBytes(24).toString('hex');
400
+ const port = await listenOnce(server, 0);
401
+ return { port, reused: false };
402
+ }
365
403
  export async function dev(initialConfig, devOptions = {}) {
366
404
  assertLocalInstall(initialConfig.projectRoot);
367
405
  const ownVersion = readOwnVersion(import.meta.dirname);
@@ -480,14 +518,20 @@ export async function dev(initialConfig, devOptions = {}) {
480
518
  let updateEndpoint;
481
519
  let endpointServer;
482
520
  if (!devOptions.noUpdateEndpoint) {
521
+ // Reused across restarts so an already-open page's baked-in endpoint keeps working: the
522
+ // stored record's secret is the candidate here, replaced by `bindEndpointServer` itself only
523
+ // if the stored port turns out to be taken.
524
+ const stored = readEndpointRecord(config);
525
+ endpointDeps.secret = stored?.secret ?? secret;
483
526
  endpointServer = createUpdateEndpoint(endpointDeps);
484
- await new Promise((resolve) => endpointServer.listen(0, '127.0.0.1', resolve));
485
- const endpointPort = endpointServer.address().port;
527
+ const binding = await bindEndpointServer(endpointServer, endpointDeps, stored?.port);
528
+ if (!binding.reused)
529
+ writeEndpointRecord(config, { port: binding.port, secret: endpointDeps.secret });
486
530
  // Printed for the same reason the Metro port above is printed: a developer (or Task 20's own
487
531
  // verification) checking the endpoint directly (e.g. with curl) needs its port — the secret
488
532
  // itself is never printed; the endpoint rejects anything that doesn't already have it.
489
- console.log(`Update endpoint listening on 127.0.0.1:${endpointPort}`);
490
- updateEndpoint = { baseUrl: `http://127.0.0.1:${endpointPort}`, secret };
533
+ console.log(`Update endpoint listening on 127.0.0.1:${binding.port}`);
534
+ updateEndpoint = { baseUrl: `http://127.0.0.1:${binding.port}`, secret: endpointDeps.secret };
491
535
  }
492
536
  else {
493
537
  console.log('Update endpoint disabled for this run (--no-update-endpoint).');
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globBaseFolder } from "./glob.js";
4
4
  import { DEFAULT_WORKSPACE_DIR } from "./types.js";
@@ -151,6 +151,42 @@ export function liveDevLock(config, ownPid = process.pid) {
151
151
  return undefined;
152
152
  return lock;
153
153
  }
154
+ const ENDPOINT_RECORD_FILE = 'endpoint.json';
155
+ /** `bindEndpointServer` never writes a secret shorter than this (`randomBytes(24).toString('hex')`
156
+ * is 48 characters), so a shorter one can only come from a hand-written record — but a
157
+ * hand-written `{"secret":""}` is reused verbatim and `secretMatches` in `cli/endpoint.ts`
158
+ * compares two zero-length buffers as equal, authenticating every request. Minor finding,
159
+ * correction pass 4: treated the same as any other invalid record field below. */
160
+ const MIN_SECRET_LENGTH = 32;
161
+ /** The endpoint address a previous `dev` run bound, so a restart can reuse it instead of minting a
162
+ * fresh one — otherwise an already-open page's baked-in endpoint goes stale on every restart.
163
+ * Missing, corrupt, or partially-written is all treated the same: absent, so the caller falls
164
+ * back to generating a fresh pair exactly as if `dev` had never run before. */
165
+ export function readEndpointRecord(config) {
166
+ const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
167
+ if (!existsSync(recordPath))
168
+ return undefined;
169
+ try {
170
+ const record = JSON.parse(readFileSync(recordPath, 'utf8'));
171
+ if (typeof record.port !== 'number' || record.port <= 0 || typeof record.secret !== 'string' || record.secret.length < MIN_SECRET_LENGTH)
172
+ return undefined;
173
+ return { port: record.port, secret: record.secret };
174
+ }
175
+ catch {
176
+ return undefined; // A partial write from a process that crashed mid-write owns nothing.
177
+ }
178
+ }
179
+ /** Written with mode 0600 — not a new exposure (the same secret already lands in cleartext inside
180
+ * the generated `entry.tsx`, see `writeWorkspace`), but it must not be any more exposed than
181
+ * that. `chmodSync` runs in addition to `writeFileSync`'s own `mode` option: that option only
182
+ * applies when the file is newly created, so a pre-existing record left with looser permissions
183
+ * would otherwise keep them across every later rewrite. */
184
+ export function writeEndpointRecord(config, record) {
185
+ mkdirSync(workspacePath(config), { recursive: true });
186
+ const recordPath = workspacePath(config, ENDPOINT_RECORD_FILE);
187
+ writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
188
+ chmodSync(recordPath, 0o600);
189
+ }
154
190
  /** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
155
191
  * `babel.config.js` is conditional and the update files are written separately, so neither counts
156
192
  * as missing here. */
@@ -26,6 +26,30 @@ export const CATALOG_TYPE = {
26
26
  '3xl': 32,
27
27
  '4xl': 40,
28
28
  };
29
+ export const CATALOG_FONT = { code: 'Menlo, monospace' };
30
+ /** The catalog's fixed set of typography roles, each a complete `fontSize`/`fontWeight`/
31
+ * `lineHeight` (plus `letterSpacing`/`textTransform`/`fontFamily` where the role needs them)
32
+ * built on `CATALOG_TYPE`. Every catalog-chrome text style spreads one of these rather than
33
+ * setting its own typography properties directly (enforced by
34
+ * `__tests__/textRoles.test.ts`). */
35
+ export const CATALOG_TEXT = {
36
+ pageTitle: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '700', lineHeight: 34 },
37
+ heading1: { fontSize: CATALOG_TYPE['2xl'], fontWeight: '700', lineHeight: 30 },
38
+ heading2: { fontSize: CATALOG_TYPE.xl, fontWeight: '700', lineHeight: 24 },
39
+ heading3: { fontSize: CATALOG_TYPE.lg, fontWeight: '700', lineHeight: 22 },
40
+ icon: { fontSize: CATALOG_TYPE.lg, fontWeight: '400', lineHeight: 22 },
41
+ body: { fontSize: CATALOG_TYPE.md, fontWeight: '400', lineHeight: 20 },
42
+ bodyStrong: { fontSize: CATALOG_TYPE.md, fontWeight: '700', lineHeight: 20 },
43
+ caption: { fontSize: CATALOG_TYPE.sm, fontWeight: '400', lineHeight: 18 },
44
+ captionStrong: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', lineHeight: 18 },
45
+ label: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', lineHeight: 18, letterSpacing: 0.72, textTransform: 'uppercase' },
46
+ micro: { fontSize: CATALOG_TYPE.xs, fontWeight: '400', lineHeight: 14 },
47
+ microLabel: { fontSize: CATALOG_TYPE.xs, fontWeight: '700', lineHeight: 14, letterSpacing: 0.6, textTransform: 'uppercase' },
48
+ code: { fontSize: CATALOG_TYPE.md, fontWeight: '400', lineHeight: 20, fontFamily: CATALOG_FONT.code },
49
+ codeSmall: { fontSize: CATALOG_TYPE.sm, fontWeight: '400', lineHeight: 18, fontFamily: CATALOG_FONT.code },
50
+ codeSmallStrong: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', lineHeight: 18, fontFamily: CATALOG_FONT.code },
51
+ codeMicro: { fontSize: CATALOG_TYPE.xs, fontWeight: '400', lineHeight: 14, fontFamily: CATALOG_FONT.code },
52
+ };
29
53
  export const CATALOG_SPACE = {
30
54
  xs: 4,
31
55
  sm: 8,
@@ -69,21 +93,42 @@ export const CATALOG_LAYOUT = {
69
93
  /** Max width of the main content column. */
70
94
  export const CATALOG_MAX_CONTENT_WIDTH = 1200;
71
95
  /** When to reach for each catalog-chrome type size. Rendered in the framework catalog's Type
72
- * Scale page. */
96
+ * Scale page. Every size is now consumed through a `CATALOG_TEXT` role (see `CATALOG_TEXT_USE`)
97
+ * rather than directly. */
73
98
  export const CATALOG_TYPE_USE = {
74
- xs: 'Sidebar group labels (uppercase); a prop\'s type annotation.',
75
- tableHeader: 'Grid column headers and list cell captions (uppercase).',
76
- sm: 'Breadcrumb, catalog caption, block labels, prop names and descriptions.',
77
- panelHeading: 'Reference-panel headings: Guidance, Quick reference, Props (uppercase).',
78
- md: 'Default body text: descriptions, nav links, grid row headers, quick-reference rows, the filter field.',
79
- lg: 'Pager arrows and the filter field\'s clear (×) glyph.',
80
- brand: 'The app name at the top of the sidebar.',
81
- xl: "The major-update banner's close (×) glyph.",
82
- '2xl': "The update page's version numbers and the arrow between them.",
83
- pageTitle: 'The selected page\'s title.',
99
+ xs: 'Backs the `micro`, `microLabel` and `codeMicro` roles: token values and meta captions; sidebar group labels and OS badges (uppercase); a prop\'s type and default value (monospace).',
100
+ tableHeader: 'Reserved — no current consumer; grid column headers and list cell captions now use the `captionStrong` role (built on `sm`).',
101
+ sm: 'Backs the `caption`, `captionStrong`, `label`, `codeSmall` and `codeSmallStrong` roles: breadcrumbs, block/prop descriptions, swatch/token names, table captions, monospace prop names and file paths.',
102
+ panelHeading: 'Reserved — no current consumer; reference-panel and update-page headings now use the `label` role (built on `sm`).',
103
+ md: 'Backs the `body`, `bodyStrong` and `code` roles: descriptions, nav links, the filter field, switch labels, a long unbroken value that needs to wrap at its \'/\'s (monospace).',
104
+ lg: 'Backs the `heading3` and `icon` roles: a result note\'s title; pager arrows and the filter field\'s clear (×) glyph.',
105
+ brand: 'Reserved — no current consumer; the sidebar app name now uses the `heading2` role (built on `xl`).',
106
+ xl: 'Backs the `heading2` role: the app name at the top of the sidebar.',
107
+ '2xl': 'Backs the `heading1` role: the update page\'s version numbers and the arrow between them.',
108
+ pageTitle: 'Backs the `pageTitle` role: the selected page\'s title, including the update page\'s own title.',
84
109
  '3xl': 'Reserved — no current consumer.',
85
110
  '4xl': 'Reserved — no current consumer.',
86
111
  };
112
+ /** When to reach for each catalog-chrome typography role. Rendered in the framework catalog's
113
+ * Type Scale page. */
114
+ export const CATALOG_TEXT_USE = {
115
+ pageTitle: 'The selected page\'s title, including the update page\'s own title.',
116
+ heading1: 'The update page\'s version numbers and the arrow between them.',
117
+ heading2: 'The app name at the top of the sidebar.',
118
+ heading3: 'A result note\'s title (the update page\'s success/failure banner).',
119
+ icon: 'Glyph-only controls: pager arrows, the filter field\'s clear (×) glyph, the update banner\'s close (×) glyph.',
120
+ body: 'Default body text: descriptions, nav links, the filter field, update-page facts/notes/steps.',
121
+ bodyStrong: 'Emphasised body text: grid/list row headers, switch and step labels, inline-bold notice text.',
122
+ caption: 'Secondary text: breadcrumbs, status captions, block/prop descriptions, sidebar subtitle/footer, update-page captions.',
123
+ captionStrong: 'Emphasised secondary text: grid column headers, grid/list/table captions, reference-panel subheadings, swatch/token names, replay and result links, step-icon glyphs.',
124
+ label: 'Uppercase labels and headings: block and preview-frame labels, token-section labels, reference-panel and update-page headings.',
125
+ micro: 'The smallest text: token values and meta captions, a prop\'s default-value label.',
126
+ microLabel: 'Uppercase micro labels: variant-group names, sidebar group labels, OS badges.',
127
+ code: 'Monospace body text: a long unbroken value that needs to wrap at its \'/\'s.',
128
+ codeSmall: 'Monospace secondary text: a prop\'s optional marker, the update page\'s inline code, file paths and log lines.',
129
+ codeSmallStrong: 'Emphasised monospace secondary text: a prop\'s own name.',
130
+ codeMicro: 'Monospace micro text: a prop\'s type and default value.',
131
+ };
87
132
  /** When to reach for each catalog-chrome spacing step. Rendered in the framework catalog's
88
133
  * Spacing page. */
89
134
  export const CATALOG_SPACE_USE = {
@@ -127,7 +172,10 @@ export const CATALOG_COLOR = {
127
172
  accentSubtle: '#e9efff',
128
173
  /** 3px keyboard focus ring on every catalog control. */
129
174
  focusRing: '#c9d7ff',
130
- code: 'Menlo',
175
+ /** @deprecated kept for the public API; re-points at `CATALOG_FONT.code` so there is one source
176
+ * of truth. New code should reach for `CATALOG_FONT.code` (or a `CATALOG_TEXT` `code*` role)
177
+ * instead. */
178
+ code: CATALOG_FONT.code,
131
179
  /** Major-update banner background/border/text (design §5 "Viewer notice"). */
132
180
  warning: '#8a4b00',
133
181
  warningSubtle: '#fff4e0',
@@ -1,6 +1,6 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Platform, StyleSheet, Text, View } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_TEXT } from './tokens';
4
4
  import { isFiniteOverlaySize, overlayStageDimensions, resolveOverlayViewport, type OverlaySlot } from './overlayViewport';
5
5
 
6
6
  function isWeb(): boolean {
@@ -108,5 +108,5 @@ const styles = StyleSheet.create({
108
108
  height: '100%',
109
109
  borderWidth: 0,
110
110
  },
111
- status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
111
+ status: { position: 'absolute', ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
112
112
  });
@@ -1,6 +1,6 @@
1
1
  import React, { forwardRef, useState } from 'react';
2
2
  import { ActivityIndicator, Pressable, StyleSheet, Text, type View } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
4
 
5
5
  /** The viewer's own button, for catalog chrome and demo triggers (not an app component): a rounded
6
6
  * rectangle, primary in the accent blue, secondary outlined. `disabled` greys it and stops the
@@ -77,7 +77,7 @@ const styles = StyleSheet.create({
77
77
  disabled: { backgroundColor: CATALOG_COLOR.controlDisabled, borderColor: CATALOG_COLOR.controlDisabled },
78
78
  // Same ring as the sidebar's: an outline, so focusing never shifts the layout.
79
79
  focus: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing } as object,
80
- label: { fontSize: CATALOG_TYPE.md, fontWeight: '700' },
80
+ label: { ...CATALOG_TEXT.bodyStrong },
81
81
  labelPrimary: { color: '#ffffff' },
82
82
  labelSecondary: { color: CATALOG_COLOR.text },
83
83
  labelBusy: { color: CATALOG_COLOR.textMuted },
@@ -1,6 +1,6 @@
1
1
  import { useRef, useState } from 'react';
2
2
  import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
3
- import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS, CATALOG_LAYOUT } from './tokens';
3
+ import { CATALOG_TEXT, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS, CATALOG_LAYOUT } from './tokens';
4
4
 
5
5
  /**
6
6
  * Plain search input for filtering the catalog's own sidebar nav. Deliberately built from bare
@@ -74,7 +74,7 @@ const styles = StyleSheet.create({
74
74
  backgroundColor: CATALOG_COLOR.surfaceMuted,
75
75
  },
76
76
  boxFocused: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
77
- input: { flex: 1, fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text, padding: 0 },
77
+ input: { flex: 1, ...CATALOG_TEXT.body, color: CATALOG_COLOR.text, padding: 0 },
78
78
  // A full 44×44 target: react-native-web does not honor hitSlop for pointer or keyboard.
79
79
  clearButton: {
80
80
  width: CATALOG_LAYOUT.controlSize, height: CATALOG_LAYOUT.controlSize,
@@ -82,5 +82,5 @@ const styles = StyleSheet.create({
82
82
  },
83
83
  focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
84
84
  clearButtonActive: { backgroundColor: CATALOG_COLOR.surfacePressed },
85
- clear: { fontSize: CATALOG_TYPE.lg, color: CATALOG_COLOR.textMuted, paddingHorizontal: 2 },
85
+ clear: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.textMuted, paddingHorizontal: 2 },
86
86
  });
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { AccessibilityInfo, Platform, Pressable, ScrollView, StyleSheet, Text, View, findNodeHandle } from 'react-native';
3
3
  import type { ImageSourcePropType } from 'react-native';
4
- import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
5
5
  import { CatalogSidebar } from './CatalogSidebar';
6
6
  import { SectionBlock } from './SectionBlock';
7
7
  import { UpdatePanel } from './UpdatePanel';
@@ -101,11 +101,11 @@ const bannerStyles = StyleSheet.create({
101
101
  paddingHorizontal: 14,
102
102
  marginBottom: 18,
103
103
  },
104
- text: { flex: 1, fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.warning },
105
- bold: { fontWeight: '700' },
106
- link: { color: CATALOG_COLOR.warning, fontWeight: '700', textDecorationLine: 'underline' },
104
+ text: { flex: 1, ...CATALOG_TEXT.body, color: CATALOG_COLOR.warning },
105
+ bold: { ...CATALOG_TEXT.bodyStrong },
106
+ link: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.warning, textDecorationLine: 'underline' },
107
107
  close: { width: 32, height: 32, borderRadius: CATALOG_RADIUS.sm, alignItems: 'center', justifyContent: 'center' },
108
- closeGlyph: { fontSize: CATALOG_TYPE.xl, color: CATALOG_COLOR.warning },
108
+ closeGlyph: { ...CATALOG_TEXT.icon, color: CATALOG_COLOR.warning },
109
109
  });
110
110
 
111
111
  /**
@@ -327,7 +327,7 @@ const styles = StyleSheet.create({
327
327
  // A `BoundedOverlayViewport` child document's own root: no sidebar/chrome, just the one
328
328
  // addressed node — sized by whatever that node itself renders, not the catalog's own layout.
329
329
  overlayDocumentRoot: { flex: 1 },
330
- overlayUnavailable: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
330
+ overlayUnavailable: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted, padding: CATALOG_SPACE.lg },
331
331
  main: { flex: 1 },
332
332
  mainContent: {
333
333
  width: '100%',
@@ -336,5 +336,5 @@ const styles = StyleSheet.create({
336
336
  paddingHorizontal: CATALOG_LAYOUT.mainPaddingX,
337
337
  paddingBottom: CATALOG_SPACE['3xl'],
338
338
  },
339
- empty: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
339
+ empty: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted },
340
340
  });
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { View, Text, ScrollView, Pressable, StyleSheet, Image } from 'react-native';
3
3
  import type { ImageSourcePropType, LayoutChangeEvent, NativeSyntheticEvent, ImageLoadEventData } from 'react-native';
4
- import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
4
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
5
5
  import { logoLayout } from './logoLayout';
6
6
  import { filterGroups, hashForId, UPDATE_PAGE_ID } from './catalogNavigation';
7
7
  import type { NavGroup } from './types';
@@ -153,8 +153,8 @@ export function CatalogSidebar<TId extends string>({
153
153
  const onLogoError = () => setLogoFailed(true);
154
154
  // Wrap detection for the "mark" row: the name Text's own measured height, not `onTextLayout`
155
155
  // (react-native-web's `Text` doesn't implement it) and not a character-count heuristic (wraps at
156
- // a different length per name). More than one line of `CATALOG_TYPE.brand` means it wrapped.
157
- const NAME_LINE_HEIGHT = Math.round(CATALOG_TYPE.brand * 1.3);
156
+ // a different length per name). More than one line of `CATALOG_TEXT.heading2` means it wrapped.
157
+ const NAME_LINE_HEIGHT = CATALOG_TEXT.heading2.lineHeight;
158
158
  const onNameLayout = (e: LayoutChangeEvent) => {
159
159
  setNameWrapped(e.nativeEvent.layout.height > NAME_LINE_HEIGHT * 1.5);
160
160
  };
@@ -235,8 +235,8 @@ const styles = StyleSheet.create({
235
235
  paddingBottom: CATALOG_LAYOUT.sidebarPaddingTop,
236
236
  },
237
237
  header: { marginBottom: 20 },
238
- logo: { fontSize: CATALOG_TYPE.brand, fontWeight: '800', color: CATALOG_COLOR.text },
239
- subtitle: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 2 },
238
+ logo: { ...CATALOG_TEXT.heading2, color: CATALOG_COLOR.text },
239
+ subtitle: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, marginTop: 2 },
240
240
  subtitleAfterWordmark: { marginTop: CATALOG_LAYOUT.logoCaptionGap },
241
241
  mark: { width: CATALOG_LAYOUT.logoMark, height: CATALOG_LAYOUT.logoMark },
242
242
  wordmark: { height: CATALOG_LAYOUT.logoWordmarkHeight, alignSelf: 'flex-start' },
@@ -244,13 +244,12 @@ const styles = StyleSheet.create({
244
244
  inlineHeader: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
245
245
  inlineHeaderTop: { alignItems: 'flex-start' },
246
246
  inlineText: { flex: 1, minWidth: 0 },
247
- empty: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
247
+ empty: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
248
248
  footerContainer: {
249
249
  borderTopWidth: 1,
250
250
  borderTopColor: CATALOG_COLOR.borderHairline,
251
251
  paddingHorizontal: CATALOG_LAYOUT.sidebarPaddingX,
252
- paddingTop: 10,
253
- paddingBottom: 14,
252
+ paddingVertical: CATALOG_SPACE.md,
254
253
  },
255
254
  footerLink: {
256
255
  flexDirection: 'row',
@@ -263,13 +262,10 @@ const styles = StyleSheet.create({
263
262
  footerLinkHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
264
263
  footerLinkActive: { backgroundColor: CATALOG_COLOR.accentSubtle },
265
264
  footerDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: CATALOG_COLOR.accent, flexShrink: 0 },
266
- footerLabel: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
267
- footerLabelActive: { color: CATALOG_COLOR.accent, fontWeight: '700' },
265
+ footerLabel: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
266
+ footerLabelActive: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.accent },
268
267
  groupLabel: {
269
- fontSize: CATALOG_TYPE.xs,
270
- fontWeight: '800',
271
- letterSpacing: 1,
272
- textTransform: 'uppercase',
268
+ ...CATALOG_TEXT.microLabel,
273
269
  color: CATALOG_COLOR.text,
274
270
  marginTop: 22,
275
271
  marginBottom: 7,
@@ -285,6 +281,6 @@ const styles = StyleSheet.create({
285
281
  itemHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
286
282
  itemActive: { backgroundColor: CATALOG_COLOR.accentSubtle },
287
283
  focusRing: { outlineWidth: CATALOG_LAYOUT.focusRingWidth, outlineStyle: 'solid', outlineColor: CATALOG_COLOR.focusRing },
288
- label: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
289
- labelActive: { color: CATALOG_COLOR.accent, fontWeight: '700' },
284
+ label: { ...CATALOG_TEXT.body, color: CATALOG_COLOR.textMuted },
285
+ labelActive: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.accent },
290
286
  });
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridMinWidth, indexCells, validateComparison } from './comparison';
5
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
6
  import { SpecimenAddressProvider } from './SpecimenAddress';
@@ -108,7 +108,7 @@ const styles = StyleSheet.create({
108
108
  headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
109
109
  bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
110
110
  specimenCell: { alignItems: 'center', justifyContent: 'center' },
111
- headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
112
- rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
113
- unavailable: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', textAlign: 'center', color: CATALOG_COLOR.textMuted },
111
+ headerText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
112
+ rowHeaderText: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.text },
113
+ unavailable: { ...CATALOG_TEXT.caption, textAlign: 'center', color: CATALOG_COLOR.textMuted },
114
114
  });
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
5
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
6
  import { SpecimenAddressProvider } from './SpecimenAddress';
@@ -90,7 +90,7 @@ const styles = StyleSheet.create({
90
90
  borderRightWidth: 1,
91
91
  borderRightColor: CATALOG_COLOR.border,
92
92
  },
93
- rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
93
+ rowHeaderText: { ...CATALOG_TEXT.bodyStrong, color: CATALOG_COLOR.text },
94
94
  cells: { flex: 1, flexDirection: 'row' },
95
95
  cell: { flex: 1, borderRightWidth: 1, borderRightColor: CATALOG_COLOR.border },
96
96
  lastColumn: { borderRightWidth: 0 },
@@ -100,7 +100,7 @@ const styles = StyleSheet.create({
100
100
  borderBottomWidth: 1,
101
101
  borderBottomColor: CATALOG_COLOR.border,
102
102
  },
103
- captionText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
103
+ captionText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
104
104
  specimen: {
105
105
  flexGrow: 1,
106
106
  minHeight: MATRIX_LAYOUT.rowMinHeight,
@@ -1,6 +1,6 @@
1
1
  import React, { useState } from 'react';
2
2
  import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
4
4
  import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
5
5
  import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
6
  import { SpecimenAddressProvider } from './SpecimenAddress';
@@ -107,7 +107,7 @@ const styles = StyleSheet.create({
107
107
  borderBottomWidth: 1,
108
108
  borderBottomColor: CATALOG_COLOR.border,
109
109
  },
110
- captionText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
110
+ captionText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
111
111
  specimen: {
112
112
  flexGrow: 1,
113
113
  minHeight: MATRIX_LAYOUT.rowMinHeight,
@@ -1,6 +1,6 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { Animated, AccessibilityInfo, Pressable, StyleSheet, Text, View } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
4
4
  import { motionReplayLabel, motionTranslatesDirectly, motionValueTarget, type MotionValueRange } from './motionValueDomain';
5
5
 
6
6
  /** `Animated.spring`'s physics fields — the same shape as a design system's own spring-config
@@ -137,7 +137,7 @@ const styles = StyleSheet.create({
137
137
  backgroundColor: CATALOG_COLOR.accent,
138
138
  },
139
139
  footer: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
140
- label: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
140
+ label: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
141
141
  labelSpacer: { flex: 1 },
142
142
  replayButton: {
143
143
  paddingHorizontal: CATALOG_SPACE.md,
@@ -148,5 +148,5 @@ const styles = StyleSheet.create({
148
148
  backgroundColor: CATALOG_COLOR.surface,
149
149
  },
150
150
  replayButtonPressed: { backgroundColor: CATALOG_COLOR.surfacePressed },
151
- replayText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.text },
151
+ replayText: { ...CATALOG_TEXT.captionStrong, color: CATALOG_COLOR.text },
152
152
  });
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useRef } from 'react';
2
2
  import { Platform, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
3
+ import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TEXT } from './tokens';
4
4
  import { DEVICE_FRAME, DEVICE_SCALE_WARN_BELOW, deviceFrameScale } from './deviceFrame';
5
5
  import { resolveOverlayViewport } from './overlayViewport';
6
6
  import { useSpecimenAddress } from './SpecimenAddress';
@@ -149,5 +149,5 @@ const styles = StyleSheet.create({
149
149
  justifyContent: 'center',
150
150
  overflow: 'hidden',
151
151
  },
152
- status: { position: 'absolute', fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
152
+ status: { position: 'absolute', ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
153
153
  });
@@ -1,5 +1,5 @@
1
1
  import { View, Text, StyleSheet } from 'react-native';
2
- import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
2
+ import { CATALOG_TEXT, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
3
3
  import { PROPS_COLUMN_GAP } from './comparison';
4
4
  import type { PropDef } from './types';
5
5
 
@@ -86,14 +86,14 @@ const styles = StyleSheet.create({
86
86
  rowFirst: { paddingTop: 0 },
87
87
  rowLast: { borderBottomWidth: 0, paddingBottom: 0 },
88
88
  header: { width: 140, gap: 2 },
89
- name: { fontSize: CATALOG_TYPE.sm, fontFamily: CATALOG_COLOR.code, fontWeight: '700', color: CATALOG_COLOR.text },
90
- optionalMark: { fontWeight: '400', color: CATALOG_COLOR.textMuted },
91
- type: { fontSize: CATALOG_TYPE.xs, fontFamily: CATALOG_COLOR.code, color: CATALOG_COLOR.accent },
89
+ name: { ...CATALOG_TEXT.codeSmallStrong, color: CATALOG_COLOR.text },
90
+ optionalMark: { ...CATALOG_TEXT.codeSmall, color: CATALOG_COLOR.textMuted },
91
+ type: { ...CATALOG_TEXT.codeMicro, color: CATALOG_COLOR.accent },
92
92
  // The second column — description + default — sized by the row's remaining width.
93
93
  body: { flex: 1, gap: 2 },
94
- desc: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, lineHeight: 17 },
95
- default: { fontSize: CATALOG_TYPE.xs, color: CATALOG_COLOR.textMuted },
96
- defaultVal: { fontFamily: CATALOG_COLOR.code, color: CATALOG_COLOR.textMuted },
94
+ desc: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
95
+ default: { ...CATALOG_TEXT.micro, color: CATALOG_COLOR.textMuted },
96
+ defaultVal: { ...CATALOG_TEXT.codeMicro, color: CATALOG_COLOR.textMuted },
97
97
  gridRow: { flexDirection: 'row', gap: PROPS_COLUMN_GAP },
98
98
  cell: { flex: 1, minWidth: 0, paddingVertical: CATALOG_SPACE.md, gap: 4 },
99
99
  cellDivider: { borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },