@krapwoo/ds-viewer 0.4.7 → 0.4.9
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 +21 -6
- package/dist/cli/dev.js +66 -37
- package/dist/cli/doctor.js +4 -2
- package/dist/cli/endpoint.js +33 -0
- package/dist/cli/init.js +5 -8
- package/dist/native/catalog/comparison.js +17 -8
- package/native/catalog/CatalogShell.tsx +5 -1
- package/native/catalog/ComparisonGrid.tsx +5 -1
- package/native/catalog/ComparisonGroups.tsx +7 -1
- package/native/catalog/ComparisonList.tsx +7 -1
- package/native/catalog/PhoneFrame.tsx +108 -24
- package/native/catalog/ReferenceDetails.tsx +8 -0
- package/native/catalog/SectionBlock.tsx +10 -4
- package/native/catalog/SpecimenAddress.tsx +15 -0
- package/native/catalog/SpecimenSurface.tsx +66 -4
- package/native/catalog/UpdatePanel.tsx +114 -103
- package/native/catalog/comparison.ts +21 -8
- package/native/catalog/deviceFrame.ts +14 -0
- package/native/catalog/overlayViewport.ts +4 -2
- package/native/catalog/specimenSurfaceStyle.ts +54 -0
- package/native/catalog/updatePanelState.ts +68 -1
- package/package.json +5 -4
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +1 -1
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +1 -1
- package/starter-kit/components/Badge/Badge.catalog.tsx +1 -1
- package/starter-kit/components/Banner/Banner.catalog.tsx +2 -1
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +5 -1
- package/starter-kit/components/Button/Button.catalog.tsx +1 -1
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +8 -1
- package/starter-kit/components/Card/Card.catalog.tsx +2 -1
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +2 -2
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +1 -1
- package/starter-kit/components/Divider/Divider.catalog.tsx +2 -1
- package/starter-kit/components/Dock/Dock.catalog.tsx +4 -1
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +1 -1
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +2 -1
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +5 -1
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +1 -1
- package/starter-kit/components/InputField/InputField.catalog.tsx +3 -1
- package/starter-kit/components/List/List.catalog.tsx +2 -1
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +7 -1
- package/starter-kit/components/Loading/Loading.catalog.tsx +2 -1
- package/starter-kit/components/Pill/Pill.catalog.tsx +1 -1
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +1 -1
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +1 -1
- package/starter-kit/components/Radio/Radio.catalog.tsx +2 -2
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +2 -1
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +2 -1
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +1 -3
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +2 -1
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +2 -1
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +5 -1
- package/starter-kit/components/Surface/Surface.catalog.tsx +2 -1
- package/starter-kit/components/Switch/Switch.catalog.tsx +2 -2
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +2 -1
- package/starter-kit/components/Toast/Toast.catalog.tsx +2 -1
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +2 -1
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +4 -1
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +1 -3
- package/starter-kit/pages/ReportIssue.catalog.tsx +18 -1
- package/starter-kit/pages/SavedTrips.catalog.tsx +27 -6
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +7 -1
|
@@ -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 { SpecimenAddressProvider } from './SpecimenAddress';
|
|
9
10
|
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
10
11
|
import { TokenSections } from './TokenLayouts';
|
|
11
12
|
import { MATRIX_LAYOUT, choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
@@ -169,15 +170,19 @@ function BlockContent<TId extends string>({ block, def, surface }: { block: Pres
|
|
|
169
170
|
case 'grid':
|
|
170
171
|
return <ComparisonGrid def={block.comparison} size={block.size} sectionId={def.id} surface={surface} />;
|
|
171
172
|
case 'grouped':
|
|
172
|
-
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} />;
|
|
173
|
+
return <ComparisonGroups groups={block.groups} size={block.size} label={def.id} surface={surface} pageId={def.id} />;
|
|
173
174
|
case 'list':
|
|
174
|
-
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} />;
|
|
175
|
+
return <ComparisonList items={block.items} size={block.size} label={`${def.id}: ${block.title}`} maxColumns={block.maxColumns} surface={surface} pageId={def.id} />;
|
|
175
176
|
case 'preview':
|
|
176
177
|
// A token gallery's `preview` block shows full-width raw token data, not a component
|
|
177
178
|
// specimen — it keeps its current, unstaged presentation.
|
|
178
179
|
// `Preview`'s own `widths === 'full'` check already excludes a full-width or token-gallery
|
|
179
180
|
// preview from ever reaching the table layout, regardless of `def.previewLayout`.
|
|
180
|
-
return def.render ?
|
|
181
|
+
return def.render ? (
|
|
182
|
+
<SpecimenAddressProvider address={{ pageId: def.id, slot: 'preview', itemKey: 'preview' }}>
|
|
183
|
+
<Preview render={def.render} widths={block.widths} surface={def.tokenGallery ? undefined : surface} layout={def.previewLayout === 'table' ? 'table' : undefined} />
|
|
184
|
+
</SpecimenAddressProvider>
|
|
185
|
+
) : null;
|
|
181
186
|
case 'tokenSections':
|
|
182
187
|
return <TokenSections sections={block.sections} columns={block.columns} />;
|
|
183
188
|
default:
|
|
@@ -369,7 +374,8 @@ const styles = StyleSheet.create({
|
|
|
369
374
|
blocks: { gap: CATALOG_LAYOUT.blockGap, marginBottom: CATALOG_LAYOUT.blockGap },
|
|
370
375
|
blocksSide: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
371
376
|
blockFill: { flex: 1, minWidth: 0 },
|
|
372
|
-
|
|
377
|
+
// Centred in the card, like every other example (a single phone-width frame used to sit left).
|
|
378
|
+
frames: { flexDirection: 'row', flexWrap: 'wrap', gap: CATALOG_SPACE.xl, alignItems: 'flex-start', justifyContent: 'center' },
|
|
373
379
|
frame: { maxWidth: '100%', gap: CATALOG_SPACE.sm },
|
|
374
380
|
frameLabel: {
|
|
375
381
|
fontSize: CATALOG_TYPE.tableHeader,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import type { OverlayAddress } from './overlayViewport';
|
|
3
|
+
|
|
4
|
+
const SpecimenAddressContext = createContext<OverlayAddress | undefined>(undefined);
|
|
5
|
+
|
|
6
|
+
/** Tells the example rendered inside it which catalog example it is (page, slot, item key), so a
|
|
7
|
+
* `PhoneFrame` in it can open that one example in its own device-sized document. Set by the
|
|
8
|
+
* catalog's own list, grid and preview renderers, and by the child document itself. */
|
|
9
|
+
export function SpecimenAddressProvider({ address, children }: { address: OverlayAddress | undefined; children: React.ReactNode }) {
|
|
10
|
+
return <SpecimenAddressContext.Provider value={address}>{children}</SpecimenAddressContext.Provider>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useSpecimenAddress(): OverlayAddress | undefined {
|
|
14
|
+
return useContext(SpecimenAddressContext);
|
|
15
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
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 {
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE, overflowsCell, paintedExtent, shouldCenterFilledContent, specimenContentAlignItems,
|
|
6
|
+
specimenContentBoundedStyle, visibleBoxes, type LayoutBox,
|
|
7
|
+
} from './specimenSurfaceStyle';
|
|
8
|
+
import { useSpecimenAddress } from './SpecimenAddress';
|
|
5
9
|
import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
|
|
6
10
|
import type { SpecimenAlign, SpecimenSurfaceKind } from './types';
|
|
7
11
|
|
|
@@ -75,12 +79,70 @@ export function SpecimenSurface({
|
|
|
75
79
|
* (e.g. Badge's `alignSelf: 'flex-start'`) free to override that positioning outright, since
|
|
76
80
|
* `alignItems` only sets a child's *default* `alignSelf`. `fill` keeps the existing column +
|
|
77
81
|
* `alignItems` stretch behavior, unchanged. */
|
|
82
|
+
type MeasurableNode = {
|
|
83
|
+
getBoundingClientRect: () => { left: number; right: number; width: number };
|
|
84
|
+
children: ArrayLike<MeasurableNode>;
|
|
85
|
+
childElementCount: number;
|
|
86
|
+
tagName: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The example's element tree as `LayoutBox`es (up to 400 elements), so `visibleBoxes` can skip
|
|
90
|
+
* what scrolling or clipping containers hide. */
|
|
91
|
+
function readTree(root: MeasurableNode, browser: Browser): LayoutBox {
|
|
92
|
+
let budget = 400;
|
|
93
|
+
const read = (el: MeasurableNode): LayoutBox => {
|
|
94
|
+
const rect = el.getBoundingClientRect();
|
|
95
|
+
const style = browser.getComputedStyle!(el) as unknown as { backgroundColor: string; borderTopWidth: string; visibility: string; overflowX: string };
|
|
96
|
+
const kids: LayoutBox[] = [];
|
|
97
|
+
const children = el.children;
|
|
98
|
+
for (let i = 0; i < children.length && budget > 0; i++) {
|
|
99
|
+
budget -= 1;
|
|
100
|
+
const child = children[i];
|
|
101
|
+
if (child.getBoundingClientRect().width < 1) continue;
|
|
102
|
+
kids.push(read(child));
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
left: rect.left,
|
|
106
|
+
right: rect.right,
|
|
107
|
+
painted:
|
|
108
|
+
style.visibility !== 'hidden' &&
|
|
109
|
+
(el.childElementCount === 0 || el.tagName === 'svg' || el.tagName === 'IFRAME' ||
|
|
110
|
+
style.backgroundColor !== 'rgba(0, 0, 0, 0)' || parseFloat(style.borderTopWidth) > 0),
|
|
111
|
+
clips: style.overflowX === 'hidden' || style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'clip',
|
|
112
|
+
children: kids,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
return read(root);
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
export function SpecimenContent({ fill, align, children }: { fill?: boolean; align?: SpecimenAlign; children: React.ReactNode }) {
|
|
119
|
+
// After layout (browser only): a `fill` example whose visible content is narrower than the cell
|
|
120
|
+
// (a fixed-width skeleton, a tooltip around a small trigger) is centred instead of sitting
|
|
121
|
+
// against the leading edge; an explicit `align` always wins. And in development, an example
|
|
122
|
+
// wider than its cell logs a warning naming a wider `specimenSize`.
|
|
123
|
+
const address = useSpecimenAddress();
|
|
124
|
+
const ref = useRef<View>(null);
|
|
125
|
+
const [centerNarrow, setCenterNarrow] = useState(false);
|
|
126
|
+
const warned = useRef(false);
|
|
127
|
+
const measure = useCallback(() => {
|
|
128
|
+
const browser = globalThis as unknown as Browser;
|
|
129
|
+
const node = ref.current as unknown as MeasurableNode | null;
|
|
130
|
+
if (!browser.Element || !browser.getComputedStyle || !node?.getBoundingClientRect) return;
|
|
131
|
+
const wrapperWidth = node.getBoundingClientRect().width;
|
|
132
|
+
const visible = paintedExtent(visibleBoxes(readTree(node, browser)));
|
|
133
|
+
if (fill && align === undefined && !centerNarrow && shouldCenterFilledContent(visible, wrapperWidth)) setCenterNarrow(true);
|
|
134
|
+
if ((globalThis as { __DEV__?: boolean }).__DEV__ && !warned.current && overflowsCell(visible, wrapperWidth)) {
|
|
135
|
+
warned.current = true;
|
|
136
|
+
console.warn(
|
|
137
|
+
`[Catalog] ${address ? `${address.pageId} (${address.itemKey})` : 'An example'} is ${Math.round(visible)}px wide in a ${Math.round(wrapperWidth)}px cell, so it's cut off. Give the page a wider specimenSize ('regular' or 'wide').`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}, [fill, align, centerNarrow, address]);
|
|
79
141
|
if (fill) {
|
|
80
|
-
const alignItems = specimenContentAlignItems(fill, align);
|
|
81
|
-
return <View style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
142
|
+
const alignItems = centerNarrow ? 'center' : specimenContentAlignItems(fill, align);
|
|
143
|
+
return <View ref={ref} onLayout={measure} style={[SPECIMEN_SURFACE_WIDTH_STYLE, { alignItems }]}>{children}</View>;
|
|
82
144
|
}
|
|
83
|
-
return <View style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
145
|
+
return <View ref={ref} onLayout={measure} style={specimenContentBoundedStyle(align)}>{children}</View>;
|
|
84
146
|
}
|
|
85
147
|
|
|
86
148
|
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
|
|
@@ -3,7 +3,7 @@ import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Switch,
|
|
|
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
5
|
import {
|
|
6
|
-
autoCheckHelp, bumpLabel, checkedAgoLabel, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
|
|
6
|
+
autoCheckHelp, bumpLabel, checkedAgoLabel, resultNote, daysAgoLabel, isAlreadyUpToDateError, resolvePanelPhase, versionOverview, type UpdatePlanView, type UpdateStatusView,
|
|
7
7
|
} from './updatePanelState';
|
|
8
8
|
import type { UpdateNotice, VersionStatus } from './types';
|
|
9
9
|
|
|
@@ -51,20 +51,6 @@ function FilesList({ files }: { files: UpdatePlanView['files'] }) {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** Same shape as `FilesList`, but with no right-hand reason column — the `success` phase's
|
|
55
|
-
* `status.files` carries plain paths, never a reason (Task 16 Step 2 table: a dedicated,
|
|
56
|
-
* smaller render here is simplest rather than over-generalizing `FilesList` for one caller). */
|
|
57
|
-
function PlainFilesList({ files }: { files: string[] }) {
|
|
58
|
-
return (
|
|
59
|
-
<View style={styles.filesBox}>
|
|
60
|
-
{files.map((path, i) => (
|
|
61
|
-
<View key={path} style={[styles.fileRow, i > 0 && styles.fileRowBorder]}>
|
|
62
|
-
<Text style={styles.filePath}>{path}</Text>
|
|
63
|
-
</View>
|
|
64
|
-
))}
|
|
65
|
-
</View>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
54
|
|
|
69
55
|
const NOTE_BOX_VARIANT = {
|
|
70
56
|
muted: { box: { backgroundColor: CATALOG_COLOR.surfaceMuted }, text: { color: CATALOG_COLOR.text } },
|
|
@@ -139,59 +125,66 @@ function Code({ children }: { children: string }) {
|
|
|
139
125
|
return <Text style={styles.code}>{children}</Text>;
|
|
140
126
|
}
|
|
141
127
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The result of the last update, as a note at the top of the update page (approved mockup
|
|
132
|
+
* 2026-10-11): a bold header, a description, one detail line, and a bottom-left button in the
|
|
133
|
+
* note's colour. The rest of the page stays usable below it.
|
|
134
|
+
*/
|
|
135
|
+
function ResultNoteBox({
|
|
136
|
+
status,
|
|
137
|
+
current,
|
|
138
|
+
latest,
|
|
139
|
+
busy,
|
|
140
|
+
onAction,
|
|
141
|
+
}: {
|
|
142
|
+
status: Extract<UpdateStatusView, { phase: 'success' | 'failure' }>;
|
|
143
|
+
current: string;
|
|
144
|
+
latest?: string;
|
|
145
|
+
busy: boolean;
|
|
146
|
+
onAction: (kind: 'dismiss' | 'retry' | 'resume') => void;
|
|
147
|
+
}) {
|
|
148
|
+
const [showLog, setShowLog] = useState(false);
|
|
149
|
+
const note = resultNote(status, current, latest);
|
|
150
|
+
const ok = note.tone === 'ok';
|
|
151
|
+
const tone = ok ? CATALOG_COLOR.success : CATALOG_COLOR.danger;
|
|
148
152
|
return (
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
153
|
+
<View style={[styles.resultNote, ok ? styles.resultNoteOk : styles.resultNoteErr]} role="status">
|
|
154
|
+
<Text style={[styles.resultTitle, { color: tone }]}>{note.title}</Text>
|
|
155
|
+
<Text style={[styles.resultDescription, { color: tone }]}>{note.description}</Text>
|
|
156
|
+
<Text style={[styles.resultDetail, { color: tone }]}>
|
|
157
|
+
{note.command ? (
|
|
158
|
+
<>
|
|
159
|
+
{'You can also run '}
|
|
160
|
+
<Code>{note.command}</Code>
|
|
161
|
+
{' in your terminal.'}
|
|
162
|
+
</>
|
|
163
|
+
) : (
|
|
164
|
+
note.detail
|
|
165
|
+
)}
|
|
166
|
+
</Text>
|
|
167
|
+
<View style={styles.resultRow}>
|
|
168
|
+
<Pressable
|
|
169
|
+
onPress={() => onAction(note.action.kind)}
|
|
170
|
+
disabled={busy}
|
|
171
|
+
accessibilityRole="button"
|
|
172
|
+
accessibilityState={{ disabled: busy }}
|
|
173
|
+
style={({ pressed }) => [styles.resultButton, { backgroundColor: tone }, (pressed || busy) && styles.resultButtonPressed]}
|
|
174
|
+
>
|
|
175
|
+
<Text style={styles.resultButtonText}>{note.action.label}</Text>
|
|
176
|
+
</Pressable>
|
|
177
|
+
{note.hasLog && status.phase === 'failure' && (
|
|
178
|
+
<Pressable onPress={() => setShowLog((v) => !v)} accessibilityRole="button" accessibilityState={{ expanded: showLog }}>
|
|
179
|
+
<Text style={[styles.resultLink, { color: tone }]}>{showLog ? 'Hide log' : 'Show log'}</Text>
|
|
180
|
+
</Pressable>
|
|
181
|
+
)}
|
|
167
182
|
</View>
|
|
168
|
-
|
|
183
|
+
{showLog && status.phase === 'failure' && <LogBlock>{status.log}</LogBlock>}
|
|
184
|
+
</View>
|
|
169
185
|
);
|
|
170
186
|
}
|
|
171
187
|
|
|
172
|
-
/** The `failure` phase's headline/body copy, keyed off which step failed — `runUpdate`/
|
|
173
|
-
* `performUpdate` each name their own distinct recovery command (Important finding, Fable
|
|
174
|
-
* correction pass: one hardcoded headline/body for every failure was wrong for two of the
|
|
175
|
-
* three). */
|
|
176
|
-
function failureCopy(failedStep: string, current: string): { headline: string; body: React.ReactNode } {
|
|
177
|
-
if (/install/i.test(failedStep)) {
|
|
178
|
-
return {
|
|
179
|
-
headline: 'The update stopped while installing.',
|
|
180
|
-
body: <>Your files weren’t migrated. Recover with <Code>npx ds-viewer update</Code> in your terminal.</>,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (/migrat/i.test(failedStep)) {
|
|
184
|
-
return {
|
|
185
|
-
headline: 'The update installed, but migrating failed.',
|
|
186
|
-
body: <>Recover with <Code>{`npx ds-viewer migrate --from ${current}`}</Code> in your terminal.</>,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
headline: 'The update installed and migrated, but doctor failed to run.',
|
|
191
|
-
body: <>Recover with <Code>npx ds-viewer doctor</Code> in your terminal.</>,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
188
|
/**
|
|
196
189
|
* The update page when no newer version is known (approved mockup
|
|
197
190
|
* docs/design/2026-10-10-ds-viewer-update-page-always-approved.html, states 3, 4, 6, 7 and 8): the
|
|
@@ -202,10 +195,13 @@ function VersionDetails({
|
|
|
202
195
|
status,
|
|
203
196
|
endpoint,
|
|
204
197
|
onStatus,
|
|
198
|
+
onCheck,
|
|
205
199
|
}: {
|
|
206
200
|
status: VersionStatus;
|
|
207
201
|
endpoint?: Endpoint;
|
|
208
202
|
onStatus: (next: VersionStatus) => void;
|
|
203
|
+
/** Called as Check now starts (clears an update result note). */
|
|
204
|
+
onCheck?: () => void;
|
|
209
205
|
}) {
|
|
210
206
|
const [checking, setChecking] = useState(false);
|
|
211
207
|
const [justChecked, setJustChecked] = useState(false);
|
|
@@ -217,6 +213,7 @@ function VersionDetails({
|
|
|
217
213
|
|
|
218
214
|
const checkNow = async () => {
|
|
219
215
|
if (!endpoint || checking) return;
|
|
216
|
+
onCheck?.();
|
|
220
217
|
setChecking(true);
|
|
221
218
|
setJustChecked(false);
|
|
222
219
|
setLocalError(undefined);
|
|
@@ -395,7 +392,20 @@ export function UpdatePanel({
|
|
|
395
392
|
});
|
|
396
393
|
}, [fetchStatus, fetchPlan, update]);
|
|
397
394
|
|
|
398
|
-
|
|
395
|
+
// A finished update (success or failure) shows as a note at the top; the page under it behaves
|
|
396
|
+
// as if idle, so Check now, the switch and a newer update all keep working.
|
|
397
|
+
const result = status.phase === 'success' || status.phase === 'failure' ? status : undefined;
|
|
398
|
+
const phase = resolvePanelPhase(result ? { phase: 'idle' } : status, planResult);
|
|
399
|
+
const [noteBusy, setNoteBusy] = useState(false);
|
|
400
|
+
const dismissResult = useCallback(async () => {
|
|
401
|
+
if (!endpoint || !isWeb()) return;
|
|
402
|
+
try {
|
|
403
|
+
const res = await authedFetch(endpoint, '/update/dismiss', { method: 'POST' });
|
|
404
|
+
if (res.ok) setStatus((await res.json()) as UpdateStatusView);
|
|
405
|
+
} catch {
|
|
406
|
+
// The note stays; the next status poll or reload settles it.
|
|
407
|
+
}
|
|
408
|
+
}, [endpoint]);
|
|
399
409
|
|
|
400
410
|
// Polls `/update/status` at a plain interval while an update is in flight — design §5 never
|
|
401
411
|
// asks for push/websocket progress, only "the panel shows progress."
|
|
@@ -463,14 +473,14 @@ export function UpdatePanel({
|
|
|
463
473
|
if (!update) {
|
|
464
474
|
// Errata 1c: no update known (disabled, offline, or genuinely up to date). `status` still
|
|
465
475
|
// reflects a just-finished update read from the (surviving) endpoint across the reload.
|
|
466
|
-
content =
|
|
467
|
-
|
|
468
|
-
<SuccessContent status={status} />
|
|
469
|
-
) : versionStatus ? (
|
|
470
|
-
<VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} />
|
|
476
|
+
content = versionStatus ? (
|
|
477
|
+
<VersionDetails status={versionStatus} endpoint={endpoint} onStatus={onVersionStatus ?? (() => {})} onCheck={result ? dismissResult : undefined} />
|
|
471
478
|
) : (
|
|
472
479
|
<NoteBox variant="ok">You’re already up to date.</NoteBox>
|
|
473
480
|
);
|
|
481
|
+
} else if (result?.phase === 'failure') {
|
|
482
|
+
// The failure note's own button is the recovery; don't offer Update now twice.
|
|
483
|
+
content = <VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />;
|
|
474
484
|
} else if (phase === 'ready' && plan) {
|
|
475
485
|
content = (
|
|
476
486
|
<>
|
|
@@ -587,40 +597,7 @@ export function UpdatePanel({
|
|
|
587
597
|
<Text style={styles.muted}>If it doesn’t come back, run <Code>npx ds-viewer dev</Code> in your terminal.</Text>
|
|
588
598
|
</>
|
|
589
599
|
);
|
|
590
|
-
|
|
591
|
-
content = <SuccessContent status={status} />;
|
|
592
|
-
} else if (phase === 'failure' && status.phase === 'failure') {
|
|
593
|
-
const { headline, body } = failureCopy(status.failedStep, update.current);
|
|
594
|
-
const failedLabel = `${status.failedStep} failed`;
|
|
595
|
-
content = (
|
|
596
|
-
<>
|
|
597
|
-
<VersionsHeader current={update.current} latest={update.latest} breaking={update.breaking} releasedAt={update.releasedAt} />
|
|
598
|
-
<NoteBox variant="err">
|
|
599
|
-
<Text style={styles.noteBoxBold}>{headline}</Text>
|
|
600
|
-
{' '}
|
|
601
|
-
{body}
|
|
602
|
-
</NoteBox>
|
|
603
|
-
<Steps
|
|
604
|
-
steps={[
|
|
605
|
-
{ label: `Downloaded ${update.latest}`, state: 'done' },
|
|
606
|
-
{ label: failedLabel, state: 'todo' },
|
|
607
|
-
]}
|
|
608
|
-
failedStep={failedLabel}
|
|
609
|
-
/>
|
|
610
|
-
<LogBlock>{status.log}</LogBlock>
|
|
611
|
-
<View style={styles.actions}>
|
|
612
|
-
<Pressable
|
|
613
|
-
onPress={() => {
|
|
614
|
-
if (isWeb()) navigator.clipboard?.writeText(status.log);
|
|
615
|
-
}}
|
|
616
|
-
accessibilityRole="button"
|
|
617
|
-
style={styles.secondaryButton}
|
|
618
|
-
>
|
|
619
|
-
<Text style={styles.secondaryButtonText}>Copy log</Text>
|
|
620
|
-
</Pressable>
|
|
621
|
-
</View>
|
|
622
|
-
</>
|
|
623
|
-
);
|
|
600
|
+
|
|
624
601
|
}
|
|
625
602
|
|
|
626
603
|
return (
|
|
@@ -631,6 +608,28 @@ export function UpdatePanel({
|
|
|
631
608
|
<View ref={headingRef} tabIndex={-1} role="heading" {...({ 'aria-level': 1 } as Record<string, unknown>)} style={styles.headingTarget}>
|
|
632
609
|
<Text style={styles.title}>DS Viewer updates</Text>
|
|
633
610
|
</View>
|
|
611
|
+
{result && (
|
|
612
|
+
<ResultNoteBox
|
|
613
|
+
status={result}
|
|
614
|
+
current={update?.current ?? versionStatus?.current ?? ''}
|
|
615
|
+
latest={update?.latest}
|
|
616
|
+
busy={noteBusy || starting}
|
|
617
|
+
onAction={async (kind) => {
|
|
618
|
+
if (!endpoint) return;
|
|
619
|
+
setNoteBusy(true);
|
|
620
|
+
try {
|
|
621
|
+
if (kind === 'dismiss') await dismissResult();
|
|
622
|
+
else if (kind === 'retry') await startUpdate();
|
|
623
|
+
else {
|
|
624
|
+
await authedFetch(endpoint, '/update/resume', { method: 'POST' });
|
|
625
|
+
await fetchStatus();
|
|
626
|
+
}
|
|
627
|
+
} finally {
|
|
628
|
+
setNoteBusy(false);
|
|
629
|
+
}
|
|
630
|
+
}}
|
|
631
|
+
/>
|
|
632
|
+
)}
|
|
634
633
|
{content}
|
|
635
634
|
</View>
|
|
636
635
|
);
|
|
@@ -638,6 +637,18 @@ export function UpdatePanel({
|
|
|
638
637
|
|
|
639
638
|
const styles = StyleSheet.create({
|
|
640
639
|
wrap: { maxWidth: CATALOG_LAYOUT.updateContentMaxWidth },
|
|
640
|
+
resultNote: { borderRadius: CATALOG_RADIUS.md, padding: 14, marginTop: 12, marginBottom: 18, gap: 4 },
|
|
641
|
+
resultNoteOk: { backgroundColor: CATALOG_COLOR.successSubtle },
|
|
642
|
+
resultNoteErr: { backgroundColor: CATALOG_COLOR.dangerSubtle, borderWidth: 1, borderColor: CATALOG_COLOR.dangerBorder },
|
|
643
|
+
resultTitle: { fontSize: CATALOG_TYPE.lg, fontWeight: '800' },
|
|
644
|
+
resultDescription: { fontSize: CATALOG_TYPE.panelHeading, lineHeight: 19 },
|
|
645
|
+
resultDetail: { fontSize: CATALOG_TYPE.sm, lineHeight: 18 },
|
|
646
|
+
resultRow: { flexDirection: 'row', alignItems: 'center', gap: CATALOG_SPACE.md, marginTop: 8 },
|
|
647
|
+
// The catalog's smallest control size (the major banner's close button), filled in the note's colour.
|
|
648
|
+
resultButton: { height: 32, paddingHorizontal: 14, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
|
|
649
|
+
resultButtonPressed: { opacity: 0.8 },
|
|
650
|
+
resultButtonText: { color: '#ffffff', fontSize: CATALOG_TYPE.sm, fontWeight: '700' },
|
|
651
|
+
resultLink: { fontSize: CATALOG_TYPE.panelHeading, fontWeight: '700', textDecorationLine: 'underline' },
|
|
641
652
|
crumb: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
642
653
|
headingTarget: { marginTop: 4, marginBottom: 6 },
|
|
643
654
|
title: { fontSize: CATALOG_TYPE.pageTitle, fontWeight: '800', color: CATALOG_COLOR.text },
|
|
@@ -229,6 +229,9 @@ export function remainingStates(states: VariantSlot | undefined, comparison: Com
|
|
|
229
229
|
|
|
230
230
|
export interface ListItem {
|
|
231
231
|
key: string;
|
|
232
|
+
/** Which authored slot this item came from, so a device frame inside it can address it
|
|
233
|
+
* (`overlayViewport.ts`). */
|
|
234
|
+
slot?: 'variants' | 'states';
|
|
232
235
|
label: string;
|
|
233
236
|
node: unknown;
|
|
234
237
|
fill?: boolean;
|
|
@@ -265,10 +268,20 @@ export function slotSize<TId extends string>(def: SectionDef<TId>, slot: Variant
|
|
|
265
268
|
return def.specimenSize ?? (slot.itemsFill ? 'wide' : 'regular');
|
|
266
269
|
}
|
|
267
270
|
|
|
268
|
-
|
|
271
|
+
/** Full-width (filled) examples are block components (cards, banners, rows): narrower than about a
|
|
272
|
+
* third of a laptop's content width they stop reading as themselves, so they wrap at most 3
|
|
273
|
+
* across unless the page sets its own `maxColumns`. Small centred examples wrap freely. */
|
|
274
|
+
export const FILLED_SLOT_MAX_COLUMNS = 3;
|
|
275
|
+
|
|
276
|
+
function slotColumnCap(slot: VariantSlot): { maxColumns?: 1 | 2 | 3 | 4 | 5 } {
|
|
277
|
+
if (slot.maxColumns !== undefined) return { maxColumns: slot.maxColumns };
|
|
278
|
+
return slot.itemsFill ? { maxColumns: FILLED_SLOT_MAX_COLUMNS } : {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function slotItems(slot: VariantSlot, from: 'variants' | 'states'): ListItem[] {
|
|
269
282
|
// Nullish, not `||`: an item's own explicit `fill: false` must override an inherited
|
|
270
283
|
// `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 }));
|
|
284
|
+
return slot.items.map((item) => ({ key: item.key, slot: from, label: item.name, node: item.node, fill: item.fill ?? slot.itemsFill, surface: item.surface, align: item.align }));
|
|
272
285
|
}
|
|
273
286
|
|
|
274
287
|
/** Preview widths, each capped at phone width. 'full' is kept for catalog chrome and token pages. */
|
|
@@ -289,8 +302,8 @@ function groupStates(variants: VariantSlot, states: VariantSlot): { groups: List
|
|
|
289
302
|
const groups = variants.items.map((variant) => {
|
|
290
303
|
const own = states.items.filter((s) => s.group === variant.key);
|
|
291
304
|
const items = own.length > 0
|
|
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 }];
|
|
305
|
+
? own.map((s) => ({ key: s.key, slot: 'states' as const, label: s.name, node: s.node, fill: fill(s, states), surface: s.surface, align: s.align }))
|
|
306
|
+
: [{ key: variant.key, slot: 'variants' as const, label: variant.name, node: variant.node, fill: fill(variant, variants), surface: variant.surface, align: variant.align }];
|
|
294
307
|
return { key: variant.key, label: variant.name, items };
|
|
295
308
|
});
|
|
296
309
|
const rest = states.items.filter((s) => s.group === undefined || !variantKeys.has(s.group));
|
|
@@ -337,8 +350,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
337
350
|
kind: 'list',
|
|
338
351
|
title: 'Variants',
|
|
339
352
|
size: slotSize(def, def.variants),
|
|
340
|
-
items: slotItems(def.variants),
|
|
341
|
-
...(def.variants
|
|
353
|
+
items: slotItems(def.variants, 'variants'),
|
|
354
|
+
...slotColumnCap(def.variants),
|
|
342
355
|
});
|
|
343
356
|
} else if (def.render) {
|
|
344
357
|
blocks.push({ kind: 'preview', title: 'Preview', widths: previewWidths(def.previewWidths, options.defaultPreviewWidths) });
|
|
@@ -351,8 +364,8 @@ export function presentationBlocks<TId extends string>(def: SectionDef<TId>, opt
|
|
|
351
364
|
kind: 'list',
|
|
352
365
|
title: secondary ? 'Other configurations' : 'States / configurations',
|
|
353
366
|
size: slotSize(def, states),
|
|
354
|
-
items: slotItems(states),
|
|
355
|
-
...(states
|
|
367
|
+
items: slotItems(states, 'states'),
|
|
368
|
+
...slotColumnCap(states),
|
|
356
369
|
});
|
|
357
370
|
}
|
|
358
371
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device every `PhoneFrame` shows: a real phone's viewport, in points, at the small end of
|
|
3
|
+
* current phones (iPhone SE, 3rd generation). Examples lay out at exactly these dimensions; a cell
|
|
4
|
+
* narrower than the device scales the whole viewport down instead of squeezing its layout. Kept
|
|
5
|
+
* free of runtime imports so `node --test` can check it.
|
|
6
|
+
*/
|
|
7
|
+
export const DEVICE_FRAME = { name: 'iPhone SE', width: 375, height: 667 } as const;
|
|
8
|
+
|
|
9
|
+
/** How much to scale the device so it fits `availableWidth` (never enlarged). An unmeasured cell
|
|
10
|
+
* (0) renders at full size rather than collapsing. */
|
|
11
|
+
export function deviceFrameScale(availableWidth: number): number {
|
|
12
|
+
if (!(availableWidth > 0)) return 1;
|
|
13
|
+
return Math.min(1, availableWidth / DEVICE_FRAME.width);
|
|
14
|
+
}
|
|
@@ -9,7 +9,7 @@ import type React from 'react';
|
|
|
9
9
|
import type { SectionDef } from './types.ts';
|
|
10
10
|
|
|
11
11
|
/** Which block on a page an addressed specimen's `node` lives in — see `findOverlayNode`. */
|
|
12
|
-
export type OverlaySlot = 'variants' | 'states' | 'comparison';
|
|
12
|
+
export type OverlaySlot = 'variants' | 'states' | 'comparison' | 'preview';
|
|
13
13
|
|
|
14
14
|
/** Identifies exactly one authored specimen: a page id (`SectionDef.id`), which block it lives
|
|
15
15
|
* in, and that item's own key — `VariantExample.key` for `'variants'`/`'states'`, or this
|
|
@@ -23,7 +23,7 @@ export interface OverlayAddress {
|
|
|
23
23
|
itemKey: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison'];
|
|
26
|
+
const OVERLAY_SLOTS: readonly OverlaySlot[] = ['variants', 'states', 'comparison', 'preview'];
|
|
27
27
|
|
|
28
28
|
/** `encodeURIComponent` leaves `.` untouched (it's an unreserved character), which would let a
|
|
29
29
|
* literal `.` inside a segment collide with the `.`-joined encoding below; escape it too so
|
|
@@ -117,6 +117,8 @@ export function findOverlayNode(sections: readonly SectionDef<string>[], address
|
|
|
117
117
|
if (!def) return undefined;
|
|
118
118
|
if (address.slot === 'variants') return def.variants?.items.find((item) => item.key === address.itemKey)?.node;
|
|
119
119
|
if (address.slot === 'states') return def.states?.items.find((item) => item.key === address.itemKey)?.node;
|
|
120
|
+
// A `render()` page's preview (one per page; `itemKey` is always 'preview').
|
|
121
|
+
if (address.slot === 'preview') return def.render?.();
|
|
120
122
|
// Matched by recomputing each cell's own canonical key, never by splitting `address.itemKey` —
|
|
121
123
|
// a row/column key may itself contain any delimiter this module could pick.
|
|
122
124
|
return def.comparison?.cells.find((cell) => comparisonCellItemKey(cell.rowKey, cell.columnKey) === address.itemKey)?.node;
|
|
@@ -88,3 +88,57 @@ export function specimenContentBoundedStyle(align: SpecimenAlign | undefined) {
|
|
|
88
88
|
/** Each example's cell turns gray only when its example would vanish against white (see
|
|
89
89
|
* `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
|
|
90
90
|
export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
|
|
91
|
+
|
|
92
|
+
/** A `fill` example stretches to its cell, but one with its own fixed width (a 160-point skeleton
|
|
93
|
+
* bar) stays that width; it should then sit in the middle of the cell, not against its leading
|
|
94
|
+
* edge. True when the measured example is narrower than its wrapper (by more than rounding). */
|
|
95
|
+
export function shouldCenterFilledContent(contentWidth: number, wrapperWidth: number): boolean {
|
|
96
|
+
if (!(contentWidth > 0) || !(wrapperWidth > 0)) return false;
|
|
97
|
+
return contentWidth < wrapperWidth - 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The width of what's actually visible in an example: from the leftmost to the rightmost painted
|
|
101
|
+
* box (text, an icon, anything with a fill), ignoring transparent layout wrappers that may span
|
|
102
|
+
* the whole cell. */
|
|
103
|
+
export function paintedExtent(boxes: readonly { left: number; right: number; painted: boolean }[]): number {
|
|
104
|
+
let left = Infinity;
|
|
105
|
+
let right = -Infinity;
|
|
106
|
+
for (const box of boxes) {
|
|
107
|
+
if (!box.painted || box.right <= box.left) continue;
|
|
108
|
+
left = Math.min(left, box.left);
|
|
109
|
+
right = Math.max(right, box.right);
|
|
110
|
+
}
|
|
111
|
+
return Number.isFinite(left) ? right - left : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when an example is wider than its cell (beyond rounding): its edges are cut off or touch
|
|
115
|
+
* the neighbouring cell, so the page should use a wider `specimenSize`. */
|
|
116
|
+
export function overflowsCell(contentWidth: number, cellWidth: number): boolean {
|
|
117
|
+
return contentWidth > 0 && cellWidth > 0 && contentWidth > cellWidth + 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One element of an example as measured in the browser. `clips` is true for a container that
|
|
121
|
+
* hides or scrolls its overflow (a scrolling row, a device frame). */
|
|
122
|
+
export interface LayoutBox {
|
|
123
|
+
left: number;
|
|
124
|
+
right: number;
|
|
125
|
+
painted: boolean;
|
|
126
|
+
clips: boolean;
|
|
127
|
+
children: LayoutBox[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The boxes a reader can actually see: a clipping container counts as its own box, and what it
|
|
131
|
+
* hides or scrolls inside it is skipped. */
|
|
132
|
+
export function visibleBoxes(root: LayoutBox): { left: number; right: number; painted: boolean }[] {
|
|
133
|
+
const out: { left: number; right: number; painted: boolean }[] = [];
|
|
134
|
+
const walk = (box: LayoutBox, isRoot: boolean) => {
|
|
135
|
+
if (!isRoot && box.clips) {
|
|
136
|
+
out.push({ left: box.left, right: box.right, painted: true });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!isRoot) out.push({ left: box.left, right: box.right, painted: box.painted });
|
|
140
|
+
for (const child of box.children) walk(child, false);
|
|
141
|
+
};
|
|
142
|
+
walk(root, true);
|
|
143
|
+
return out;
|
|
144
|
+
}
|