@krapwoo/ds-viewer 0.6.9 → 0.6.11

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 CHANGED
@@ -72,6 +72,23 @@ loser then serves a workspace that no longer describes it, which Expo reports as
72
72
  with `--workspace-dir .ds-viewer-second`. A lock left behind by a crashed `dev` never blocks the
73
73
  next one.
74
74
 
75
+ `.ds-viewer/` belongs to the running server, and it repairs it. If something outside the server
76
+ deletes the generated files while it is serving, it says so and writes them back, rather than
77
+ leaving Expo to report an unresolvable `main` field — which names your project's `package.json`, a
78
+ file that is fine and that you never touched:
79
+
80
+ ```
81
+ Something outside this server deleted package.json, app.json, tsconfig.json, metro.config.js,
82
+ entry.tsx from .ds-viewer/. Rewriting them — your project is fine, this is the catalog's own
83
+ generated workspace. If the catalog looks broken, reload the page.
84
+ ```
85
+
86
+ Metro picks the files back up without a restart. The repair is capped at five per run: if
87
+ something is deleting continuously, repairing forever would only hide it, so past the cap the
88
+ server stops and keeps reporting. If you ever see that message, what else was running at the time
89
+ would be useful on [issue #41](https://github.com/krapwoo/design-system-viewer/issues/41) — the
90
+ cause is not yet identified.
91
+
75
92
  `ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
76
93
  sidebar beside the name, or in place of it for a wide wordmark; validated at `sync`/`dev` time
77
94
  and bundled into the preview workspace), `components`, `exclude`, `tokens`, `pages`, `groupOrder`, `starterKit` (new-project only —
@@ -502,6 +519,22 @@ Button
502
519
  gallery or a whole-page layout.
503
520
  - `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
504
521
  so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
522
+
523
+ The catalog also warns at render time when a page under-declares the width its examples need.
524
+ A `PhoneFrame` given a cell narrower than the 375px device scales the whole phone down, and its
525
+ own body text shrinks with it, so it reports the page, the example and the cell:
526
+
527
+ [Catalog] BottomSheet (variants/content-only) is shown at 75% of device size: its cell is
528
+ 283px, narrower than the 375px device. Declare a larger specimenSize so the example keeps
529
+ the width it is used at.
530
+
531
+ `check:catalog` fails on any `[Catalog]` warning, so this is caught in CI. The threshold is 95%,
532
+ not 100%: a regular cell leaves 370px for a 375px device, and warning about a reduction nobody
533
+ can see would only train you to ignore the warning. Where the scaling *is* the subject — a page
534
+ documenting that a narrow cell shrinks the device rather than reflowing it — write
535
+ `<PhoneFrame intentionalScale>` in that example, so the exemption is visible where the decision
536
+ was made.
537
+
505
538
  A page's `internalProps: { size: 'why' }` exempts a named prop from `option-not-covered` — for
506
539
  plumbing a parent passes (a layout size, an internal surface kind) rather than a value the
507
540
  reader picks. It needs a reason per prop, so the exemption is a visible declaration in the page;
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, workspacePath, writeDevLock, writeWorkspace } from "./workspace.js";
9
+ import { clearDevLock, liveDevLock, missingWorkspaceFiles, workspacePath, writeDevLock, 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";
@@ -326,6 +326,42 @@ export async function performRestart(handoff, deps = {}) {
326
326
  * component/token/page changes to re-run `sync`. Metro's own HMR updates the rendered component
327
327
  * when its source changes, but not the generated props table or page list (Task 17 Step 4 relies
328
328
  * on this: editing `Button.tsx` must update its props table, not just hot-reload the component). */
329
+ /** How often a running `dev` checks that its own workspace still exists. Cheap (five `existsSync`
330
+ * calls), and slow enough to be invisible: the failure it covers is rare, and noticing within a
331
+ * few seconds is soon enough to repair before the next page load. */
332
+ const WORKSPACE_WATCH_INTERVAL_MS = 4000;
333
+ /** How many times one `dev` run will rewrite a workspace that keeps losing files. Past this,
334
+ * something is deleting continuously and repairing again would only hide it. */
335
+ const WORKSPACE_REPAIR_LIMIT = 5;
336
+ /** One pass of the workspace integrity check. Returns what it did, so the caller owns the repair
337
+ * budget and the tests can drive it without a timer.
338
+ *
339
+ * `updateEndpoint` is the caller's own, passed through unchanged: rebuilding it here from the
340
+ * Metro port would point the repaired catalog at the wrong server, silently breaking the update
341
+ * panel while telling the developer everything is fine. */
342
+ export function repairWorkspaceIfMissing(deps) {
343
+ const { config, updateEndpoint, versionStatus, sync, reclaimLock, log = console.warn } = deps;
344
+ const missing = missingWorkspaceFiles(config);
345
+ if (missing.length === 0)
346
+ return { missing, repaired: false };
347
+ log(`Something outside this server deleted ${missing.join(', ')} from ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/. ` +
348
+ "Rewriting them — your project is fine, this is the catalog's own generated workspace. " +
349
+ 'If the catalog looks broken, reload the page.');
350
+ writeWorkspace(config, updateEndpoint);
351
+ // `entry.tsx` imports both of these, so a rewrite that skips them resolves nothing. Both come
352
+ // from one status, the way `refreshUpdateFile` writes them, so a repair cannot leave
353
+ // `version.json` announcing an update that `update.json` denies.
354
+ const status = versionStatus();
355
+ if (!existsSync(workspacePath(config, 'update.json'))) {
356
+ const update = status.update ? { ...status.update, checkedAt: status.lastCheckedAt ?? new Date().toISOString() } : undefined;
357
+ writeUpdateFile(config.projectRoot, update, config.workspaceDir);
358
+ }
359
+ if (!existsSync(workspacePath(config, 'version.json')))
360
+ writeVersionFile(config.projectRoot, status, config.workspaceDir);
361
+ reclaimLock();
362
+ sync(config);
363
+ return { missing, repaired: true };
364
+ }
329
365
  export async function dev(initialConfig, devOptions = {}) {
330
366
  assertLocalInstall(initialConfig.projectRoot);
331
367
  const ownVersion = readOwnVersion(import.meta.dirname);
@@ -505,8 +541,60 @@ export async function dev(initialConfig, devOptions = {}) {
505
541
  shell: process.platform === 'win32',
506
542
  detached: true,
507
543
  });
544
+ // This process owns its workspace: nothing else should remove the files that define it. When
545
+ // they vanish anyway, Metro reports it as `Cannot resolve entry file: the 'main' field defined
546
+ // in your package.json points to an unresolvable or non-existent path` — which names the
547
+ // *project's* package.json, a file that is fine and that the developer did not touch. The cause
548
+ // is still unidentified (issue #41), so this does not pretend to prevent it; it notices, says
549
+ // plainly what happened, and writes the files back, because `writeWorkspace` is idempotent and
550
+ // already knows how.
551
+ //
552
+ // Capped rather than endless: if something is deleting continuously, rewriting forever would
553
+ // hide it behind a repair loop. After the cap this stops repairing and keeps saying so, which
554
+ // leaves the evidence visible.
555
+ let repairs = 0;
556
+ const workspaceWatch = setInterval(() => {
557
+ if (watchersPaused)
558
+ return; // an update is rewriting the workspace on purpose.
559
+ if (repairs >= WORKSPACE_REPAIR_LIMIT) {
560
+ const missing = missingWorkspaceFiles(config);
561
+ if (missing.length > 0) {
562
+ console.warn(`The catalog workspace is still losing files (missing ${missing.join(', ')}). Not rewriting them again — ` +
563
+ 'something outside this server is deleting them. Please report what else was running at https://github.com/krapwoo/design-system-viewer/issues/41');
564
+ }
565
+ return;
566
+ }
567
+ // Counted as an attempt, not a success: a workspace that cannot be rewritten (a permission
568
+ // error, a sync failure) would otherwise retry every four seconds forever.
569
+ if (missingWorkspaceFiles(config).length > 0)
570
+ repairs += 1;
571
+ try {
572
+ // `updateEndpoint` is passed through, never rebuilt: the endpoint listens on its own
573
+ // ephemeral port, so deriving one from Metro's port here would repair the files and break
574
+ // the update panel in the same breath.
575
+ repairWorkspaceIfMissing({
576
+ config, updateEndpoint, versionStatus: version.getVersionStatus, sync,
577
+ // Only if no one else took it meanwhile: between losing the lock and this tick, a second
578
+ // `dev` can legitimately have started and be serving the directory. Overwriting its lock
579
+ // would leave two servers each believing they own it.
580
+ reclaimLock: () => {
581
+ const other = liveDevLock(config);
582
+ if (other && other.pid !== process.pid) {
583
+ console.warn(`Not reclaiming ${config.workspaceDir ?? DEFAULT_WORKSPACE_DIR}/: pid ${other.pid} owns it now.`);
584
+ return;
585
+ }
586
+ writeDevLock(config, { pid: process.pid, port });
587
+ },
588
+ });
589
+ }
590
+ catch (error) {
591
+ console.warn(`Could not rewrite the workspace: ${error.message}`);
592
+ }
593
+ }, WORKSPACE_WATCH_INTERVAL_MS);
594
+ workspaceWatch.unref?.();
508
595
  child.on('error', (error) => console.warn(`Could not start Expo: ${error.message}`));
509
596
  child.on('exit', (code) => {
597
+ clearInterval(workspaceWatch);
510
598
  for (const watcher of watchers)
511
599
  watcher.close();
512
600
  endpointServer?.close();
@@ -70,7 +70,7 @@ function toSectionDef(page) {
70
70
  hide: (page.hideVariants || page.hideStates) ? { variants: page.hideVariants, states: page.hideStates } : undefined,
71
71
  };
72
72
  }
73
- function explainBlock(block) {
73
+ function explainBlock(block, availableWidth) {
74
74
  if (block.kind === 'grid') {
75
75
  const rows = axisItems(block.comparison.rows).length;
76
76
  const columns = axisItems(block.comparison.columns).length;
@@ -82,10 +82,10 @@ function explainBlock(block) {
82
82
  };
83
83
  }
84
84
  if (block.kind === 'list') {
85
- const geometry = listGeometry(block.items.length, MATRIX_LAYOUT.laptopContentWidth, block.size, block.maxColumns);
85
+ const geometry = listGeometry(block.items.length, availableWidth, block.size, block.maxColumns);
86
86
  return {
87
87
  kind: 'list', title: block.title,
88
- reason: `${block.items.length} items (${block.size}) → ${geometry.columns} columns × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
88
+ reason: `${block.items.length} items (${block.size}) → ${geometry.columns} column${geometry.columns === 1 ? '' : 's'} × ${geometry.rows} row${geometry.rows === 1 ? '' : 's'}` +
89
89
  (geometry.fillers > 0 ? ` (${geometry.fillers} filler${geometry.fillers === 1 ? '' : 's'})` : '') + '.' +
90
90
  surfaceOverrideNote(block.items) + alignOverrideNote(block.items),
91
91
  };
@@ -149,29 +149,40 @@ export function explainPage(config, pageId, options = {}) {
149
149
  return { pageId, notCheckable: true, blocks: [] };
150
150
  const def = toSectionDef(page);
151
151
  const blocks = presentationBlocks(def, { defaultPreviewWidths: [402] });
152
- const explained = blocks.map(explainBlock);
152
+ // Two blocks placed side by side get half the row each, which changes how many columns fit.
153
+ // Reporting the full-width counts for a page the viewer will halve states the wrong geometry as
154
+ // fact, so the width every block is measured at follows the placement decision.
155
+ const full = MATRIX_LAYOUT.laptopContentWidth;
156
+ const half = Math.floor((full - CATALOG_LAYOUT.blockGap) / 2);
153
157
  let placement;
158
+ let blockWidth = full;
154
159
  if (blocks.length === 2) {
155
160
  if (options.heights) {
156
161
  const [firstHeight, secondHeight] = options.heights;
157
162
  const decision = choosePlacement({
158
- available: MATRIX_LAYOUT.laptopContentWidth,
163
+ available: full,
159
164
  gap: CATALOG_LAYOUT.blockGap,
160
165
  first: toPlacementBlock(blocks[0], firstHeight),
161
166
  second: toPlacementBlock(blocks[1], secondHeight),
162
167
  });
168
+ if (decision === 'side')
169
+ blockWidth = half;
163
170
  placement = {
164
171
  decision,
165
- reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${MATRIX_LAYOUT.laptopContentWidth}px content width): ${decision}.`,
172
+ reason: `With heights ${firstHeight}px/${secondHeight}px on a 1280px laptop (${full}px content width): ${decision}` +
173
+ (decision === 'side' ? `, so each block gets ${half}px.` : `, so each block gets the full ${full}px.`),
166
174
  };
167
175
  }
168
176
  else {
169
177
  placement = {
170
178
  decision: 'decided-in-viewer',
171
- reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. Pass --heights <first>,<second> to check it here.",
179
+ reason: "Side-by-side placement depends on each block's measured height — decided in the viewer at runtime. " +
180
+ `The counts above are for the full ${full}px row; side by side each block gets ${half}px instead, which can fit fewer columns. ` +
181
+ 'Pass --heights <first>,<second> to resolve it here.',
172
182
  };
173
183
  }
174
184
  }
185
+ const explained = blocks.map((block) => explainBlock(block, blockWidth));
175
186
  return { pageId, blocks: explained, placement, intentionalStaticPreviewReason: page.intentionalStaticPreviewReason };
176
187
  }
177
188
  export function formatExplain(result) {
@@ -151,6 +151,18 @@ export function liveDevLock(config, ownPid = process.pid) {
151
151
  return undefined;
152
152
  return lock;
153
153
  }
154
+ /** The files `writeWorkspace` always writes: the ones that define the workspace to Expo and Metro.
155
+ * `babel.config.js` is conditional and the update files are written separately, so neither counts
156
+ * as missing here. */
157
+ export const WORKSPACE_DEFINING_FILES = ['package.json', 'app.json', 'tsconfig.json', 'metro.config.js', 'entry.tsx'];
158
+ /** Which of those a workspace has lost. A running `dev` owns these files and nothing else should
159
+ * remove them, so a non-empty answer means something outside this process deleted them — see
160
+ * issue #41, where the cause is still unidentified. Reported rather than diagnosed: the point is
161
+ * that the server can say what happened instead of leaving Expo to blame the project's own
162
+ * `package.json` with `Cannot resolve entry file`. */
163
+ export function missingWorkspaceFiles(config) {
164
+ return WORKSPACE_DEFINING_FILES.filter((name) => !existsSync(workspacePath(config, name)));
165
+ }
154
166
  /** Writes the self-contained Expo web workspace at `.ds-viewer/` (design §2 "The preview
155
167
  * workspace and `npx ds-viewer dev`"). Rewritten freely on every `dev` run — nothing here is
156
168
  * user-owned.
@@ -158,9 +170,9 @@ export function liveDevLock(config, ownPid = process.pid) {
158
170
  * `updateEndpoint` is the caller's own, inlined into `entry.tsx`. Without one (a render check's
159
171
  * throwaway server, `--no-update-endpoint`) the catalog is served with no endpoint at all, so it
160
172
  * makes no calls: the shell polls `/version` from every page, so inlining a *different* server's
161
- * endpoint here would have every page call it from the wrong origin. Two `dev` servers share this
162
- * one directory, so the last writer decides — see the README on running a render check beside an
163
- * open catalog. */
173
+ * endpoint here would have every page call it from the wrong origin. Each `dev` owns its own
174
+ * workspace directory (`dev-lock.json`), so a render check beside an open catalog writes to
175
+ * `.ds-viewer-render/` rather than this one — see the README. */
164
176
  export function writeWorkspace(config, updateEndpoint) {
165
177
  const workspace = workspacePath(config);
166
178
  mkdirSync(workspace, { recursive: true });
@@ -1,7 +1,7 @@
1
- import React, { useState } from 'react';
1
+ import React, { useState, useRef } from 'react';
2
2
  import { Platform, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
- import { DEVICE_FRAME, deviceFrameScale } from './deviceFrame';
4
+ import { DEVICE_FRAME, DEVICE_SCALE_WARN_BELOW, deviceFrameScale } from './deviceFrame';
5
5
  import { resolveOverlayViewport } from './overlayViewport';
6
6
  import { useSpecimenAddress } from './SpecimenAddress';
7
7
 
@@ -27,7 +27,16 @@ function isWeb(): boolean {
27
27
  * Purely a documentation device: a real app screen never frames itself, so this lives in the
28
28
  * catalog framework rather than in an app's component library.
29
29
  */
30
- export function PhoneFrame({ children }: { children: React.ReactNode }) {
30
+ export function PhoneFrame({
31
+ children,
32
+ intentionalScale = false,
33
+ }: {
34
+ children: React.ReactNode;
35
+ /** This example is *about* the scaling — a page demonstrating that a narrow cell shrinks the
36
+ * device rather than reflowing it. Declared here, in the example itself, so the exemption is
37
+ * visible where the decision was made instead of hidden in config. */
38
+ intentionalScale?: boolean;
39
+ }) {
31
40
  const address = useSpecimenAddress();
32
41
  const [available, setAvailable] = useState(0);
33
42
  const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
@@ -36,6 +45,23 @@ export function PhoneFrame({ children }: { children: React.ReactNode }) {
36
45
  if (width > 0 && width !== available) setAvailable(width);
37
46
  };
38
47
  const scale = deviceFrameScale(available);
48
+ // A scaled device is a page that under-declared its width: the cell it was given is narrower
49
+ // than the phone, so the whole viewport shrinks and the example's own text shrinks with it,
50
+ // which is not the size anyone reads the component at. The scale is already computed, so this
51
+ // needs no measurement of its own. Guarded to once per transition, like TokenSections' own
52
+ // degradation warning; `check:catalog` fails on any [Catalog] warning, so this blocks CI.
53
+ const warnedScale = useRef<string | undefined>(undefined);
54
+ if (!intentionalScale && available > 0 && scale < DEVICE_SCALE_WARN_BELOW && (globalThis as { __DEV__?: boolean }).__DEV__) {
55
+ const key = `${available}`;
56
+ if (warnedScale.current !== key) {
57
+ warnedScale.current = key;
58
+ const where = address ? `${address.pageId} (${address.slot}/${address.itemKey})` : 'a PhoneFrame';
59
+ console.warn(
60
+ `[Catalog] ${where} is shown at ${Math.round(scale * 100)}% of device size: its cell is ${available}px, ` +
61
+ `narrower than the ${DEVICE_FRAME.width}px device. Declare a larger specimenSize so the example keeps the width it is used at.`,
62
+ );
63
+ }
64
+ }
39
65
  // Until the cell is measured, fit it at the device's proportions rather than drawing at full size
40
66
  // for a frame (which would briefly overflow a cell narrower than the device).
41
67
  const box = available > 0
@@ -12,3 +12,12 @@ export function deviceFrameScale(availableWidth: number): number {
12
12
  if (!(availableWidth > 0)) return 1;
13
13
  return Math.min(1, availableWidth / DEVICE_FRAME.width);
14
14
  }
15
+
16
+ /** Below this, a device is scaled enough that its own text is no longer the size a reader sees it
17
+ * at, and the page should declare a wider specimen instead.
18
+ *
19
+ * Not exactly 1. A regular cell leaves 370px for a 375px device — a 1.3% reduction nobody can
20
+ * see, and warning about it would train authors to ignore the warning. The cases worth a page's
21
+ * attention are the ones that shrink a phone by a tenth or more: a 283px cell renders the device
22
+ * at 75%, which is the difference between readable body text and not. */
23
+ export const DEVICE_SCALE_WARN_BELOW = 0.95;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -65,6 +65,10 @@ const dock = (close: () => void) => (
65
65
  export default defineCatalogPage({
66
66
  component: 'BottomSheet',
67
67
  group: 'Components',
68
+ // Every example is a phone. At the default size two blocks share the row and each cell falls to
69
+ // 270px, which squeezes the frame until the sheet's own text is barely readable — 'wide' keeps a
70
+ // 402px minimum, the width the sheet is actually used at.
71
+ specimenSize: 'wide',
68
72
  composedOf: [
69
73
  { component: 'TopNav', role: "The header slot, typically a TopNav with a close or back action.", relationship: 'slot' },
70
74
  { component: 'Dock', role: "The footer slot, typically a Dock with the sheet's primary actions; a Dock footer gets its shadow once the content scrolls.", relationship: 'slot' },
@@ -33,6 +33,9 @@ function Demo({ title, body, actions }: { title: string; body?: string; actions:
33
33
  export default defineCatalogPage({
34
34
  component: 'Dialog',
35
35
  group: 'Components',
36
+ // Every example is a phone. The default size left 283px for a 375px device, rendering it at 75%
37
+ // — small enough that the dialog's own body text stopped being the size a reader sees.
38
+ specimenSize: 'wide',
36
39
  composedOf: [
37
40
  { component: 'ButtonGroup', role: "Lays out the dialog's actions; a dialog's own children decide this, not Dialog.", relationship: 'related' },
38
41
  ],