@krapwoo/ds-viewer 0.4.4 → 0.4.5

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
@@ -190,13 +190,15 @@ actually apply:
190
190
  within what fits a 1280px laptop: 5 compact, 3 regular, or 2 wide columns. `states` items whose
191
191
  key matches a row or column key are not repeated below the grid.
192
192
  - `specimenSize: 'compact' | 'regular' | 'wide'` — the page's specimen width class.
193
- - `specimenSurface: 'neutral' | 'white' | 'dark' | 'transparent'` — the catalog-owned background
194
- behind every live specimen on this page (list/grouped/grid cells, and a non-token `render()`
195
- preview). Defaults to `'neutral'` (a light gray stage, visibly distinct from the surrounding
196
- white card) — reach for `'dark'` to check a low-contrast component against a dark background, or
197
- `'transparent'` when the surface itself would be misleading (e.g. a component whose own
198
- elevation/shadow needs to read against the page, not a chip). Token galleries and sections always
199
- keep their current, unstaged presentation regardless of this setting.
193
+ - `specimenSurface: 'auto' | 'neutral' | 'white' | 'dark' | 'transparent'` — the fill of each
194
+ example's cell (list, grouped and grid cells, and a non-token `render()` preview). Defaults to
195
+ `'auto'`: after the page draws, a cell turns light gray only when its own example would vanish
196
+ against white (its largest coloured box is near-white with no visible border, like a white card,
197
+ sheet or neutral banner; a shadow alone doesn't count). Neighbouring examples keep a white cell,
198
+ and most pages need nothing. Override it when the guess is wrong: `'neutral'` makes every example
199
+ cell gray, `'white'` or `'transparent'` keeps them all white, and `'dark'` checks a light
200
+ component on a dark fill. Detection runs in the browser viewer; elsewhere cells stay white.
201
+ Token galleries and sections are never filled.
200
202
  - `variants`/`states`' own `maxColumns: 1 | 2 | 3 | 4 | 5` — caps how many columns that slot's list
201
203
  ever wraps into (e.g. a 3-item size scale that should always read as one row of exactly 3, never
202
204
  more just because a laptop is wide). A narrow window still drops below the cap when it has to —
@@ -111,6 +111,8 @@ export const CATALOG_COLOR = {
111
111
  surfaceMuted: '#fafafa',
112
112
  /** Hover and pressed feedback on nav rows and buttons. */
113
113
  surfacePressed: '#f1f3f8',
114
+ /** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
115
+ specimenStage: '#f2f2f2',
114
116
  pageBackground: '#f6f6f4',
115
117
  chip: '#eeeeee',
116
118
  /** Active nav label, prop types, scale bars. */
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
5
- import { SpecimenSurface } from './SpecimenSurface';
5
+ import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
6
7
  import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
7
8
 
8
9
  /**
@@ -15,7 +16,7 @@ export function ComparisonGrid({
15
16
  def,
16
17
  size,
17
18
  sectionId,
18
- surface = 'neutral',
19
+ surface = DEFAULT_SPECIMEN_SURFACE,
19
20
  }: {
20
21
  def: ComparisonDef;
21
22
  size: SpecimenSize;
@@ -63,17 +64,17 @@ export function ComparisonGrid({
63
64
  {columns.map((c, ci) => {
64
65
  const cell = cells.get(cellKey(row.key, c.key));
65
66
  return (
66
- <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
67
- {cell?.node !== undefined ? (
68
- <SpecimenSurface surface={surface} style={cell.fill && styles.fill}>
69
- {cell.node}
70
- </SpecimenSurface>
71
- ) : (
67
+ cell?.node !== undefined ? (
68
+ <SpecimenSurface key={c.key} role="cell" surface={surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
69
+ <SpecimenContent fill={cell.fill}>{cell.node}</SpecimenContent>
70
+ </SpecimenSurface>
71
+ ) : (
72
+ <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
72
73
  <Text style={styles.unavailable}>
73
74
  {cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
74
75
  </Text>
75
- )}
76
- </View>
76
+ </View>
77
+ )
77
78
  );
78
79
  })}
79
80
  </View>
@@ -102,7 +103,6 @@ const styles = StyleSheet.create({
102
103
  headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
103
104
  bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
104
105
  specimenCell: { alignItems: 'center', justifyContent: 'center' },
105
- fill: { alignSelf: 'stretch' },
106
106
  headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
107
107
  rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
108
108
  unavailable: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', textAlign: 'center', color: CATALOG_COLOR.textMuted },
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { View, Text, ScrollView, StyleSheet } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
5
- import { SpecimenSurface } from './SpecimenSurface';
5
+ import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
6
7
  import type { SpecimenSize, SpecimenSurfaceKind } from './types';
7
8
 
8
9
  /**
@@ -16,7 +17,7 @@ export function ComparisonGroups({
16
17
  groups,
17
18
  size,
18
19
  label,
19
- surface = 'neutral',
20
+ surface = DEFAULT_SPECIMEN_SURFACE,
20
21
  }: {
21
22
  groups: ListGroup[];
22
23
  size: SpecimenSize;
@@ -46,11 +47,9 @@ export function ComparisonGroups({
46
47
  <View style={styles.caption}>
47
48
  <Text style={styles.captionText}>{item.label}</Text>
48
49
  </View>
49
- <View style={styles.specimen}>
50
- <SpecimenSurface surface={surface} style={item.fill && styles.fill}>
51
- {item.node as React.ReactNode}
52
- </SpecimenSurface>
53
- </View>
50
+ <SpecimenSurface surface={surface} style={styles.specimen}>
51
+ <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
52
+ </SpecimenSurface>
54
53
  </View>
55
54
  );
56
55
  })}
@@ -102,5 +101,4 @@ const styles = StyleSheet.create({
102
101
  alignItems: 'center',
103
102
  justifyContent: 'center',
104
103
  },
105
- fill: { alignSelf: 'stretch' },
106
104
  });
@@ -2,7 +2,8 @@ import React, { useState } from 'react';
2
2
  import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
3
  import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
4
4
  import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
5
- import { SpecimenSurface } from './SpecimenSurface';
5
+ import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
6
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
6
7
  import type { SpecimenSize, SpecimenSurfaceKind } from './types';
7
8
 
8
9
  /**
@@ -17,7 +18,7 @@ export function ComparisonList({
17
18
  size,
18
19
  label,
19
20
  maxColumns,
20
- surface = 'neutral',
21
+ surface = DEFAULT_SPECIMEN_SURFACE,
21
22
  }: {
22
23
  items: ListItem[];
23
24
  size: SpecimenSize;
@@ -60,11 +61,9 @@ export function ComparisonList({
60
61
  <View style={styles.caption}>
61
62
  <Text style={styles.captionText}>{item.label}</Text>
62
63
  </View>
63
- <View style={styles.specimen}>
64
- <SpecimenSurface surface={surface} style={item.fill && styles.fill}>
65
- {item.node as React.ReactNode}
66
- </SpecimenSurface>
67
- </View>
64
+ <SpecimenSurface surface={surface} style={styles.specimen}>
65
+ <SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
66
+ </SpecimenSurface>
68
67
  </View>
69
68
  );
70
69
  })}
@@ -101,5 +100,4 @@ const styles = StyleSheet.create({
101
100
  alignItems: 'center',
102
101
  justifyContent: 'center',
103
102
  },
104
- fill: { alignSelf: 'stretch' },
105
103
  });
@@ -6,6 +6,7 @@ import { ComparisonGroups } from './ComparisonGroups';
6
6
  import { ComparisonList } from './ComparisonList';
7
7
  import { ReferenceDetails } from './ReferenceDetails';
8
8
  import { SpecimenSurface } from './SpecimenSurface';
9
+ import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
9
10
  import { TokenSections } from './TokenLayouts';
10
11
  import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
11
12
  import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
@@ -104,21 +105,23 @@ function frameLabel(width: number): string {
104
105
  * chrome and token galleries. Every frame is its own live instance with its own state. `surface`
105
106
  * is omitted for token galleries, which keep their current, unstaged presentation. */
106
107
  function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
107
- const stage = (node: React.ReactNode) => (surface ? <SpecimenSurface surface={surface}>{node}</SpecimenSurface> : node);
108
- if (widths === 'full') return <View style={styles.previewCard}>{stage(render())}</View>;
108
+ // Token galleries pass no surface: their card stays white.
109
+ const kind = surface ?? 'transparent';
110
+ if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
109
111
  return (
110
112
  <View style={styles.previewCard}>
111
113
  <View style={styles.frames}>
112
114
  {widths.map((width, i) => (
113
- <View
115
+ <SpecimenSurface
114
116
  key={`${width}-${i}`}
117
+ surface={kind}
115
118
  // Two live instances of the same component: name each so assistive tech can tell them apart.
116
119
  {...(widths.length > 1 ? ({ role: 'group', 'aria-label': frameLabel(width) } as Record<string, unknown>) : null)}
117
120
  style={[styles.frame, { width }]}
118
121
  >
119
122
  {widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
120
- {stage(render())}
121
- </View>
123
+ {render()}
124
+ </SpecimenSurface>
122
125
  ))}
123
126
  </View>
124
127
  </View>
@@ -246,7 +249,7 @@ export function SectionBlock<TId extends string>({
246
249
  checkCompleteness(def);
247
250
  // Memoized so Blocks' placement effect runs on real changes, not on every render.
248
251
  const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
249
- const surface = def.specimenSurface ?? 'neutral';
252
+ const surface = def.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
250
253
  // react-native-web reads `aria-level`; React Native's prop types do not declare it.
251
254
  const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
252
255
 
@@ -1,46 +1,84 @@
1
- import React from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_SPACE } from './tokens';
4
- import { SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
3
+ import { CATALOG_COLOR } from './tokens';
4
+ import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
5
+ import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
5
6
  import type { SpecimenSurfaceKind } from './types';
6
7
 
8
+ type Browser = {
9
+ getComputedStyle?: (element: unknown) => never;
10
+ Element?: new () => unknown;
11
+ requestAnimationFrame?: (callback: () => void) => number;
12
+ };
13
+
7
14
  /**
8
- * The catalog-owned background directly behind a live component specimen — shared by
9
- * `ComparisonList`, `ComparisonGroups`, `ComparisonGrid`, and `SectionBlock`'s component preview,
10
- * so a page's `specimenSurface` renders identically everywhere a specimen appears. Purely a fixed
11
- * backdrop: it never reads the wrapped component's own colors, never adds a border, and is never
12
- * used for token galleries/sections (those keep their existing full-width presentation). Always
13
- * stretches to its parent's width (see `specimenSurfaceStyle.ts`) rather than shrink-wrapping to
14
- * its content, centering that content itself — so a percentage-width specimen resolves against
15
- * the real comparison-cell width instead of collapsing to 0px.
15
+ * An example's whole cell area — shared by `ComparisonList`, `ComparisonGroups`,
16
+ * `ComparisonGrid`, and `SectionBlock`'s preview frames, so a page's `specimenSurface` fills every
17
+ * example cell the same way. With `'auto'` (the default) the cell measures its example after it
18
+ * draws and turns gray only when the example would vanish against white (a white card, sheet or
19
+ * neutral banner; see `specimenFill.ts`). Explicit kinds fill the cell without measuring. Adds no
20
+ * padding or border of its own: pass the cell's own layout in `style`. Measuring needs the
21
+ * browser's DOM; elsewhere `'auto'` leaves the cell white. Extra props (e.g. `role`) go to the View.
16
22
  */
17
23
  export function SpecimenSurface({
18
- surface = 'neutral',
24
+ surface = DEFAULT_SPECIMEN_SURFACE,
19
25
  style,
20
26
  children,
27
+ ...viewProps
21
28
  }: {
22
29
  surface?: SpecimenSurfaceKind;
23
30
  style?: StyleProp<ViewStyle>;
24
31
  children: React.ReactNode;
32
+ [prop: string]: unknown;
25
33
  }) {
26
- return <View style={[styles.base, SURFACE_FILL[surface], style]}>{children}</View>;
34
+ const ref = useRef<View>(null);
35
+ const [blends, setBlends] = useState(false);
36
+
37
+ const measure = useCallback(() => {
38
+ if (surface !== 'auto') return;
39
+ const browser = globalThis as unknown as Browser;
40
+ const node = ref.current as unknown;
41
+ if (!browser.getComputedStyle || !browser.Element || !(node instanceof browser.Element)) return;
42
+ const { boxes, cellArea } = measurePaintedBoxes(node as Parameters<typeof measurePaintedBoxes>[0], (element) => browser.getComputedStyle!(element));
43
+ setBlends(blendsIntoCell(boxes, cellArea));
44
+ }, [surface]);
45
+
46
+ useEffect(() => {
47
+ const raf = (globalThis as unknown as Browser).requestAnimationFrame;
48
+ if (surface !== 'auto' || !raf) return;
49
+ // Two frames, so the example's own styles and fonts have applied before measuring.
50
+ let cancelled = false;
51
+ raf(() => raf(() => {
52
+ if (!cancelled) measure();
53
+ }));
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, [surface, measure]);
58
+
59
+ const fill = surface === 'auto' ? (blends ? FILL.neutral : undefined) : FILL[surface];
60
+ return (
61
+ <View ref={ref} onLayout={surface === 'auto' ? measure : undefined} style={[style, fill]} {...viewProps}>
62
+ {children}
63
+ </View>
64
+ );
27
65
  }
28
66
 
29
- // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token) rather than adding a new
30
- // background-specific color — the catalog chrome has no existing dark-surface token to reach for.
31
- const SURFACE_FILL: Record<SpecimenSurfaceKind, ViewStyle> = {
32
- neutral: { backgroundColor: CATALOG_COLOR.surfaceMuted },
67
+ /** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
68
+ * against the cell), centered unless the example fills the cell. */
69
+ export function SpecimenContent({ fill, children }: { fill?: boolean; children: React.ReactNode }) {
70
+ return <View style={fill ? styles.fill : styles.center}>{children}</View>;
71
+ }
72
+
73
+ // 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
74
+ const FILL = StyleSheet.create({
75
+ neutral: { backgroundColor: CATALOG_COLOR.specimenStage },
33
76
  white: { backgroundColor: CATALOG_COLOR.surface },
34
77
  dark: { backgroundColor: CATALOG_COLOR.text },
35
- transparent: { backgroundColor: 'transparent' },
36
- };
78
+ transparent: {},
79
+ });
37
80
 
38
81
  const styles = StyleSheet.create({
39
- base: {
40
- ...SPECIMEN_SURFACE_WIDTH_STYLE,
41
- alignItems: 'center',
42
- justifyContent: 'center',
43
- borderRadius: CATALOG_RADIUS.sm,
44
- padding: CATALOG_SPACE.md,
45
- },
82
+ center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
83
+ fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
46
84
  });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Automatic cell fill for `specimenSurface: 'auto'` (the default): decides whether an example
3
+ * blends into its white comparison cell, so that one cell can turn gray. Kept free of any runtime
4
+ * import so `node --test` can assert on it directly (see `__tests__/specimenFill.test.ts`).
5
+ *
6
+ * The rule looks only at the example's largest painted box (the element with a background that
7
+ * covers the most area): if that box is near-white (every channel close to 255, so pale tints
8
+ * don't count) and has no visible border, the example's edges
9
+ * vanish against the white cell. A shadow alone doesn't count as an edge. Small boxes (under a
10
+ * tenth of the cell) and text-only examples never trigger it.
11
+ */
12
+
13
+ export interface Rgba { r: number; g: number; b: number; a: number }
14
+
15
+ /** One element inside an example, as measured in the browser. */
16
+ export interface PaintedBox {
17
+ area: number;
18
+ background: Rgba | null;
19
+ /** The top border's colour when its width is above 0, else null. */
20
+ border: Rgba | null;
21
+ }
22
+
23
+ /** A fill is near-white when every channel (after compositing over the white cell) is at least
24
+ * this. #fafafa and #f8f8f8 qualify; #f4f4f4 already reads as gray, and pale tints such as a
25
+ * positive banner's rgb(240, 255, 244) read as colour, even when they're as bright as white. */
26
+ const NEAR_WHITE_CHANNEL = 247;
27
+ /** At or above this, a border draws the edge itself (#e4e4e4 is 1.27:1). */
28
+ const VISIBLE_BORDER_CONTRAST = 1.2;
29
+ /** A box smaller than this share of the cell is a detail, not the example's body. */
30
+ const MIN_AREA_SHARE = 0.1;
31
+
32
+ /** Parses a computed CSS colour (`rgb(…)`/`rgba(…)`). Fully transparent or unknown values are null. */
33
+ export function parseCssColor(value: string): Rgba | null {
34
+ const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(value.trim());
35
+ if (!match) return null;
36
+ const alphaText = match[4];
37
+ const a = alphaText === undefined ? 1 : alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText);
38
+ if (!(a > 0)) return null;
39
+ return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a };
40
+ }
41
+
42
+ function channel(value: number): number {
43
+ const s = value / 255;
44
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
45
+ }
46
+
47
+ /** True when every channel of `color`, composited over white, is at least `NEAR_WHITE_CHANNEL`. */
48
+ export function isNearWhite(color: Rgba): boolean {
49
+ const over = (c: number) => color.a * c + (1 - color.a) * 255;
50
+ return Math.min(over(color.r), over(color.g), over(color.b)) >= NEAR_WHITE_CHANNEL;
51
+ }
52
+
53
+ /** WCAG contrast ratio against white, after compositing a translucent colour over the white cell. */
54
+ export function contrastWithWhite(color: Rgba): number {
55
+ const over = (c: number) => color.a * c + (1 - color.a) * 255;
56
+ const luminance = 0.2126 * channel(over(color.r)) + 0.7152 * channel(over(color.g)) + 0.0722 * channel(over(color.b));
57
+ return 1.05 / (luminance + 0.05);
58
+ }
59
+
60
+ /** True when the example's body is near-white with no visible border, so it vanishes on a white cell. */
61
+ export function blendsIntoCell(boxes: readonly PaintedBox[], cellArea: number): boolean {
62
+ let largest: PaintedBox | undefined;
63
+ for (const box of boxes) {
64
+ if (!box.background || box.area < cellArea * MIN_AREA_SHARE) continue;
65
+ if (!largest || box.area > largest.area) largest = box;
66
+ }
67
+ if (!largest?.background) return false;
68
+ if (!isNearWhite(largest.background)) return false;
69
+ return !(largest.border && contrastWithWhite(largest.border) >= VISIBLE_BORDER_CONTRAST);
70
+ }
71
+
72
+ /** Minimal DOM shapes, so this file needs no DOM lib types. */
73
+ interface MeasurableElement {
74
+ getBoundingClientRect(): { width: number; height: number };
75
+ querySelectorAll(selector: string): ArrayLike<MeasurableElement>;
76
+ }
77
+ type ComputedStyleReader = (element: MeasurableElement) => {
78
+ backgroundColor: string;
79
+ borderTopColor: string;
80
+ borderTopWidth: string;
81
+ visibility: string;
82
+ opacity: string;
83
+ };
84
+
85
+ /** Measures every element inside `cell` (up to `limit`) in the browser. */
86
+ export function measurePaintedBoxes(cell: MeasurableElement, readStyle: ComputedStyleReader, limit = 300): { boxes: PaintedBox[]; cellArea: number } {
87
+ const cellRect = cell.getBoundingClientRect();
88
+ const elements = cell.querySelectorAll('*');
89
+ const boxes: PaintedBox[] = [];
90
+ for (let i = 0; i < Math.min(elements.length, limit); i++) {
91
+ const element = elements[i];
92
+ const style = readStyle(element);
93
+ if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
94
+ const rect = element.getBoundingClientRect();
95
+ boxes.push({
96
+ area: rect.width * rect.height,
97
+ background: parseCssColor(style.backgroundColor),
98
+ border: parseFloat(style.borderTopWidth) > 0 ? parseCssColor(style.borderTopColor) : null,
99
+ });
100
+ }
101
+ return { boxes, cellArea: cellRect.width * cellRect.height };
102
+ }
@@ -1,8 +1,10 @@
1
+ import type { SpecimenSurfaceKind } from './types.ts';
2
+
1
3
  /**
2
- * `SpecimenSurface`'s width policy, kept free of any `react-native` import so `node --test` can
3
- * assert on it directly (see `native/catalog/__tests__/specimenSurfaceStyle.test.ts`).
4
+ * The example wrapper's width policy inside a cell, kept free of any `react-native` import so
5
+ * `node --test` can assert on it directly (see `__tests__/specimenSurfaceStyle.test.ts`).
4
6
  *
5
- * The surface always stretches to the full cross-axis width of whatever wraps it (a comparison
7
+ * The wrapper always stretches to the full cross-axis width of whatever wraps it (a comparison
6
8
  * cell, typically centered) rather than shrink-wrapping to its own content — `alignSelf: 'stretch'`
7
9
  * overrides a centered parent's `alignItems` for this one child. A compact specimen (e.g. Button)
8
10
  * still reads as centered, via `SpecimenSurface`'s own `alignItems`/`justifyContent: 'center'` on
@@ -13,3 +15,7 @@
13
15
  export const SPECIMEN_SURFACE_WIDTH_STYLE = {
14
16
  alignSelf: 'stretch',
15
17
  } as const;
18
+
19
+ /** Each example's cell turns gray only when its example would vanish against white (see
20
+ * `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
21
+ export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
@@ -119,6 +119,8 @@ export const CATALOG_COLOR = {
119
119
  surfaceMuted: '#fafafa',
120
120
  /** Hover and pressed feedback on nav rows and buttons. */
121
121
  surfacePressed: '#f1f3f8',
122
+ /** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
123
+ specimenStage: '#f2f2f2',
122
124
  pageBackground: '#f6f6f4',
123
125
  chip: '#eeeeee',
124
126
  /** Active nav label, prop types, scale bars. */
@@ -65,14 +65,12 @@ export type SpecimenSize = 'compact' | 'regular' | 'wide';
65
65
  * content that is not a phone component (catalog chrome, token galleries). */
66
66
  export type PreviewWidths = readonly number[] | 'full';
67
67
 
68
- /** Background behind a live component specimen (the list/grouped/grid cell, or the preview frame)
69
- * — a neutral (default), explicit white, dark, or transparent catalog-owned stage, so a specimen
70
- * reads as visibly separate from the surrounding white card rather than floating on it, which
71
- * matters most for a low-contrast or motion-only component that could otherwise blend into a
72
- * plain white background. Purely a fixed catalog-chrome backdrop: it never inspects or infers the
73
- * wrapped component's own colors, and is never applied to token galleries/sections, which keep
74
- * their current full-width, unstaged presentation regardless of this setting. */
75
- export type SpecimenSurfaceKind = 'neutral' | 'white' | 'dark' | 'transparent';
68
+ /** Fill of each example's cell. `'auto'` (default): a cell turns gray (#f2f2f2) only when its own
69
+ * example would vanish against white — a near-white body with no visible border, like a white
70
+ * card, sheet or neutral banner — so neighbouring examples keep a white cell. `'neutral'` makes
71
+ * every example cell gray, `'white'` and `'transparent'` keep them white, `'dark'` checks light
72
+ * components on a dark fill. Never applied to token galleries or sections. */
73
+ export type SpecimenSurfaceKind = 'auto' | 'neutral' | 'white' | 'dark' | 'transparent';
76
74
 
77
75
  /** One real component this page's component is built from or composed with — rendered as a
78
76
  * "Composition" subsection in Quick reference. `relationship` is `'built-in'` for a component
@@ -218,7 +216,7 @@ export interface SectionDef<TId extends string = string> {
218
216
  * and everything else is 'regular'. */
219
217
  specimenSize?: SpecimenSize;
220
218
  /** Background stage behind every live component specimen on this page (list/grouped/grid cells,
221
- * and a `render()` preview that isn't a token gallery). Defaults to `'neutral'`. See
219
+ * and a `render()` preview that isn't a token gallery). Defaults to `'auto'`. See
222
220
  * `SpecimenSurfaceKind`. */
223
221
  specimenSurface?: SpecimenSurfaceKind;
224
222
  /** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",