@krapwoo/ds-viewer 0.6.9 → 0.6.10

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
@@ -502,6 +502,22 @@ Button
502
502
  gallery or a whole-page layout.
503
503
  - `internal-prop-unknown` — `internalProps` names something that is not a prop of the component,
504
504
  so the exemption it was written for is not in effect. Correct the spelling or delete the entry.
505
+
506
+ The catalog also warns at render time when a page under-declares the width its examples need.
507
+ A `PhoneFrame` given a cell narrower than the 375px device scales the whole phone down, and its
508
+ own body text shrinks with it, so it reports the page, the example and the cell:
509
+
510
+ [Catalog] BottomSheet (variants/content-only) is shown at 75% of device size: its cell is
511
+ 283px, narrower than the 375px device. Declare a larger specimenSize so the example keeps
512
+ the width it is used at.
513
+
514
+ `check:catalog` fails on any `[Catalog]` warning, so this is caught in CI. The threshold is 95%,
515
+ not 100%: a regular cell leaves 370px for a 375px device, and warning about a reduction nobody
516
+ can see would only train you to ignore the warning. Where the scaling *is* the subject — a page
517
+ documenting that a narrow cell shrinks the device rather than reflowing it — write
518
+ `<PhoneFrame intentionalScale>` in that example, so the exemption is visible where the decision
519
+ was made.
520
+
505
521
  A page's `internalProps: { size: 'why' }` exempts a named prop from `option-not-covered` — for
506
522
  plumbing a parent passes (a layout size, an internal surface kind) rather than a value the
507
523
  reader picks. It needs a reason per prop, so the exemption is a visible declaration in the page;
@@ -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) {
@@ -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.10",
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
  ],