@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.
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { View, Text, StyleSheet } from 'react-native';
3
- import { CATALOG_TYPE, CATALOG_COLOR } from './tokens';
3
+ import { CATALOG_TEXT, CATALOG_COLOR } from './tokens';
4
4
 
5
5
  /** Labels one example (or small group of examples) inside a section's Examples card — a bold
6
6
  * uppercase name plus a one-line description stacked below it, sitting above whatever demo content
@@ -39,10 +39,7 @@ const styles = StyleSheet.create({
39
39
  group: { gap: 6 },
40
40
  groupCenter: { alignItems: 'center' },
41
41
  groupLeft: { alignItems: 'flex-start' },
42
- name: {
43
- fontSize: CATALOG_TYPE.xs, fontWeight: '700', color: CATALOG_COLOR.text,
44
- textTransform: 'uppercase', letterSpacing: 0.4,
45
- },
46
- desc: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
42
+ name: { ...CATALOG_TEXT.microLabel, color: CATALOG_COLOR.text },
43
+ desc: { ...CATALOG_TEXT.caption, color: CATALOG_COLOR.textMuted },
47
44
  textCenter: { textAlign: 'center' },
48
45
  });
@@ -55,6 +55,9 @@ export type { ListGroup, ListItem } from './comparison';
55
55
  export {
56
56
  CATALOG_TYPE,
57
57
  CATALOG_TYPE_USE,
58
+ CATALOG_FONT,
59
+ CATALOG_TEXT,
60
+ CATALOG_TEXT_USE,
58
61
  CATALOG_SPACE,
59
62
  CATALOG_SPACE_USE,
60
63
  CATALOG_RADIUS,
@@ -28,6 +28,32 @@ export const CATALOG_TYPE = {
28
28
  '4xl': 40,
29
29
  } as const;
30
30
 
31
+ export const CATALOG_FONT = { code: 'Menlo, monospace' } as const;
32
+
33
+ /** The catalog's fixed set of typography roles, each a complete `fontSize`/`fontWeight`/
34
+ * `lineHeight` (plus `letterSpacing`/`textTransform`/`fontFamily` where the role needs them)
35
+ * built on `CATALOG_TYPE`. Every catalog-chrome text style spreads one of these rather than
36
+ * setting its own typography properties directly (enforced by
37
+ * `__tests__/textRoles.test.ts`). */
38
+ export const CATALOG_TEXT = {
39
+ pageTitle: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '700', lineHeight: 34 },
40
+ heading1: { fontSize: CATALOG_TYPE['2xl'], fontWeight: '700', lineHeight: 30 },
41
+ heading2: { fontSize: CATALOG_TYPE.xl, fontWeight: '700', lineHeight: 24 },
42
+ heading3: { fontSize: CATALOG_TYPE.lg, fontWeight: '700', lineHeight: 22 },
43
+ icon: { fontSize: CATALOG_TYPE.lg, fontWeight: '400', lineHeight: 22 },
44
+ body: { fontSize: CATALOG_TYPE.md, fontWeight: '400', lineHeight: 20 },
45
+ bodyStrong: { fontSize: CATALOG_TYPE.md, fontWeight: '700', lineHeight: 20 },
46
+ caption: { fontSize: CATALOG_TYPE.sm, fontWeight: '400', lineHeight: 18 },
47
+ captionStrong: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', lineHeight: 18 },
48
+ label: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', lineHeight: 18, letterSpacing: 0.72, textTransform: 'uppercase' },
49
+ micro: { fontSize: CATALOG_TYPE.xs, fontWeight: '400', lineHeight: 14 },
50
+ microLabel: { fontSize: CATALOG_TYPE.xs, fontWeight: '700', lineHeight: 14, letterSpacing: 0.6, textTransform: 'uppercase' },
51
+ code: { fontSize: CATALOG_TYPE.md, fontWeight: '400', lineHeight: 20, fontFamily: CATALOG_FONT.code },
52
+ codeSmall: { fontSize: CATALOG_TYPE.sm, fontWeight: '400', lineHeight: 18, fontFamily: CATALOG_FONT.code },
53
+ codeSmallStrong: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', lineHeight: 18, fontFamily: CATALOG_FONT.code },
54
+ codeMicro: { fontSize: CATALOG_TYPE.xs, fontWeight: '400', lineHeight: 14, fontFamily: CATALOG_FONT.code },
55
+ } as const;
56
+
31
57
  export const CATALOG_SPACE = {
32
58
  xs: 4,
33
59
  sm: 8,
@@ -75,22 +101,44 @@ export const CATALOG_LAYOUT = {
75
101
  export const CATALOG_MAX_CONTENT_WIDTH = 1200;
76
102
 
77
103
  /** When to reach for each catalog-chrome type size. Rendered in the framework catalog's Type
78
- * Scale page. */
104
+ * Scale page. Every size is now consumed through a `CATALOG_TEXT` role (see `CATALOG_TEXT_USE`)
105
+ * rather than directly. */
79
106
  export const CATALOG_TYPE_USE: Record<keyof typeof CATALOG_TYPE, string> = {
80
- xs: 'Sidebar group labels (uppercase); a prop\'s type annotation.',
81
- tableHeader: 'Grid column headers and list cell captions (uppercase).',
82
- sm: 'Breadcrumb, catalog caption, block labels, prop names and descriptions.',
83
- panelHeading: 'Reference-panel headings: Guidance, Quick reference, Props (uppercase).',
84
- md: 'Default body text: descriptions, nav links, grid row headers, quick-reference rows, the filter field.',
85
- lg: 'Pager arrows and the filter field\'s clear (×) glyph.',
86
- brand: 'The app name at the top of the sidebar.',
87
- xl: "The major-update banner's close (×) glyph.",
88
- '2xl': "The update page's version numbers and the arrow between them.",
89
- pageTitle: 'The selected page\'s title.',
107
+ 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).',
108
+ tableHeader: 'Reserved — no current consumer; grid column headers and list cell captions now use the `captionStrong` role (built on `sm`).',
109
+ 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.',
110
+ panelHeading: 'Reserved — no current consumer; reference-panel and update-page headings now use the `label` role (built on `sm`).',
111
+ 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).',
112
+ lg: 'Backs the `heading3` and `icon` roles: a result note\'s title; pager arrows and the filter field\'s clear (×) glyph.',
113
+ brand: 'Reserved — no current consumer; the sidebar app name now uses the `heading2` role (built on `xl`).',
114
+ xl: 'Backs the `heading2` role: the app name at the top of the sidebar.',
115
+ '2xl': 'Backs the `heading1` role: the update page\'s version numbers and the arrow between them.',
116
+ pageTitle: 'Backs the `pageTitle` role: the selected page\'s title, including the update page\'s own title.',
90
117
  '3xl': 'Reserved — no current consumer.',
91
118
  '4xl': 'Reserved — no current consumer.',
92
119
  };
93
120
 
121
+ /** When to reach for each catalog-chrome typography role. Rendered in the framework catalog's
122
+ * Type Scale page. */
123
+ export const CATALOG_TEXT_USE: Record<keyof typeof CATALOG_TEXT, string> = {
124
+ pageTitle: 'The selected page\'s title, including the update page\'s own title.',
125
+ heading1: 'The update page\'s version numbers and the arrow between them.',
126
+ heading2: 'The app name at the top of the sidebar.',
127
+ heading3: 'A result note\'s title (the update page\'s success/failure banner).',
128
+ icon: 'Glyph-only controls: pager arrows, the filter field\'s clear (×) glyph, the update banner\'s close (×) glyph.',
129
+ body: 'Default body text: descriptions, nav links, the filter field, update-page facts/notes/steps.',
130
+ bodyStrong: 'Emphasised body text: grid/list row headers, switch and step labels, inline-bold notice text.',
131
+ caption: 'Secondary text: breadcrumbs, status captions, block/prop descriptions, sidebar subtitle/footer, update-page captions.',
132
+ captionStrong: 'Emphasised secondary text: grid column headers, grid/list/table captions, reference-panel subheadings, swatch/token names, replay and result links, step-icon glyphs.',
133
+ label: 'Uppercase labels and headings: block and preview-frame labels, token-section labels, reference-panel and update-page headings.',
134
+ micro: 'The smallest text: token values and meta captions, a prop\'s default-value label.',
135
+ microLabel: 'Uppercase micro labels: variant-group names, sidebar group labels, OS badges.',
136
+ code: 'Monospace body text: a long unbroken value that needs to wrap at its \'/\'s.',
137
+ codeSmall: 'Monospace secondary text: a prop\'s optional marker, the update page\'s inline code, file paths and log lines.',
138
+ codeSmallStrong: 'Emphasised monospace secondary text: a prop\'s own name.',
139
+ codeMicro: 'Monospace micro text: a prop\'s type and default value.',
140
+ };
141
+
94
142
  /** When to reach for each catalog-chrome spacing step. Rendered in the framework catalog's
95
143
  * Spacing page. */
96
144
  export const CATALOG_SPACE_USE: Record<keyof typeof CATALOG_SPACE, string> = {
@@ -135,7 +183,10 @@ export const CATALOG_COLOR = {
135
183
  accentSubtle: '#e9efff',
136
184
  /** 3px keyboard focus ring on every catalog control. */
137
185
  focusRing: '#c9d7ff',
138
- code: 'Menlo',
186
+ /** @deprecated kept for the public API; re-points at `CATALOG_FONT.code` so there is one source
187
+ * of truth. New code should reach for `CATALOG_FONT.code` (or a `CATALOG_TEXT` `code*` role)
188
+ * instead. */
189
+ code: CATALOG_FONT.code,
139
190
  /** Major-update banner background/border/text (design §5 "Viewer notice"). */
140
191
  warning: '#8a4b00',
141
192
  warningSubtle: '#fff4e0',
@@ -219,3 +219,176 @@ export function resultNote(
219
219
  export function isRemoteOrigin(protocol: string, hostname: string): boolean {
220
220
  return !(protocol === 'http:' && hostname === 'localhost');
221
221
  }
222
+
223
+ /** `VersionDetails`' own near-miss message, when the endpoint answered with a non-ok status (not
224
+ * a rejected fetch) and the page is open from a tunnelled/remote origin. Pulled out as its own
225
+ * function only so a test can pin it exactly — the approved design leaves this condition alone
226
+ * ("Keep the existing distinct remote-origin/tunnel message exactly as is"). */
227
+ export function remoteOriginLocalErrorMessage(): string {
228
+ return 'This page is open from another address, and the viewer only takes actions from the machine it runs on. Open the catalog on that machine, or run npx ds-viewer update there.';
229
+ }
230
+
231
+ /** The result of one endpoint call, classified the one way that matters for self-healing: did
232
+ * `fetch` itself reject (the socket is simply gone — *unreachable*, see the implementation
233
+ * brief), or did it resolve with a response at all, however unhappy (401/409/502 included — the
234
+ * endpoint answered, so the caller's existing status/body handling is unchanged and untouched by
235
+ * this module). Every endpoint call on the update page routes through this one function so "one
236
+ * condition has one treatment" instead of each call site re-deriving it. */
237
+ export type EndpointOutcome = { kind: 'ok'; response: Response } | { kind: 'unreachable' };
238
+
239
+ export async function requestOrUnreachable(doFetch: () => Promise<Response>): Promise<EndpointOutcome> {
240
+ try {
241
+ return { kind: 'ok', response: await doFetch() };
242
+ } catch {
243
+ return { kind: 'unreachable' };
244
+ }
245
+ }
246
+
247
+ /** Critical finding, Fable correction pass: `cli/endpoint.ts` answers a rejected `Origin` with
248
+ * 403 and *no* CORS headers, so the browser blocks the response and the page's own `fetch`
249
+ * rejects — exactly like a genuinely dead socket. Without this check, that rejection was routed
250
+ * straight into the page-wide reachability flow, which reloads and then polls forever for a
251
+ * server that was never down; self-healing is meaningless for a page that can never use the
252
+ * endpoint in the first place (a tunnel, or `isRemoteOrigin`'s own `127.0.0.1` case). Every call
253
+ * site with a rejected fetch must check this *before* calling into the reachability flow, and
254
+ * keep showing `remoteOriginLocalErrorMessage()` instead. */
255
+ export function classifyRejectedFetch(isRemoteOriginPage: boolean): 'remote-origin' | 'unreachable' {
256
+ return isRemoteOriginPage ? 'remote-origin' : 'unreachable';
257
+ }
258
+
259
+ /** The pre-existing offline plan-error outcome (`fetchPlan`'s own `catch`) — pulled out so the one
260
+ * other place that needs it (below) can't drift from this wording. */
261
+ export const PLAN_FETCH_ERROR: { error: string } = { error: "Couldn't prepare the update. You may be offline, or npm didn't respond. Nothing was changed." };
262
+
263
+ /** Correction pass 3: a background `fetchPlan` whose fetch was rejected routes through
264
+ * `routeUnreachableInBackground`, which refuses to act for a remote-origin page (it must never
265
+ * latch the page-wide `remoteOriginError` state for a call the user didn't make — correction pass
266
+ * #2). That refusal used to be the end of it: `planResult` stayed `undefined` forever, and
267
+ * `resolvePanelPhase` reads that as `'checking'` with no button and no way out. `handled` is
268
+ * whatever that route reports back — `true` once it has entered the reachability flow (which owns
269
+ * the page from there), `false` when it declined to act. Only the `false` case needs anything
270
+ * here: fall back to the exact same plan-error note (and working **Try again**) the pre-existing
271
+ * `catch` already shows for every other kind of plan failure. */
272
+ export function backgroundPlanFetchOutcome(handled: boolean): { error: string } | undefined {
273
+ return handled ? undefined : PLAN_FETCH_ERROR;
274
+ }
275
+
276
+ /** Critical finding, Fable correction pass: the restart-handoff rule ("a failed `/update/status`
277
+ * poll while an update was already in flight or restarting means 'still restarting,' not
278
+ * unreachable") used to be decided by reading a value assigned inside the very `setStatus`
279
+ * updater that decided it — updaters run during React's render phase, not at dispatch, so under
280
+ * any pending update the read could see stale or re-invoked state. Pulled out as a pure function
281
+ * of the *already-committed* phase (read from a ref, never from inside an updater) so the
282
+ * decision is both correct and independently testable. `undefined` means "not mid-update": the
283
+ * caller must still classify the failure itself (unreachable vs. remote-origin, vs. a delivered
284
+ * but unusable response). */
285
+ export function statusPollFailureOutcome(currentPhase: UpdateStatusView['phase']): UpdateStatusView | undefined {
286
+ return currentPhase === 'updating' || currentPhase === 'restarting' ? { phase: 'restarting' } : undefined;
287
+ }
288
+
289
+ /** Correction pass 4 (Critical): a stable endpoint address means the restarted server answers the
290
+ * already-open page's poll *before* Metro has served the new bundle — same host, same pinned
291
+ * Metro origin, same reused secret, so the poll succeeds and would otherwise accept the delivered
292
+ * `{ phase: 'success' }` onto the still-old page. `restarting` must be terminal for the page: only
293
+ * the Metro-origin reload poller (which owns the actual reload) may leave it, by delivering
294
+ * `'restarting'` right back — every other delivered phase while `restarting` is stale and must be
295
+ * ignored. */
296
+ export function acceptsDeliveredStatus(current: UpdateStatusView['phase'], next: UpdateStatusView['phase']): boolean {
297
+ return current !== 'restarting' || next === 'restarting';
298
+ }
299
+
300
+ /** Correction pass 5 (Important): whether a just-fetched plan is the offline error note rather
301
+ * than a usable plan — `/plan` answers 200 with an `{ error }` body for that case, so a reached
302
+ * plan fetch alone doesn't mean "Everything below is current." Takes the plan straight from the
303
+ * fetch that produced it, never from a ref a passive effect fills in later, so this reads the
304
+ * value that was actually just stored. */
305
+ export function planShowsError(result?: UpdatePlanView | { error: string }): boolean {
306
+ return result !== undefined && 'error' in result && !isAlreadyUpToDateError(result);
307
+ }
308
+
309
+ /** The approved design's unreachable-recovery states (`waiting`/`stuck` replace this page's normal
310
+ * content; `recovered` is a transient banner over content that's already current). Never
311
+ * `'ok'`/absent here — the panel represents "nothing wrong" as no `ReachabilityState` at all, the
312
+ * same way `UpdateStatusView`'s `idle` phase represents nothing in flight.
313
+ *
314
+ * Final slice: a stable endpoint address (`cli/dev.ts` now reuses its port and secret across
315
+ * restarts) means an ordinary restart recovers through the `waiting` poller's own direct endpoint
316
+ * retry alone — no reload is needed, so there is no `reconnecting` phase anymore, and nothing in
317
+ * this module ever calls `window.location.reload()`. The only reload left is the user's own
318
+ * explicit press of **Reconnect** on `stuck`, handled entirely in `UpdatePanel.tsx`. */
319
+ export type ReachabilityPhase = 'waiting' | 'recovered' | 'stuck';
320
+
321
+ export interface ReachabilityState {
322
+ phase: ReachabilityPhase;
323
+ /** Consecutive failed endpoint retries while `waiting` — the bound `nextWaitingAttempt` gives up
324
+ * at. Meaningless (and left unchanged) for every other phase. */
325
+ attempts: number;
326
+ }
327
+
328
+ /** ~20 seconds of 500ms retries (the same interval the existing restart flow already polls at) —
329
+ * long enough for someone to actually type `npx ds-viewer dev`, short enough that the page
330
+ * doesn't spin forever before offering the manual `Reconnect`. */
331
+ export const REACHABILITY_MAX_ATTEMPTS = 40;
332
+
333
+ /** One more failed attempt while `waiting`: advances the bounded counter and gives up once it
334
+ * reaches `REACHABILITY_MAX_ATTEMPTS`, exactly as the approved design's "Edge · Still not back"
335
+ * state requires. The caller (the `waiting` poller) only calls this when its own direct retry
336
+ * against the endpoint has already failed; a retry that succeeds recovers in place and never
337
+ * reaches this function at all. */
338
+ export function nextWaitingAttempt(current: ReachabilityState): ReachabilityState {
339
+ const attempts = current.attempts + 1;
340
+ return { phase: attempts >= REACHABILITY_MAX_ATTEMPTS ? 'stuck' : 'waiting', attempts };
341
+ }
342
+
343
+ /** The manual **Reconnect** button on the gave-up state: starts the same bounded cycle over from
344
+ * zero, exactly like the first time an endpoint call found the viewer unreachable. `UpdatePanel.tsx`
345
+ * pairs this with its own, separate `window.location.reload()` — one press, one reload, initiated
346
+ * by the user, so no marker or loop guard is needed here or anywhere else. */
347
+ export function restartReachability(): ReachabilityState {
348
+ return { phase: 'waiting', attempts: 0 };
349
+ }
350
+
351
+ /** Called on every endpoint call that actually succeeds. Most of the time there was nothing to
352
+ * recover from (`current` is `undefined`) and this is a no-op — the ordinary case, which must not
353
+ * show a "Reconnected" banner on every routine poll. Otherwise the panel shows the approved
354
+ * design's auto-dismissing `recovered` note while its normal content — already current, since the
355
+ * endpoint itself just answered — resumes underneath. */
356
+ export function recoveredReachabilityState(current: ReachabilityState | undefined): ReachabilityState | undefined {
357
+ if (current === undefined) return undefined;
358
+ return { phase: 'recovered', attempts: 0 };
359
+ }
360
+
361
+ export interface ReachabilityNote {
362
+ variant: 'muted' | 'ok';
363
+ spinner: boolean;
364
+ bold?: string;
365
+ text: string;
366
+ /** A recovery command to render in `Code`, split from its lead-in text so the component can
367
+ * place the two in separate `Text`/`Code` nodes. */
368
+ hint?: { prefix: string; command: string };
369
+ }
370
+
371
+ /** The exact approved copy (`.hermes-task/approved-unreachable-states.html`) for each reachability
372
+ * phase — never invent wording beyond this. */
373
+ export function reachabilityNote(phase: ReachabilityPhase): ReachabilityNote {
374
+ switch (phase) {
375
+ case 'waiting':
376
+ return {
377
+ variant: 'muted',
378
+ spinner: true,
379
+ bold: 'The viewer isn’t running.',
380
+ text: ' Nothing was changed. This page reconnects by itself as soon as it’s back.',
381
+ hint: { prefix: 'Start it with ', command: 'npx ds-viewer dev' },
382
+ };
383
+ case 'recovered':
384
+ return { variant: 'ok', spinner: false, text: 'Reconnected. Everything below is current.' };
385
+ case 'stuck':
386
+ return {
387
+ variant: 'muted',
388
+ spinner: false,
389
+ bold: 'The viewer still isn’t answering.',
390
+ text: ' Nothing was changed.',
391
+ hint: { prefix: 'or start it with ', command: 'npx ds-viewer dev' },
392
+ };
393
+ }
394
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",