@multiplatform.one/storybook 6.0.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.
@@ -0,0 +1,750 @@
1
+ import {
2
+ type ComponentType,
3
+ type ReactNode,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import {
11
+ type Knobs,
12
+ type ThemeConfig,
13
+ BodyFont,
14
+ BorderRadius,
15
+ BorderWidth,
16
+ Elevation,
17
+ FillStyle,
18
+ FontWeight,
19
+ HeadingFont,
20
+ animationNames,
21
+ Space,
22
+ Size,
23
+ TextAccent,
24
+ getPreset,
25
+ getPresetNames,
26
+ useTheme,
27
+ PresetContext,
28
+ defaultPreset,
29
+ defaultKnobs,
30
+ } from "@multiplatform.one/theme";
31
+ import { config } from "@multiplatform.one/platform";
32
+ import { CookiesProvider } from "react-cookie";
33
+ import { Theme, YStack, useTheme as useTamaguiTheme } from "tamagui";
34
+ import type { ThemeName } from "@tamagui/web";
35
+ import type { InputType } from "storybook/internal/csf";
36
+
37
+ export const themeColorsChannelEvent = "theme-addon/theme-colors";
38
+ export const themeColorsOppositeChannelEvent = "theme-addon/theme-colors-opposite";
39
+ export const baseThemeColorsChannelEvent = "theme-addon/base-theme-colors";
40
+ export const baseOppositeChannelEvent = "theme-addon/base-colors-opposite";
41
+ export const accentColorsChannelEvent = "theme-addon/accent-colors";
42
+ export const accentOppositeChannelEvent = "theme-addon/accent-colors-opposite";
43
+ export const animationNamesChannelEvent = "theme-addon/animation-names";
44
+ export const frappeDebugChannelEvent = "frappe-addon/debug-state";
45
+
46
+ /**
47
+ * Storybook 10 changed the `Preview` type to an opaque wrapper.
48
+ * We define a simple ProjectAnnotations-compatible type for the factory return.
49
+ */
50
+ interface Preview {
51
+ globalTypes?: Record<string, InputType>;
52
+ initialGlobals?: Record<string, unknown>;
53
+ parameters?: Record<string, unknown>;
54
+ decorators?: Array<(Story: any, context: any) => any>;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Public types
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export interface CreatePreviewI18n {
62
+ /** An i18next-compatible instance (must expose changeLanguage). */
63
+ instance: { changeLanguage(lang: string): Promise<unknown> };
64
+ /** Available language codes shown in the Storybook toolbar. */
65
+ languages: readonly string[];
66
+ /** Fallback language. */
67
+ defaultLanguage: string;
68
+ }
69
+
70
+ export interface CreatePreviewOptions {
71
+ /** ThemeConfig produced by createDefaultThemeConfig(). */
72
+ themeConfig: ThemeConfig;
73
+ /** i18n configuration for language switching via Storybook globals. */
74
+ i18n: CreatePreviewI18n;
75
+ /** AppProvider component produced by createApp(). */
76
+ AppProvider: ComponentType<{
77
+ themeConfig?: ThemeConfig;
78
+ systemTheme?: string;
79
+ children?: ReactNode;
80
+ }>;
81
+ /** Extra decorators appended after the framework decorator. */
82
+ decorators?: Preview["decorators"];
83
+ /** Extra parameters merged into the default set. */
84
+ parameters?: Record<string, unknown>;
85
+ /** Extra globalTypes merged into the generated set. */
86
+ globalTypes?: Record<string, InputType>;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Knob options map — enum values for Storybook controls
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const knobOptions: Record<string, readonly string[]> = {
94
+ fillStyle: Object.values(FillStyle),
95
+ borderRadius: Object.values(BorderRadius),
96
+ borderWidth: Object.values(BorderWidth),
97
+ elevation: Object.values(Elevation),
98
+ space: Object.values(Space),
99
+ size: Object.values(Size),
100
+ textAccent: Object.values(TextAccent),
101
+ headingFont: Object.values(HeadingFont),
102
+ bodyFont: Object.values(BodyFont),
103
+ fontWeight: Object.values(FontWeight),
104
+ animation: ["none", ...animationNames],
105
+ };
106
+
107
+ const stateKnobFields = [
108
+ "fillStyle",
109
+ "borderRadius",
110
+ "borderWidth",
111
+ "elevation",
112
+ "space",
113
+ "size",
114
+ "textAccent",
115
+ "headingFont",
116
+ "bodyFont",
117
+ "fontWeight",
118
+ ] as const;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Helpers
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function parseValue(value: unknown): unknown {
125
+ if (typeof value !== "string") return value;
126
+ try {
127
+ return JSON.parse(value);
128
+ } catch {
129
+ return value;
130
+ }
131
+ }
132
+
133
+ const toTitleCase = (str: string) =>
134
+ str.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
135
+
136
+ function createControl(key: string): InputType {
137
+ const name = toTitleCase(key);
138
+ const values = knobOptions[key] ?? [];
139
+ // SB 10 expects options as a flat array of primitive values.
140
+ const serialized = (values as readonly string[]).map((value) => value);
141
+ const labels = Object.fromEntries(serialized.map((v) => [v, v.replace("$", "")]));
142
+ // Include defaultValue so Storybook's internal getValuesFromArgTypes
143
+ // always registers this key — even if initialGlobals gets serialized away.
144
+ const fallback = defaultKnobs[key as keyof typeof defaultKnobs];
145
+ return {
146
+ name,
147
+ description: name.toLowerCase(),
148
+ options: serialized,
149
+ control: { type: "select", labels },
150
+ ...(fallback !== undefined && { defaultValue: fallback }),
151
+ };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Canvas background — keeps the preview iframe body in sync with $background
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Wraps story content and sets both the Tamagui `$background` on the wrapper
160
+ * and the preview iframe `document.body` background so the entire canvas
161
+ * matches the active color scheme (light/dark/system).
162
+ */
163
+ function CanvasBackground({ children }: { children: ReactNode }) {
164
+ const tamaguiTheme = useTamaguiTheme();
165
+ const bg = tamaguiTheme.background?.val as string | undefined;
166
+
167
+ useLayoutEffect(() => {
168
+ if (bg) {
169
+ document.body.style.backgroundColor = bg;
170
+ document.documentElement.style.backgroundColor = bg;
171
+ }
172
+ }, [bg]);
173
+
174
+ return (
175
+ <YStack flex={1} minHeight="100vh" backgroundColor="$background">
176
+ {children}
177
+ </YStack>
178
+ );
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // ThemeColorEmitter — publishes resolved CSS color values via the Storybook
183
+ // channel so the manager-side ThemePanel can render accurate color previews
184
+ // without cross-iframe DOM scraping.
185
+ // ---------------------------------------------------------------------------
186
+
187
+ let _channelEmit: ((event: string, data: unknown) => void) | null = null;
188
+ function getChannelEmit(): ((event: string, data: unknown) => void) | null {
189
+ if (_channelEmit) return _channelEmit;
190
+ try {
191
+ const mod = require("storybook/preview-api");
192
+ const channel = mod.addons.getChannel();
193
+ _channelEmit = (event: string, data: unknown) => channel.emit(event, data);
194
+ return _channelEmit;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function AnimationNamesEmitter() {
201
+ const emittedRef = useRef(false);
202
+ useEffect(() => {
203
+ if (emittedRef.current) return;
204
+ emittedRef.current = true;
205
+ getChannelEmit()?.(animationNamesChannelEvent, animationNames as string[]);
206
+ }, []);
207
+ return null;
208
+ }
209
+
210
+ /**
211
+ * Emits SyncModule.__debug() snapshots to the manager-side Frappe addon panel.
212
+ * Lazily resolves the frappe package — no-op if not installed or no SyncModule exists.
213
+ */
214
+ function FrappeDebugEmitter() {
215
+ const prevRef = useRef<string>("");
216
+ const syncRef = useRef<any>(null);
217
+ const [tick, setTick] = useState(0);
218
+
219
+ useEffect(() => {
220
+ let cancelled = false;
221
+ async function resolve() {
222
+ try {
223
+ const { __getActiveSyncModule } = await import("@multiplatform.one/frappe");
224
+ const sync = __getActiveSyncModule();
225
+ if (sync && !cancelled) {
226
+ syncRef.current = sync;
227
+ setTick((t) => t + 1);
228
+ return sync.subscribeStore(() => {
229
+ if (!cancelled) setTick((t) => t + 1);
230
+ });
231
+ }
232
+ } catch {
233
+ // frappe not available
234
+ }
235
+ }
236
+
237
+ const cleanupPromise = resolve();
238
+ // Retry a few times since SyncModule is created lazily
239
+ let attempts = 0;
240
+ const interval = setInterval(async () => {
241
+ attempts++;
242
+ if (syncRef.current || cancelled || attempts >= 10) {
243
+ clearInterval(interval);
244
+ return;
245
+ }
246
+ await resolve();
247
+ }, 2000);
248
+
249
+ return () => {
250
+ cancelled = true;
251
+ clearInterval(interval);
252
+ cleanupPromise.then((unsub) => unsub?.());
253
+ };
254
+ }, []);
255
+
256
+ useEffect(() => {
257
+ if (!syncRef.current) return;
258
+ const emit = getChannelEmit();
259
+ if (!emit) return;
260
+ try {
261
+ const state = syncRef.current.__debug();
262
+ const key = JSON.stringify(state);
263
+ if (key === prevRef.current) return;
264
+ prevRef.current = key;
265
+ emit(frappeDebugChannelEvent, state);
266
+ } catch {
267
+ // debug not available
268
+ }
269
+ }, [tick]);
270
+
271
+ return null;
272
+ }
273
+
274
+ function ThemeColorEmitter() {
275
+ const tamaguiTheme = useTamaguiTheme();
276
+ const prevRef = useRef<string>("");
277
+
278
+ useEffect(() => {
279
+ const colors: string[] = [];
280
+ for (let i = 1; i <= 12; i++) {
281
+ const token = (tamaguiTheme as Record<string, { val?: string } | undefined>)[`color${i}`];
282
+ colors.push(token?.val ?? "");
283
+ }
284
+ const key = colors.join("|");
285
+ if (key === prevRef.current) return;
286
+ prevRef.current = key;
287
+
288
+ getChannelEmit()?.(themeColorsChannelEvent, colors);
289
+ }, [tamaguiTheme]);
290
+
291
+ return null;
292
+ }
293
+
294
+ function BaseThemeColorEmitter() {
295
+ const tamaguiTheme = useTamaguiTheme();
296
+ const prevRef = useRef<string>("");
297
+
298
+ useEffect(() => {
299
+ const colors: string[] = [];
300
+ for (let i = 1; i <= 12; i++) {
301
+ const token = (tamaguiTheme as Record<string, { val?: string } | undefined>)[`color${i}`];
302
+ colors.push(token?.val ?? "");
303
+ }
304
+ const key = colors.join("|");
305
+ if (key === prevRef.current) return;
306
+ prevRef.current = key;
307
+
308
+ getChannelEmit()?.(baseThemeColorsChannelEvent, colors);
309
+ }, [tamaguiTheme]);
310
+
311
+ return null;
312
+ }
313
+
314
+ function OppositeThemeColorEmitter() {
315
+ const tamaguiTheme = useTamaguiTheme();
316
+ const prevRef = useRef<string>("");
317
+
318
+ useEffect(() => {
319
+ const colors: string[] = [];
320
+ for (let i = 1; i <= 12; i++) {
321
+ const token = (tamaguiTheme as Record<string, { val?: string } | undefined>)[`color${i}`];
322
+ colors.push(token?.val ?? "");
323
+ }
324
+ const key = colors.join("|");
325
+ if (key === prevRef.current) return;
326
+ prevRef.current = key;
327
+
328
+ getChannelEmit()?.(themeColorsOppositeChannelEvent, colors);
329
+ }, [tamaguiTheme]);
330
+
331
+ return null;
332
+ }
333
+
334
+ function ColorChannelEmitter({
335
+ event,
336
+ prefix = "color",
337
+ }: {
338
+ event: string;
339
+ prefix?: "color" | "accent";
340
+ }) {
341
+ const tamaguiTheme = useTamaguiTheme();
342
+ const prevRef = useRef<string>("");
343
+
344
+ useEffect(() => {
345
+ const colors: string[] = [];
346
+ let found = false;
347
+ for (let i = 1; i <= 12; i++) {
348
+ const token = (tamaguiTheme as Record<string, { val?: string } | undefined>)[`${prefix}${i}`];
349
+ const val = token?.val ?? "";
350
+ colors.push(val);
351
+ if (val) found = true;
352
+ }
353
+ if (!found) return;
354
+ const key = colors.join("|");
355
+ if (key === prevRef.current) return;
356
+ prevRef.current = key;
357
+
358
+ getChannelEmit()?.(event, colors);
359
+ }, [tamaguiTheme, event, prefix]);
360
+
361
+ return null;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Factory
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * Creates a Storybook Preview config with Tamagui theme-switching, dark-mode
370
+ * sync, and i18n language-switching baked in.
371
+ *
372
+ * Returns a Preview object ready to `export default` from your `.storybook/preview.tsx`.
373
+ */
374
+ const tamaguiPropLeakPatterns = [
375
+ "pressTheme",
376
+ "scaleIcon",
377
+ "borderTopRightRadius",
378
+ "borderBottomRightRadius",
379
+ "borderTopLeftRadius",
380
+ "borderBottomLeftRadius",
381
+ ] as const;
382
+
383
+ function suppressTamaguiDomWarnings() {
384
+ if (typeof window === "undefined") return;
385
+ const origError = console.error;
386
+ console.error = (...args: unknown[]) => {
387
+ const msg = args.map((a) => (typeof a === "string" ? a : "")).join(" ");
388
+ if (
389
+ msg.includes("React does not recognize the") &&
390
+ tamaguiPropLeakPatterns.some((p) => msg.includes(p))
391
+ ) {
392
+ return;
393
+ }
394
+ origError(...args);
395
+ };
396
+ }
397
+ suppressTamaguiDomWarnings();
398
+
399
+ export function createPreview(options: CreatePreviewOptions): Preview {
400
+ const {
401
+ themeConfig,
402
+ i18n,
403
+ AppProvider,
404
+ decorators: extraDecorators = [],
405
+ parameters: extraParameters = {},
406
+ globalTypes: extraGlobalTypes = {},
407
+ } = options;
408
+
409
+ // -- globalTypes -----------------------------------------------------------
410
+
411
+ const enumKeys = Object.keys(knobOptions);
412
+
413
+ const generatedGlobalTypes: Record<string, InputType> = {};
414
+ for (const key of enumKeys) {
415
+ generatedGlobalTypes[key] = createControl(key);
416
+ }
417
+ // Register state override globals so Storybook persists them across reloads.
418
+ for (const state of ["hover", "press", "focus", "focusVisible"] as const) {
419
+ for (const field of stateKnobFields) {
420
+ const stateKey = `${state}.${field}`;
421
+ const values = knobOptions[field] ?? [];
422
+ const serialized = (values as readonly string[]).map((value) => value);
423
+ const labels = Object.fromEntries(serialized.map((v) => [v, v.replace("$", "")]));
424
+ generatedGlobalTypes[stateKey] = {
425
+ name: `${toTitleCase(state)} ${toTitleCase(field)}`,
426
+ description: `${state} override for ${field}`,
427
+ options: serialized,
428
+ control: { type: "select", labels },
429
+ defaultValue: "",
430
+ };
431
+ }
432
+ }
433
+ // Color theme control — use the pre-computed themeColors list from the
434
+ // ThemeConfig (extracted from the raw themes object at config creation
435
+ // time, with semantic/state sub-themes already filtered out).
436
+ const themeColorNames = ["", ...themeConfig.themeColors];
437
+
438
+ // Extract a representative midpoint color (color7) from each theme so the
439
+ // manager-side toolbar can show colored swatches without iframe access.
440
+ const themeSwatches: Record<string, string> = {};
441
+ const allThemes = (
442
+ themeConfig.tamagui as unknown as { themes?: Record<string, Record<string, unknown>> }
443
+ ).themes;
444
+ if (allThemes) {
445
+ for (const name of themeConfig.themeColors) {
446
+ const theme = allThemes[`light_${name}`] ?? allThemes[name];
447
+ if (!theme) continue;
448
+ const token = theme.color7;
449
+ if (typeof token === "string") {
450
+ themeSwatches[name] = token;
451
+ } else if (
452
+ token &&
453
+ typeof token === "object" &&
454
+ "val" in (token as Record<string, unknown>)
455
+ ) {
456
+ themeSwatches[name] = String((token as { val: unknown }).val);
457
+ }
458
+ }
459
+ }
460
+
461
+ generatedGlobalTypes.color = {
462
+ name: "Theme",
463
+ description: "Theme color palette",
464
+ options: themeColorNames,
465
+ control: {
466
+ type: "select",
467
+ labels: Object.fromEntries(themeColorNames.map((v) => [v, v || "(none)"])),
468
+ },
469
+ };
470
+
471
+ generatedGlobalTypes.scheme = {
472
+ name: "Scheme",
473
+ description: "Color scheme (light/dark/system)",
474
+ options: ["system", "light", "dark"],
475
+ control: { type: "select" },
476
+ };
477
+
478
+ generatedGlobalTypes.locale = {
479
+ name: "Locale",
480
+ description: "Controls internationalization language",
481
+ options: [...i18n.languages],
482
+ control: { type: "select" },
483
+ toolbar: {
484
+ title: "Locale",
485
+ items: [...i18n.languages],
486
+ dynamicTitle: true,
487
+ },
488
+ };
489
+
490
+ const globalTypes = { ...generatedGlobalTypes, ...extraGlobalTypes };
491
+
492
+ // -- initialGlobals --------------------------------------------------------
493
+
494
+ // Color and scheme get initial values (always visible in toolbar).
495
+ // All knob keys are declared (as undefined) so that Storybook's runtime
496
+ // recognises them when the ThemePanel calls updateGlobals(). The decorator
497
+ // falls through to preset → knob defaults when a value is undefined.
498
+ const initialGlobals: Record<string, unknown> = {
499
+ locale: config.get("I18N_DEFAULT_LANGUAGE", i18n.defaultLanguage),
500
+ color: "",
501
+ scheme: "system",
502
+ // Publish registered preset names so the manager-side ThemePanel can
503
+ // render a preset selector without importing the theme package.
504
+ __presetNames: getPresetNames(),
505
+ // Publish available theme color names so the ThemePanel can show only
506
+ // colors that actually have registered Tamagui sub-themes.
507
+ __themeColors: themeColorNames,
508
+ __themeSwatches: themeSwatches,
509
+ preset: themeConfig.defaultPreset ?? "",
510
+ };
511
+ // Register every knob key so Storybook allows setting them from the manager.
512
+ // Use empty string (not undefined) — JSON.stringify strips undefined values,
513
+ // so Storybook would lose the keys after serialization/reload.
514
+ for (const key of enumKeys) {
515
+ if (!(key in initialGlobals)) {
516
+ initialGlobals[key] = "";
517
+ }
518
+ }
519
+ for (const state of ["hover", "press", "focus", "focusVisible"] as const) {
520
+ for (const field of stateKnobFields) {
521
+ initialGlobals[`${state}.${field}`] = "";
522
+ }
523
+ }
524
+
525
+ // -- parameters ------------------------------------------------------------
526
+
527
+ const defaultParameters = {
528
+ actions: { argTypesRegex: "^on[A-Z].*" },
529
+ layout: "padded",
530
+ viewMode: "story",
531
+ controls: {
532
+ expanded: true,
533
+ hideNoControlsWarning: true,
534
+ matchers: {
535
+ color: /(background|color)$/i,
536
+ date: /Date$/,
537
+ },
538
+ },
539
+ // Disable the built-in backgrounds addon — the CanvasBackground component
540
+ // manages the preview background via the Tamagui $background token so it
541
+ // stays in sync with the active color scheme.
542
+ backgrounds: { disable: true },
543
+ previewTabs: {
544
+ "storybook/docs/panel": { hidden: true },
545
+ },
546
+ a11y: { test: false },
547
+ docs: {
548
+ codePanel: true,
549
+ },
550
+ darkMode: {
551
+ stylePreview: true,
552
+ },
553
+ };
554
+
555
+ // -- framework decorator ---------------------------------------------------
556
+
557
+ // useTheme() calls useCookies() internally, which requires <CookiesProvider>
558
+ // to be a parent in the React tree. Since the decorator *renders* the provider,
559
+ // we split into an outer shell (provides CookiesProvider) and an inner component
560
+ // (calls useTheme safely inside that provider).
561
+ function FrameworkDecoratorInner({
562
+ Story,
563
+ context,
564
+ }: {
565
+ Story: NonNullable<Parameters<NonNullable<Preview["decorators"]>[number]>[0]>;
566
+ context: Parameters<NonNullable<Preview["decorators"]>[number]>[1];
567
+ }) {
568
+ const [knobs, setKnobs] = useTheme();
569
+
570
+ // Resolve the effective color scheme from the toolbar global.
571
+ // "system" defers to the browser's prefers-color-scheme and reacts to
572
+ // OS changes in real-time. "light"/"dark" are used as-is.
573
+ // URL globals (?globals=scheme:dark;…) are handled natively by SB 10.
574
+ const schemeGlobal = (context.globals.scheme as string) || "system";
575
+ const [resolvedScheme, setResolvedScheme] = useState<"light" | "dark">(() => {
576
+ if (schemeGlobal === "light" || schemeGlobal === "dark") return schemeGlobal;
577
+ if (typeof window !== "undefined" && window.matchMedia) {
578
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
579
+ }
580
+ return "light";
581
+ });
582
+ useEffect(() => {
583
+ if (schemeGlobal === "light" || schemeGlobal === "dark") {
584
+ setResolvedScheme(schemeGlobal);
585
+ return;
586
+ }
587
+ if (typeof window === "undefined" || !window.matchMedia) {
588
+ setResolvedScheme("light");
589
+ return;
590
+ }
591
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
592
+ const handler = () => setResolvedScheme(mq.matches ? "dark" : "light");
593
+ handler();
594
+ mq.addEventListener("change", handler);
595
+ return () => mq.removeEventListener("change", handler);
596
+ }, [schemeGlobal]);
597
+
598
+ // Resolve the active Tamagui color theme (e.g. "purple", "blue") from
599
+ // the toolbar global. This wraps the story in <Theme name={color}> so
600
+ // all Tamagui components pick it up implicitly through context.
601
+ const colorGlobal = (context.globals.color as string) || "";
602
+
603
+ // Per-story theme overrides via `parameters.theme` (AC-5.4).
604
+ // Stories can specify e.g. `parameters: { theme: { padding: "large" } }`
605
+ // to pin a specific knob configuration for that story.
606
+ const storyThemeParams = context.parameters?.theme as Partial<Knobs> | undefined;
607
+
608
+ // Resolve the active preset (if any) from the `preset` global.
609
+ const activePresetName = context.globals.preset as string | undefined;
610
+ const activePreset = activePresetName ? getPreset(activePresetName) : undefined;
611
+
612
+ const knobUpdate = useMemo(() => {
613
+ const result: Partial<Knobs> = {};
614
+ const stateKeys = new Set(["hover", "press", "focus", "focusVisible"]);
615
+ for (const key of Object.keys(knobs) as Array<keyof Knobs>) {
616
+ if (stateKeys.has(key)) continue;
617
+ if (storyThemeParams && key in storyThemeParams) {
618
+ (result as Record<string, unknown>)[key] = storyThemeParams[key];
619
+ } else if (
620
+ key in context.globals &&
621
+ context.globals[key] !== undefined &&
622
+ context.globals[key] !== ""
623
+ ) {
624
+ (result as Record<string, unknown>)[key] = parseValue(context.globals[key]);
625
+ } else if (activePreset && key in activePreset.knobs) {
626
+ (result as Record<string, unknown>)[key] = activePreset.knobs[key];
627
+ } else {
628
+ (result as Record<string, unknown>)[key] = defaultKnobs[key];
629
+ }
630
+ }
631
+ for (const state of stateKeys) {
632
+ const stateObj: Record<string, string> = {};
633
+ let hasValues = false;
634
+ for (const field of stateKnobFields) {
635
+ const globalKey = `${state}.${field}`;
636
+ const val = context.globals[globalKey] as string | undefined;
637
+ if (val) {
638
+ stateObj[field] = val;
639
+ hasValues = true;
640
+ }
641
+ }
642
+ if (hasValues) {
643
+ (result as Record<string, unknown>)[state] = stateObj;
644
+ }
645
+ }
646
+ return result;
647
+ }, [knobs, context.globals, storyThemeParams, activePreset]);
648
+
649
+ useEffect(() => {
650
+ setKnobs(knobUpdate);
651
+ }, [knobUpdate, setKnobs]);
652
+
653
+ useEffect(() => {
654
+ i18n.instance.changeLanguage(context.globals.locale);
655
+ }, [context.globals.locale]);
656
+
657
+ // Build a PresetContext value from the resolved knobs so that
658
+ // useResolvedKnobs() inside story components picks up the live values.
659
+ // Include the active color theme so components can read preset.theme.
660
+ const presetCtxValue = useMemo(
661
+ () => ({
662
+ preset: {
663
+ ...defaultPreset,
664
+ theme: (colorGlobal || defaultPreset.theme) as ThemeName,
665
+ knobs: { ...defaultKnobs, ...knobUpdate } as Knobs,
666
+ },
667
+ overrides: undefined,
668
+ }),
669
+ [knobUpdate, colorGlobal],
670
+ );
671
+
672
+ const animationValue = presetCtxValue.preset.knobs.animation;
673
+ useEffect(() => {
674
+ const ms =
675
+ animationValue === "none"
676
+ ? "0ms"
677
+ : animationValue === "quick"
678
+ ? "100ms"
679
+ : animationValue === "medium"
680
+ ? "200ms"
681
+ : animationValue === "slow" || animationValue === "lazy"
682
+ ? "350ms"
683
+ : animationValue === "bouncy"
684
+ ? "300ms"
685
+ : "200ms";
686
+ document.documentElement.style.setProperty("--mp-transition", ms);
687
+ }, [animationValue]);
688
+
689
+ const storyContent = (
690
+ <CanvasBackground>
691
+ <Story {...context} />
692
+ </CanvasBackground>
693
+ );
694
+
695
+ const oppositeScheme = resolvedScheme === "dark" ? "light" : "dark";
696
+
697
+ return (
698
+ <PresetContext.Provider value={presetCtxValue}>
699
+ <AppProvider themeConfig={themeConfig} systemTheme={resolvedScheme}>
700
+ <AnimationNamesEmitter />
701
+ <FrappeDebugEmitter />
702
+ <BaseThemeColorEmitter />
703
+ {colorGlobal ? (
704
+ <Theme name={colorGlobal as ThemeName}>
705
+ <ThemeColorEmitter />
706
+ <ColorChannelEmitter event={accentColorsChannelEvent} prefix="accent" />
707
+ {storyContent}
708
+ </Theme>
709
+ ) : (
710
+ <>
711
+ <ThemeColorEmitter />
712
+ <ColorChannelEmitter event={accentColorsChannelEvent} prefix="accent" />
713
+ {storyContent}
714
+ </>
715
+ )}
716
+ {/* Opposite scheme emitters */}
717
+ <Theme name={oppositeScheme as ThemeName}>
718
+ <OppositeThemeColorEmitter />
719
+ {colorGlobal ? (
720
+ <Theme name={colorGlobal as ThemeName}>
721
+ <ColorChannelEmitter event={baseOppositeChannelEvent} />
722
+ <ColorChannelEmitter event={accentOppositeChannelEvent} prefix="accent" />
723
+ </Theme>
724
+ ) : (
725
+ <>
726
+ <ColorChannelEmitter event={baseOppositeChannelEvent} />
727
+ <ColorChannelEmitter event={accentOppositeChannelEvent} prefix="accent" />
728
+ </>
729
+ )}
730
+ </Theme>
731
+ </AppProvider>
732
+ </PresetContext.Provider>
733
+ );
734
+ }
735
+
736
+ const frameworkDecorator: NonNullable<Preview["decorators"]>[number] = (Story, context) => (
737
+ <CookiesProvider>
738
+ <FrameworkDecoratorInner Story={Story} context={context} />
739
+ </CookiesProvider>
740
+ );
741
+
742
+ // -- assemble --------------------------------------------------------------
743
+
744
+ return {
745
+ globalTypes,
746
+ initialGlobals,
747
+ parameters: { ...defaultParameters, ...extraParameters },
748
+ decorators: [frameworkDecorator, ...extraDecorators],
749
+ };
750
+ }