@krapwoo/ds-viewer 0.1.1 → 0.3.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.
Files changed (173) hide show
  1. package/README.md +182 -78
  2. package/dist/cli/agentsFile.js +31 -0
  3. package/dist/cli/config.js +26 -1
  4. package/dist/cli/dev.js +50 -5
  5. package/dist/cli/doctor.js +492 -0
  6. package/dist/cli/explain.js +151 -0
  7. package/dist/cli/githubAction.js +68 -0
  8. package/dist/cli/glob.js +8 -0
  9. package/dist/cli/init.js +152 -18
  10. package/dist/cli/main.js +115 -4
  11. package/dist/cli/pageIndex.js +27 -1
  12. package/dist/cli/props.js +170 -21
  13. package/dist/cli/staticPage.js +356 -0
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/workspace.js +49 -21
  16. package/dist/native/catalog/comparison.js +283 -0
  17. package/dist/native/catalog/tokens.js +119 -0
  18. package/dist/native/catalog/types.js +1 -0
  19. package/native/catalog/CatalogShell.tsx +6 -1
  20. package/native/catalog/CatalogSidebar.tsx +98 -5
  21. package/native/catalog/ComparisonGrid.tsx +9 -7
  22. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  23. package/native/catalog/TokenRow.tsx +3 -2
  24. package/native/catalog/catalogNavigation.ts +1 -1
  25. package/native/catalog/comparison.ts +63 -5
  26. package/native/catalog/index.ts +8 -6
  27. package/native/catalog/logoLayout.ts +12 -0
  28. package/native/catalog/pageApi.ts +21 -3
  29. package/native/catalog/tokens.ts +5 -0
  30. package/native/catalog/types.ts +9 -6
  31. package/package.json +12 -6
  32. package/starter-kit/WHEN_TO_USE.md +124 -0
  33. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  34. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  35. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  36. package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
  37. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  38. package/starter-kit/components/Avatar/index.ts +2 -0
  39. package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
  40. package/starter-kit/components/Badge/Badge.tsx +56 -0
  41. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  42. package/starter-kit/components/Badge/index.ts +2 -0
  43. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  44. package/starter-kit/components/Banner/Banner.tsx +257 -0
  45. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  46. package/starter-kit/components/Banner/index.ts +2 -0
  47. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  48. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  49. package/starter-kit/components/BottomSheet/index.ts +2 -0
  50. package/starter-kit/components/Button/Button.catalog.tsx +99 -0
  51. package/starter-kit/components/Button/Button.tsx +173 -0
  52. package/starter-kit/components/Button/Button.types.ts +52 -0
  53. package/starter-kit/components/Button/index.ts +2 -0
  54. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  55. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  56. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  57. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  58. package/starter-kit/components/Card/Card.tsx +133 -0
  59. package/starter-kit/components/Card/index.ts +2 -0
  60. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  61. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  62. package/starter-kit/components/Checkbox/index.ts +2 -0
  63. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  64. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  65. package/starter-kit/components/Dialog/index.ts +2 -0
  66. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  67. package/starter-kit/components/Divider/Divider.tsx +20 -0
  68. package/starter-kit/components/Divider/index.ts +2 -0
  69. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  70. package/starter-kit/components/Dock/Dock.tsx +58 -0
  71. package/starter-kit/components/Dock/index.ts +2 -0
  72. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  73. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  74. package/starter-kit/components/Dropdown/index.ts +2 -0
  75. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  76. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  77. package/starter-kit/components/EmptyState/index.ts +2 -0
  78. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  79. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  80. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  81. package/starter-kit/components/FieldContainer/index.ts +2 -0
  82. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  83. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  84. package/starter-kit/components/InputClearButton/index.ts +2 -0
  85. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  86. package/starter-kit/components/InputField/InputField.tsx +418 -0
  87. package/starter-kit/components/InputField/index.ts +2 -0
  88. package/starter-kit/components/List/List.catalog.tsx +30 -0
  89. package/starter-kit/components/List/List.tsx +36 -0
  90. package/starter-kit/components/List/index.ts +2 -0
  91. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  92. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  93. package/starter-kit/components/ListItem/index.ts +2 -0
  94. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  95. package/starter-kit/components/Loading/Loading.tsx +121 -0
  96. package/starter-kit/components/Loading/index.ts +2 -0
  97. package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
  98. package/starter-kit/components/Pill/Pill.tsx +181 -0
  99. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  100. package/starter-kit/components/Pill/index.ts +2 -0
  101. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  102. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  103. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  104. package/starter-kit/components/PillRow/index.ts +2 -0
  105. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  106. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  107. package/starter-kit/components/ProgressDots/index.ts +2 -0
  108. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  109. package/starter-kit/components/Radio/Radio.tsx +114 -0
  110. package/starter-kit/components/Radio/index.ts +2 -0
  111. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  112. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  113. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  114. package/starter-kit/components/SearchField/index.ts +2 -0
  115. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  116. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  117. package/starter-kit/components/SectionHeader/index.ts +2 -0
  118. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  119. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  120. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  121. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  122. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  123. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  124. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  125. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  126. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  127. package/starter-kit/components/Shimmer/index.ts +2 -0
  128. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  129. package/starter-kit/components/Surface/Surface.tsx +45 -0
  130. package/starter-kit/components/Surface/index.ts +2 -0
  131. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  132. package/starter-kit/components/Switch/Switch.tsx +129 -0
  133. package/starter-kit/components/Switch/index.ts +2 -0
  134. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  135. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  136. package/starter-kit/components/TextArea/index.ts +2 -0
  137. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  138. package/starter-kit/components/Toast/Toast.tsx +144 -0
  139. package/starter-kit/components/Toast/index.ts +2 -0
  140. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  141. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  142. package/starter-kit/components/Tooltip/index.ts +2 -0
  143. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  144. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  145. package/starter-kit/components/TopNav/index.ts +2 -0
  146. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  147. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  148. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  149. package/starter-kit/components/index.ts +36 -0
  150. package/starter-kit/components/statusColors.ts +27 -0
  151. package/starter-kit/icons/Icon.native.tsx +90 -0
  152. package/starter-kit/icons/index.ts +10 -0
  153. package/starter-kit/icons/paths.ts +623 -0
  154. package/starter-kit/icons/types.ts +69 -0
  155. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  156. package/starter-kit/pages/Font.catalog.tsx +45 -0
  157. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  158. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  159. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  160. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  161. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  162. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  163. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  164. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  165. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  166. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  167. package/starter-kit/tokens/index.ts +50 -0
  168. package/starter-kit/tokens/motion.ts +90 -0
  169. package/starter-kit/tokens/palette.ts +111 -0
  170. package/starter-kit/tokens/scales.ts +106 -0
  171. package/starter-kit/tokens/semantic.ts +98 -0
  172. package/starter-kit/tokens/shadow.ts +36 -0
  173. package/starter-kit/tokens/typography.ts +72 -0
@@ -1,6 +1,8 @@
1
1
  import { useState } from 'react';
2
- import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native';
3
- import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
2
+ import { View, Text, ScrollView, Pressable, StyleSheet, Image } from 'react-native';
3
+ import type { ImageSourcePropType, LayoutChangeEvent, NativeSyntheticEvent, ImageLoadEventData } from 'react-native';
4
+ import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
5
+ import { logoLayout } from './logoLayout';
4
6
  import { filterGroups, hashForId } from './catalogNavigation';
5
7
  import type { NavGroup } from './types';
6
8
  import { CatalogSearchInput } from './CatalogSearchInput';
@@ -48,25 +50,108 @@ function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active:
48
50
  */
49
51
  export function CatalogSidebar<TId extends string>({
50
52
  logo,
53
+ logoImageSource,
51
54
  caption,
52
55
  groups,
53
56
  active,
54
57
  onPress,
55
58
  }: {
56
59
  logo: string;
60
+ /** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
61
+ * config field. Visual spec: docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html
62
+ * ("C · Adaptive", approved 2026-10-08) — a mark (aspect ratio ≤ 2) sits beside the name; a
63
+ * wordmark (> 2) replaces the name text, carrying it as its own accessible label instead. */
64
+ logoImageSource?: ImageSourcePropType;
57
65
  caption: string;
58
66
  groups: NavGroup<TId>[];
59
67
  active: TId | undefined;
60
68
  onPress: (id: TId) => void;
61
69
  }) {
62
70
  const [query, setQuery] = useState('');
71
+ // A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
72
+ // above) already carries its own `width`/`height` on the required module object, both on native
73
+ // and on react-native-web (confirmed by reading `AssetRegistry`/Metro's asset plugin output) — no
74
+ // need to wait for a load event for the common case. `onLoad` below is kept only as a fallback for
75
+ // a source with no static dimensions (e.g. a bare `{ uri }`); the mock prober stops rendering as
76
+ // soon as either resolves an aspect.
77
+ const staticSource = typeof logoImageSource === 'object' && logoImageSource !== null && !Array.isArray(logoImageSource)
78
+ ? (logoImageSource as { width?: number; height?: number })
79
+ : undefined;
80
+ const staticAspect = staticSource?.width && staticSource?.height ? staticSource.width / staticSource.height : undefined;
81
+ const [loadedAspect, setLoadedAspect] = useState<number | undefined>(undefined);
82
+ const [logoFailed, setLogoFailed] = useState(false);
83
+ const [nameWrapped, setNameWrapped] = useState(false);
63
84
  const filtered = filterGroups(groups, query);
64
85
 
86
+ const logoAspect = staticAspect ?? loadedAspect;
87
+ const attemptingLogo = Boolean(logoImageSource) && !logoFailed;
88
+ const header = attemptingLogo ? logoLayout(logoAspect) : 'text';
89
+
90
+ // Fallback only (see the comment above `staticSource`) — and native-only in practice: on web,
91
+ // `ImageLoader.load`'s `onLoad` fires after `HTMLImageElement.decode()` resolves, by which point
92
+ // Chrome has already nulled the underlying DOM event's `target`, so `native.source` below reads
93
+ // `undefined` on react-native-web (confirmed empirically — `event.target` reads non-null only
94
+ // when read synchronously inside `onload`, before any `await`) and this handler never sets
95
+ // `loadedAspect` there. Native's real `Image` reports `source.{width,height}` on its `onLoad`
96
+ // nativeEvent instead, which this still reads correctly. Kept rather than removed (Minor finding,
97
+ // Fable's implementation review) because a non-bundled source with no static dimensions (e.g. a
98
+ // bare `{ uri }`) is still possible on native, even though `cli/workspace.ts` only ever generates
99
+ // a bundled `require()` today.
100
+ const onLogoLoad = (e: NativeSyntheticEvent<ImageLoadEventData>) => {
101
+ const native = e.nativeEvent as unknown as { source?: { width: number; height: number } };
102
+ const width = native.source?.width;
103
+ const height = native.source?.height;
104
+ if (width && height) setLoadedAspect(width / height);
105
+ };
106
+ const onLogoError = () => setLogoFailed(true);
107
+ // Wrap detection for the "mark" row: the name Text's own measured height, not `onTextLayout`
108
+ // (react-native-web's `Text` doesn't implement it) and not a character-count heuristic (wraps at
109
+ // a different length per name). More than one line of `CATALOG_TYPE.brand` means it wrapped.
110
+ const NAME_LINE_HEIGHT = Math.round(CATALOG_TYPE.brand * 1.3);
111
+ const onNameLayout = (e: LayoutChangeEvent) => {
112
+ setNameWrapped(e.nativeEvent.layout.height > NAME_LINE_HEIGHT * 1.5);
113
+ };
114
+
65
115
  return (
66
116
  <View role="navigation" aria-label="Catalog pages" style={styles.sidebar}>
67
117
  <ScrollView style={styles.scroll} contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
68
- <Text style={styles.logo}>{logo}</Text>
69
- <Text style={styles.subtitle}>{caption}</Text>
118
+ <View style={styles.header}>
119
+ {/* Invisible — exists only so a configured logo's `onLoad`/`onError` can fire at all
120
+ while `header` is still `'text'` (the aspect isn't known yet). Once `logoAspect`
121
+ resolves (or the image fails), `header` switches and this prober stops rendering. */}
122
+ {attemptingLogo && logoAspect === undefined && (
123
+ <Image source={logoImageSource} style={styles.logoProbe} onLoad={onLogoLoad} onError={onLogoError} />
124
+ )}
125
+ {header === 'wordmark' && (
126
+ <>
127
+ <Image
128
+ source={logoImageSource}
129
+ accessibilityRole="image"
130
+ accessibilityLabel={logo}
131
+ resizeMode="contain"
132
+ style={[styles.wordmark, { width: Math.min(CATALOG_LAYOUT.logoWordmarkHeight * (logoAspect ?? 0), 228) }]}
133
+ onLoad={onLogoLoad}
134
+ onError={onLogoError}
135
+ />
136
+ <Text style={[styles.subtitle, styles.subtitleAfterWordmark]}>{caption}</Text>
137
+ </>
138
+ )}
139
+ {header === 'mark' && (
140
+ <View style={[styles.inlineHeader, nameWrapped && styles.inlineHeaderTop]}>
141
+ <Image source={logoImageSource} resizeMode="contain" style={styles.mark} onLoad={onLogoLoad} onError={onLogoError} />
142
+ <View style={styles.inlineText}>
143
+ <Text style={styles.logo} onLayout={onNameLayout}>{logo}</Text>
144
+ <Text style={styles.subtitle}>{caption}</Text>
145
+ </View>
146
+ </View>
147
+ )}
148
+ {header === 'text' && (
149
+ <>
150
+ <Text style={styles.logo}>{logo}</Text>
151
+ <Text style={styles.subtitle}>{caption}</Text>
152
+ </>
153
+ )}
154
+ </View>
70
155
 
71
156
  <CatalogSearchInput value={query} onChangeText={setQuery} placeholder="Filter components…" />
72
157
 
@@ -97,8 +182,16 @@ const styles = StyleSheet.create({
97
182
  paddingHorizontal: CATALOG_LAYOUT.sidebarPaddingX,
98
183
  paddingBottom: CATALOG_LAYOUT.sidebarPaddingTop,
99
184
  },
185
+ header: { marginBottom: 20 },
100
186
  logo: { fontSize: CATALOG_TYPE.brand, fontWeight: '800', color: CATALOG_COLOR.text },
101
- subtitle: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 2, marginBottom: 20 },
187
+ subtitle: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 2 },
188
+ subtitleAfterWordmark: { marginTop: CATALOG_LAYOUT.logoCaptionGap },
189
+ mark: { width: CATALOG_LAYOUT.logoMark, height: CATALOG_LAYOUT.logoMark },
190
+ wordmark: { height: CATALOG_LAYOUT.logoWordmarkHeight, alignSelf: 'flex-start' },
191
+ logoProbe: { position: 'absolute', width: 1, height: 1, opacity: 0 },
192
+ inlineHeader: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md },
193
+ inlineHeaderTop: { alignItems: 'flex-start' },
194
+ inlineText: { flex: 1, minWidth: 0 },
102
195
  empty: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
103
196
  groupLabel: {
104
197
  fontSize: CATALOG_TYPE.xs,
@@ -1,7 +1,7 @@
1
1
  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
- import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
4
+ import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
5
5
  import type { ComparisonDef, SpecimenSize } from './types';
6
6
 
7
7
  /**
@@ -14,7 +14,9 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
14
14
  for (const issue of validateComparison({ ...def, size })) console.warn(`[Catalog] ${sectionId}: ${issue}`);
15
15
  }
16
16
  const cells = indexCells(def);
17
- const bounds = gridWidthBounds(def.columns.length, size);
17
+ const rows = axisItems(def.rows);
18
+ const columns = axisItems(def.columns);
19
+ const bounds = gridWidthBounds(columns.length, size);
18
20
  const column = {
19
21
  flexGrow: 1,
20
22
  flexShrink: 1,
@@ -22,7 +24,7 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
22
24
  minWidth: COLUMN_MIN_WIDTH[size],
23
25
  maxWidth: MATRIX_LAYOUT.columnMaxWidth,
24
26
  };
25
- const lastColumn = def.columns.length - 1;
27
+ const lastColumn = columns.length - 1;
26
28
 
27
29
  return (
28
30
  <ScrollView horizontal style={styles.scroller} contentContainerStyle={styles.scrollContent}>
@@ -35,18 +37,18 @@ export function ComparisonGrid({ def, size, sectionId }: { def: ComparisonDef; s
35
37
  <View role="columnheader" style={[styles.cell, styles.rowHeader, styles.headerCell]}>
36
38
  <Text style={styles.headerText}>{def.rowLabel}</Text>
37
39
  </View>
38
- {def.columns.map((c, ci) => (
40
+ {columns.map((c, ci) => (
39
41
  <View key={c.key} role="columnheader" style={[styles.cell, column, styles.headerCell, ci === lastColumn && styles.lastColumn]}>
40
42
  <Text style={styles.headerText}>{c.label}</Text>
41
43
  </View>
42
44
  ))}
43
45
  </View>
44
- {def.rows.map((row, ri) => (
45
- <View key={row.key} role="row" style={[styles.row, ri === def.rows.length - 1 && styles.lastRow]}>
46
+ {rows.map((row, ri) => (
47
+ <View key={row.key} role="row" style={[styles.row, ri === rows.length - 1 && styles.lastRow]}>
46
48
  <View role="rowheader" style={[styles.cell, styles.rowHeader, styles.bodyRow]}>
47
49
  <Text style={styles.rowHeaderText}>{row.label}</Text>
48
50
  </View>
49
- {def.columns.map((c, ci) => {
51
+ {columns.map((c, ci) => {
50
52
  const cell = cells.get(cellKey(row.key, c.key));
51
53
  return (
52
54
  <View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
@@ -8,7 +8,7 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE, CATALOG_RADIUS } from './to
8
8
  * framework's own, and any host app's). Generic over the step-name type so it works for any scale
9
9
  * (the host app's `DS_SPACING`, this framework's own `CATALOG_SPACE`, or a third-party one).
10
10
  */
11
- export function SpacingScaleGallery<TStep extends string>({
11
+ export function SpacingScaleGallery<TStep extends string | number>({
12
12
  steps,
13
13
  values,
14
14
  useNotes,
@@ -4,8 +4,9 @@ import { CATALOG_TYPE, CATALOG_COLOR, CATALOG_SPACE } from './tokens';
4
4
 
5
5
  /**
6
6
  * Wraps one token's rendered example with a grounded "when to use this" note beneath it — the
7
- * shared shape behind every token-gallery row (Spacing, Type Scale, …) in both CatalogExample.tsx
8
- * and CatalogFrameworkExample.tsx. The row's own content (a spacing bar, a type sample, …) is
7
+ * shared shape behind every token-gallery row (Spacing, Type Scale, …) in both the starter kit's
8
+ * own token pages and the catalog framework's own `kit-host/viewer-pages/` pages. The row's own
9
+ * content (a spacing bar, a type sample, …) is
9
10
  * freeform `children`; only the value-plus-use-note stacking is standardized here.
10
11
  *
11
12
  * Draws a 1px `CATALOG_COLOR.border` bottom divider by default (the same divider PropsTable's rows use) so a stack of TokenRows
@@ -3,7 +3,7 @@
3
3
  * runner. CatalogShell, CatalogSidebar, and SectionBlock all derive page order from here, so the
4
4
  * sidebar order, previous/next order, and fragment fallback can never drift apart.
5
5
  */
6
- import type { NavGroup } from './types';
6
+ import type { NavGroup } from './types.ts';
7
7
 
8
8
  /** THE canonical within-group ordering of section ids. */
9
9
  export function sortIds<TId extends string>(ids: readonly TId[]): TId[] {
@@ -8,7 +8,8 @@
8
8
  * It also decides whether two blocks sit side by side (choosePlacement) and how many columns the
9
9
  * Props box uses (propsColumns). ComparisonGrid, ComparisonGroups, and ComparisonList render it.
10
10
  */
11
- import type { ComparisonDef, PreviewWidths, SectionDef, SpecimenSize, VariantSlot } from './types';
11
+ import type { ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenSize, VariantSlot } from './types.ts';
12
+ import type React from 'react';
12
13
 
13
14
  /** Approved geometry. `cellPadding` equals CATALOG_SPACE.lg (asserted in tests). The laptop
14
15
  * content width is a 1280px viewport minus the 264px sidebar and 32px side padding. */
@@ -35,6 +36,61 @@ export const PROPS_COLUMN_GAP = 32;
35
36
  /** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
36
37
  export const COLUMN_MIN_WIDTH: Record<SpecimenSize, number> = { compact: 160, regular: 240, wide: 402 };
37
38
 
39
+ /** `Array.isArray`'s built-in type guard narrows to a mutable `any[]`, which a `readonly T[]`
40
+ * union member is never assignable to — so the `else` branch of `Array.isArray(axis) ? ... :
41
+ * ...` keeps `axis`'s full union type instead of narrowing away the array member. This explicit
42
+ * predicate sidesteps that (never caught before Task 4: this file had never been typechecked by
43
+ * `tsc` until `cli/doctor.ts`'s import pulled it into the root `tsconfig.json`'s program). */
44
+ function isBoundAxis(axis: GridAxis): axis is { prop: string; items: readonly ComparisonAxisItem[] } {
45
+ return !Array.isArray(axis);
46
+ }
47
+
48
+ /** A `GridAxis`'s own items, whether it's a plain array or bound to a prop — every reader of
49
+ * `ComparisonDef.rows`/`columns` goes through this (never `Array.isArray` directly), so a future
50
+ * third axis shape only needs to change this one function. */
51
+ export function axisItems(axis: GridAxis): ComparisonAxisItem[] {
52
+ return isBoundAxis(axis) ? [...axis.items] : [...axis];
53
+ }
54
+
55
+ /** The prop name a `GridAxis` is bound to, or undefined for a plain, unbound axis. */
56
+ export function axisProp(axis: GridAxis): string | undefined {
57
+ return isBoundAxis(axis) ? axis.prop : undefined;
58
+ }
59
+
60
+ /**
61
+ * Shared page-authoring helper (design §1 "Page shape", §4 "Binding examples to props"). Every
62
+ * grid comparison in the starter kit builds its `ComparisonDef` with this instead of its own local
63
+ * copy, so `cli/staticPage.ts`'s static reader has exactly one call shape to recognize anywhere a
64
+ * page's `comparison` field is produced by a function call rather than written as a plain object
65
+ * literal. `rows`/`columns` accept either a plain item array (unbound) or `{ prop, items }` (bound)
66
+ * — the cell grid itself is unaffected either way, since cells are always keyed by each axis's own
67
+ * items regardless of whether that axis names a prop. Lives here, beside `axisItems`/`axisProp`,
68
+ * rather than in its own `grid.ts` — every other file in this module has only *type-only* relative
69
+ * imports; a bare, no-extension value import would be the first of its kind in `native/catalog`.
70
+ *
71
+ * Each cell is a real instance with exactly the props its row and column name. Never build these by
72
+ * multiplying `variants` with `states` — those are pre-rendered nodes and cannot be combined.
73
+ */
74
+ export function grid(
75
+ rowLabel: string,
76
+ columnLabel: string,
77
+ rows: GridAxis,
78
+ columns: GridAxis,
79
+ cell: (row: string, column: string) => React.ReactNode,
80
+ size?: SpecimenSize,
81
+ ): ComparisonDef {
82
+ const rowItems = axisItems(rows);
83
+ const columnItems = axisItems(columns);
84
+ return {
85
+ rowLabel,
86
+ columnLabel,
87
+ rows,
88
+ columns,
89
+ cells: rowItems.flatMap((row) => columnItems.map((column) => ({ rowKey: row.key, columnKey: column.key, node: cell(row.key, column.key) }))),
90
+ size,
91
+ };
92
+ }
93
+
38
94
  /** How many grid columns fit beside the row header on a 1280px laptop without scrolling. */
39
95
  export function gridColumnLimit(size: SpecimenSize): number {
40
96
  return Math.floor((MATRIX_LAYOUT.laptopContentWidth - MATRIX_LAYOUT.rowHeaderWidth) / COLUMN_MIN_WIDTH[size]);
@@ -94,17 +150,19 @@ export function indexCells(def: ComparisonDef): Map<string, ComparisonDef['cells
94
150
  /** Human-readable problems in a stable order: axis duplicates, column fit, each cell in
95
151
  * declaration order, then missing coordinates in row-major order. Empty when valid. */
96
152
  export function validateComparison(def: ComparisonDef): string[] {
97
- if (def.rows.length === 0 || def.columns.length === 0) {
153
+ const rowItems = axisItems(def.rows);
154
+ const columnItems = axisItems(def.columns);
155
+ if (rowItems.length === 0 || columnItems.length === 0) {
98
156
  return ['Comparison needs at least one row and one column.'];
99
157
  }
100
158
  const issues: string[] = [];
101
159
  const rowKeys = new Set<string>();
102
- for (const row of def.rows) {
160
+ for (const row of rowItems) {
103
161
  if (rowKeys.has(row.key)) issues.push(`Duplicate row key "${row.key}".`);
104
162
  rowKeys.add(row.key);
105
163
  }
106
164
  const columnKeys = new Set<string>();
107
- for (const column of def.columns) {
165
+ for (const column of columnItems) {
108
166
  if (columnKeys.has(column.key)) issues.push(`Duplicate column key "${column.key}".`);
109
167
  columnKeys.add(column.key);
110
168
  }
@@ -149,7 +207,7 @@ export function validateComparison(def: ComparisonDef): string[] {
149
207
  export function remainingStates(states: VariantSlot | undefined, comparison: ComparisonDef | undefined): VariantSlot | undefined {
150
208
  if (!states) return undefined;
151
209
  if (!comparison) return states;
152
- const covered = new Set([...comparison.rows.map((row) => row.key), ...comparison.columns.map((column) => column.key)]);
210
+ const covered = new Set([...axisItems(comparison.rows).map((row) => row.key), ...axisItems(comparison.columns).map((column) => column.key)]);
153
211
  const items = states.items.filter((item) => !covered.has(item.key));
154
212
  return items.length > 0 ? { ...states, items } : undefined;
155
213
  }
@@ -6,17 +6,18 @@
6
6
  * token group, each with a `render()` that uses that app's real components) and a `NavGroup[]`
7
7
  * (how to bucket those sections in the sidebar), then render a single `<CatalogShell />`.
8
8
  *
9
- * See ./CatalogExample.tsx ("Native App DS Template") for a full worked example, built from this
10
- * template's own components. See ./CatalogFrameworkExample.tsx ("Design System DS Catalog") for a
11
- * worked example documenting this framework's own eleven pieces — the same catalog shape, one level
12
- * up. Neither is re-exported here, on purpose: this barrel stays free of any specific component
13
- * (including its own) so it can be copied into a different app's repo as-is.
9
+ * A page is written with `defineCatalogPage()` from this barrel and placed next to its component
10
+ * (or standalone, for tokens/recipes) — see the starter kit under `starter-kit/` for worked
11
+ * examples, and `kit-host/viewer-pages/` for pages documenting this framework's own pieces. This
12
+ * barrel itself stays free of any specific component (including its own) so it can be copied into
13
+ * a different app's repo as-is.
14
14
  */
15
15
  export { CatalogShell } from './CatalogShell';
16
16
  export { CatalogSidebar } from './CatalogSidebar';
17
17
  export { CatalogSearchInput } from './CatalogSearchInput';
18
18
  export { SectionBlock } from './SectionBlock';
19
19
  export { ComparisonGrid } from './ComparisonGrid';
20
+ export { grid, axisItems, axisProp } from './comparison';
20
21
  export { ComparisonGroups } from './ComparisonGroups';
21
22
  export { ComparisonList } from './ComparisonList';
22
23
  export { ReferenceDetails } from './ReferenceDetails';
@@ -32,7 +33,8 @@ export { buildComponentManifest } from './manifest';
32
33
  export type { ComponentManifestEntry, ManifestExample } from './manifest';
33
34
  export { defineCatalogPage, buildCatalogSections } from './pageApi';
34
35
  export type { CatalogPage, CatalogPageInput, GeneratedComponent, GeneratedPropRecord } from './pageApi';
35
- export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, PreviewWidths } from './types';
36
+ export type { PropDef, SectionDef, NavGroup, SpecimenSize, ComparisonDef, ComparisonCell, ComparisonAxisItem, GridAxis, PreviewWidths } from './types';
37
+ export type { ListGroup, ListItem } from './comparison';
36
38
  export {
37
39
  CATALOG_TYPE,
38
40
  CATALOG_TYPE_USE,
@@ -0,0 +1,12 @@
1
+ /** Sidebar logo header layout (design `docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html`,
2
+ * "C · Adaptive", approved 2026-10-08): the configured logo image's own aspect ratio decides the
3
+ * layout — no config field chooses it. An unknown or invalid aspect falls back to the safer
4
+ * text-only header rather than guessing (`CatalogSidebar.tsx` renders this case until the image's
5
+ * `onLoad` reports a real size, and whenever there is no logo, it failed to load, or its file is
6
+ * missing). */
7
+ export const LOGO_WORDMARK_MIN_ASPECT = 2;
8
+
9
+ export function logoLayout(aspect: number | undefined): 'text' | 'mark' | 'wordmark' {
10
+ if (aspect === undefined || !Number.isFinite(aspect) || aspect <= 0) return 'text';
11
+ return aspect > LOGO_WORDMARK_MIN_ASPECT ? 'wordmark' : 'mark';
12
+ }
@@ -59,16 +59,25 @@ export interface GeneratedComponent {
59
59
  }
60
60
 
61
61
  function mergePropNotes(component: GeneratedComponent, propNotes: Record<string, string> | undefined): PropDef[] {
62
- return component.props.map((prop) => ({
62
+ const own = component.props.map((prop) => ({
63
63
  name: prop.name,
64
64
  type: prop.type,
65
65
  required: prop.required,
66
66
  default: prop.default,
67
67
  desc: propNotes?.[prop.name] ? `${prop.desc} ${propNotes[prop.name]}`.trim() : prop.desc,
68
68
  }));
69
+ // Design §3: props inherited from node_modules (e.g. `extends TextInputProps`) are summarised as
70
+ // one row per source instead of being listed individually.
71
+ const inherited = component.inheritedFrom.map((source) => ({
72
+ name: `${source} props`,
73
+ type: 'inherited',
74
+ required: true,
75
+ desc: `Plus all ${source} props, not listed individually.`,
76
+ }));
77
+ return [...own, ...inherited];
69
78
  }
70
79
 
71
- function orderGroups(groupIds: Map<string, string[]>, groupOrder: string[] | undefined): { label: string; ids: string[] }[] {
80
+ function orderGroups<TId extends string>(groupIds: Map<string, TId[]>, groupOrder: string[] | undefined): NavGroup<TId>[] {
72
81
  const listed = groupOrder ?? [];
73
82
  const labels = [...groupIds.keys()];
74
83
  const ordered = [...listed.filter((label) => groupIds.has(label)), ...labels.filter((label) => !listed.includes(label)).sort()];
@@ -94,7 +103,16 @@ export function buildCatalogSections<TId extends string>(
94
103
  const groupIds = new Map<string, TId[]>();
95
104
  const addToGroup = (group: string, id: TId) => groupIds.set(group, [...(groupIds.get(group) ?? []), id]);
96
105
 
106
+ // Design §4 "duplicate-page-id" rule's viewer-side equivalent — this is the one place in the
107
+ // whole viewer that already resolves every page's id to build sections/groups, so it costs
108
+ // nothing extra to also notice two pages claiming the same one (`sectionsById` in
109
+ // `CatalogShell.tsx` would otherwise just let the last one silently win).
110
+ const seenIds = new Set<TId>();
97
111
  const sections: SectionDef<TId>[] = pages.map((page) => {
112
+ if ((globalThis as { __DEV__?: boolean }).__DEV__) {
113
+ if (seenIds.has(page.id)) console.warn(`[Catalog] Page id "${page.id}" is used by more than one page; only the last one will show. Give one of them its own "id".`);
114
+ seenIds.add(page.id);
115
+ }
98
116
  const componentName = page.component ?? page.id;
99
117
  const component = componentByName.get(componentName);
100
118
  addToGroup(page.group, page.id);
@@ -127,5 +145,5 @@ export function buildCatalogSections<TId extends string>(
127
145
  addToGroup('Components', id);
128
146
  }
129
147
 
130
- return { sections, groups: orderGroups(groupIds, groupOrder) as NavGroup<TId>[] };
148
+ return { sections, groups: orderGroups(groupIds, groupOrder) };
131
149
  }
@@ -58,6 +58,11 @@ export const CATALOG_LAYOUT = {
58
58
  factPaddingY: 9,
59
59
  blockLabelGap: 14,
60
60
  blockGap: 28,
61
+ /** Sidebar logo header (design `docs/design/2026-10-08-ds-viewer-sidebar-logo-approved.html`,
62
+ * "C · Adaptive", approved 2026-10-08) — see `native/catalog/logoLayout.ts`. */
63
+ logoMark: 32,
64
+ logoWordmarkHeight: 28,
65
+ logoCaptionGap: 6,
61
66
  } as const;
62
67
 
63
68
  /** Max width of the main content column. */
@@ -65,6 +65,13 @@ export interface ComparisonAxisItem {
65
65
  label: string;
66
66
  }
67
67
 
68
+ /** A grid axis's rows or columns: a plain list of items (today's shape, unchanged), or items bound
69
+ * to a real prop so `doctor` can check each item's key against that prop's actual options (design
70
+ * §4 "Binding examples to props"). Only a prop typed as a string-literal union can be bound —
71
+ * binding any other prop is a `doctor` error, never a runtime check here (the viewer renders a
72
+ * bound and an unbound axis identically; see `native/catalog/comparison.ts`'s `axisItems`). */
73
+ export type GridAxis = readonly ComparisonAxisItem[] | { prop: string; items: readonly ComparisonAxisItem[] };
74
+
68
75
  /** One authored row × column specimen. Provide exactly one of `node` (a real instance of the
69
76
  * documented component) or `unavailableReason` (the combination genuinely does not exist). */
70
77
  export interface ComparisonCell {
@@ -83,8 +90,8 @@ export interface ComparisonDef {
83
90
  rowLabel: string;
84
91
  /** Names the column axis (e.g. "State"). */
85
92
  columnLabel: string;
86
- rows: ComparisonAxisItem[];
87
- columns: ComparisonAxisItem[];
93
+ rows: GridAxis;
94
+ columns: GridAxis;
88
95
  cells: ComparisonCell[];
89
96
  /** Defaults to the section's `specimenSize`, then 'regular'. */
90
97
  size?: SpecimenSize;
@@ -188,7 +195,3 @@ export interface NavGroup<TId extends string = string> {
188
195
  label: string;
189
196
  ids: readonly TId[];
190
197
  }
191
-
192
- /** Canonical within-group ordering — implemented in ./catalogNavigation (pure, unit-tested) and
193
- * re-exported here so existing imports keep working. */
194
- export { sortIds } from './catalogNavigation';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -25,11 +25,11 @@
25
25
  },
26
26
  "files": [
27
27
  "native/catalog",
28
- "!native/catalog/CatalogExample.tsx",
29
- "!native/catalog/CatalogFrameworkExample.tsx",
30
28
  "!native/catalog/**/__tests__",
29
+ "starter-kit",
31
30
  "dist/cli",
32
31
  "dist/config",
32
+ "dist/native",
33
33
  "config/index.ts",
34
34
  "README.md",
35
35
  "LICENSE"
@@ -39,8 +39,8 @@
39
39
  "react": "*",
40
40
  "react-dom": "*",
41
41
  "react-native": "*",
42
- "react-native-web": "*",
43
- "react-native-safe-area-context": "*"
42
+ "react-native-safe-area-context": "*",
43
+ "react-native-web": "*"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "react-native-safe-area-context": {
@@ -51,12 +51,18 @@
51
51
  "typescript": "^6.0.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@types/node": "^22.0.0"
54
+ "@types/node": "^22.0.0",
55
+ "@types/react": "^19.0.0",
56
+ "puppeteer": "^25.12.0"
55
57
  },
56
58
  "scripts": {
57
59
  "build": "tsc -p tsconfig.json",
58
60
  "test": "node --experimental-strip-types --test 'cli/**/*.test.ts' 'config/**/*.test.ts' 'native/catalog/__tests__/*.test.ts'",
59
61
  "typecheck": "tsc -p tsconfig.json --noEmit",
62
+ "kit:dev": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js dev",
63
+ "check:catalog": "npm run build && node scripts/checkCatalogConsole.mjs kit-host",
64
+ "check:doctor": "npm run build && cd kit-host && node node_modules/@krapwoo/ds-viewer/dist/cli/main.js doctor --ci",
65
+ "check:types": "tsc --noEmit -p kit-host",
60
66
  "release": "node scripts/release.mjs"
61
67
  }
62
68
  }