@krapwoo/ds-viewer 0.4.4 → 0.4.6
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 +20 -10
- package/dist/cli/dev.js +31 -6
- package/dist/cli/endpoint.js +76 -1
- package/dist/cli/main.js +4 -2
- 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/tokens.js +2 -0
- package/native/catalog/CatalogShell.tsx +47 -12
- package/native/catalog/CatalogSidebar.tsx +8 -7
- package/native/catalog/ComparisonGrid.tsx +11 -11
- package/native/catalog/ComparisonGroups.tsx +6 -8
- package/native/catalog/ComparisonList.tsx +6 -8
- package/native/catalog/SectionBlock.tsx +9 -6
- package/native/catalog/SpecimenSurface.tsx +64 -26
- package/native/catalog/UpdatePanel.tsx +161 -5
- package/native/catalog/catalogNavigation.ts +14 -1
- package/native/catalog/specimenFill.ts +102 -0
- package/native/catalog/specimenSurfaceStyle.ts +9 -3
- package/native/catalog/tokens.ts +2 -0
- package/native/catalog/types.ts +27 -9
- package/native/catalog/updatePanelState.ts +72 -0
- package/package.json +1 -1
|
@@ -43,7 +43,7 @@ function NavItem<TId extends string>({ id, label, active, onPress }: { id: TId;
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function FooterLink({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
46
|
+
function FooterLink({ label, kind, active, onPress }: { label: string; kind: 'update' | 'version'; active: boolean; onPress: () => void }) {
|
|
47
47
|
const [hovered, setHovered] = useState(false);
|
|
48
48
|
const [focused, setFocused] = useState(false);
|
|
49
49
|
return (
|
|
@@ -63,7 +63,8 @@ function FooterLink({ label, active, onPress }: { label: string; active: boolean
|
|
|
63
63
|
focused && styles.focusRing,
|
|
64
64
|
]}
|
|
65
65
|
>
|
|
66
|
-
|
|
66
|
+
{/* The dot marks an available update; the everyday version line is quiet. */}
|
|
67
|
+
{kind === 'update' && <View style={styles.footerDot} />}
|
|
67
68
|
<Text style={[styles.footerLabel, active && styles.footerLabelActive]}>{label}</Text>
|
|
68
69
|
</Pressable>
|
|
69
70
|
);
|
|
@@ -96,10 +97,10 @@ export function CatalogSidebar<TId extends string>({
|
|
|
96
97
|
labels?: ReadonlyMap<string, string>;
|
|
97
98
|
active: string | undefined;
|
|
98
99
|
onPress: (id: TId) => void;
|
|
99
|
-
/** The
|
|
100
|
-
*
|
|
101
|
-
* gets the same active styling a selected nav item would. */
|
|
102
|
-
footer?: { label: string; active: boolean; onPress: () => void };
|
|
100
|
+
/** The link below the nav list that opens the update page: the installed version (`'version'`),
|
|
101
|
+
* or the update notice with a dot (`'update'`, design §5's footer line). `active` is true while
|
|
102
|
+
* the update page itself is open — it gets the same active styling a selected nav item would. */
|
|
103
|
+
footer?: { label: string; kind?: 'update' | 'version'; active: boolean; onPress: () => void };
|
|
103
104
|
}) {
|
|
104
105
|
const [query, setQuery] = useState('');
|
|
105
106
|
// A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
|
|
@@ -201,7 +202,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
201
202
|
</ScrollView>
|
|
202
203
|
{footer && (
|
|
203
204
|
<View style={styles.footerContainer}>
|
|
204
|
-
<FooterLink label={footer.label} active={footer.active} onPress={footer.onPress} />
|
|
205
|
+
<FooterLink label={footer.label} kind={footer.kind ?? 'update'} active={footer.active} onPress={footer.onPress} />
|
|
205
206
|
</View>
|
|
206
207
|
)}
|
|
207
208
|
</View>
|
|
@@ -2,7 +2,8 @@ import React from 'react';
|
|
|
2
2
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, axisItems, cellKey, gridWidthBounds, indexCells, validateComparison } from './comparison';
|
|
5
|
-
import { SpecimenSurface } from './SpecimenSurface';
|
|
5
|
+
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
6
7
|
import type { ComparisonDef, SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,7 +16,7 @@ export function ComparisonGrid({
|
|
|
15
16
|
def,
|
|
16
17
|
size,
|
|
17
18
|
sectionId,
|
|
18
|
-
surface =
|
|
19
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
19
20
|
}: {
|
|
20
21
|
def: ComparisonDef;
|
|
21
22
|
size: SpecimenSize;
|
|
@@ -63,17 +64,17 @@ export function ComparisonGrid({
|
|
|
63
64
|
{columns.map((c, ci) => {
|
|
64
65
|
const cell = cells.get(cellKey(row.key, c.key));
|
|
65
66
|
return (
|
|
66
|
-
|
|
67
|
-
{cell
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
cell?.node !== undefined ? (
|
|
68
|
+
<SpecimenSurface key={c.key} role="cell" surface={surface} style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
69
|
+
<SpecimenContent fill={cell.fill}>{cell.node}</SpecimenContent>
|
|
70
|
+
</SpecimenSurface>
|
|
71
|
+
) : (
|
|
72
|
+
<View key={c.key} role="cell" style={[styles.cell, column, styles.bodyRow, styles.specimenCell, ci === lastColumn && styles.lastColumn]}>
|
|
72
73
|
<Text style={styles.unavailable}>
|
|
73
74
|
{cell?.unavailableReason ? `Not supported — ${cell.unavailableReason}` : 'Missing example'}
|
|
74
75
|
</Text>
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
</View>
|
|
77
|
+
)
|
|
77
78
|
);
|
|
78
79
|
})}
|
|
79
80
|
</View>
|
|
@@ -102,7 +103,6 @@ const styles = StyleSheet.create({
|
|
|
102
103
|
headerCell: { backgroundColor: CATALOG_COLOR.surfaceMuted, justifyContent: 'center' },
|
|
103
104
|
bodyRow: { minHeight: MATRIX_LAYOUT.rowMinHeight },
|
|
104
105
|
specimenCell: { alignItems: 'center', justifyContent: 'center' },
|
|
105
|
-
fill: { alignSelf: 'stretch' },
|
|
106
106
|
headerText: { fontSize: CATALOG_TYPE.tableHeader, fontWeight: '800', letterSpacing: 0.44, color: CATALOG_COLOR.text },
|
|
107
107
|
rowHeaderText: { fontSize: CATALOG_TYPE.md, fontWeight: '700', color: CATALOG_COLOR.text },
|
|
108
108
|
unavailable: { fontSize: CATALOG_TYPE.sm, fontStyle: 'italic', textAlign: 'center', color: CATALOG_COLOR.textMuted },
|
|
@@ -2,7 +2,8 @@ import React from 'react';
|
|
|
2
2
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { COLUMN_MIN_WIDTH, MATRIX_LAYOUT, type ListGroup } from './comparison';
|
|
5
|
-
import { SpecimenSurface } from './SpecimenSurface';
|
|
5
|
+
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
6
7
|
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -16,7 +17,7 @@ export function ComparisonGroups({
|
|
|
16
17
|
groups,
|
|
17
18
|
size,
|
|
18
19
|
label,
|
|
19
|
-
surface =
|
|
20
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
20
21
|
}: {
|
|
21
22
|
groups: ListGroup[];
|
|
22
23
|
size: SpecimenSize;
|
|
@@ -46,11 +47,9 @@ export function ComparisonGroups({
|
|
|
46
47
|
<View style={styles.caption}>
|
|
47
48
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
48
49
|
</View>
|
|
49
|
-
<
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
</SpecimenSurface>
|
|
53
|
-
</View>
|
|
50
|
+
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
51
|
+
<SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
|
|
52
|
+
</SpecimenSurface>
|
|
54
53
|
</View>
|
|
55
54
|
);
|
|
56
55
|
})}
|
|
@@ -102,5 +101,4 @@ const styles = StyleSheet.create({
|
|
|
102
101
|
alignItems: 'center',
|
|
103
102
|
justifyContent: 'center',
|
|
104
103
|
},
|
|
105
|
-
fill: { alignSelf: 'stretch' },
|
|
106
104
|
});
|
|
@@ -2,7 +2,8 @@ import React, { useState } from 'react';
|
|
|
2
2
|
import { View, Text, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_RADIUS, CATALOG_TYPE } from './tokens';
|
|
4
4
|
import { MATRIX_LAYOUT, listGeometry, type ListItem } from './comparison';
|
|
5
|
-
import { SpecimenSurface } from './SpecimenSurface';
|
|
5
|
+
import { SpecimenContent, SpecimenSurface } from './SpecimenSurface';
|
|
6
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
6
7
|
import type { SpecimenSize, SpecimenSurfaceKind } from './types';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -17,7 +18,7 @@ export function ComparisonList({
|
|
|
17
18
|
size,
|
|
18
19
|
label,
|
|
19
20
|
maxColumns,
|
|
20
|
-
surface =
|
|
21
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
21
22
|
}: {
|
|
22
23
|
items: ListItem[];
|
|
23
24
|
size: SpecimenSize;
|
|
@@ -60,11 +61,9 @@ export function ComparisonList({
|
|
|
60
61
|
<View style={styles.caption}>
|
|
61
62
|
<Text style={styles.captionText}>{item.label}</Text>
|
|
62
63
|
</View>
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
</SpecimenSurface>
|
|
67
|
-
</View>
|
|
64
|
+
<SpecimenSurface surface={surface} style={styles.specimen}>
|
|
65
|
+
<SpecimenContent fill={item.fill}>{item.node as React.ReactNode}</SpecimenContent>
|
|
66
|
+
</SpecimenSurface>
|
|
68
67
|
</View>
|
|
69
68
|
);
|
|
70
69
|
})}
|
|
@@ -101,5 +100,4 @@ const styles = StyleSheet.create({
|
|
|
101
100
|
alignItems: 'center',
|
|
102
101
|
justifyContent: 'center',
|
|
103
102
|
},
|
|
104
|
-
fill: { alignSelf: 'stretch' },
|
|
105
103
|
});
|
|
@@ -6,6 +6,7 @@ import { ComparisonGroups } from './ComparisonGroups';
|
|
|
6
6
|
import { ComparisonList } from './ComparisonList';
|
|
7
7
|
import { ReferenceDetails } from './ReferenceDetails';
|
|
8
8
|
import { SpecimenSurface } from './SpecimenSurface';
|
|
9
|
+
import { DEFAULT_SPECIMEN_SURFACE } from './specimenSurfaceStyle';
|
|
9
10
|
import { TokenSections } from './TokenLayouts';
|
|
10
11
|
import { choosePlacement, listGeometry, presentationBlocks, type PresentationBlock } from './comparison';
|
|
11
12
|
import type { PreviewWidths, SectionDef, SpecimenSurfaceKind } from './types';
|
|
@@ -104,21 +105,23 @@ function frameLabel(width: number): string {
|
|
|
104
105
|
* chrome and token galleries. Every frame is its own live instance with its own state. `surface`
|
|
105
106
|
* is omitted for token galleries, which keep their current, unstaged presentation. */
|
|
106
107
|
function Preview({ render, widths, surface }: { render: () => React.ReactNode; widths: PreviewWidths; surface?: SpecimenSurfaceKind }) {
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// Token galleries pass no surface: their card stays white.
|
|
109
|
+
const kind = surface ?? 'transparent';
|
|
110
|
+
if (widths === 'full') return <SpecimenSurface surface={kind} style={styles.previewCard}>{render()}</SpecimenSurface>;
|
|
109
111
|
return (
|
|
110
112
|
<View style={styles.previewCard}>
|
|
111
113
|
<View style={styles.frames}>
|
|
112
114
|
{widths.map((width, i) => (
|
|
113
|
-
<
|
|
115
|
+
<SpecimenSurface
|
|
114
116
|
key={`${width}-${i}`}
|
|
117
|
+
surface={kind}
|
|
115
118
|
// Two live instances of the same component: name each so assistive tech can tell them apart.
|
|
116
119
|
{...(widths.length > 1 ? ({ role: 'group', 'aria-label': frameLabel(width) } as Record<string, unknown>) : null)}
|
|
117
120
|
style={[styles.frame, { width }]}
|
|
118
121
|
>
|
|
119
122
|
{widths.length > 1 && <Text style={styles.frameLabel}>{frameLabel(width)}</Text>}
|
|
120
|
-
{
|
|
121
|
-
</
|
|
123
|
+
{render()}
|
|
124
|
+
</SpecimenSurface>
|
|
122
125
|
))}
|
|
123
126
|
</View>
|
|
124
127
|
</View>
|
|
@@ -246,7 +249,7 @@ export function SectionBlock<TId extends string>({
|
|
|
246
249
|
checkCompleteness(def);
|
|
247
250
|
// Memoized so Blocks' placement effect runs on real changes, not on every render.
|
|
248
251
|
const blocks = useMemo(() => presentationBlocks(def, { defaultPreviewWidths }), [def, defaultPreviewWidths]);
|
|
249
|
-
const surface = def.specimenSurface ??
|
|
252
|
+
const surface = def.specimenSurface ?? DEFAULT_SPECIMEN_SURFACE;
|
|
250
253
|
// react-native-web reads `aria-level`; React Native's prop types do not declare it.
|
|
251
254
|
const headingLevelProps = { 'aria-level': headingLevel } as Record<string, unknown>;
|
|
252
255
|
|
|
@@ -1,46 +1,84 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
-
import { CATALOG_COLOR
|
|
4
|
-
import { SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
|
|
3
|
+
import { CATALOG_COLOR } from './tokens';
|
|
4
|
+
import { DEFAULT_SPECIMEN_SURFACE, SPECIMEN_SURFACE_WIDTH_STYLE } from './specimenSurfaceStyle';
|
|
5
|
+
import { blendsIntoCell, measurePaintedBoxes } from './specimenFill';
|
|
5
6
|
import type { SpecimenSurfaceKind } from './types';
|
|
6
7
|
|
|
8
|
+
type Browser = {
|
|
9
|
+
getComputedStyle?: (element: unknown) => never;
|
|
10
|
+
Element?: new () => unknown;
|
|
11
|
+
requestAnimationFrame?: (callback: () => void) => number;
|
|
12
|
+
};
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the real comparison-cell width instead of collapsing to 0px.
|
|
15
|
+
* An example's whole cell area — shared by `ComparisonList`, `ComparisonGroups`,
|
|
16
|
+
* `ComparisonGrid`, and `SectionBlock`'s preview frames, so a page's `specimenSurface` fills every
|
|
17
|
+
* example cell the same way. With `'auto'` (the default) the cell measures its example after it
|
|
18
|
+
* draws and turns gray only when the example would vanish against white (a white card, sheet or
|
|
19
|
+
* neutral banner; see `specimenFill.ts`). Explicit kinds fill the cell without measuring. Adds no
|
|
20
|
+
* padding or border of its own: pass the cell's own layout in `style`. Measuring needs the
|
|
21
|
+
* browser's DOM; elsewhere `'auto'` leaves the cell white. Extra props (e.g. `role`) go to the View.
|
|
16
22
|
*/
|
|
17
23
|
export function SpecimenSurface({
|
|
18
|
-
surface =
|
|
24
|
+
surface = DEFAULT_SPECIMEN_SURFACE,
|
|
19
25
|
style,
|
|
20
26
|
children,
|
|
27
|
+
...viewProps
|
|
21
28
|
}: {
|
|
22
29
|
surface?: SpecimenSurfaceKind;
|
|
23
30
|
style?: StyleProp<ViewStyle>;
|
|
24
31
|
children: React.ReactNode;
|
|
32
|
+
[prop: string]: unknown;
|
|
25
33
|
}) {
|
|
26
|
-
|
|
34
|
+
const ref = useRef<View>(null);
|
|
35
|
+
const [blends, setBlends] = useState(false);
|
|
36
|
+
|
|
37
|
+
const measure = useCallback(() => {
|
|
38
|
+
if (surface !== 'auto') return;
|
|
39
|
+
const browser = globalThis as unknown as Browser;
|
|
40
|
+
const node = ref.current as unknown;
|
|
41
|
+
if (!browser.getComputedStyle || !browser.Element || !(node instanceof browser.Element)) return;
|
|
42
|
+
const { boxes, cellArea } = measurePaintedBoxes(node as Parameters<typeof measurePaintedBoxes>[0], (element) => browser.getComputedStyle!(element));
|
|
43
|
+
setBlends(blendsIntoCell(boxes, cellArea));
|
|
44
|
+
}, [surface]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const raf = (globalThis as unknown as Browser).requestAnimationFrame;
|
|
48
|
+
if (surface !== 'auto' || !raf) return;
|
|
49
|
+
// Two frames, so the example's own styles and fonts have applied before measuring.
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
raf(() => raf(() => {
|
|
52
|
+
if (!cancelled) measure();
|
|
53
|
+
}));
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
}, [surface, measure]);
|
|
58
|
+
|
|
59
|
+
const fill = surface === 'auto' ? (blends ? FILL.neutral : undefined) : FILL[surface];
|
|
60
|
+
return (
|
|
61
|
+
<View ref={ref} onLayout={surface === 'auto' ? measure : undefined} style={[style, fill]} {...viewProps}>
|
|
62
|
+
{children}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
27
65
|
}
|
|
28
66
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
67
|
+
/** Wraps one example inside its cell: full cell width (so a percentage-width example resolves
|
|
68
|
+
* against the cell), centered unless the example fills the cell. */
|
|
69
|
+
export function SpecimenContent({ fill, children }: { fill?: boolean; children: React.ReactNode }) {
|
|
70
|
+
return <View style={fill ? styles.fill : styles.center}>{children}</View>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 'dark' reuses CATALOG_COLOR.text (the catalog's own darkest token); there is no dark-surface token.
|
|
74
|
+
const FILL = StyleSheet.create({
|
|
75
|
+
neutral: { backgroundColor: CATALOG_COLOR.specimenStage },
|
|
33
76
|
white: { backgroundColor: CATALOG_COLOR.surface },
|
|
34
77
|
dark: { backgroundColor: CATALOG_COLOR.text },
|
|
35
|
-
transparent: {
|
|
36
|
-
};
|
|
78
|
+
transparent: {},
|
|
79
|
+
});
|
|
37
80
|
|
|
38
81
|
const styles = StyleSheet.create({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
alignItems: 'center',
|
|
42
|
-
justifyContent: 'center',
|
|
43
|
-
borderRadius: CATALOG_RADIUS.sm,
|
|
44
|
-
padding: CATALOG_SPACE.md,
|
|
45
|
-
},
|
|
82
|
+
center: { ...SPECIMEN_SURFACE_WIDTH_STYLE, alignItems: 'center' },
|
|
83
|
+
fill: { ...SPECIMEN_SURFACE_WIDTH_STYLE },
|
|
46
84
|
});
|
|
@@ -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
|
+
}
|