@repobuddy/storybook 2.21.0 → 2.22.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.
package/esm/index.d.ts CHANGED
@@ -9,7 +9,7 @@ import { Args, DecoratorFunction, Renderer } from "storybook/internal/csf";
9
9
  import { Decorator, Meta, StoryContext, StoryObj, StrictArgs } from "@storybook/react-vite";
10
10
  export * from "@repobuddy/test";
11
11
 
12
- //#region src/arg-types/fn-to-arg-types.d.ts
12
+ //#region src/arg-types/fn-to-arg.types.d.ts
13
13
  /**
14
14
  * Converts a function's parameter types to `Args` type for Storybook.
15
15
  * Each name maps to the parameter type at the same index in F.
@@ -97,28 +97,43 @@ type StoryCardThemeState = Pick<StoryCardProps, 'status' | 'appearance'> & {
97
97
  */
98
98
  declare const StoryCard: react.NamedExoticComponent<StoryCardProps>;
99
99
  //#endregion
100
- //#region src/decorators/show_doc_source.d.ts
100
+ //#region src/decorators/show_source.d.ts
101
+ /**
102
+ * Options for the {@link showSource} decorator.
103
+ *
104
+ * @property className - Class name to apply to the source card. Can be a string or a function that receives the card state and returns a string.
105
+ * @property source - Source code to display. A string, or a function `(originalSource) => string` that receives the story's original source and returns the code to show. Defaults to the story's docs source.
106
+ * @property showOriginalSource - When true, use the story's original (untransformed) source instead of the rendered source.
107
+ * @property placement - Where to show the source code relative to the story. Defaults to `'before'`.
108
+ */
109
+ type ShowSourceOptions = Pick<StoryCardProps, 'className'> & {
110
+ source?: ((source: string | undefined) => string) | string | undefined;
111
+ showOriginalSource?: boolean | undefined;
112
+ placement?: 'before' | 'after' | undefined;
113
+ };
101
114
  /**
102
- * A decorator that shows the source code of a story above the rendered story.
115
+ * A decorator that shows the source code of a story relative to the rendered story.
103
116
  * The source code is taken from the story's `parameters.docs.source.code`.
104
117
  *
105
- * @param options - Options for the showDocSource decorator
118
+ * @param options - Options for the showSource decorator
106
119
  * @param options.showOriginalSource - Whether to show the original source code in a card
107
120
  * @param options.className - Class name to apply to the card
108
121
  * @param options.source - Source code to show. Can be a string, or a function `(originalSource) => string` that receives the story's original source and returns the code to display.
109
122
  * @param options.placement - Where to show the source code relative to the story.
110
123
  * @returns A decorator function that shows the source code of a story above or below the rendered story
111
124
  */
112
- declare function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Args>(options?: Pick<StoryCardProps, 'className'> & {
113
- source?: ((source: string | undefined) => string) | string | undefined;
114
- showOriginalSource?: boolean | undefined;
115
- /**
116
- * Where to show the source code relative to the story.
117
- *
118
- * @default 'after'
119
- */
120
- placement?: 'before' | 'after' | undefined;
121
- }): DecoratorFunction<TRenderer, TArgs>;
125
+ declare function showSource<TRenderer extends Renderer = Renderer, TArgs = Args>(options?: ShowSourceOptions): DecoratorFunction<TRenderer, TArgs>;
126
+ //#endregion
127
+ //#region src/decorators/show_doc_source.d.ts
128
+ /**
129
+ * A decorator that shows the source code of a story below the rendered story.
130
+ * Uses {@link showSource} with `placement: 'after'`.
131
+ *
132
+ * @deprecated Use `showSource({ placement: 'after' })` instead.
133
+ * @param options - Same options as showSource; placement is forced to 'after'
134
+ * @returns A decorator function that shows the source code below the story
135
+ */
136
+ declare function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Args>(options?: ShowSourceOptions): DecoratorFunction<TRenderer, TArgs>;
122
137
  //#endregion
123
138
  //#region src/decorators/with_story_card.d.ts
124
139
  type WithStoryCardProps = Omit<StoryCardProps, 'children' | 'className'> & {
@@ -935,4 +950,4 @@ type ExtendStoryObj<TMetaOrCmpOrArgs, S extends StoryObj<TMetaOrCmpOrArgs>, E ex
935
950
  tags?: Array<E['tag'] | (string & {})> | undefined;
936
951
  };
937
952
  //#endregion
938
- export { ActionsParam, BackgroundsParam, DocsParam, ExtendMeta, ExtendStoryObj, ExtendsMeta, ExtendsStoryObj, FnToArgTypes, GlobalApiBackgroundsParam, GlobalApiViewportParam, LayoutParam, ShowHtml, ShowHtmlProps, SourceProps, StoryCard, StoryCardAppearance, StoryCardParam, StoryCardProps, StoryCardStatus, StoryCardThemeState, StorySortParam, StorybookBuiltInParams, TestParam, Viewport, ViewportParam, WithStoryCardProps, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineStoryCardParam, defineTestParam, defineViewportParam, showDocSource, whenRunningInTest, withStoryCard };
953
+ export { ActionsParam, BackgroundsParam, DocsParam, ExtendMeta, ExtendStoryObj, ExtendsMeta, ExtendsStoryObj, FnToArgTypes, GlobalApiBackgroundsParam, GlobalApiViewportParam, LayoutParam, ShowHtml, ShowHtmlProps, ShowSourceOptions, SourceProps, StoryCard, StoryCardAppearance, StoryCardParam, StoryCardProps, StoryCardStatus, StoryCardThemeState, StorySortParam, StorybookBuiltInParams, TestParam, Viewport, ViewportParam, WithStoryCardProps, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineStoryCardParam, defineTestParam, defineViewportParam, showDocSource, showSource, whenRunningInTest, withStoryCard };
package/esm/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { isRunningInTest } from "@repobuddy/test";
3
3
  import { prettify } from "htmlfy";
4
4
  import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { cva } from "class-variance-authority";
7
7
  import { twJoin, twMerge } from "tailwind-merge";
8
8
  import { SyntaxHighlighter } from "storybook/internal/components";
@@ -108,6 +108,17 @@ const StoryCardScope = memo(function StoryCardScope(props) {
108
108
  if (context === null) return /* @__PURE__ */ jsx(StoryCardContainer, { children: collector });
109
109
  return collector;
110
110
  });
111
+ /** Renders cards from registry state without re-rendering when only children change (avoids cascade). */
112
+ const StoryCardList = memo(function StoryCardList({ cards }) {
113
+ return /* @__PURE__ */ jsx(Fragment, { children: cards.map(({ content, key, ...rest }) => /* @__PURE__ */ jsx(StoryCard, {
114
+ ...rest,
115
+ children: content
116
+ }, key)) });
117
+ });
118
+ /** Keeps container children from re-rendering when container state (cards) updates. */
119
+ const StableScopeChildren = memo(function StableScopeChildren({ children }) {
120
+ return /* @__PURE__ */ jsx(Fragment, { children });
121
+ });
111
122
  function StoryCardContainer({ children }) {
112
123
  const [cards, setCards] = useState([]);
113
124
  const contextValue = useMemo(() => ({
@@ -133,13 +144,13 @@ function StoryCardContainer({ children }) {
133
144
  value: contextValue,
134
145
  children: /* @__PURE__ */ jsxs("div", {
135
146
  className: "rbsb:flex rbsb:flex-col rbsb:gap-2",
136
- children: [cards.map(({ content, key, ...rest }) => /* @__PURE__ */ jsx(StoryCard, {
137
- ...rest,
138
- children: content
139
- }, key)), children]
147
+ children: [/* @__PURE__ */ jsx(StoryCardList, { cards }), /* @__PURE__ */ jsx(StableScopeChildren, { children })]
140
148
  })
141
149
  });
142
150
  }
151
+ function entryPropsEqual(a, b) {
152
+ return a.Story === b.Story && a.title === b.title && a.status === b.status && a.appearance === b.appearance && a.className === b.className && a.content === b.content;
153
+ }
143
154
  const StoryCardCollector = memo(function StoryCardCollector({ Story, title, status, appearance, className, content }) {
144
155
  const context = useContext(StoryCardRegistryContext);
145
156
  const cardIdRef = useRef(null);
@@ -169,23 +180,23 @@ const StoryCardCollector = memo(function StoryCardCollector({ Story, title, stat
169
180
  if (cardIdRef.current !== null) context.update(cardIdRef.current, entry);
170
181
  }, [context, entry]);
171
182
  return /* @__PURE__ */ jsx(Story, {});
172
- }, (prev, next) => prev.Story === next.Story);
183
+ }, entryPropsEqual);
173
184
 
174
185
  //#endregion
175
- //#region src/decorators/show_doc_source.tsx
186
+ //#region src/decorators/show_source.tsx
176
187
  const channel = addons.getChannel();
177
188
  /**
178
- * A decorator that shows the source code of a story above the rendered story.
189
+ * A decorator that shows the source code of a story relative to the rendered story.
179
190
  * The source code is taken from the story's `parameters.docs.source.code`.
180
191
  *
181
- * @param options - Options for the showDocSource decorator
192
+ * @param options - Options for the showSource decorator
182
193
  * @param options.showOriginalSource - Whether to show the original source code in a card
183
194
  * @param options.className - Class name to apply to the card
184
195
  * @param options.source - Source code to show. Can be a string, or a function `(originalSource) => string` that receives the story's original source and returns the code to display.
185
196
  * @param options.placement - Where to show the source code relative to the story.
186
197
  * @returns A decorator function that shows the source code of a story above or below the rendered story
187
198
  */
188
- function showDocSource(options) {
199
+ function showSource(options) {
189
200
  if (isRunningInTest()) return (Story) => /* @__PURE__ */ jsx(Story, {});
190
201
  return (Story, { parameters: { docs, darkMode } }) => {
191
202
  const storedItem = window.localStorage.getItem("sb-addon-themes-3");
@@ -204,7 +215,7 @@ function showDocSource(options) {
204
215
  language,
205
216
  children: code
206
217
  }), [code, language]);
207
- const showBefore = options?.placement === "before";
218
+ const showBefore = (options?.placement ?? "before") === "before";
208
219
  const sourceCardClassName = useCallback((state) => {
209
220
  const modifiedState = {
210
221
  ...state,
@@ -217,21 +228,15 @@ function showDocSource(options) {
217
228
  function DocSourceCard({ children }) {
218
229
  return /* @__PURE__ */ jsx("div", { children });
219
230
  }
220
- const scopeContent = useMemo(() => /* @__PURE__ */ jsx(DocSourceCard, { children: sourceContent }), [sourceContent]);
221
- const scopeProps = useMemo(() => ({
231
+ if (isRunningInTest()) return /* @__PURE__ */ jsx(Story, {});
232
+ if (showBefore) return /* @__PURE__ */ jsx(StoryCardScope, {
222
233
  Story,
223
- content: scopeContent,
234
+ content: /* @__PURE__ */ jsx(ThemeProvider, {
235
+ theme,
236
+ children: /* @__PURE__ */ jsx(DocSourceCard, { children: sourceContent })
237
+ }),
224
238
  className: sourceCardClassName,
225
239
  appearance: "source"
226
- }), [
227
- Story,
228
- scopeContent,
229
- sourceCardClassName
230
- ]);
231
- if (isRunningInTest()) return /* @__PURE__ */ jsx(Story, {});
232
- if (showBefore) return /* @__PURE__ */ jsx(ThemeProvider, {
233
- theme,
234
- children: /* @__PURE__ */ jsx(StoryCardScope, { ...scopeProps })
235
240
  });
236
241
  return /* @__PURE__ */ jsx(ThemeProvider, {
237
242
  theme,
@@ -251,6 +256,23 @@ function showDocSource(options) {
251
256
  };
252
257
  }
253
258
 
259
+ //#endregion
260
+ //#region src/decorators/show_doc_source.tsx
261
+ /**
262
+ * A decorator that shows the source code of a story below the rendered story.
263
+ * Uses {@link showSource} with `placement: 'after'`.
264
+ *
265
+ * @deprecated Use `showSource({ placement: 'after' })` instead.
266
+ * @param options - Same options as showSource; placement is forced to 'after'
267
+ * @returns A decorator function that shows the source code below the story
268
+ */
269
+ function showDocSource(options) {
270
+ return showSource({
271
+ placement: "after",
272
+ ...options
273
+ });
274
+ }
275
+
254
276
  //#endregion
255
277
  //#region src/decorators/with_story_card.tsx
256
278
  /**
@@ -335,6 +357,7 @@ function showDocSource(options) {
335
357
  function withStoryCard({ title, status, appearance, content: contentProp, className, ...rest } = {}) {
336
358
  if (isRunningInTest()) return (Story) => /* @__PURE__ */ jsx(Story, {});
337
359
  return (Story, { parameters, viewMode }) => {
360
+ if (viewMode === "docs") return /* @__PURE__ */ jsx(Story, {});
338
361
  const storyCardParam = parameters.storyCard;
339
362
  const finalTitle = title ?? storyCardParam?.title;
340
363
  const finalAppearance = appearance ?? storyCardParam?.appearance ?? status ?? storyCardParam?.status ?? "info";
@@ -342,7 +365,8 @@ function withStoryCard({ title, status, appearance, content: contentProp, classN
342
365
  const finalContent = contentProp ?? storyCardParam?.content;
343
366
  const finalClassName = className ?? storyCardParam?.className;
344
367
  const content = finalContent ?? parameters.docs?.description?.story ?? parameters.docs?.description?.component;
345
- const scopeProps = useMemo(() => ({
368
+ if (!content && !finalTitle) return /* @__PURE__ */ jsx(Story, {});
369
+ return /* @__PURE__ */ jsx(StoryCardScope, {
346
370
  Story,
347
371
  content,
348
372
  title: finalTitle,
@@ -350,18 +374,7 @@ function withStoryCard({ title, status, appearance, content: contentProp, classN
350
374
  appearance: finalAppearance,
351
375
  className: finalClassName,
352
376
  ...rest
353
- }), [
354
- Story,
355
- content,
356
- finalTitle,
357
- finalStatus,
358
- finalAppearance,
359
- finalClassName,
360
- rest
361
- ]);
362
- if (viewMode === "docs") return /* @__PURE__ */ jsx(Story, {});
363
- if (!content && !finalTitle) return /* @__PURE__ */ jsx(Story, {});
364
- return /* @__PURE__ */ jsx(StoryCardScope, { ...scopeProps });
377
+ });
365
378
  };
366
379
  }
367
380
 
@@ -594,4 +607,4 @@ function whenRunningInTest(decoratorOrHandler) {
594
607
  }
595
608
 
596
609
  //#endregion
597
- export { ShowHtml, StoryCard, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineStoryCardParam, defineTestParam, defineViewportParam, showDocSource, whenRunningInTest, withStoryCard };
610
+ export { ShowHtml, StoryCard, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineStoryCardParam, defineTestParam, defineViewportParam, showDocSource, showSource, whenRunningInTest, withStoryCard };
@@ -8,7 +8,7 @@ type TagBadgeParameter = TagBadgeParameters[0];
8
8
  /**
9
9
  * Type representing the names of predefined tags used in Storybook stories.
10
10
  */
11
- type TagNames = '!test' | 'editor' | 'source' | 'type' | '!type' | 'func' | '!func' | 'var' | '!var' | 'new' | 'alpha' | 'beta' | 'rc' | 'props' | 'deprecated' | 'outdated' | 'danger' | 'todo' | 'code-only' | 'snapshot' | '!snapshot' | 'unit' | 'integration' | 'keyboard' | 'internal' | 'usecase' | 'use-case' | 'example' | 'version:next' | 'remove' | 'remove:next' | 'autodocs';
11
+ type TagNames = '!test' | 'editor' | 'source' | 'type' | '!type' | 'func' | '!func' | 'var' | '!var' | 'new' | 'alpha' | 'beta' | 'rc' | 'props' | 'deprecated' | 'outdated' | 'danger' | 'todo' | 'code-only' | 'snapshot' | '!snapshot' | 'unit' | 'integration' | 'keyboard' | 'internal' | 'usecase' | 'use-case' | 'example' | 'perf' | 'version:next' | 'remove' | 'remove:next' | 'autodocs';
12
12
  /** Badge (✏️) for stories with a live editor. Shown in sidebar on story and inherited. */
13
13
  declare const editorBadge: TagBadgeParameter;
14
14
  /** Badge (🆕) for recently added stories. */
@@ -55,12 +55,14 @@ declare const internalBadge: TagBadgeParameter;
55
55
  declare const useCaseBadge: TagBadgeParameter;
56
56
  /** Badge (✨) for example or demo stories. */
57
57
  declare const exampleBadge: TagBadgeParameter;
58
+ /** Badge (⚡) for stories that demonstrate or test performance. */
59
+ declare const perfBadge: TagBadgeParameter;
58
60
  /**
59
61
  * Configuration for story tag badges that appear in the Storybook sidebar.
60
62
  * Each badge is associated with a specific tag and displays an emoji or symbol with a tooltip.
61
63
  *
62
64
  * Badge order (first match wins): New → Alpha → Beta → RC → Deprecated → Remove → Outdated → Danger → Use Case →
63
- * Example → Keyboard → Source → Type → Function → Var → Props → Todo → Unit → Integration →
65
+ * Example → Perf → Keyboard → Source → Type → Function → Var → Props → Todo → Unit → Integration →
64
66
  * Editor → Code Only → Version → Internal → Snapshot.
65
67
  *
66
68
  * - 🆕 New - Recently added stories
@@ -73,6 +75,7 @@ declare const exampleBadge: TagBadgeParameter;
73
75
  * - 🚨 Danger - Stories demonstrating dangerous patterns
74
76
  * - 🎯 Use Case - Stories that demonstrate a specific use case or scenario
75
77
  * - ✨ Example - Example or demo stories
78
+ * - ⚡ Perf - Stories that demonstrate or test performance
76
79
  * - ⌨️ Keyboard - Stories that demonstrate or test keyboard interaction
77
80
  * - `</>` Source - Source-code-focused stories
78
81
  * - `<T>` Type - Stories that showcase or document TypeScript types
@@ -162,4 +165,4 @@ type StoryObj<TMetaOrCmpOrArgs = Args> = ExtendStoryObj<TMetaOrCmpOrArgs, StoryO
162
165
  tag: TagNames;
163
166
  }>;
164
167
  //#endregion
165
- export { Meta, StoryObj, TagNames, alphaBadge, betaBadge, codeOnlyBadge, dangerBadge, deprecatedBadge, editorBadge, exampleBadge, functionBadge, integrationBadge, internalBadge, keyboardBadge, newBadge, outdatedBadge, propsBadge, rcBadge, removeBadge, snapshotBadge, sourceBadge, tagBadges, todoBadge, typeBadge, unitBadge, useCaseBadge, varBadge };
168
+ export { Meta, StoryObj, TagNames, alphaBadge, betaBadge, codeOnlyBadge, dangerBadge, deprecatedBadge, editorBadge, exampleBadge, functionBadge, integrationBadge, internalBadge, keyboardBadge, newBadge, outdatedBadge, perfBadge, propsBadge, rcBadge, removeBadge, snapshotBadge, sourceBadge, tagBadges, todoBadge, typeBadge, unitBadge, useCaseBadge, varBadge };
@@ -331,12 +331,28 @@ const exampleBadge = {
331
331
  skipInherited: false
332
332
  } }
333
333
  };
334
+ /** Badge (⚡) for stories that demonstrate or test performance. */
335
+ const perfBadge = {
336
+ tags: "perf",
337
+ badge: {
338
+ text: "⚡",
339
+ style: {
340
+ backgroundColor: "transparent",
341
+ borderColor: "transparent"
342
+ },
343
+ tooltip: "Performance"
344
+ },
345
+ display: { sidebar: {
346
+ type: "story",
347
+ skipInherited: false
348
+ } }
349
+ };
334
350
  /**
335
351
  * Configuration for story tag badges that appear in the Storybook sidebar.
336
352
  * Each badge is associated with a specific tag and displays an emoji or symbol with a tooltip.
337
353
  *
338
354
  * Badge order (first match wins): New → Alpha → Beta → RC → Deprecated → Remove → Outdated → Danger → Use Case →
339
- * Example → Keyboard → Source → Type → Function → Var → Props → Todo → Unit → Integration →
355
+ * Example → Perf → Keyboard → Source → Type → Function → Var → Props → Todo → Unit → Integration →
340
356
  * Editor → Code Only → Version → Internal → Snapshot.
341
357
  *
342
358
  * - 🆕 New - Recently added stories
@@ -349,6 +365,7 @@ const exampleBadge = {
349
365
  * - 🚨 Danger - Stories demonstrating dangerous patterns
350
366
  * - 🎯 Use Case - Stories that demonstrate a specific use case or scenario
351
367
  * - ✨ Example - Example or demo stories
368
+ * - ⚡ Perf - Stories that demonstrate or test performance
352
369
  * - ⌨️ Keyboard - Stories that demonstrate or test keyboard interaction
353
370
  * - `</>` Source - Source-code-focused stories
354
371
  * - `<T>` Type - Stories that showcase or document TypeScript types
@@ -376,6 +393,7 @@ const tagBadges = [
376
393
  dangerBadge,
377
394
  useCaseBadge,
378
395
  exampleBadge,
396
+ perfBadge,
379
397
  keyboardBadge,
380
398
  sourceBadge,
381
399
  typeBadge,
@@ -393,4 +411,4 @@ const tagBadges = [
393
411
  ];
394
412
 
395
413
  //#endregion
396
- export { alphaBadge, betaBadge, codeOnlyBadge, dangerBadge, deprecatedBadge, editorBadge, exampleBadge, functionBadge, integrationBadge, internalBadge, keyboardBadge, newBadge, outdatedBadge, propsBadge, rcBadge, removeBadge, snapshotBadge, sourceBadge, tagBadges, todoBadge, typeBadge, unitBadge, useCaseBadge, varBadge };
414
+ export { alphaBadge, betaBadge, codeOnlyBadge, dangerBadge, deprecatedBadge, editorBadge, exampleBadge, functionBadge, integrationBadge, internalBadge, keyboardBadge, newBadge, outdatedBadge, perfBadge, propsBadge, rcBadge, removeBadge, snapshotBadge, sourceBadge, tagBadges, todoBadge, typeBadge, unitBadge, useCaseBadge, varBadge };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repobuddy/storybook",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "Storybook repo buddy",
5
5
  "keywords": [
6
6
  "storybook",
@@ -120,7 +120,7 @@
120
120
  "build:code": "tsdown",
121
121
  "build:css": "tailwindcss -i ./tailwind.css -o ./styles.css",
122
122
  "clean": "rimraf .turbo coverage cjs esm storybook-static *.tsbuildinfo",
123
- "coverage": "vitest run --coverage",
123
+ "cov": "vitest run --coverage",
124
124
  "sb": "storybook dev -p 6006",
125
125
  "sb:build": "storybook build",
126
126
  "test": "vitest run",
package/readme.md CHANGED
@@ -88,6 +88,7 @@ we provide a different set of badges that uses emojis (order: first match wins):
88
88
  - 🚨 `danger` - Dangerous or cautionary patterns
89
89
  - 🎯 `use-case` - Specific use case or scenario
90
90
  - ✨ `example` - Example or demo stories
91
+ - ⚡ `perf` - Performance (stories that demonstrate or test performance)
91
92
  - ⌨️ `keyboard` - Keyboard interaction
92
93
  - `</>` `source` - Source-code-focused stories
93
94
  - `<T>` `type` - TypeScript types (shown in MDX)
@@ -24,6 +24,24 @@ export const StoryCardScope = memo(function StoryCardScope(props: StoryCardScope
24
24
  return collector
25
25
  })
26
26
 
27
+ /** Renders cards from registry state without re-rendering when only children change (avoids cascade). */
28
+ const StoryCardList = memo(function StoryCardList({ cards }: { cards: StoryCardEntryWithKey[] }) {
29
+ return (
30
+ <>
31
+ {cards.map(({ content, key, ...rest }) => (
32
+ <StoryCard key={key} {...rest}>
33
+ {content}
34
+ </StoryCard>
35
+ ))}
36
+ </>
37
+ )
38
+ })
39
+
40
+ /** Keeps container children from re-rendering when container state (cards) updates. */
41
+ const StableScopeChildren = memo(function StableScopeChildren({ children }: { children: ReactNode }) {
42
+ return <>{children}</>
43
+ })
44
+
27
45
  function StoryCardContainer({ children }: { children: ReactNode }) {
28
46
  const [cards, setCards] = useState<StoryCardEntryWithKey[]>([])
29
47
 
@@ -47,12 +65,8 @@ function StoryCardContainer({ children }: { children: ReactNode }) {
47
65
  return (
48
66
  <StoryCardRegistryContext.Provider value={contextValue}>
49
67
  <div className="rbsb:flex rbsb:flex-col rbsb:gap-2">
50
- {cards.map(({ content, key, ...rest }) => (
51
- <StoryCard key={key} {...rest}>
52
- {content}
53
- </StoryCard>
54
- ))}
55
- {children}
68
+ <StoryCardList cards={cards} />
69
+ <StableScopeChildren>{children}</StableScopeChildren>
56
70
  </div>
57
71
  </StoryCardRegistryContext.Provider>
58
72
  )
@@ -62,35 +76,50 @@ type StoryCardEntryWithKey = StoryCardEntry & { key: string }
62
76
 
63
77
  interface StoryCardCollectorProps extends StoryCardScopeProps {}
64
78
 
65
- const StoryCardCollector = memo(
66
- function StoryCardCollector({ Story, title, status, appearance, className, content }: StoryCardCollectorProps) {
67
- const context = useContext(StoryCardRegistryContext)!
68
- const cardIdRef = useRef<string | null>(null)
69
-
70
- const entry = useMemo(
71
- () => ({ title, status, appearance, className, content }),
72
- [title, status, appearance, className, content]
73
- )
74
-
75
- // Register on mount, unregister on unmount only
76
- useLayoutEffect(() => {
77
- cardIdRef.current = context.add(entry)
78
- return () => {
79
- if (cardIdRef.current !== null) {
80
- context.remove(cardIdRef.current)
81
- cardIdRef.current = null
82
- }
83
- }
84
- }, [context])
79
+ function entryPropsEqual(a: StoryCardCollectorProps, b: StoryCardCollectorProps): boolean {
80
+ return (
81
+ a.Story === b.Story &&
82
+ a.title === b.title &&
83
+ a.status === b.status &&
84
+ a.appearance === b.appearance &&
85
+ a.className === b.className &&
86
+ a.content === b.content
87
+ )
88
+ }
89
+
90
+ const StoryCardCollector = memo(function StoryCardCollector({
91
+ Story,
92
+ title,
93
+ status,
94
+ appearance,
95
+ className,
96
+ content
97
+ }: StoryCardCollectorProps) {
98
+ const context = useContext(StoryCardRegistryContext)!
99
+ const cardIdRef = useRef<string | null>(null)
85
100
 
86
- // Update registry when entry changes (avoids remove+add churn)
87
- useLayoutEffect(() => {
101
+ const entry = useMemo(
102
+ () => ({ title, status, appearance, className, content }),
103
+ [title, status, appearance, className, content]
104
+ )
105
+
106
+ // Register on mount, unregister on unmount only
107
+ useLayoutEffect(() => {
108
+ cardIdRef.current = context.add(entry)
109
+ return () => {
88
110
  if (cardIdRef.current !== null) {
89
- context.update(cardIdRef.current, entry)
111
+ context.remove(cardIdRef.current)
112
+ cardIdRef.current = null
90
113
  }
91
- }, [context, entry])
114
+ }
115
+ }, [context])
116
+
117
+ // Update registry when entry changes (avoids remove+add churn)
118
+ useLayoutEffect(() => {
119
+ if (cardIdRef.current !== null) {
120
+ context.update(cardIdRef.current, entry)
121
+ }
122
+ }, [context, entry])
92
123
 
93
- return <Story />
94
- },
95
- (prev, next) => prev.Story === next.Story
96
- )
124
+ return <Story />
125
+ }, entryPropsEqual)
@@ -1,143 +1,16 @@
1
- import { isRunningInTest } from '@repobuddy/test'
2
- import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
3
- import { SyntaxHighlighter } from 'storybook/internal/components'
4
1
  import type { Args, DecoratorFunction, Renderer } from 'storybook/internal/csf'
5
- import { addons } from 'storybook/preview-api'
6
- import { convert, ThemeProvider, themes } from 'storybook/theming'
7
- import { twJoin } from 'tailwind-merge'
8
- import { StoryCard, type StoryCardProps } from '../components/story_card'
9
- import { StoryCardScope } from '../contexts/_story_card_scope'
10
-
11
- const channel = addons.getChannel()
2
+ import { type ShowSourceOptions, showSource } from './show_source'
12
3
 
13
4
  /**
14
- * A decorator that shows the source code of a story above the rendered story.
15
- * The source code is taken from the story's `parameters.docs.source.code`.
5
+ * A decorator that shows the source code of a story below the rendered story.
6
+ * Uses {@link showSource} with `placement: 'after'`.
16
7
  *
17
- * @param options - Options for the showDocSource decorator
18
- * @param options.showOriginalSource - Whether to show the original source code in a card
19
- * @param options.className - Class name to apply to the card
20
- * @param options.source - Source code to show. Can be a string, or a function `(originalSource) => string` that receives the story's original source and returns the code to display.
21
- * @param options.placement - Where to show the source code relative to the story.
22
- * @returns A decorator function that shows the source code of a story above or below the rendered story
8
+ * @deprecated Use `showSource({ placement: 'after' })` instead.
9
+ * @param options - Same options as showSource; placement is forced to 'after'
10
+ * @returns A decorator function that shows the source code below the story
23
11
  */
24
12
  export function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Args>(
25
- options?: Pick<StoryCardProps, 'className'> & {
26
- source?: ((source: string | undefined) => string) | string | undefined
27
- showOriginalSource?: boolean | undefined
28
- /**
29
- * Where to show the source code relative to the story.
30
- *
31
- * @default 'after'
32
- */
33
- placement?: 'before' | 'after' | undefined
34
- }
13
+ options?: ShowSourceOptions
35
14
  ): DecoratorFunction<TRenderer, TArgs> {
36
- if (isRunningInTest()) {
37
- return (Story) => <Story />
38
- }
39
-
40
- return (Story, { parameters: { docs, darkMode } }) => {
41
- // This is a workaround to get the current dark mode from `@storybook-community/storybook-dark-mode` without referencing it.
42
- const storedItem = window.localStorage.getItem('sb-addon-themes-3')
43
- const current = typeof storedItem === 'string' ? JSON.parse(storedItem).current : darkMode?.current
44
- const [isDark, setIsDark] = useState((darkMode?.stylePreview && current === 'dark') ?? false)
45
-
46
- useEffect(() => {
47
- channel.on('DARK_MODE', setIsDark)
48
-
49
- return () => channel.off('DARK_MODE', setIsDark)
50
- }, [])
51
-
52
- const originalSource = options?.showOriginalSource
53
- ? docs?.source?.originalSource
54
- : (docs?.source?.code ?? docs?.source?.originalSource)
55
-
56
- const code =
57
- typeof options?.source === 'function' ? options?.source(originalSource) : (options?.source ?? originalSource)
58
-
59
- const language = code === docs?.source?.originalSource ? undefined : docs?.source?.language
60
-
61
- const isOriginalSource = code === docs?.source?.originalSource
62
-
63
- const sourceContent = useMemo(
64
- () => (
65
- <SyntaxHighlighter data-testid="source-content" language={language}>
66
- {code}
67
- </SyntaxHighlighter>
68
- ),
69
- [code, language]
70
- )
71
-
72
- const showBefore = options?.placement === 'before'
73
-
74
- const sourceCardClassName = useCallback(
75
- (state: Pick<StoryCardProps, 'status' | 'appearance'> & { defaultClassName: string }) => {
76
- const modifiedState = {
77
- ...state,
78
- defaultClassName: twJoin(
79
- state.defaultClassName,
80
- isOriginalSource && 'rbsb:bg-transparent rbsb:dark:bg-transparent'
81
- )
82
- }
83
-
84
- const className = options?.className
85
- return typeof className === 'function'
86
- ? className(modifiedState)
87
- : twJoin(modifiedState.defaultClassName, className)
88
- },
89
- [options?.className, isOriginalSource]
90
- )
91
-
92
- const theme = convert(docs?.theme ?? (isDark ? themes.dark : themes.light))
93
-
94
- function DocSourceCard({ children }: { children: ReactNode }) {
95
- return <div>{children}</div>
96
- }
97
-
98
- const scopeContent = useMemo(() => <DocSourceCard>{sourceContent}</DocSourceCard>, [sourceContent])
99
-
100
- const scopeProps = useMemo(
101
- () => ({
102
- Story,
103
- content: scopeContent,
104
- className: sourceCardClassName,
105
- appearance: 'source' as const
106
- }),
107
- [Story, scopeContent, sourceCardClassName]
108
- )
109
-
110
- if (isRunningInTest()) {
111
- return <Story />
112
- }
113
-
114
- if (showBefore) {
115
- return (
116
- <ThemeProvider theme={theme}>
117
- <StoryCardScope {...scopeProps} />
118
- </ThemeProvider>
119
- )
120
- }
121
-
122
- const storyCard = (
123
- <StoryCard className={sourceCardClassName} appearance="source">
124
- <DocSourceCard>{sourceContent}</DocSourceCard>
125
- </StoryCard>
126
- )
127
-
128
- return (
129
- <ThemeProvider theme={theme}>
130
- <section
131
- style={{
132
- display: 'flex',
133
- flexDirection: 'column',
134
- gap: '1rem'
135
- }}
136
- >
137
- <Story />
138
- {storyCard}
139
- </section>
140
- </ThemeProvider>
141
- )
142
- }
15
+ return showSource({ placement: 'after', ...options })
143
16
  }
@@ -0,0 +1,139 @@
1
+ import { isRunningInTest } from '@repobuddy/test'
2
+ import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
3
+ import { SyntaxHighlighter } from 'storybook/internal/components'
4
+ import type { Args, DecoratorFunction, Renderer } from 'storybook/internal/csf'
5
+ import { addons } from 'storybook/preview-api'
6
+ import { convert, ThemeProvider, themes } from 'storybook/theming'
7
+ import { twJoin } from 'tailwind-merge'
8
+ import { StoryCard, type StoryCardProps } from '../components/story_card'
9
+ import { StoryCardScope } from '../contexts/_story_card_scope'
10
+
11
+ const channel = addons.getChannel()
12
+
13
+ /**
14
+ * Options for the {@link showSource} decorator.
15
+ *
16
+ * @property className - Class name to apply to the source card. Can be a string or a function that receives the card state and returns a string.
17
+ * @property source - Source code to display. A string, or a function `(originalSource) => string` that receives the story's original source and returns the code to show. Defaults to the story's docs source.
18
+ * @property showOriginalSource - When true, use the story's original (untransformed) source instead of the rendered source.
19
+ * @property placement - Where to show the source code relative to the story. Defaults to `'before'`.
20
+ */
21
+ export type ShowSourceOptions = Pick<StoryCardProps, 'className'> & {
22
+ source?: ((source: string | undefined) => string) | string | undefined
23
+ showOriginalSource?: boolean | undefined
24
+ placement?: 'before' | 'after' | undefined
25
+ }
26
+
27
+ /**
28
+ * A decorator that shows the source code of a story relative to the rendered story.
29
+ * The source code is taken from the story's `parameters.docs.source.code`.
30
+ *
31
+ * @param options - Options for the showSource decorator
32
+ * @param options.showOriginalSource - Whether to show the original source code in a card
33
+ * @param options.className - Class name to apply to the card
34
+ * @param options.source - Source code to show. Can be a string, or a function `(originalSource) => string` that receives the story's original source and returns the code to display.
35
+ * @param options.placement - Where to show the source code relative to the story.
36
+ * @returns A decorator function that shows the source code of a story above or below the rendered story
37
+ */
38
+ export function showSource<TRenderer extends Renderer = Renderer, TArgs = Args>(
39
+ options?: ShowSourceOptions
40
+ ): DecoratorFunction<TRenderer, TArgs> {
41
+ if (isRunningInTest()) {
42
+ return (Story) => <Story />
43
+ }
44
+
45
+ return (Story, { parameters: { docs, darkMode } }) => {
46
+ // This is a workaround to get the current dark mode from `@storybook-community/storybook-dark-mode` without referencing it.
47
+ const storedItem = window.localStorage.getItem('sb-addon-themes-3')
48
+ const current = typeof storedItem === 'string' ? JSON.parse(storedItem).current : darkMode?.current
49
+ const [isDark, setIsDark] = useState((darkMode?.stylePreview && current === 'dark') ?? false)
50
+
51
+ useEffect(() => {
52
+ channel.on('DARK_MODE', setIsDark)
53
+
54
+ return () => channel.off('DARK_MODE', setIsDark)
55
+ }, [])
56
+
57
+ const originalSource = options?.showOriginalSource
58
+ ? docs?.source?.originalSource
59
+ : (docs?.source?.code ?? docs?.source?.originalSource)
60
+
61
+ const code =
62
+ typeof options?.source === 'function' ? options?.source(originalSource) : (options?.source ?? originalSource)
63
+
64
+ const language = code === docs?.source?.originalSource ? undefined : docs?.source?.language
65
+
66
+ const isOriginalSource = code === docs?.source?.originalSource
67
+
68
+ const sourceContent = useMemo(
69
+ () => (
70
+ <SyntaxHighlighter data-testid="source-content" language={language}>
71
+ {code}
72
+ </SyntaxHighlighter>
73
+ ),
74
+ [code, language]
75
+ )
76
+
77
+ const showBefore = (options?.placement ?? 'before') === 'before'
78
+
79
+ const sourceCardClassName = useCallback(
80
+ (state: Pick<StoryCardProps, 'status' | 'appearance'> & { defaultClassName: string }) => {
81
+ const modifiedState = {
82
+ ...state,
83
+ defaultClassName: twJoin(
84
+ state.defaultClassName,
85
+ isOriginalSource && 'rbsb:bg-transparent rbsb:dark:bg-transparent'
86
+ )
87
+ }
88
+
89
+ const className = options?.className
90
+ return typeof className === 'function'
91
+ ? className(modifiedState)
92
+ : twJoin(modifiedState.defaultClassName, className)
93
+ },
94
+ [options?.className, isOriginalSource]
95
+ )
96
+
97
+ const theme = convert(docs?.theme ?? (isDark ? themes.dark : themes.light))
98
+
99
+ function DocSourceCard({ children }: { children: ReactNode }) {
100
+ return <div>{children}</div>
101
+ }
102
+
103
+ if (isRunningInTest()) {
104
+ return <Story />
105
+ }
106
+
107
+ // For 'before', use StoryCardScope so this card participates in parent scope order.
108
+ // Wrap content in ThemeProvider so SyntaxHighlighter has theme when rendered by parent.
109
+ if (showBefore) {
110
+ const scopeContent = (
111
+ <ThemeProvider theme={theme}>
112
+ <DocSourceCard>{sourceContent}</DocSourceCard>
113
+ </ThemeProvider>
114
+ )
115
+ return <StoryCardScope Story={Story} content={scopeContent} className={sourceCardClassName} appearance="source" />
116
+ }
117
+
118
+ const storyCard = (
119
+ <StoryCard className={sourceCardClassName} appearance="source">
120
+ <DocSourceCard>{sourceContent}</DocSourceCard>
121
+ </StoryCard>
122
+ )
123
+
124
+ return (
125
+ <ThemeProvider theme={theme}>
126
+ <section
127
+ style={{
128
+ display: 'flex',
129
+ flexDirection: 'column',
130
+ gap: '1rem'
131
+ }}
132
+ >
133
+ <Story />
134
+ {storyCard}
135
+ </section>
136
+ </ThemeProvider>
137
+ )
138
+ }
139
+ }
@@ -1,5 +1,5 @@
1
1
  import { isRunningInTest } from '@repobuddy/test'
2
- import { type ReactNode, useMemo } from 'react'
2
+ import type { ReactNode } from 'react'
3
3
  import type { DecoratorFunction, Renderer } from 'storybook/internal/csf'
4
4
  import type { StoryCardProps, StoryCardStatus } from '../components/story_card.js'
5
5
  import { StoryCardScope } from '../contexts/_story_card_scope.js'
@@ -124,6 +124,8 @@ export function withStoryCard<TRenderer extends Renderer = Renderer>({
124
124
  return (Story) => <Story />
125
125
  }
126
126
  return (Story, { parameters, viewMode }) => {
127
+ if (viewMode === 'docs') return <Story />
128
+
127
129
  // Get story card config from parameters if available
128
130
  const storyCardParam = (parameters as Partial<StoryCardParam>).storyCard
129
131
  // Decorator props override parameter values
@@ -137,22 +139,18 @@ export function withStoryCard<TRenderer extends Renderer = Renderer>({
137
139
  // Fallback to docs description if no content provided
138
140
  const content = finalContent ?? parameters.docs?.description?.story ?? parameters.docs?.description?.component
139
141
 
140
- const scopeProps = useMemo(
141
- () => ({
142
- Story,
143
- content,
144
- title: finalTitle,
145
- status: finalStatus,
146
- appearance: finalAppearance,
147
- className: finalClassName,
148
- ...rest
149
- }),
150
- [Story, content, finalTitle, finalStatus, finalAppearance, finalClassName, rest]
151
- )
152
-
153
- if (viewMode === 'docs') return <Story />
154
142
  if (!content && !finalTitle) return <Story />
155
143
 
156
- return <StoryCardScope {...scopeProps} />
144
+ return (
145
+ <StoryCardScope
146
+ Story={Story}
147
+ content={content}
148
+ title={finalTitle}
149
+ status={finalStatus}
150
+ appearance={finalAppearance}
151
+ className={finalClassName}
152
+ {...rest}
153
+ />
154
+ )
157
155
  }
158
156
  }
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export * from '@repobuddy/test'
2
- export type * from './arg-types/fn-to-arg-types.ts'
2
+ export type * from './arg-types/fn-to-arg.types.ts'
3
3
  export * from './components/show_html.tsx'
4
4
  export * from './components/story_card.tsx'
5
5
  export * from './decorators/show_doc_source.tsx'
6
+ export * from './decorators/show_source.tsx'
6
7
  export * from './decorators/with_story_card.tsx'
7
8
  export * from './parameters/define_actions_param.ts'
8
9
  export * from './parameters/define_backgrounds_param.ts'
@@ -36,6 +36,7 @@ export type TagNames =
36
36
  | 'usecase'
37
37
  | 'use-case'
38
38
  | 'example'
39
+ | 'perf'
39
40
  | 'version:next'
40
41
  | 'remove'
41
42
  | 'remove:next'
@@ -413,12 +414,31 @@ export const exampleBadge: TagBadgeParameter = {
413
414
  }
414
415
  }
415
416
 
417
+ /** Badge (⚡) for stories that demonstrate or test performance. */
418
+ export const perfBadge: TagBadgeParameter = {
419
+ tags: 'perf',
420
+ badge: {
421
+ text: '⚡',
422
+ style: {
423
+ backgroundColor: 'transparent',
424
+ borderColor: 'transparent'
425
+ },
426
+ tooltip: 'Performance'
427
+ },
428
+ display: {
429
+ sidebar: {
430
+ type: 'story',
431
+ skipInherited: false
432
+ }
433
+ }
434
+ }
435
+
416
436
  /**
417
437
  * Configuration for story tag badges that appear in the Storybook sidebar.
418
438
  * Each badge is associated with a specific tag and displays an emoji or symbol with a tooltip.
419
439
  *
420
440
  * Badge order (first match wins): New → Alpha → Beta → RC → Deprecated → Remove → Outdated → Danger → Use Case →
421
- * Example → Keyboard → Source → Type → Function → Var → Props → Todo → Unit → Integration →
441
+ * Example → Perf → Keyboard → Source → Type → Function → Var → Props → Todo → Unit → Integration →
422
442
  * Editor → Code Only → Version → Internal → Snapshot.
423
443
  *
424
444
  * - 🆕 New - Recently added stories
@@ -431,6 +451,7 @@ export const exampleBadge: TagBadgeParameter = {
431
451
  * - 🚨 Danger - Stories demonstrating dangerous patterns
432
452
  * - 🎯 Use Case - Stories that demonstrate a specific use case or scenario
433
453
  * - ✨ Example - Example or demo stories
454
+ * - ⚡ Perf - Stories that demonstrate or test performance
434
455
  * - ⌨️ Keyboard - Stories that demonstrate or test keyboard interaction
435
456
  * - `</>` Source - Source-code-focused stories
436
457
  * - `<T>` Type - Stories that showcase or document TypeScript types
@@ -458,6 +479,7 @@ export const tagBadges: TagBadgeParameters = [
458
479
  dangerBadge,
459
480
  useCaseBadge,
460
481
  exampleBadge,
482
+ perfBadge,
461
483
  keyboardBadge,
462
484
  sourceBadge,
463
485
  typeBadge,
package/styles.css CHANGED
@@ -1,4 +1,4 @@
1
- /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
1
+ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
2
2
  @layer properties;
3
3
  @layer theme {
4
4
  :root, :host {
@@ -33,6 +33,7 @@
33
33
  --rbsb-color-blue-500: oklch(62.3% 0.214 259.815);
34
34
  --rbsb-color-blue-800: oklch(42.4% 0.199 265.638);
35
35
  --rbsb-color-blue-900: oklch(37.9% 0.146 265.522);
36
+ --rbsb-color-purple-400: oklch(71.4% 0.203 305.504);
36
37
  --rbsb-color-purple-500: oklch(62.7% 0.265 303.9);
37
38
  --rbsb-color-rose-400: oklch(71.2% 0.194 13.428);
38
39
  --rbsb-color-rose-900: oklch(41% 0.159 10.272);
@@ -80,6 +81,9 @@
80
81
  .rbsb\:rounded {
81
82
  border-radius: 0.25rem;
82
83
  }
84
+ .rbsb\:rounded-full {
85
+ border-radius: calc(infinity * 1px);
86
+ }
83
87
  .rbsb\:rounded-lg {
84
88
  border-radius: var(--rbsb-radius-lg);
85
89
  }
@@ -234,6 +238,11 @@
234
238
  border-color: var(--rbsb-color-green-700);
235
239
  }
236
240
  }
241
+ .rbsb\:dark\:border-purple-400 {
242
+ @media (prefers-color-scheme: dark) {
243
+ border-color: var(--rbsb-color-purple-400);
244
+ }
245
+ }
237
246
  .rbsb\:dark\:border-red-700 {
238
247
  @media (prefers-color-scheme: dark) {
239
248
  border-color: var(--rbsb-color-red-700);