@krapwoo/ds-viewer 0.4.5 → 0.4.7
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 +111 -18
- package/dist/cli/dev.js +31 -6
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/endpoint.js +76 -1
- package/dist/cli/explain.js +44 -6
- package/dist/cli/main.js +4 -2
- package/dist/cli/props.js +80 -1
- package/dist/cli/staticPage.js +228 -6
- package/dist/cli/updateCheck.js +174 -43
- package/dist/cli/workspace.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +11 -0
- package/dist/native/catalog/comparison.js +29 -4
- package/native/catalog/BoundedOverlayViewport.tsx +112 -0
- package/native/catalog/CatalogShell.tsx +69 -12
- package/native/catalog/CatalogSidebar.tsx +8 -7
- package/native/catalog/ComparisonGrid.tsx +2 -2
- package/native/catalog/ComparisonGroups.tsx +2 -2
- package/native/catalog/ComparisonList.tsx +2 -2
- package/native/catalog/PhoneFrame.tsx +6 -0
- package/native/catalog/ReferenceDetails.tsx +75 -20
- package/native/catalog/SectionBlock.tsx +66 -4
- package/native/catalog/SpecimenSurface.tsx +18 -10
- package/native/catalog/UpdatePanel.tsx +161 -5
- package/native/catalog/catalogNavigation.ts +14 -1
- package/native/catalog/comparison.ts +39 -5
- package/native/catalog/index.ts +4 -0
- package/native/catalog/overlayViewport.ts +156 -0
- package/native/catalog/pageApi.ts +6 -0
- package/native/catalog/specimenSurfaceStyle.ts +70 -1
- package/native/catalog/types.ts +62 -12
- package/native/catalog/updatePanelState.ts +72 -0
- package/package.json +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
- package/starter-kit/components/Button/Button.catalog.tsx +3 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
- package/starter-kit/components/List/List.catalog.tsx +3 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +7 -0
|
@@ -8,7 +8,7 @@ import { ReferenceDetails } from './ReferenceDetails';
|
|
|
8
8
|
import { SpecimenSurface } from './SpecimenSurface';
|
|
9
9
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
10
10
|
import { TokenSections } from './TokenLayouts';
|
|
11
|
-
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
11
|
+
import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
12
12
|
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
13
13
|
|
|
14
14
|
// Matches a quoted-string-literal union type, e.g. "'primary' | 'secondary' | 'tertiary'" — anything
|
|
@@ -103,11 +103,14 @@ function frameLabel(width: number): string {
|
|
|
103
103
|
|
|
104
104
|
/** A component preview: one frame per width (each at most phone width), or full width for catalog
|
|
105
105
|
* chrome and token galleries. Every frame is its own live instance with its own state. `surface`
|
|
106
|
-
* is omitted for token galleries, which keep their current, unstaged presentation.
|
|
107
|
-
|
|
106
|
+
* is omitted for token galleries, which keep their current, unstaged presentation. `layout: 'table'`
|
|
107
|
+
* (only ever set for a numeric-width preview — see `presentationBlocks`) renders `TablePreview`
|
|
108
|
+
* instead; omitted/`'frames'` keeps this exact, unchanged presentation. */
|
|
109
|
+
function Preview({ render, widths, surface, layout }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind; layout?: 'table' }) {
|
|
108
110
|
// Token galleries pass no surface: their card stays white.
|
|
109
111
|
const kind = surface ?? 'transparent';
|
|
110
112
|
if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
|
|
113
|
+
if (layout === 'table') return <TablePreview render={render} widths={widths} surface={kind} />;
|
|
111
114
|
return (
|
|
112
115
|
<View style={styles.previewCard}>
|
|
113
116
|
<View style={styles.frames}>
|
|
@@ -128,6 +131,39 @@ function Preview({ render, widths, surface }: { render: () => React.ReactNode; w
|
|
|
128
131
|
);
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
/** `layout: 'table'` presentation for a numeric-width Preview: every width shares ONE
|
|
135
|
+
* `ComparisonList`-style card (same caption typography, muted caption strip, border/radius/surface
|
|
136
|
+
* tokens) instead of each width getting its own bordered frame-card. Each width keeps its own
|
|
137
|
+
* caption strip directly above its own live body, in a single flex-wrapped row — one stable parent
|
|
138
|
+
* with keyed cells, so a viewport resize that wraps the row to stacked columns reflows in place
|
|
139
|
+
* rather than remounting any demo. A cell's body carries the same `MATRIX_LAYOUT.cellPadding`
|
|
140
|
+
* `ComparisonList`'s own cells use; the requested width instead reaches the demo untouched through
|
|
141
|
+
* an inner width-qualified viewport nested inside that padded body (capped only by
|
|
142
|
+
* `maxWidth: '100%'`, the same cap `Preview`'s own frames already use), so a cell's `flexBasis`
|
|
143
|
+
* reserving `width + 2 * cellPadding` leaves the padded body exactly enough room for it, with no
|
|
144
|
+
* blank trailing slack. The row's cells share 1px gutters (a `gap` on a bordered backing) for
|
|
145
|
+
* dividers, in both the side-by-side and stacked-wrap arrangements. */
|
|
146
|
+
function TablePreview({ render, widths, surface }: { render: () => React.ReactNode; widths: readonly number[]; surface: SpecimenSurfaceKind }) {
|
|
147
|
+
return (
|
|
148
|
+
<View style={styles.tableCard}>
|
|
149
|
+
<View style={styles.tableRow}>
|
|
150
|
+
{widths.map((width, i) => (
|
|
151
|
+
<View key={`${width}-${i}`} style={[styles.tableCell, { flexBasis: width + 2 * MATRIX_LAYOUT.cellPadding }]}>
|
|
152
|
+
<View style={styles.tableCaption}>
|
|
153
|
+
<Text style={styles.tableCaptionText}>{frameLabel(width)}</Text>
|
|
154
|
+
</View>
|
|
155
|
+
<SpecimenSurface surface={surface} style={styles.tableBody}>
|
|
156
|
+
<View role="group" aria-label={frameLabel(width)} style={[styles.tableBodyViewport, { width }]}>
|
|
157
|
+
{render()}
|
|
158
|
+
</View>
|
|
159
|
+
</SpecimenSurface>
|
|
160
|
+
</View>
|
|
161
|
+
))}
|
|
162
|
+
</View>
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
131
167
|
function BlockContent<TId extends string>({ block, def, surface }: { block: PresentationBlock; def: SectionDef<TId>; surface: SpecimenSurfaceKind }) {
|
|
132
168
|
switch (block.kind) {
|
|
133
169
|
case 'grid':
|
|
@@ -139,7 +175,9 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
|
|
|
139
175
|
case 'preview':
|
|
140
176
|
// A token gallery's `preview` block shows full-width raw token data, not a component
|
|
141
177
|
// specimen — it keeps its current, unstaged presentation.
|
|
142
|
-
|
|
178
|
+
// `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
|
|
179
|
+
// preview from ever reaching the table layout, regardless of `def.previewLayout`.
|
|
180
|
+
return def.render ? <Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} /> : null;
|
|
143
181
|
case 'tokenSections':
|
|
144
182
|
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
145
183
|
default:
|
|
@@ -356,5 +394,29 @@ const styles = StyleSheet.create({
|
|
|
356
394
|
padding: CATALOG_SPACE.xl,
|
|
357
395
|
gap: CATALOG_SPACE.md,
|
|
358
396
|
},
|
|
397
|
+
// `ComparisonList`'s own card/caption tokens, reused so a 'table' preview reads as the same shared
|
|
398
|
+
// surface — no outer `previewCard` padding/chrome around it (that card provides its own).
|
|
399
|
+
tableCard: {
|
|
400
|
+
backgroundColor: CATALOG_COLOR.surface,
|
|
401
|
+
borderWidth: 1,
|
|
402
|
+
borderColor: CATALOG_COLOR.borderStrong,
|
|
403
|
+
borderRadius: CATALOG_RADIUS.card,
|
|
404
|
+
overflow: 'hidden',
|
|
405
|
+
},
|
|
406
|
+
// 1px gutters between cells: a gap over a bordered backing, so dividers read correctly whether
|
|
407
|
+
// cells sit side by side or wrap into stacked rows.
|
|
408
|
+
tableRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 1, backgroundColor: CATALOG_COLOR.border },
|
|
409
|
+
tableCell: { backgroundColor: CATALOG_COLOR.surface, flexGrow: 1, minWidth: 0, maxWidth: '100%' },
|
|
410
|
+
tableCaption: {
|
|
411
|
+
padding: MATRIX_LAYOUT.cellPadding,
|
|
412
|
+
backgroundColor: CATALOG_COLOR.surfaceMuted,
|
|
413
|
+
borderBottomWidth: 1,
|
|
414
|
+
borderBottomColor: CATALOG_COLOR.border,
|
|
415
|
+
},
|
|
416
|
+
tableCaptionText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
|
|
417
|
+
// Same cell padding `ComparisonList` uses: the requested width instead reaches the live demo
|
|
418
|
+
// untouched through `tableBodyViewport`, the inner width-qualified group nested inside this body.
|
|
419
|
+
tableBody: { padding: MATRIX_LAYOUT.cellPadding, flexGrow: 1, alignItems: 'center', justifyContent: 'flex-start' },
|
|
420
|
+
tableBodyViewport: { maxWidth: '100%' },
|
|
359
421
|
emptyText: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', color: CATALOG_COLOR.textMuted },
|
|
360
422
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR } from './tokens';
|
|
4
|
-
import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
|
|
4
|
+
import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, specimenContentAlignItems, specimenContentBoundedStyle } from './specimenSurfaceStyle';
|
|
5
5
|
import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
|
|
6
|
-
import type { SpecimenSurfaceKind } from './types';
|
|
6
|
+
import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
|
|
7
7
|
|
|
8
8
|
type Browser = {
|
|
9
9
|
getComputedStyle?: (element: unknown) => never;
|
|
@@ -65,9 +65,22 @@ export function SpecimenSurface({
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
|
|
68
|
-
* against the cell)
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
* against the cell). `fill` changes whether the example itself also stretches to that width
|
|
69
|
+
* (default) or keeps its own content size — a non-`fill` specimen centers by default; a `fill`
|
|
70
|
+
* one stretches by default instead, preserving an auto-sized full-width child (see
|
|
71
|
+
* `specimenContentAlignItems`). `align` always overrides that default explicitly.
|
|
72
|
+
*
|
|
73
|
+
* A non-`fill` specimen uses a row main axis (`specimenContentBoundedStyle`) so `justifyContent`
|
|
74
|
+
* positions it horizontally — a column + `alignItems` left the specimen's own root `alignSelf`
|
|
75
|
+
* (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
|
|
76
|
+
* `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
|
|
77
|
+
* `alignItems` stretch behavior, unchanged. */
|
|
78
|
+
export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
|
|
79
|
+
if (fill) {
|
|
80
|
+
const alignItems = specimenContentAlignItems(fill, align);
|
|
81
|
+
return <View style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
82
|
+
}
|
|
83
|
+
return <View style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
|
|
@@ -77,8 +90,3 @@ const FILL = StyleSheet.create({
|
|
|
77
90
|
dark: { backgroundColor: CATALOG_COLOR.text },
|
|
78
91
|
transparent: {},
|
|
79
92
|
});
|
|
80
|
-
|
|
81
|
-
const styles = StyleSheet.create({
|
|
82
|
-
center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
|
|
83
|
-
fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
|
|
84
|
-
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
2
|
+
import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { DS_VIEWER_SECRET_HEADER } from './catalogNavigation';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
|
|
7
|
+
} from './updatePanelState';
|
|
8
|
+
import type { UpdateNotice, VersionStatus } from './types';
|
|
7
9
|
|
|
8
10
|
function isWeb(): boolean {
|
|
9
11
|
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
@@ -25,7 +27,7 @@ function VersionsHeader({ current, latest, breaking, releasedAt }: { current: st
|
|
|
25
27
|
<Text style={styles.arrow}>→</Text>
|
|
26
28
|
<Text style={styles.versionsText}>{latest}</Text>
|
|
27
29
|
<View style={[styles.pill, breaking && styles.pillMajor]}>
|
|
28
|
-
<Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{
|
|
30
|
+
<Text style={[styles.pillText, breaking && styles.pillTextMajor]}>{bumpLabel(current, latest, breaking)}</Text>
|
|
29
31
|
</View>
|
|
30
32
|
</View>
|
|
31
33
|
<Text style={styles.muted}>{`You’re on ${current}.${releasedAt ? ` ${daysAgoLabel(releasedAt, Date.now())}` : ''}`}</Text>
|
|
@@ -190,6 +192,140 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The update page when no newer version is known (approved mockup
|
|
197
|
+
* docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
|
|
198
|
+
* installed version and which situation applies, when it last checked, the automatic-check
|
|
199
|
+
* switch, and **Check now**. Without an endpoint (a static build) it shows the status only.
|
|
200
|
+
*/
|
|
201
|
+
function VersionDetails({
|
|
202
|
+
status,
|
|
203
|
+
endpoint,
|
|
204
|
+
onStatus,
|
|
205
|
+
}: {
|
|
206
|
+
status: VersionStatus;
|
|
207
|
+
endpoint?: Endpoint;
|
|
208
|
+
onStatus: (next: VersionStatus) => void;
|
|
209
|
+
}) {
|
|
210
|
+
const [checking, setChecking] = useState(false);
|
|
211
|
+
const [justChecked, setJustChecked] = useState(false);
|
|
212
|
+
const [saving, setSaving] = useState(false);
|
|
213
|
+
const [localError, setLocalError] = useState<string | undefined>(undefined);
|
|
214
|
+
const live = Boolean(endpoint) && isWeb();
|
|
215
|
+
const overview = versionOverview(status, justChecked, Date.now());
|
|
216
|
+
const help = autoCheckHelp(status.autoCheck);
|
|
217
|
+
|
|
218
|
+
const checkNow = async () => {
|
|
219
|
+
if (!endpoint || checking) return;
|
|
220
|
+
setChecking(true);
|
|
221
|
+
setJustChecked(false);
|
|
222
|
+
setLocalError(undefined);
|
|
223
|
+
try {
|
|
224
|
+
const res = await authedFetch(endpoint, '/version/check', { method: 'POST' });
|
|
225
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
226
|
+
const next = (await res.json()) as VersionStatus;
|
|
227
|
+
// A newer version moves the whole page into the update flow (the parent re-renders with it).
|
|
228
|
+
setJustChecked(next.lastOutcome === 'ok' && !next.update);
|
|
229
|
+
onStatus(next);
|
|
230
|
+
} catch {
|
|
231
|
+
setLocalError('Couldn’t reach the viewer’s local server. If this keeps happening, restart npx ds-viewer dev.');
|
|
232
|
+
} finally {
|
|
233
|
+
setChecking(false);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const setAutoCheck = async (enabled: boolean) => {
|
|
238
|
+
if (!endpoint || saving) return;
|
|
239
|
+
setSaving(true);
|
|
240
|
+
setLocalError(undefined);
|
|
241
|
+
try {
|
|
242
|
+
const res = await authedFetch(endpoint, '/version/auto-check', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({ enabled }),
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
248
|
+
onStatus((await res.json()) as VersionStatus);
|
|
249
|
+
} catch {
|
|
250
|
+
setLocalError('Couldn’t save the setting. Try again.');
|
|
251
|
+
} finally {
|
|
252
|
+
setSaving(false);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const unreachable = status.lastOutcome === 'unreachable';
|
|
257
|
+
return (
|
|
258
|
+
<>
|
|
259
|
+
<View style={styles.versionsRow}>
|
|
260
|
+
<Text style={styles.versionsText}>{status.current}</Text>
|
|
261
|
+
{overview.pill === 'latest' && (
|
|
262
|
+
<View style={[styles.pill, styles.pillSuccess]}>
|
|
263
|
+
<Text style={[styles.pillText, styles.pillTextSuccess]}>Latest</Text>
|
|
264
|
+
</View>
|
|
265
|
+
)}
|
|
266
|
+
{overview.pill === 'off' && (
|
|
267
|
+
<View style={[styles.pill, styles.pillOff]}>
|
|
268
|
+
<Text style={[styles.pillText, styles.pillTextOff]}>Checks off</Text>
|
|
269
|
+
</View>
|
|
270
|
+
)}
|
|
271
|
+
</View>
|
|
272
|
+
<Text style={styles.muted}>{overview.line}</Text>
|
|
273
|
+
{overview.note && (
|
|
274
|
+
<NoteBox variant={overview.note.variant}>
|
|
275
|
+
{overview.note.bold ? <Text style={styles.noteBoxBold}>{overview.note.bold}</Text> : null}
|
|
276
|
+
{overview.note.text}
|
|
277
|
+
</NoteBox>
|
|
278
|
+
)}
|
|
279
|
+
{!unreachable && (
|
|
280
|
+
<View style={styles.facts}>
|
|
281
|
+
<View style={styles.factRow}>
|
|
282
|
+
<Text style={styles.factLabel}>Last checked</Text>
|
|
283
|
+
<Text style={styles.factValue}>{checkedAgoLabel(status.lastCheckedAt, Date.now())}</Text>
|
|
284
|
+
</View>
|
|
285
|
+
<View style={styles.factRow}>
|
|
286
|
+
<View style={styles.switchText}>
|
|
287
|
+
<Text style={styles.switchLabel}>Check for updates automatically</Text>
|
|
288
|
+
<Text style={styles.switchHelp}>{help.text}</Text>
|
|
289
|
+
</View>
|
|
290
|
+
<Switch
|
|
291
|
+
value={status.autoCheck.enabled}
|
|
292
|
+
onValueChange={setAutoCheck}
|
|
293
|
+
disabled={!live || help.locked || saving || checking}
|
|
294
|
+
// React Native web passes only the label to the real input (no description), so the
|
|
295
|
+
// helper text rides in the accessible name: a screen reader hears why it's on or off.
|
|
296
|
+
accessibilityLabel={`Check for updates automatically. ${help.text}`}
|
|
297
|
+
trackColor={{ false: '#c9c9c9', true: CATALOG_COLOR.accent }}
|
|
298
|
+
thumbColor="#ffffff"
|
|
299
|
+
// The approved mockup's 44 × 26 switch; React Native web's default is 40 × 20, below
|
|
300
|
+
// the 24px minimum hit target.
|
|
301
|
+
style={styles.switch}
|
|
302
|
+
{...({ activeThumbColor: '#ffffff' } as Record<string, unknown>)}
|
|
303
|
+
/>
|
|
304
|
+
</View>
|
|
305
|
+
</View>
|
|
306
|
+
)}
|
|
307
|
+
{localError && <NoteBox variant="err">{localError}</NoteBox>}
|
|
308
|
+
{live && (
|
|
309
|
+
<View style={styles.actions}>
|
|
310
|
+
<Pressable
|
|
311
|
+
onPress={checkNow}
|
|
312
|
+
disabled={checking}
|
|
313
|
+
accessibilityRole="button"
|
|
314
|
+
accessibilityState={{ disabled: checking, busy: checking }}
|
|
315
|
+
style={[styles.secondaryButton, styles.buttonRow, checking && styles.busyButton]}
|
|
316
|
+
>
|
|
317
|
+
{checking && <ActivityIndicator size="small" color={CATALOG_COLOR.textMuted} />}
|
|
318
|
+
<Text style={[styles.secondaryButtonText, checking && styles.busyButtonText]}>
|
|
319
|
+
{checking ? 'Checking…' : overview.action === 'retry' ? 'Try again' : 'Check now'}
|
|
320
|
+
</Text>
|
|
321
|
+
</Pressable>
|
|
322
|
+
{unreachable && !checking && <Text style={styles.muted}>or run <Code>npm view @krapwoo/ds-viewer version</Code></Text>}
|
|
323
|
+
</View>
|
|
324
|
+
)}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
193
329
|
/**
|
|
194
330
|
* Design §5's update page (`#ds-viewer-update`), approved mockup direction "C · Update page".
|
|
195
331
|
* Fetches `/update/status` once on mount, then `/plan` — `resolvePanelPhase` (Task 15) picks which
|
|
@@ -203,11 +339,16 @@ function failureCopy(failedStep: string, current: string): { headline: string; b
|
|
|
203
339
|
*/
|
|
204
340
|
export function UpdatePanel({
|
|
205
341
|
update,
|
|
342
|
+
versionStatus,
|
|
343
|
+
onVersionStatus,
|
|
206
344
|
endpoint,
|
|
207
345
|
appName,
|
|
208
346
|
headingRef,
|
|
209
347
|
}: {
|
|
210
348
|
update: UpdateNotice | null;
|
|
349
|
+
/** When present, the no-update view shows the version, the switch and **Check now**. */
|
|
350
|
+
versionStatus?: VersionStatus | null;
|
|
351
|
+
onVersionStatus?: (next: VersionStatus) => void;
|
|
211
352
|
endpoint?: Endpoint;
|
|
212
353
|
appName: string;
|
|
213
354
|
headingRef?: React.Ref<View>;
|
|
@@ -325,6 +466,8 @@ export function UpdatePanel({
|
|
|
325
466
|
content =
|
|
326
467
|
status.phase === 'success' ? (
|
|
327
468
|
<SuccessContent status={status} />
|
|
469
|
+
) : versionStatus ? (
|
|
470
|
+
<VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
|
|
328
471
|
) : (
|
|
329
472
|
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
330
473
|
);
|
|
@@ -486,7 +629,7 @@ export function UpdatePanel({
|
|
|
486
629
|
{/* react-native-web reads `aria-level`; React Native's own prop types do not declare it —
|
|
487
630
|
* the same cast `SectionBlock.tsx`'s own heading target already uses. */}
|
|
488
631
|
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
489
|
-
<Text style={styles.title}>
|
|
632
|
+
<Text style={styles.title}>DS Viewer updates</Text>
|
|
490
633
|
</View>
|
|
491
634
|
{content}
|
|
492
635
|
</View>
|
|
@@ -517,6 +660,19 @@ const styles = StyleSheet.create({
|
|
|
517
660
|
pillText: { fontSize: CATALOG_TYPE.sm, fontWeight: '700', color: CATALOG_COLOR.accent },
|
|
518
661
|
pillTextMajor: { color: CATALOG_COLOR.warning },
|
|
519
662
|
pillTextSuccess: { color: CATALOG_COLOR.success },
|
|
663
|
+
pillOff: { backgroundColor: CATALOG_COLOR.chip },
|
|
664
|
+
pillTextOff: { color: CATALOG_COLOR.textMuted },
|
|
665
|
+
facts: { marginTop: 18, borderTopWidth: 1, borderTopColor: CATALOG_COLOR.border },
|
|
666
|
+
factRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: CATALOG_SPACE.md, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: CATALOG_COLOR.border },
|
|
667
|
+
factLabel: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.textMuted },
|
|
668
|
+
factValue: { fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.text },
|
|
669
|
+
switchText: { flex: 1, gap: 2 },
|
|
670
|
+
switchLabel: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
671
|
+
switch: { width: 44, height: 26 },
|
|
672
|
+
switchHelp: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
673
|
+
buttonRow: { flexDirection: 'row', gap: CATALOG_SPACE.sm },
|
|
674
|
+
busyButton: { borderColor: CATALOG_COLOR.border },
|
|
675
|
+
busyButtonText: { color: CATALOG_COLOR.textMuted },
|
|
520
676
|
muted: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, marginTop: 4 },
|
|
521
677
|
block: { marginTop: 18 },
|
|
522
678
|
h3: { fontSize: CATALOG_TYPE.panelHeading, textTransform: 'uppercase', letterSpacing: 0.5, color: CATALOG_COLOR.text, marginBottom: 8 },
|
|
@@ -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, UpdateNotice } from './types.ts';
|
|
6
|
+
import type { NavGroup, UpdateNotice, VersionStatus } 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[] {
|
|
@@ -149,3 +149,16 @@ export function footerLabel(update: UpdateNotice | null | undefined): string | u
|
|
|
149
149
|
if (!update || !isNewerVersion(update.latest, update.current)) return undefined;
|
|
150
150
|
return `Update available · ${update.latest}`;
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
/** The sidebar's footer line: the blue "Update available" notice when a newer version is known
|
|
154
|
+
* (from startup or **Check now**), else the installed version, quietly. `undefined` only for a
|
|
155
|
+
* workspace generated before version status existed, which keeps the old notice-only footer. */
|
|
156
|
+
export function footerLink(
|
|
157
|
+
update: UpdateNotice | null | undefined,
|
|
158
|
+
status: VersionStatus | null | undefined,
|
|
159
|
+
): { label: string; kind: 'update' | 'version' } | undefined {
|
|
160
|
+
const notice = footerLabel(update) ?? footerLabel(status?.update);
|
|
161
|
+
if (notice) return { label: notice, kind: 'update' };
|
|
162
|
+
if (status) return { label: `DS Viewer ${status.current}`, kind: 'version' };
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
@@ -8,7 +8,9 @@
|
|
|
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 {
|
|
11
|
+
import type {
|
|
12
|
+
ComparisonAxisItem, ComparisonDef, GridAxis, PreviewWidths, SectionDef, SpecimenAlign, SpecimenSize, SpecimenSurfaceKind, TokenSection, VariantSlot,
|
|
13
|
+
} from './types.ts';
|
|
12
14
|
import type React from 'react';
|
|
13
15
|
|
|
14
16
|
/** Approved geometry. `cellPadding` equals CATALOG_SPACE.lg (asserted in tests). The laptop
|
|
@@ -33,6 +35,14 @@ export const PROPS_MIN_COLUMN_WIDTH = 360;
|
|
|
33
35
|
/** Gap between the two Props columns (CATALOG_SPACE['2xl']). */
|
|
34
36
|
export const PROPS_COLUMN_GAP = 32;
|
|
35
37
|
|
|
38
|
+
/** A reference column (Guidance, Quick reference, or Composition) is never narrower than this;
|
|
39
|
+
* below it, `referenceColumns` drops to fewer columns. */
|
|
40
|
+
export const REFERENCE_MIN_COLUMN_WIDTH = 280;
|
|
41
|
+
|
|
42
|
+
/** Gap between reference columns (CATALOG_SPACE['2xl'], the same token the card's columns already
|
|
43
|
+
* use). */
|
|
44
|
+
export const REFERENCE_COLUMN_GAP = 32;
|
|
45
|
+
|
|
36
46
|
/** Smallest column or cell width per specimen size. Wide specimens are fixed at phone width. */
|
|
37
47
|
export const COLUMN_MIN_WIDTH: Record<SpecimenSize, number> = { compact: 160, regular: 240, wide: 402 };
|
|
38
48
|
|
|
@@ -222,6 +232,12 @@ export interface ListItem {
|
|
|
222
232
|
label: string;
|
|
223
233
|
node: unknown;
|
|
224
234
|
fill?: boolean;
|
|
235
|
+
/** This item's own `specimenSurface` override — see `SpecimenPresentation`. Omit to inherit the
|
|
236
|
+
* surface already in effect (the slot's/page's `specimenSurface`, else `'auto'`). */
|
|
237
|
+
surface?: SpecimenSurfaceKind;
|
|
238
|
+
/** This item's own cross-axis alignment override — see `SpecimenPresentation.align`. Omit to
|
|
239
|
+
* inherit the established default (centered, or stretched when `fill` is set). */
|
|
240
|
+
align?: SpecimenAlign;
|
|
225
241
|
}
|
|
226
242
|
|
|
227
243
|
export interface ListGroup {
|
|
@@ -250,7 +266,9 @@ export function slotSize<TId extends string>(def: SectionDef<TId>, slot: Variant
|
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
function slotItems(slot: VariantSlot): ListItem[] {
|
|
253
|
-
|
|
269
|
+
// Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
|
|
270
|
+
// `itemsFill: true`, which `false || slot.itemsFill` would silently discard.
|
|
271
|
+
return slot.items.map((item) => ({ key: item.key, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
|
|
254
272
|
}
|
|
255
273
|
|
|
256
274
|
/** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
|
|
@@ -265,12 +283,14 @@ function previewWidths(requested: PreviewWidths | undefined, fallback: PreviewWi
|
|
|
265
283
|
function groupStates(variants: VariantSlot, states: VariantSlot): { groups: ListGroup[]; leftovers: VariantSlot | undefined } | undefined {
|
|
266
284
|
const variantKeys = new Set(variants.items.map((v) => v.key));
|
|
267
285
|
if (!states.items.some((s) => s.group !== undefined && variantKeys.has(s.group))) return undefined;
|
|
268
|
-
|
|
286
|
+
// Nullish, not `||` — same reasoning as `slotItems`: an explicit `fill: false` must win over an
|
|
287
|
+
// inherited `itemsFill: true`.
|
|
288
|
+
const fill = (item: VariantSlot['items'][number], slot: VariantSlot) => item.fill ?? slot.itemsFill;
|
|
269
289
|
const groups = variants.items.map((variant) => {
|
|
270
290
|
const own = states.items.filter((s) => s.group === variant.key);
|
|
271
291
|
const items = own.length > 0
|
|
272
|
-
? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states) }))
|
|
273
|
-
: [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants) }];
|
|
292
|
+
? own.map((s) => ({ key: s.key, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
|
|
293
|
+
: [{ key: variant.key, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
|
|
274
294
|
return { key: variant.key, label: variant.name, items };
|
|
275
295
|
});
|
|
276
296
|
const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
|
|
@@ -377,3 +397,17 @@ export function choosePlacement({ available, gap, first, second }: { available:
|
|
|
377
397
|
export function propsColumns(count: number, width: number): 1 | 2 {
|
|
378
398
|
return count >= 4 && width >= 2 * PROPS_MIN_COLUMN_WIDTH + PROPS_COLUMN_GAP ? 2 : 1;
|
|
379
399
|
}
|
|
400
|
+
|
|
401
|
+
/** How many columns the reference card's Guidance / Quick reference / Composition panel uses at a
|
|
402
|
+
* given measured inner card width: 3 only when there is confirmed composition to show as its own
|
|
403
|
+
* column AND the card is wide enough for three REFERENCE_MIN_COLUMN_WIDTH columns; 2 when wide
|
|
404
|
+
* enough for two (any composition then stays nested under Quick reference, as it always has been);
|
|
405
|
+
* 1 (stacked, full content in document order — nothing dropped) when even two columns wouldn't
|
|
406
|
+
* each keep their minimum. An unmeasured width (`<= 0`) stacks, so the first frame before layout
|
|
407
|
+
* settles never overflows. */
|
|
408
|
+
export function referenceColumns(width: number, hasComposition: boolean): 1 | 2 | 3 {
|
|
409
|
+
const fits = (n: number) => width >= n * REFERENCE_MIN_COLUMN_WIDTH + (n - 1) * REFERENCE_COLUMN_GAP;
|
|
410
|
+
if (hasComposition && fits(3)) return 3;
|
|
411
|
+
if (fits(2)) return 2;
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
package/native/catalog/index.ts
CHANGED
|
@@ -31,6 +31,10 @@ export { TokenSections, TokenGrid, TokenTile } from './TokenLayouts';
|
|
|
31
31
|
export { DividedStack } from './DividedStack';
|
|
32
32
|
export { Swatch } from './Swatch';
|
|
33
33
|
export { PhoneFrame } from './PhoneFrame';
|
|
34
|
+
export { BoundedOverlayViewport } from './BoundedOverlayViewport';
|
|
35
|
+
export type { BoundedOverlayViewportProps } from './BoundedOverlayViewport';
|
|
36
|
+
export { comparisonCellItemKey } from './overlayViewport';
|
|
37
|
+
export type { OverlayAddress, OverlaySlot } from './overlayViewport';
|
|
34
38
|
export { SpacingScaleGallery } from './SpacingScaleGallery';
|
|
35
39
|
export { TypeScaleGallery } from './TypeScaleGallery';
|
|
36
40
|
export { buildComponentManifest } from './manifest';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic behind `BoundedOverlayViewport.tsx` — no React/React Native imports, so it runs
|
|
3
|
+
* under Node's test runner the same way `catalogNavigation.ts` does for `CatalogShell`. Addresses
|
|
4
|
+
* one specific authored specimen, decides whether an instance should frame itself in a bounded
|
|
5
|
+
* child document or render inline, and (for `CatalogShell`'s own chromeless selected-document
|
|
6
|
+
* render) resolves an address back to the `node` it names.
|
|
7
|
+
*/
|
|
8
|
+
import type React from 'react';
|
|
9
|
+
import type { SectionDef } from './types.ts';
|
|
10
|
+
|
|
11
|
+
/** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
|
|
12
|
+
export type OverlaySlot = 'variants' | 'states' | 'comparison';
|
|
13
|
+
|
|
14
|
+
/** Identifies exactly one authored specimen: a page id (`SectionDef.id`), which block it lives
|
|
15
|
+
* in, and that item's own key — `VariantExample.key` for `'variants'`/`'states'`, or this
|
|
16
|
+
* module's own `comparisonCellItemKey(rowKey, columnKey)` for a `'comparison'` cell (NOT an ad
|
|
17
|
+
* hoc `"<rowKey>:<columnKey>"` join — a plain colon join is ambiguous whenever a row or column
|
|
18
|
+
* key contains a colon itself). Always an existing author-assigned identifier, never inferred
|
|
19
|
+
* from pixels, source strings, or a DOM selector. */
|
|
20
|
+
export interface OverlayAddress {
|
|
21
|
+
pageId: string;
|
|
22
|
+
slot: OverlaySlot;
|
|
23
|
+
itemKey: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison'];
|
|
27
|
+
|
|
28
|
+
/** `encodeURIComponent` leaves `.` untouched (it's an unreserved character), which would let a
|
|
29
|
+
* literal `.` inside a segment collide with the `.`-joined encoding below; escape it too so
|
|
30
|
+
* splitting on `.` is always safe. `decodeURIComponent` already reverses `%2E` with no special
|
|
31
|
+
* casing needed. */
|
|
32
|
+
function encodeSegment(segment: string): string {
|
|
33
|
+
return encodeURIComponent(segment).replace(/\./g, '%2E');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The query param a `BoundedOverlayViewport`'s child document is opened with — distinct from
|
|
37
|
+
* `CatalogShell`'s own page-routing hash (`#PageId`), which the child document's URL also keeps
|
|
38
|
+
* (see `overlayViewportHrefSuffix`) so the SAME app entry still resolves the right page/providers
|
|
39
|
+
* before the viewport component itself ever runs. */
|
|
40
|
+
export const OVERLAY_ADDRESS_PARAM = 'ds-viewer-overlay';
|
|
41
|
+
|
|
42
|
+
/** `pageId.slot.itemKey`, each segment percent-encoded — one URL-safe token for a query value. */
|
|
43
|
+
export function encodeOverlayAddress(address: OverlayAddress): string {
|
|
44
|
+
return [address.pageId, address.slot, address.itemKey].map(encodeSegment).join('.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The inverse of `encodeOverlayAddress` — `undefined` for anything that isn't exactly three
|
|
48
|
+
* non-empty segments with a recognized slot: the same "malformed means absent" recovery
|
|
49
|
+
* `catalogNavigation.ts`'s `idFromHash` uses for an unknown page fragment, rather than guessing
|
|
50
|
+
* at a partial or corrupted address. */
|
|
51
|
+
export function decodeOverlayAddress(value: string): OverlayAddress | undefined {
|
|
52
|
+
const parts = value.split('.');
|
|
53
|
+
if (parts.length !== 3) return undefined;
|
|
54
|
+
let decoded: string[];
|
|
55
|
+
try {
|
|
56
|
+
decoded = parts.map((part) => decodeURIComponent(part));
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const [pageId, slot, itemKey] = decoded;
|
|
61
|
+
if (!pageId || !itemKey) return undefined;
|
|
62
|
+
if (!OVERLAY_SLOTS.includes(slot as OverlaySlot)) return undefined;
|
|
63
|
+
return { pageId, slot: slot as OverlaySlot, itemKey };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reads `OVERLAY_ADDRESS_PARAM` out of a URL search string (e.g. `window.location.search`);
|
|
67
|
+
* `undefined` when the param is absent or its value doesn't decode. A trailing `#fragment` is
|
|
68
|
+
* dropped first — `URLSearchParams` has no notion of a URL fragment, so without this a caller
|
|
69
|
+
* that (like `overlayViewportHrefSuffix`'s own combined query+hash string) passes one along
|
|
70
|
+
* would see it swallowed into the last param's value instead of ignored. */
|
|
71
|
+
export function overlayAddressFromSearch(search: string): OverlayAddress | undefined {
|
|
72
|
+
const raw = new URLSearchParams(search.split('#')[0]).get(OVERLAY_ADDRESS_PARAM);
|
|
73
|
+
return raw ? decodeOverlayAddress(raw) : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function overlayAddressesEqual(a: OverlayAddress, b: OverlayAddress): boolean {
|
|
77
|
+
return a.pageId === b.pageId && a.slot === b.slot && a.itemKey === b.itemKey;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The query + hash suffix that boots the SAME app entry directly into this address's
|
|
81
|
+
* selected-document mode: the overlay param (so the `BoundedOverlayViewport` instance that
|
|
82
|
+
* matches this address renders its children inline instead of framing itself again) plus the
|
|
83
|
+
* page's own routing hash (so `CatalogShell`'s existing hash-based routing still resolves the
|
|
84
|
+
* right `SectionDef`/providers before `findOverlayNode` narrows further). */
|
|
85
|
+
export function overlayViewportHrefSuffix(address: OverlayAddress): string {
|
|
86
|
+
return `?${OVERLAY_ADDRESS_PARAM}=${encodeURIComponent(encodeOverlayAddress(address))}#${encodeURIComponent(address.pageId)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `'inline'`: render the wrapped demo directly, no frame — either this isn't a web runtime (no
|
|
90
|
+
* same-origin child-document mechanism to offer; see `BoundedOverlayViewport`'s own doc comment)
|
|
91
|
+
* or `currentSearch` already names this exact address, meaning this instance IS the selected
|
|
92
|
+
* child document's own addressed specimen and must not frame itself again (recursion guard).
|
|
93
|
+
* `'frame'`: open `href` — the current pathname plus this address's query+hash — in a bounded
|
|
94
|
+
* child document. */
|
|
95
|
+
export type OverlayViewportDecision = { mode: 'inline' } | { mode: 'frame'; href: string };
|
|
96
|
+
|
|
97
|
+
export function resolveOverlayViewport(args: {
|
|
98
|
+
address: OverlayAddress;
|
|
99
|
+
isWeb: boolean;
|
|
100
|
+
/** The hosting document's current `location.search` (e.g. `window.location.search`). */
|
|
101
|
+
currentSearch: string;
|
|
102
|
+
/** The hosting document's current `location.pathname` — same pathname the child document loads,
|
|
103
|
+
* since this viewer is a single-page app whose page lives in the hash, not the path. */
|
|
104
|
+
pathname: string;
|
|
105
|
+
}): OverlayViewportDecision {
|
|
106
|
+
if (!args.isWeb) return { mode: 'inline' };
|
|
107
|
+
const current = overlayAddressFromSearch(args.currentSearch);
|
|
108
|
+
if (current && overlayAddressesEqual(current, args.address)) return { mode: 'inline' };
|
|
109
|
+
return { mode: 'frame', href: `${args.pathname}${overlayViewportHrefSuffix(args.address)}` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The authored `node` an `OverlayAddress` points at, or `undefined` for a stale/unknown address
|
|
113
|
+
* (a removed example, a renamed key, a typo) — `CatalogShell`'s chromeless selected-document
|
|
114
|
+
* render shows an honest "unavailable" recovery rather than rendering nothing silently. */
|
|
115
|
+
export function findOverlayNode(sections: readonly SectionDef<string>[], address: OverlayAddress): React.ReactNode | undefined {
|
|
116
|
+
const def = sections.find((section) => section.id === address.pageId);
|
|
117
|
+
if (!def) return undefined;
|
|
118
|
+
if (address.slot === 'variants') return def.variants?.items.find((item) => item.key === address.itemKey)?.node;
|
|
119
|
+
if (address.slot === 'states') return def.states?.items.find((item) => item.key === address.itemKey)?.node;
|
|
120
|
+
// Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
|
|
121
|
+
// a row/column key may itself contain any delimiter this module could pick.
|
|
122
|
+
return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The canonical, unambiguous `itemKey` for one comparison cell's row+column — this module's own
|
|
126
|
+
* counterpart to `comparison.ts`'s `cellKey` (not imported from there: a VALUE import between two
|
|
127
|
+
* pure `.ts` logic modules here can't carry the `.ts` extension that both Node's direct test run
|
|
128
|
+
* and `kit-host`'s bundler-mode typechecker require/reject at once — `import type` is exempt
|
|
129
|
+
* since it is fully erased, which is why `./types.ts` above is fine). Exported so a `.tsx` call
|
|
130
|
+
* site building a `'comparison'` `OverlayAddress` constructs the exact same key this module looks
|
|
131
|
+
* it up by — a plain `"${rowKey}:${columnKey}"` join is ambiguous whenever a row or column key
|
|
132
|
+
* contains a colon itself. */
|
|
133
|
+
export function comparisonCellItemKey(rowKey: string, columnKey: string): string {
|
|
134
|
+
return `${rowKey}${columnKey}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Usable CSS pixel dimensions for a bounded overlay viewport: finite and strictly positive.
|
|
138
|
+
* `NaN`/`Infinity`/zero/negative (a measurement that hasn't settled yet, a miscomputed layout
|
|
139
|
+
* value, an author typo) must never reach `BoundedOverlayViewport`'s iframe — an iframe sized to
|
|
140
|
+
* a meaningless bound is worse than no iframe, since it still renders (and can still mount the
|
|
141
|
+
* wrapped demo's overlay) at some browser-decided fallback size instead of honestly reporting
|
|
142
|
+
* unavailable. */
|
|
143
|
+
export function isFiniteOverlaySize(width: number, height: number): boolean {
|
|
144
|
+
return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The framed stage's own box dimensions. `height` is exact — the production overlay's full
|
|
148
|
+
* usable height is never renegotiated by its owner. `width` is only a MAXIMUM: `'100%'` lets the
|
|
149
|
+
* stage fill and shrink to whatever its real owning specimen cell actually gives it, capped at
|
|
150
|
+
* `width` when the owner has at least that much room. A fixed pixel width here previously let the
|
|
151
|
+
* stage overflow a narrower owner (e.g. a 240px comparison cell at a narrow browser width) while
|
|
152
|
+
* that owner's own `overflow: hidden` silently clipped the excess — a passing-looking but false
|
|
153
|
+
* containment, not the honest unavailable/shrink this design requires. */
|
|
154
|
+
export function overlayStageDimensions(width: number, height: number): { width: '100%'; maxWidth: number; height: number } {
|
|
155
|
+
return { width: '100%', maxWidth: width, height };
|
|
156
|
+
}
|