@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.
@@ -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 { groupLabelFor, hashForId, idFromHash, neighbors, orderedIds } from './catalogNavigation';
8
- import type { NavGroup, PreviewWidths, SectionDef } from './types';
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() ? idFromHash(window.location.hash, order) : order[0],
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 = idFromHash(window.location.hash, order);
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
- {activeDef ? (
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: TId | undefined;
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. */}