@krapwoo/ds-viewer 0.3.0 → 0.4.1
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 +80 -0
- package/config/index.ts +4 -2
- package/dist/cli/dev.js +315 -15
- package/dist/cli/doctor.js +10 -0
- package/dist/cli/endpoint.js +142 -0
- package/dist/cli/githubAction.js +8 -11
- package/dist/cli/init.js +3 -3
- package/dist/cli/kitDiff.js +132 -0
- package/dist/cli/kitRoot.js +20 -0
- package/dist/cli/main.js +77 -18
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/migrateEntry.js +19 -0
- package/dist/cli/migrations/index.js +14 -0
- package/dist/cli/migrations/types.js +9 -0
- package/dist/cli/packageManager.js +40 -0
- package/dist/cli/packageVersion.js +24 -0
- package/dist/cli/semver.js +26 -0
- package/dist/cli/update.js +96 -0
- package/dist/cli/updateCheck.js +143 -0
- package/dist/cli/updatePlan.js +211 -0
- package/dist/cli/workspace.js +10 -5
- package/dist/native/catalog/catalogNavigation.js +129 -0
- package/dist/native/catalog/tokens.js +15 -0
- package/native/catalog/CatalogShell.tsx +105 -11
- package/native/catalog/CatalogSidebar.tsx +58 -2
- package/native/catalog/SectionBlock.tsx +5 -0
- package/native/catalog/UpdatePanel.tsx +556 -0
- package/native/catalog/catalogNavigation.ts +70 -1
- package/native/catalog/index.ts +3 -0
- package/native/catalog/tokens.ts +15 -0
- package/native/catalog/types.ts +11 -0
- package/native/catalog/updatePanelState.ts +74 -0
- package/package.json +1 -1
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { AccessibilityInfo, Platform, ScrollView, StyleSheet, Text, View, findNodeHandle } from 'react-native';
|
|
2
|
+
import { AccessibilityInfo, Platform, Pressable, ScrollView, StyleSheet, Text, View, findNodeHandle } from 'react-native';
|
|
3
3
|
import type { ImageSourcePropType } from 'react-native';
|
|
4
|
-
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
4
|
+
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_MAX_CONTENT_WIDTH, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
5
5
|
import { CatalogSidebar } from './CatalogSidebar';
|
|
6
6
|
import { SectionBlock } from './SectionBlock';
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
7
|
+
import { UpdatePanel } from './UpdatePanel';
|
|
8
|
+
import { groupLabelFor, hashForId, neighbors, orderedIds, resolveActiveFromHash, shouldShowMajorBanner, footerLabel, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
9
|
+
import type { NavGroup, PreviewWidths, SectionDef, UpdateNotice } from './types';
|
|
10
|
+
|
|
11
|
+
/** `active` can be a real page id, or the reserved update-page id — never both a generic `TId`
|
|
12
|
+
* constraint and a hardcoded string literal type at once, which is why this is its own alias. */
|
|
13
|
+
type ShellPageId<TId extends string> = TId | typeof UPDATE_PAGE_ID;
|
|
9
14
|
|
|
10
15
|
function isWeb(): boolean {
|
|
11
16
|
return Platform.OS === 'web' && typeof window !== 'undefined';
|
|
@@ -36,6 +41,62 @@ function resetMainScroll(scrollView: ScrollView | null): void {
|
|
|
36
41
|
scrollView.scrollTo({ y: 0, animated: false });
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/** The approved mockup's exact copy shape: `"⚠ DS Viewer <version> is available — includes
|
|
45
|
+
* breaking changes · Migration guide · Review update"`, dismissible with an `×` labeled "Dismiss
|
|
46
|
+
* for <version>". The release's own GitHub page stands in for a dedicated migration-guide URL,
|
|
47
|
+
* which this plan's design text does not otherwise name.
|
|
48
|
+
*
|
|
49
|
+
* Important finding, Fable correction pass: both links now carry a real `href` — `role="link"`
|
|
50
|
+
* with only `onPress` (as `CatalogSidebar.tsx`'s own finding already documents) never activates on
|
|
51
|
+
* Enter under react-native-web unless the element is a genuine anchor. `role="status"` (not
|
|
52
|
+
* `"alert"`, the mockup's own choice) — a non-urgent notice should not interrupt assistive tech. */
|
|
53
|
+
function MajorBanner({ update, onReviewUpdate, onDismiss }: { update: UpdateNotice; onReviewUpdate: () => void; onDismiss: () => void }) {
|
|
54
|
+
const releaseUrl = `https://github.com/krapwoo/design-system-viewer/releases/tag/v${update.latest}`;
|
|
55
|
+
return (
|
|
56
|
+
<View style={bannerStyles.banner} role="status">
|
|
57
|
+
<Text style={bannerStyles.text}>
|
|
58
|
+
{'⚠ '}
|
|
59
|
+
<Text style={bannerStyles.bold}>{`DS Viewer ${update.latest} is available`}</Text>
|
|
60
|
+
{' — includes breaking changes · '}
|
|
61
|
+
<Text
|
|
62
|
+
role="link"
|
|
63
|
+
style={bannerStyles.link}
|
|
64
|
+
{...({ href: releaseUrl, hrefAttrs: { target: '_blank', rel: 'noopener noreferrer' } } as Record<string, unknown>)}
|
|
65
|
+
>
|
|
66
|
+
Migration guide
|
|
67
|
+
</Text>
|
|
68
|
+
{' · '}
|
|
69
|
+
<Text role="link" style={bannerStyles.link} onPress={onReviewUpdate} {...({ href: hashForId(UPDATE_PAGE_ID) } as Record<string, unknown>)}>
|
|
70
|
+
Review update
|
|
71
|
+
</Text>
|
|
72
|
+
</Text>
|
|
73
|
+
<Pressable onPress={onDismiss} accessibilityRole="button" accessibilityLabel={`Dismiss for ${update.latest}`} style={bannerStyles.close}>
|
|
74
|
+
<Text style={bannerStyles.closeGlyph}>×</Text>
|
|
75
|
+
</Pressable>
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const bannerStyles = StyleSheet.create({
|
|
81
|
+
banner: {
|
|
82
|
+
flexDirection: 'row',
|
|
83
|
+
alignItems: 'center',
|
|
84
|
+
gap: CATALOG_SPACE.md,
|
|
85
|
+
backgroundColor: CATALOG_COLOR.warningSubtle,
|
|
86
|
+
borderWidth: 1,
|
|
87
|
+
borderColor: CATALOG_COLOR.warningBorder,
|
|
88
|
+
borderRadius: CATALOG_RADIUS.md,
|
|
89
|
+
paddingVertical: 10,
|
|
90
|
+
paddingHorizontal: 14,
|
|
91
|
+
marginBottom: 18,
|
|
92
|
+
},
|
|
93
|
+
text: { flex: 1, fontSize: CATALOG_TYPE.md, color: CATALOG_COLOR.warning },
|
|
94
|
+
bold: { fontWeight: '700' },
|
|
95
|
+
link: { color: CATALOG_COLOR.warning, fontWeight: '700', textDecorationLine: 'underline' },
|
|
96
|
+
close: { width: 32, height: 32, borderRadius: CATALOG_RADIUS.sm, alignItems: 'center', justifyContent: 'center' },
|
|
97
|
+
closeGlyph: { fontSize: 18, color: CATALOG_COLOR.warning },
|
|
98
|
+
});
|
|
99
|
+
|
|
39
100
|
/**
|
|
40
101
|
* The whole catalog: a persistent, searchable sidebar plus ONE selected page. Selecting a page
|
|
41
102
|
* replaces the main content (no long scrolling document, no scroll-spy). On web the page lives in
|
|
@@ -49,6 +110,8 @@ export function CatalogShell<TId extends string>({
|
|
|
49
110
|
groups,
|
|
50
111
|
sections,
|
|
51
112
|
defaultPreviewWidths,
|
|
113
|
+
update,
|
|
114
|
+
updateEndpoint,
|
|
52
115
|
}: {
|
|
53
116
|
/** Short product/app name — the sidebar logo and the breadcrumb root. */
|
|
54
117
|
appName: string;
|
|
@@ -62,11 +125,20 @@ export function CatalogShell<TId extends string>({
|
|
|
62
125
|
/** Preview widths for component pages without their own `previewWidths`. Default: `[402]`
|
|
63
126
|
* (phone width). Pass 'full' for a catalog whose previews are not phone components. */
|
|
64
127
|
defaultPreviewWidths?: PreviewWidths;
|
|
128
|
+
/** Design §5's update notice — `null`/`undefined` when no update is known (disabled, offline,
|
|
129
|
+
* or already up to date; the viewer treats all three identically: no footer, no banner, no
|
|
130
|
+
* update page content). */
|
|
131
|
+
update?: UpdateNotice | null;
|
|
132
|
+
/** The local endpoint's base URL and per-run secret — `dev` generates both (Task 18) and
|
|
133
|
+
* `writeWorkspace`'s generated entry file bakes them in (Task 17). Absent in a build that
|
|
134
|
+
* disables the endpoint entirely — `UpdatePanel` degrades to showing the plan/notice with no
|
|
135
|
+
* **Update now** button when this is missing. */
|
|
136
|
+
updateEndpoint?: { baseUrl: string; secret: string };
|
|
65
137
|
}) {
|
|
66
138
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
67
139
|
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
68
|
-
const [active, setActive] = useState<TId | undefined>(() =>
|
|
69
|
-
isWeb() ?
|
|
140
|
+
const [active, setActive] = useState<ShellPageId<TId> | undefined>(() =>
|
|
141
|
+
isWeb() ? resolveActiveFromHash(window.location.hash, order) : order[0],
|
|
70
142
|
);
|
|
71
143
|
|
|
72
144
|
const scrollRef = useRef<ScrollView>(null);
|
|
@@ -76,7 +148,7 @@ export function CatalogShell<TId extends string>({
|
|
|
76
148
|
const pushHistoryNext = useRef(false);
|
|
77
149
|
const focusHeadingNext = useRef(false);
|
|
78
150
|
|
|
79
|
-
const select = useCallback((id: TId) => {
|
|
151
|
+
const select = useCallback((id: ShellPageId<TId>) => {
|
|
80
152
|
if (id === activeRef.current) {
|
|
81
153
|
focusElement(headingRef.current);
|
|
82
154
|
return;
|
|
@@ -90,7 +162,7 @@ export function CatalogShell<TId extends string>({
|
|
|
90
162
|
useEffect(() => {
|
|
91
163
|
if (!isWeb()) return;
|
|
92
164
|
const onHashChange = () => {
|
|
93
|
-
const id =
|
|
165
|
+
const id = resolveActiveFromHash(window.location.hash, order);
|
|
94
166
|
if (id === undefined) return;
|
|
95
167
|
if (id !== activeRef.current) {
|
|
96
168
|
focusHeadingNext.current = true;
|
|
@@ -123,18 +195,40 @@ export function CatalogShell<TId extends string>({
|
|
|
123
195
|
if (focus) focusElement(headingRef.current);
|
|
124
196
|
}, [active]);
|
|
125
197
|
|
|
126
|
-
const activeDef = active !== undefined ? sectionsById.get(active) : undefined;
|
|
198
|
+
const activeDef = active !== undefined && active !== UPDATE_PAGE_ID ? sectionsById.get(active) : undefined;
|
|
199
|
+
|
|
200
|
+
const [dismissedMajor, setDismissedMajor] = useState<string | null>(() =>
|
|
201
|
+
isWeb() ? window.localStorage.getItem('ds-viewer-dismissed-major') : null,
|
|
202
|
+
);
|
|
203
|
+
const dismissMajorBanner = useCallback(() => {
|
|
204
|
+
if (!update) return;
|
|
205
|
+
if (isWeb()) window.localStorage.setItem('ds-viewer-dismissed-major', update.latest);
|
|
206
|
+
setDismissedMajor(update.latest);
|
|
207
|
+
}, [update]);
|
|
208
|
+
const footer = footerLabel(update)
|
|
209
|
+
? { label: footerLabel(update)!, active: active === UPDATE_PAGE_ID, onPress: () => select(UPDATE_PAGE_ID) }
|
|
210
|
+
: undefined;
|
|
211
|
+
// Design: the banner shows on every *normal* page; once you're already looking at the update
|
|
212
|
+
// page there is nothing left for "Review update" to do, so it's hidden there (confirmed against
|
|
213
|
+
// the approved mockup's own `frame()`: the banner only ever renders when `!isPanel`).
|
|
214
|
+
const showBanner = active !== UPDATE_PAGE_ID && shouldShowMajorBanner(update, dismissedMajor);
|
|
127
215
|
|
|
128
216
|
return (
|
|
129
217
|
<View style={styles.root}>
|
|
130
|
-
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} active={active} onPress={select} />
|
|
218
|
+
<CatalogSidebar logo={appName} logoImageSource={logoImageSource} caption={title} groups={groups} active={active} onPress={select} footer={footer} />
|
|
131
219
|
<ScrollView ref={scrollRef} style={styles.main} contentContainerStyle={styles.mainContent}>
|
|
132
|
-
{
|
|
220
|
+
{active === UPDATE_PAGE_ID ? (
|
|
221
|
+
<UpdatePanel update={update ?? null} endpoint={updateEndpoint} appName={appName} headingRef={headingRef} />
|
|
222
|
+
) : activeDef ? (
|
|
133
223
|
<SectionBlock
|
|
134
224
|
key={activeDef.id}
|
|
135
225
|
def={activeDef}
|
|
136
226
|
groupLabel={groupLabelFor(groups, activeDef.id)}
|
|
137
227
|
breadcrumbRoot={appName}
|
|
228
|
+
// The approved mockup's own `PAGE_BG` order: crumb, then banner, then title — the
|
|
229
|
+
// banner used to render above the breadcrumb instead (Important finding, Fable
|
|
230
|
+
// correction pass).
|
|
231
|
+
banner={showBanner && update ? <MajorBanner update={update} onReviewUpdate={() => select(UPDATE_PAGE_ID)} onDismiss={dismissMajorBanner} /> : undefined}
|
|
138
232
|
pager={{
|
|
139
233
|
previousId: neighbors(order, activeDef.id).previous,
|
|
140
234
|
nextId: neighbors(order, activeDef.id).next,
|
|
@@ -3,7 +3,7 @@ import { View, Text, ScrollView, Pressable, StyleSheet, Image } from 'react-nati
|
|
|
3
3
|
import type { ImageSourcePropType, LayoutChangeEvent, NativeSyntheticEvent, ImageLoadEventData } from 'react-native';
|
|
4
4
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TYPE } from './tokens';
|
|
5
5
|
import { logoLayout } from './logoLayout';
|
|
6
|
-
import { filterGroups, hashForId } from './catalogNavigation';
|
|
6
|
+
import { filterGroups, hashForId, UPDATE_PAGE_ID } from './catalogNavigation';
|
|
7
7
|
import type { NavGroup } from './types';
|
|
8
8
|
import { CatalogSearchInput } from './CatalogSearchInput';
|
|
9
9
|
|
|
@@ -43,6 +43,32 @@ function NavItem<TId extends string>({ id, active, onPress }: { id: TId; active:
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function FooterLink({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
|
47
|
+
const [hovered, setHovered] = useState(false);
|
|
48
|
+
const [focused, setFocused] = useState(false);
|
|
49
|
+
return (
|
|
50
|
+
<Pressable
|
|
51
|
+
onPress={onPress}
|
|
52
|
+
onHoverIn={() => setHovered(true)}
|
|
53
|
+
onHoverOut={() => setHovered(false)}
|
|
54
|
+
onFocus={() => setFocused(true)}
|
|
55
|
+
onBlur={() => setFocused(false)}
|
|
56
|
+
accessibilityRole="link"
|
|
57
|
+
accessibilityState={{ selected: active }}
|
|
58
|
+
{...({ href: hashForId(UPDATE_PAGE_ID), ...(active ? { 'aria-current': 'page' } : null) } as Record<string, unknown>)}
|
|
59
|
+
style={({ pressed }) => [
|
|
60
|
+
styles.footerLink,
|
|
61
|
+
(pressed || hovered) && styles.footerLinkHover,
|
|
62
|
+
active && styles.footerLinkActive,
|
|
63
|
+
focused && styles.focusRing,
|
|
64
|
+
]}
|
|
65
|
+
>
|
|
66
|
+
<View style={styles.footerDot} />
|
|
67
|
+
<Text style={[styles.footerLabel, active && styles.footerLabelActive]}>{label}</Text>
|
|
68
|
+
</Pressable>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
/**
|
|
47
73
|
* Catalog navigation: app name, caption, a filter field, and one link per page under grouped
|
|
48
74
|
* headings. Selecting a link opens that page (CatalogShell owns which page is shown). The filter
|
|
@@ -55,6 +81,7 @@ export function CatalogSidebar<TId extends string>({
|
|
|
55
81
|
groups,
|
|
56
82
|
active,
|
|
57
83
|
onPress,
|
|
84
|
+
footer,
|
|
58
85
|
}: {
|
|
59
86
|
logo: string;
|
|
60
87
|
/** Optional logo image, validated and bundled by `cli/workspace.ts` from the project's `logo`
|
|
@@ -64,8 +91,12 @@ export function CatalogSidebar<TId extends string>({
|
|
|
64
91
|
logoImageSource?: ImageSourcePropType;
|
|
65
92
|
caption: string;
|
|
66
93
|
groups: NavGroup<TId>[];
|
|
67
|
-
active:
|
|
94
|
+
active: string | undefined;
|
|
68
95
|
onPress: (id: TId) => void;
|
|
96
|
+
/** The quiet update-notice link below the nav list (design §5's footer line), or `undefined`
|
|
97
|
+
* when there is no update to show. `active` is true while the update page itself is open — it
|
|
98
|
+
* gets the same active styling a selected nav item would. */
|
|
99
|
+
footer?: { label: string; active: boolean; onPress: () => void };
|
|
69
100
|
}) {
|
|
70
101
|
const [query, setQuery] = useState('');
|
|
71
102
|
// A bundled local image (the only kind `cli/workspace.ts` ever produces — see its doc comment
|
|
@@ -165,6 +196,11 @@ export function CatalogSidebar<TId extends string>({
|
|
|
165
196
|
</View>
|
|
166
197
|
))}
|
|
167
198
|
</ScrollView>
|
|
199
|
+
{footer && (
|
|
200
|
+
<View style={styles.footerContainer}>
|
|
201
|
+
<FooterLink label={footer.label} active={footer.active} onPress={footer.onPress} />
|
|
202
|
+
</View>
|
|
203
|
+
)}
|
|
168
204
|
</View>
|
|
169
205
|
);
|
|
170
206
|
}
|
|
@@ -193,6 +229,26 @@ const styles = StyleSheet.create({
|
|
|
193
229
|
inlineHeaderTop: { alignItems: 'flex-start' },
|
|
194
230
|
inlineText: { flex: 1, minWidth: 0 },
|
|
195
231
|
empty: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted, paddingHorizontal: CATALOG_LAYOUT.navItemPaddingX, paddingVertical: 8 },
|
|
232
|
+
footerContainer: {
|
|
233
|
+
borderTopWidth: 1,
|
|
234
|
+
borderTopColor: CATALOG_COLOR.borderHairline,
|
|
235
|
+
paddingHorizontal: CATALOG_LAYOUT.sidebarPaddingX,
|
|
236
|
+
paddingTop: 10,
|
|
237
|
+
paddingBottom: 14,
|
|
238
|
+
},
|
|
239
|
+
footerLink: {
|
|
240
|
+
flexDirection: 'row',
|
|
241
|
+
alignItems: 'center',
|
|
242
|
+
gap: CATALOG_SPACE.sm,
|
|
243
|
+
minHeight: CATALOG_LAYOUT.controlSize,
|
|
244
|
+
paddingHorizontal: 10,
|
|
245
|
+
borderRadius: CATALOG_RADIUS.sm,
|
|
246
|
+
},
|
|
247
|
+
footerLinkHover: { backgroundColor: CATALOG_COLOR.surfacePressed },
|
|
248
|
+
footerLinkActive: { backgroundColor: CATALOG_COLOR.accentSubtle },
|
|
249
|
+
footerDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: CATALOG_COLOR.accent, flexShrink: 0 },
|
|
250
|
+
footerLabel: { fontSize: CATALOG_TYPE.sm, color: CATALOG_COLOR.textMuted },
|
|
251
|
+
footerLabelActive: { color: CATALOG_COLOR.accent, fontWeight: '700' },
|
|
196
252
|
groupLabel: {
|
|
197
253
|
fontSize: CATALOG_TYPE.xs,
|
|
198
254
|
fontWeight: '800',
|
|
@@ -213,6 +213,7 @@ export function SectionBlock<TId extends string>({
|
|
|
213
213
|
def,
|
|
214
214
|
groupLabel,
|
|
215
215
|
breadcrumbRoot,
|
|
216
|
+
banner,
|
|
216
217
|
pager,
|
|
217
218
|
headingRef,
|
|
218
219
|
headingLevel = 1,
|
|
@@ -221,6 +222,9 @@ export function SectionBlock<TId extends string>({
|
|
|
221
222
|
def: SectionDef<TId>;
|
|
222
223
|
groupLabel?: string;
|
|
223
224
|
breadcrumbRoot?: string;
|
|
225
|
+
/** The major-update banner (`CatalogShell`'s own `MajorBanner`), rendered between the breadcrumb
|
|
226
|
+
* and the title — the approved mockup's own `PAGE_BG` order: crumb, then banner, then title. */
|
|
227
|
+
banner?: React.ReactNode;
|
|
224
228
|
pager?: SectionPager<TId>;
|
|
225
229
|
headingRef?: React.Ref<View>;
|
|
226
230
|
headingLevel?: 1 | 2;
|
|
@@ -238,6 +242,7 @@ export function SectionBlock<TId extends string>({
|
|
|
238
242
|
{groupLabel && (
|
|
239
243
|
<Text style={styles.breadcrumb}>{breadcrumbRoot ? `${breadcrumbRoot} / ${groupLabel}` : groupLabel}</Text>
|
|
240
244
|
)}
|
|
245
|
+
{banner}
|
|
241
246
|
<View style={styles.titlebar}>
|
|
242
247
|
<View style={styles.titleText}>
|
|
243
248
|
{/* The focus target is the heading itself, so assistive tech announces its role and level. */}
|