component-previewer 0.1.1 → 0.2.0

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.
Files changed (53) hide show
  1. package/README.md +67 -12
  2. package/lib/module/PreviewControls.js +159 -14
  3. package/lib/module/PreviewControls.js.map +1 -1
  4. package/lib/module/PreviewList.js +3 -0
  5. package/lib/module/PreviewList.js.map +1 -1
  6. package/lib/module/PreviewStage.js +47 -12
  7. package/lib/module/PreviewStage.js.map +1 -1
  8. package/lib/module/Previewer.js +22 -3
  9. package/lib/module/Previewer.js.map +1 -1
  10. package/lib/module/controls.js +32 -6
  11. package/lib/module/controls.js.map +1 -1
  12. package/lib/module/csf.js +5 -2
  13. package/lib/module/csf.js.map +1 -1
  14. package/lib/module/globals.js +14 -0
  15. package/lib/module/globals.js.map +1 -0
  16. package/lib/module/index.js +2 -0
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/persistence.js +45 -0
  19. package/lib/module/persistence.js.map +1 -0
  20. package/lib/module/shell.js +13 -3
  21. package/lib/module/shell.js.map +1 -1
  22. package/lib/typescript/src/PreviewControls.d.ts +6 -1
  23. package/lib/typescript/src/PreviewControls.d.ts.map +1 -1
  24. package/lib/typescript/src/PreviewList.d.ts.map +1 -1
  25. package/lib/typescript/src/PreviewStage.d.ts +8 -2
  26. package/lib/typescript/src/PreviewStage.d.ts.map +1 -1
  27. package/lib/typescript/src/Previewer.d.ts +6 -2
  28. package/lib/typescript/src/Previewer.d.ts.map +1 -1
  29. package/lib/typescript/src/controls.d.ts +8 -0
  30. package/lib/typescript/src/controls.d.ts.map +1 -1
  31. package/lib/typescript/src/csf.d.ts.map +1 -1
  32. package/lib/typescript/src/globals.d.ts +3 -0
  33. package/lib/typescript/src/globals.d.ts.map +1 -0
  34. package/lib/typescript/src/index.d.ts +4 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/lib/typescript/src/persistence.d.ts +11 -0
  37. package/lib/typescript/src/persistence.d.ts.map +1 -0
  38. package/lib/typescript/src/shell.d.ts +3 -2
  39. package/lib/typescript/src/shell.d.ts.map +1 -1
  40. package/lib/typescript/src/types.d.ts +9 -1
  41. package/lib/typescript/src/types.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/PreviewControls.tsx +109 -6
  44. package/src/PreviewList.tsx +7 -1
  45. package/src/PreviewStage.tsx +54 -9
  46. package/src/Previewer.tsx +30 -3
  47. package/src/controls.ts +24 -4
  48. package/src/csf.ts +6 -1
  49. package/src/globals.ts +13 -0
  50. package/src/index.ts +6 -0
  51. package/src/persistence.ts +49 -0
  52. package/src/shell.tsx +15 -5
  53. package/src/types.ts +19 -1
@@ -1,27 +1,76 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import { Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View } from 'react-native';
3
3
  import type { Control } from './controls';
4
+ import type { GlobalTypes, Globals } from './types';
4
5
 
5
6
  // Dependency-free controls panel (bottom sheet). Neutral chrome — the host theme
6
- // applies to the previewed story, not here. Each control edits one arg live.
7
+ // applies to the previewed story, not here. A top "Globals" section switches the
8
+ // shell (theme/locale, …); below it, each control edits one arg live.
7
9
  export function PreviewControls({
8
10
  controls,
9
11
  onChange,
10
12
  onReset,
13
+ onCopy,
14
+ globalTypes,
15
+ globals,
16
+ onGlobalChange,
11
17
  }: {
12
18
  controls: Control[];
13
19
  onChange: (name: string, value: unknown) => void;
14
20
  onReset: () => void;
21
+ onCopy?: () => void;
22
+ globalTypes?: GlobalTypes;
23
+ globals?: Globals;
24
+ onGlobalChange?: (key: string, value: unknown) => void;
15
25
  }) {
26
+ const globalKeys = globalTypes ? Object.keys(globalTypes) : [];
16
27
  return (
17
28
  <View style={styles.sheet}>
18
29
  <View style={styles.header}>
19
30
  <Text style={styles.title}>Props</Text>
20
- <Pressable onPress={onReset} accessibilityLabel="Reset props">
21
- <Text style={styles.reset}>Reset</Text>
22
- </Pressable>
31
+ <View style={styles.headerActions}>
32
+ {onCopy && (
33
+ <Pressable onPress={onCopy} accessibilityRole="button" accessibilityLabel="Copy props">
34
+ <Text style={styles.action}>Copy</Text>
35
+ </Pressable>
36
+ )}
37
+ <Pressable onPress={onReset} accessibilityRole="button" accessibilityLabel="Reset props">
38
+ <Text style={styles.action}>Reset</Text>
39
+ </Pressable>
40
+ </View>
23
41
  </View>
24
42
  <ScrollView keyboardShouldPersistTaps="handled">
43
+ {globalKeys.length > 0 && globalTypes && globals && onGlobalChange && (
44
+ <View style={styles.globals}>
45
+ <Text style={styles.sectionLabel}>Globals</Text>
46
+ {globalKeys.map((key) => {
47
+ const gt = globalTypes[key];
48
+ return (
49
+ <View key={key} style={styles.row}>
50
+ <Text style={styles.label}>{gt.label ?? key}</Text>
51
+ <View style={styles.control}>
52
+ <View style={styles.options}>
53
+ {gt.options.map((opt) => {
54
+ const selected = opt === globals[key];
55
+ return (
56
+ <Pressable
57
+ key={String(opt)}
58
+ style={[styles.pill, selected && styles.pillSelected]}
59
+ onPress={() => onGlobalChange(key, opt)}
60
+ accessibilityRole="button"
61
+ accessibilityLabel={`${gt.label ?? key}: ${String(opt)}`}
62
+ >
63
+ <Text style={[styles.pillText, selected && styles.pillTextSelected]}>{String(opt)}</Text>
64
+ </Pressable>
65
+ );
66
+ })}
67
+ </View>
68
+ </View>
69
+ </View>
70
+ );
71
+ })}
72
+ </View>
73
+ )}
25
74
  {controls.map((c) => (
26
75
  <View key={c.name} style={styles.row}>
27
76
  <Text style={styles.label}>{c.name}</Text>
@@ -75,6 +124,25 @@ function ControlInput({ control, onChange }: { control: Control; onChange: (valu
75
124
  </View>
76
125
  );
77
126
 
127
+ case 'color':
128
+ return (
129
+ <View style={styles.colorRow}>
130
+ <View style={[styles.swatch, { backgroundColor: control.value || 'transparent' }]} />
131
+ <TextInput
132
+ style={[styles.input, styles.colorInput]}
133
+ value={control.value}
134
+ autoCapitalize="none"
135
+ autoCorrect={false}
136
+ placeholder="#rrggbb"
137
+ placeholderTextColor="#666"
138
+ onChangeText={onChange}
139
+ />
140
+ </View>
141
+ );
142
+
143
+ case 'object':
144
+ return <ObjectInput value={control.value} onChange={onChange} />;
145
+
78
146
  default: // 'text'
79
147
  return (
80
148
  <TextInput
@@ -88,6 +156,33 @@ function ControlInput({ control, onChange }: { control: Control; onChange: (valu
88
156
  }
89
157
  }
90
158
 
159
+ // Object/array editor. Holds the raw text locally so a transiently-invalid edit
160
+ // (mid-typing) isn't reverted keystroke-by-keystroke; commits only on valid JSON.
161
+ // Reset reseeds it because the panel is keyed by a reset nonce in PreviewStage.
162
+ function ObjectInput({ value, onChange }: { value: unknown; onChange: (value: unknown) => void }) {
163
+ const [text, setText] = useState(() => JSON.stringify(value, null, 2));
164
+ const [valid, setValid] = useState(true);
165
+ return (
166
+ <TextInput
167
+ style={[styles.input, styles.objectInput, !valid && styles.objectInvalid]}
168
+ value={text}
169
+ multiline
170
+ autoCapitalize="none"
171
+ autoCorrect={false}
172
+ onChangeText={(t) => {
173
+ setText(t);
174
+ try {
175
+ const parsed: unknown = JSON.parse(t);
176
+ setValid(true);
177
+ onChange(parsed);
178
+ } catch {
179
+ setValid(false); // keep the text; don't commit until it parses
180
+ }
181
+ }}
182
+ />
183
+ );
184
+ }
185
+
91
186
  const styles = StyleSheet.create({
92
187
  sheet: {
93
188
  position: 'absolute',
@@ -106,7 +201,10 @@ const styles = StyleSheet.create({
106
201
  },
107
202
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
108
203
  title: { color: '#fff', fontSize: 16, fontWeight: '700' },
109
- reset: { color: '#7aa2ff', fontSize: 14, fontWeight: '600' },
204
+ headerActions: { flexDirection: 'row', gap: 16 },
205
+ action: { color: '#7aa2ff', fontSize: 14, fontWeight: '600' },
206
+ globals: { marginBottom: 8, paddingBottom: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#333' },
207
+ sectionLabel: { color: '#7f7f88', fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.6 },
110
208
  row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 12 },
111
209
  label: { color: '#cbcbd2', fontSize: 14, width: 96 },
112
210
  control: { flex: 1, alignItems: 'flex-end' },
@@ -120,6 +218,11 @@ const styles = StyleSheet.create({
120
218
  width: '100%',
121
219
  textAlign: 'right',
122
220
  },
221
+ colorRow: { flexDirection: 'row', alignItems: 'center', gap: 8, width: '100%' },
222
+ swatch: { width: 26, height: 26, borderRadius: 6, borderWidth: StyleSheet.hairlineWidth, borderColor: '#555' },
223
+ colorInput: { flex: 1, minWidth: 0 },
224
+ objectInput: { textAlign: 'left', minHeight: 88, fontFamily: 'Courier', fontSize: 12 },
225
+ objectInvalid: { borderWidth: 1, borderColor: '#e0566f' },
123
226
  options: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-end', gap: 6 },
124
227
  pill: { backgroundColor: '#262629', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 6 },
125
228
  pillSelected: { backgroundColor: '#7aa2ff' },
@@ -39,7 +39,13 @@ export function PreviewList({
39
39
  data={filtered}
40
40
  keyExtractor={(e) => e.id}
41
41
  renderItem={({ item }) => (
42
- <Pressable style={styles.row} onPress={() => onSelect(item)}>
42
+ <Pressable
43
+ style={styles.row}
44
+ onPress={() => onSelect(item)}
45
+ accessibilityRole="button"
46
+ accessibilityLabel={`${item.title} / ${item.name}`}
47
+ testID={`story-row-${item.id}`}
48
+ >
43
49
  <Text style={styles.rowTitle}>
44
50
  {item.title} <Text style={styles.rowName}>/ {item.name}</Text>
45
51
  </Text>
@@ -2,45 +2,90 @@ import React, { useMemo, useState } from 'react';
2
2
  import { Pressable, StyleSheet, Text, View } from 'react-native';
3
3
  import { inferControls } from './controls';
4
4
  import { PreviewControls } from './PreviewControls';
5
+ import { loadArgs, resetArgs, saveArgs, type ArgsPersistence } from './persistence';
5
6
  import { composeStory, type ShellComponent } from './shell';
6
- import type { Args, StoryEntry } from './types';
7
+ import type { Args, GlobalTypes, Globals, StoryEntry } from './types';
7
8
 
8
9
  // Renders the selected story full-bleed, composed with the real shell (outer)
9
10
  // and the story's decorators (inner). The picker chrome stays outside the shell.
10
- // Live props controls edit `args` and re-render the story in place.
11
+ // Live props controls edit `args` and re-render the story in place. Edits persist
12
+ // per story id (session by default; cross-reload if a `persistence` adapter is
13
+ // passed), so navigating away and back keeps your tweaks. `globals` parameterize
14
+ // the shell (theme/locale) from the same panel.
11
15
  export function PreviewStage({
12
16
  entry,
13
17
  shell,
14
18
  onBack,
19
+ persistence,
20
+ onCopyArgs,
21
+ globalTypes,
22
+ globals,
23
+ onGlobalChange,
15
24
  }: {
16
25
  entry: StoryEntry;
17
26
  shell?: ShellComponent;
18
27
  onBack: () => void;
28
+ persistence?: ArgsPersistence;
29
+ onCopyArgs?: (args: Args, entry: StoryEntry) => void;
30
+ globalTypes?: GlobalTypes;
31
+ globals?: Globals;
32
+ onGlobalChange?: (key: string, value: unknown) => void;
19
33
  }) {
20
- const [args, setArgs] = useState<Args>(entry.args);
34
+ const [args, setArgs] = useState<Args>(() => loadArgs(entry.id, entry.args, persistence));
21
35
  const [controlsOpen, setControlsOpen] = useState(false);
36
+ // Bumped on Reset to remount the controls panel so the JSON object editor
37
+ // reseeds its local text from the restored args.
38
+ const [resetNonce, setResetNonce] = useState(0);
22
39
  const controls = useMemo(() => inferControls(args, entry.argTypes), [args, entry.argTypes]);
23
40
 
41
+ const change = (name: string, value: unknown) =>
42
+ setArgs((a) => {
43
+ const next = { ...a, [name]: value };
44
+ saveArgs(entry.id, next, persistence);
45
+ return next;
46
+ });
47
+
48
+ const reset = () => {
49
+ resetArgs(entry.id, persistence);
50
+ setArgs(entry.args);
51
+ setResetNonce((n) => n + 1);
52
+ };
53
+
54
+ // Default copy logs the args JSON (zero-dep); a host can wire a real clipboard.
55
+ const copy = () => {
56
+ if (onCopyArgs) onCopyArgs(args, entry);
57
+ else console.log(`[previewer] ${entry.id} args:\n${JSON.stringify(args, null, 2)}`);
58
+ };
59
+
60
+ const hasGlobals = !!globalTypes && Object.keys(globalTypes).length > 0;
61
+ const hasPanel = controls.length > 0 || hasGlobals;
62
+
24
63
  return (
25
64
  <View style={styles.root}>
26
- <View style={styles.content}>{composeStory(entry, shell, args)}</View>
27
- <Pressable style={styles.back} onPress={onBack} accessibilityLabel="Back to stories">
65
+ <View style={styles.content}>{composeStory(entry, shell, args, globals)}</View>
66
+ <Pressable style={styles.back} onPress={onBack} accessibilityRole="button" accessibilityLabel="Back to stories">
28
67
  <Text style={styles.backText}>‹ {entry.title} / {entry.name}</Text>
29
68
  </Pressable>
30
- {controls.length > 0 && (
69
+ {hasPanel && (
31
70
  <Pressable
32
71
  style={styles.toggle}
33
72
  onPress={() => setControlsOpen((v) => !v)}
73
+ accessibilityRole="button"
34
74
  accessibilityLabel="Toggle props controls"
35
75
  >
36
76
  <Text style={styles.backText}>{controlsOpen ? 'Controls ▾' : 'Controls ▴'}</Text>
37
77
  </Pressable>
38
78
  )}
39
- {controlsOpen && controls.length > 0 && (
79
+ {controlsOpen && hasPanel && (
40
80
  <PreviewControls
81
+ key={resetNonce}
41
82
  controls={controls}
42
- onChange={(name, value) => setArgs((a) => ({ ...a, [name]: value }))}
43
- onReset={() => setArgs(entry.args)}
83
+ onChange={change}
84
+ onReset={reset}
85
+ onCopy={copy}
86
+ globalTypes={globalTypes}
87
+ globals={globals}
88
+ onGlobalChange={onGlobalChange}
44
89
  />
45
90
  )}
46
91
  </View>
package/src/Previewer.tsx CHANGED
@@ -1,28 +1,55 @@
1
1
  import React, { useState } from 'react';
2
+ import { initGlobals } from './globals';
2
3
  import { PreviewList } from './PreviewList';
3
4
  import { PreviewStage } from './PreviewStage';
5
+ import type { ArgsPersistence } from './persistence';
4
6
  import type { ShellComponent } from './shell';
5
- import type { StoryEntry } from './types';
7
+ import type { Args, GlobalTypes, StoryEntry } from './types';
6
8
 
7
9
  // The reusable picker + stage UI. The host builds `stories` via
8
10
  // fromRequireContext/fromGlob and passes its real `shell` (AppProviders).
9
11
  // `initialStoryId` boots straight into one story (useful for deep links /
10
- // screenshots / "jump to the thing I'm editing").
12
+ // screenshots / "jump to the thing I'm editing"). `persistence` (optional) makes
13
+ // edited args survive a cold reload; `onCopyArgs` (optional) wires a real
14
+ // clipboard for the Copy button (defaults to console.log). `globalTypes`
15
+ // (optional) declares toolbar params (theme/locale) that the shell reads — one
16
+ // real shell parameterized, instead of forked light/dark copies.
11
17
  export function Previewer({
12
18
  stories,
13
19
  shell,
14
20
  initialStoryId,
21
+ persistence,
22
+ onCopyArgs,
23
+ globalTypes,
15
24
  }: {
16
25
  stories: StoryEntry[];
17
26
  shell?: ShellComponent;
18
27
  initialStoryId?: string;
28
+ persistence?: ArgsPersistence;
29
+ onCopyArgs?: (args: Args, entry: StoryEntry) => void;
30
+ globalTypes?: GlobalTypes;
19
31
  }) {
20
32
  const initial = initialStoryId ? (stories.find((s) => s.id === initialStoryId) ?? null) : null;
21
33
  const [selected, setSelected] = useState<StoryEntry | null>(initial);
34
+ // Globals live above the selected story so they survive list <-> stage nav.
35
+ const [globals, setGlobals] = useState(() => initGlobals(globalTypes));
36
+ const changeGlobal = (key: string, value: unknown) => setGlobals((g) => ({ ...g, [key]: value }));
22
37
 
23
38
  if (selected) {
24
39
  // key by story id so live-edited arg state resets cleanly per story.
25
- return <PreviewStage key={selected.id} entry={selected} shell={shell} onBack={() => setSelected(null)} />;
40
+ return (
41
+ <PreviewStage
42
+ key={selected.id}
43
+ entry={selected}
44
+ shell={shell}
45
+ onBack={() => setSelected(null)}
46
+ persistence={persistence}
47
+ onCopyArgs={onCopyArgs}
48
+ globalTypes={globalTypes}
49
+ globals={globals}
50
+ onGlobalChange={changeGlobal}
51
+ />
52
+ );
26
53
  }
27
54
  return <PreviewList entries={stories} onSelect={setSelected} />;
28
55
  }
package/src/controls.ts CHANGED
@@ -3,9 +3,14 @@ import type { Args, ArgType, ArgTypes, ControlKind } from './types';
3
3
  // A render-ready control descriptor. Pure data — the UI maps these to inputs.
4
4
  export type Control =
5
5
  | { name: string; kind: 'text'; value: string }
6
+ | { name: string; kind: 'color'; value: string }
6
7
  | { name: string; kind: 'boolean'; value: boolean }
7
8
  | { name: string; kind: 'number'; value: number; min?: number; max?: number; step?: number }
8
- | { name: string; kind: 'select'; value: unknown; options: unknown[] };
9
+ | { name: string; kind: 'select'; value: unknown; options: unknown[] }
10
+ | { name: string; kind: 'object'; value: unknown };
11
+
12
+ // A full hex color: #rgb, #rgba, #rrggbb, or #rrggbbaa.
13
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
9
14
 
10
15
  type Bounds = { min?: number; max?: number; step?: number };
11
16
 
@@ -59,16 +64,31 @@ export function inferControls(args: Args, argTypes: ArgTypes = {}): Control[] {
59
64
  controls.push({ name, kind: 'boolean', value: Boolean(value) });
60
65
  continue;
61
66
  }
67
+ if (kind === 'color') {
68
+ controls.push({ name, kind: 'color', value: toText(value) });
69
+ continue;
70
+ }
71
+ if (kind === 'object') {
72
+ // Editable as JSON; the value passes through as-is until edited.
73
+ controls.push({ name, kind: 'object', value });
74
+ continue;
75
+ }
62
76
  if (kind === 'text') {
63
77
  controls.push({ name, kind: 'text', value: toText(value) });
64
78
  continue;
65
79
  }
66
80
 
67
81
  // No explicit control — infer from the value type.
68
- if (typeof value === 'string') controls.push({ name, kind: 'text', value });
69
- else if (typeof value === 'number') controls.push({ name, kind: 'number', value });
82
+ if (typeof value === 'string') {
83
+ // A bare hex string is unambiguously a color; everything else is text.
84
+ controls.push(
85
+ HEX_COLOR.test(value)
86
+ ? { name, kind: 'color', value }
87
+ : { name, kind: 'text', value },
88
+ );
89
+ } else if (typeof value === 'number') controls.push({ name, kind: 'number', value });
70
90
  else if (typeof value === 'boolean') controls.push({ name, kind: 'boolean', value });
71
- // else: skipped (function/object/array/null/undefined).
91
+ // else: skipped (function/object/array/null/undefined) — pass through untouched.
72
92
  }
73
93
 
74
94
  return controls;
package/src/csf.ts CHANGED
@@ -43,11 +43,16 @@ export function parseCsfModule(id: string, mod: CsfModule): StoryEntry[] {
43
43
  const decorators = [...(story.decorators ?? []), ...(meta?.decorators ?? [])];
44
44
  const name = story.name ?? exportName;
45
45
 
46
+ // Resolution order (Storybook CSF3): the story's own render, then a
47
+ // meta-level default render, then the meta `component` itself.
46
48
  const render =
47
49
  story.render ??
50
+ meta?.render ??
48
51
  ((a: Args) => {
49
52
  if (!meta?.component) {
50
- throw new Error(`Story "${exportName}" in ${id}: no render fn and meta has no component`);
53
+ throw new Error(
54
+ `Story "${exportName}" in ${id}: no story render, no meta render, and meta has no component`,
55
+ );
51
56
  }
52
57
  return createElement(meta.component, a);
53
58
  });
package/src/globals.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { GlobalTypes, Globals } from './types';
2
+
3
+ // Build the initial globals object from the declared globalTypes: each key takes
4
+ // its `default`, falling back to the first option. Pure; the Previewer seeds its
5
+ // state from this and the shell reads the result.
6
+ export function initGlobals(globalTypes: GlobalTypes = {}): Globals {
7
+ const globals: Globals = {};
8
+ for (const key of Object.keys(globalTypes)) {
9
+ const gt = globalTypes[key];
10
+ globals[key] = gt.default !== undefined ? gt.default : gt.options[0];
11
+ }
12
+ return globals;
13
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,9 @@ export { composeStory } from './shell';
5
5
  export type { ShellComponent } from './shell';
6
6
  export { inferControls } from './controls';
7
7
  export type { Control } from './controls';
8
+ export { initGlobals } from './globals';
9
+ export { loadArgs, saveArgs, resetArgs } from './persistence';
10
+ export type { ArgsPersistence } from './persistence';
8
11
  export type {
9
12
  Args,
10
13
  ArgType,
@@ -13,6 +16,9 @@ export type {
13
16
  CsfModule,
14
17
  Decorator,
15
18
  GlobModules,
19
+ Globals,
20
+ GlobalType,
21
+ GlobalTypes,
16
22
  Meta,
17
23
  RequireContext,
18
24
  Story,
@@ -0,0 +1,49 @@
1
+ import type { Args } from './types';
2
+
3
+ // Optional host-provided adapter for cross-reload persistence. The package stays
4
+ // dependency-free, so it never imports AsyncStorage/MMKV itself — the host wires
5
+ // one if it wants edited args to survive a COLD reload:
6
+ //
7
+ // import AsyncStorage from '@react-native-async-storage/async-storage';
8
+ // // ...a thin synchronous cache around AsyncStorage, or any sync store...
9
+ // <Previewer persistence={myAdapter} ... />
10
+ export type ArgsPersistence = {
11
+ get(storyId: string): Args | undefined;
12
+ set(storyId: string, args: Args): void;
13
+ remove?(storyId: string): void;
14
+ };
15
+
16
+ // In-memory session store. Survives list <-> stage navigation AND Fast Refresh
17
+ // (this module is untouched when a *.stories.tsx file edits), with zero deps.
18
+ // Cleared only on a cold reload; pass `persistence` to outlast that too.
19
+ const session = new Map<string, Args>();
20
+
21
+ // Resolve the args to seed a story with: a live session edit wins, then a
22
+ // host-persisted value (cached back into the session), then the declared args.
23
+ export function loadArgs(storyId: string, declared: Args, persistence?: ArgsPersistence): Args {
24
+ const live = session.get(storyId);
25
+ if (live) return live;
26
+ const stored = persistence?.get(storyId);
27
+ if (stored) {
28
+ session.set(storyId, stored);
29
+ return stored;
30
+ }
31
+ return declared;
32
+ }
33
+
34
+ // Record an edit. Always updates the session; mirrors to the host adapter.
35
+ export function saveArgs(storyId: string, args: Args, persistence?: ArgsPersistence): void {
36
+ session.set(storyId, args);
37
+ persistence?.set(storyId, args);
38
+ }
39
+
40
+ // Drop the override so the story falls back to its declared args.
41
+ export function resetArgs(storyId: string, persistence?: ArgsPersistence): void {
42
+ session.delete(storyId);
43
+ persistence?.remove?.(storyId);
44
+ }
45
+
46
+ // Test seam: forget every session edit.
47
+ export function _clearSession(): void {
48
+ session.clear();
49
+ }
package/src/shell.tsx CHANGED
@@ -1,15 +1,25 @@
1
1
  import { createElement } from 'react';
2
2
  import type { ComponentType, ReactElement } from 'react';
3
- import type { Args, StoryEntry } from './types';
3
+ import type { Args, Globals, StoryEntry } from './types';
4
4
 
5
- export type ShellComponent = ComponentType<{ children: ReactElement }>;
5
+ // The host's real app shell. Receives `children` always, and `globals` (the
6
+ // toolbar params) so the SAME shell can switch theme/locale instead of being
7
+ // forked. Shells written before globals existed (`{ children }` only) keep
8
+ // working — the extra prop is simply ignored.
9
+ export type ShellComponent = ComponentType<{ children: ReactElement; globals?: Globals }>;
6
10
 
7
11
  // Compose the full element for a story:
8
- // AppShell (outer, single source of truth) > decorators > story.render(args).
12
+ // AppShell(globals) (outer, single source of truth) > decorators > render(args).
9
13
  // The real-shell contract: the host's AppShell ALWAYS wraps outside; CSF
10
14
  // decorators run inside it. Pure — returns an element tree, does not mount.
11
15
  // `args` defaults to the story's declared args; pass an override for live edits.
12
- export function composeStory(entry: StoryEntry, Shell?: ShellComponent, args: Args = entry.args): ReactElement {
16
+ // `globals` parameterize the shell (theme, locale, ...).
17
+ export function composeStory(
18
+ entry: StoryEntry,
19
+ Shell?: ShellComponent,
20
+ args: Args = entry.args,
21
+ globals?: Globals,
22
+ ): ReactElement {
13
23
  let node: ReactElement = entry.render(args);
14
24
 
15
25
  for (const decorate of entry.decorators) {
@@ -18,5 +28,5 @@ export function composeStory(entry: StoryEntry, Shell?: ShellComponent, args: Ar
18
28
  node = decorate(Story);
19
29
  }
20
30
 
21
- return Shell ? createElement(Shell, null, node) : node;
31
+ return Shell ? createElement(Shell, { children: node, globals }) : node;
22
32
  }
package/src/types.ts CHANGED
@@ -10,7 +10,7 @@ export type Decorator = (Story: ComponentType) => ReactElement;
10
10
 
11
11
  // CSF-compatible control hints. Optional: controls default to value-inference.
12
12
  // `control` accepts the Storybook string shorthand or an object { type, ... }.
13
- export type ControlKind = 'text' | 'number' | 'boolean' | 'select' | 'range';
13
+ export type ControlKind = 'text' | 'number' | 'boolean' | 'select' | 'range' | 'color' | 'object';
14
14
 
15
15
  export type ArgType = {
16
16
  control?: ControlKind | { type: ControlKind; min?: number; max?: number; step?: number };
@@ -22,6 +22,21 @@ export type ArgType = {
22
22
 
23
23
  export type ArgTypes = Record<string, ArgType>;
24
24
 
25
+ // --- Globals: toolbar-level params that parameterize the SHELL across every
26
+ // story (theme, locale, ...). Unlike args (per-component props), globals let the
27
+ // one real shell vary instead of being forked into light/dark copies or a
28
+ // lighter hand-built lab. Declared once on the host <Previewer globalTypes=…>. ---
29
+
30
+ export type Globals = Record<string, unknown>;
31
+
32
+ export type GlobalType = {
33
+ options: unknown[]; // the toolbar choices (rendered as a pill select)
34
+ default?: unknown; // initial value; defaults to options[0]
35
+ label?: string; // toolbar label; defaults to the key
36
+ };
37
+
38
+ export type GlobalTypes = Record<string, GlobalType>;
39
+
25
40
  // `export default` of a *.stories.tsx file.
26
41
  export type Meta = {
27
42
  title?: string;
@@ -31,6 +46,9 @@ export type Meta = {
31
46
  args?: Args;
32
47
  argTypes?: ArgTypes;
33
48
  decorators?: Decorator[];
49
+ // A default render shared by every story in the file (CSF3). A story's own
50
+ // `render` overrides it; this overrides the `component` fallback.
51
+ render?: (args: Args) => ReactElement;
34
52
  };
35
53
 
36
54
  // A named export of a *.stories.tsx file (CSF3 object, or a CSF2 render fn).