@repobuddy/storybook 2.1.2 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
+
1
2
  import { UserConfig } from "htmlfy";
3
+ import { ReactNode } from "react";
2
4
  import * as react_jsx_runtime0 from "react/jsx-runtime";
3
5
  import { ClassNameProps, StyleProps } from "@just-web/css";
4
6
  import { Args, DecoratorFunction, Renderer } from "storybook/internal/csf";
7
+ import { ClassValue } from "class-variance-authority/types";
5
8
  import { Decorator, Meta, StoryContext, StoryObj, StrictArgs } from "@storybook/react-vite";
6
9
  export * from "@repobuddy/test";
7
10
 
@@ -32,6 +35,112 @@ declare function ShowHtml({
32
35
  */
33
36
  declare function showDocSource<TRenderer extends Renderer = Renderer, TArgs = Args>(): DecoratorFunction<TRenderer, TArgs>;
34
37
  //#endregion
38
+ //#region src/decorators/with_story_card.d.ts
39
+ type StoryCardProps = {
40
+ /**
41
+ * Optional title displayed as a heading in the card.
42
+ * Can be any React node (string, JSX, etc.).
43
+ */
44
+ title?: ReactNode | undefined;
45
+ /**
46
+ * Visual status of the card, affecting its background color.
47
+ * - `'error'`: Red background (bg-red-100 dark:bg-red-900)
48
+ * - `'warn'`: Yellow background (bg-yellow-100 dark:bg-yellow-900)
49
+ * - `'info'`: Blue background (bg-sky-100 dark:bg-sky-900) - default
50
+ */
51
+ status?: 'error' | 'warn' | 'info' | undefined;
52
+ /**
53
+ * Additional CSS classes or a function to compute classes.
54
+ *
55
+ * If a string is provided, it will be merged with the default classes.
56
+ * If a function is provided, it receives the card state and default className,
57
+ * and should return the final className string.
58
+ */
59
+ className?: ((state: Pick<StoryCardProps, 'status'> & {
60
+ defaultClassName: string;
61
+ }) => string) | ClassValue | undefined;
62
+ /**
63
+ * Content to display in the card body.
64
+ * Can be any React node (string, JSX, etc.).
65
+ *
66
+ * If not provided, the decorator will automatically use:
67
+ * 1. Story description (`parameters.docs.description.story`)
68
+ * 2. Component description (`parameters.docs.description.component`)
69
+ * 3. Nothing (card won't render if no content and no title)
70
+ */
71
+ content?: ReactNode | undefined;
72
+ };
73
+ /**
74
+ * A decorator that adds a card section to display additional information about the story.
75
+ *
76
+ * The card is automatically hidden when the story is shown in docs mode.
77
+ * Multiple decorators can be chained together,
78
+ * and all cards will be collected and displayed above the story content.
79
+ *
80
+ * @returns A Storybook decorator function.
81
+ *
82
+ * @example
83
+ * Basic usage - automatically uses component or story description:
84
+ * ```tsx
85
+ * export const MyStory: Story = {
86
+ * parameters: defineDocsParam({
87
+ * description: {
88
+ * story: 'This description will be shown in the card'
89
+ * }
90
+ * }),
91
+ * decorators: [withStoryCard()]
92
+ * }
93
+ * ```
94
+ *
95
+ * @example
96
+ * With custom content:
97
+ * ```tsx
98
+ * export const MyStory: Story = {
99
+ * decorators: [
100
+ * withStoryCard({
101
+ * content: <p>This is a custom message displayed in the card.</p>
102
+ * })
103
+ * ]
104
+ * }
105
+ * ```
106
+ *
107
+ * @example
108
+ * With title and status:
109
+ * ```tsx
110
+ * export const MyStory: Story = {
111
+ * decorators: [
112
+ * withStoryCard({
113
+ * title: 'Important Notice',
114
+ * status: 'warn',
115
+ * content: <p>Please review this carefully.</p>
116
+ * })
117
+ * ]
118
+ * }
119
+ * ```
120
+ *
121
+ * @example
122
+ * Multiple cards:
123
+ * ```tsx
124
+ * export const MyStory: Story = {
125
+ * decorators: [
126
+ * withStoryCard({ title: 'First Card', status: 'info' }),
127
+ * withStoryCard({ title: 'Second Card', status: 'warn' })
128
+ * ]
129
+ * }
130
+ * ```
131
+ *
132
+ * @remarks
133
+ * - The card will not render if both `content` and `title` are missing.
134
+ * - If `content` is not provided, it will automatically use the story description,
135
+ * or fall back to the component description.
136
+ * - Cards are collected and displayed in the order they are defined in the decorators array.
137
+ */
138
+ declare function withStoryCard<TRenderer extends Renderer = Renderer>({
139
+ title,
140
+ content: contentProp,
141
+ ...rest
142
+ }?: StoryCardProps): DecoratorFunction<TRenderer>;
143
+ //#endregion
35
144
  //#region src/parameters/define_actions_param.d.ts
36
145
  interface ActionsParam {
37
146
  actions: {
@@ -571,4 +680,4 @@ type ExtendStoryObj<TMetaOrCmpOrArgs, S extends StoryObj<TMetaOrCmpOrArgs>, E ex
571
680
  tags?: Array<E['tag'] | (string & {})> | undefined;
572
681
  };
573
682
  //#endregion
574
- export { ActionsParam, BackgroundsParam, DocsParam, ExtendMeta, ExtendStoryObj, GlobalApiBackgroundsParam, GlobalApiViewportParam, LayoutParam, ShowHtml, ShowHtmlProps, SourceProps, StorySortParam, StorybookBuiltInParams, TestParam, Viewport, ViewportParam, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineTestParam, defineViewportParam, showDocSource, whenRunningInTest };
683
+ export { ActionsParam, BackgroundsParam, DocsParam, ExtendMeta, ExtendStoryObj, GlobalApiBackgroundsParam, GlobalApiViewportParam, LayoutParam, ShowHtml, ShowHtmlProps, SourceProps, StoryCardProps, StorySortParam, StorybookBuiltInParams, TestParam, Viewport, ViewportParam, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineTestParam, defineViewportParam, showDocSource, whenRunningInTest, withStoryCard };
package/esm/index.js CHANGED
@@ -1,10 +1,13 @@
1
+
1
2
  import { isRunningInTest } from "@repobuddy/test";
2
3
  import { prettify } from "htmlfy";
3
- import { useEffect, useState } from "react";
4
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  import { SyntaxHighlighter } from "storybook/internal/components";
6
7
  import { addons } from "storybook/preview-api";
7
8
  import { ThemeProvider, convert, themes } from "storybook/theming";
9
+ import { cva } from "class-variance-authority";
10
+ import { twMerge } from "tailwind-merge";
8
11
 
9
12
  export * from "@repobuddy/test"
10
13
 
@@ -67,6 +70,174 @@ function showDocSource() {
67
70
  };
68
71
  }
69
72
 
73
+ //#endregion
74
+ //#region src/utils/generate_key.ts
75
+ /**
76
+ * Generates a key for React collections, falling back to a simple counter-based ID if crypto.randomUUID is unavailable.
77
+ * crypto.randomUUID() requires a secure context (HTTPS, localhost, or 127.0.0.1).
78
+ *
79
+ * This can be moved to `@just-web` in the future.
80
+ */
81
+ function generateKey(prefix) {
82
+ const randomId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
83
+ return prefix ? `${prefix}-${randomId}` : randomId;
84
+ }
85
+
86
+ //#endregion
87
+ //#region src/decorators/with_story_card.tsx
88
+ /**
89
+ * A decorator that adds a card section to display additional information about the story.
90
+ *
91
+ * The card is automatically hidden when the story is shown in docs mode.
92
+ * Multiple decorators can be chained together,
93
+ * and all cards will be collected and displayed above the story content.
94
+ *
95
+ * @returns A Storybook decorator function.
96
+ *
97
+ * @example
98
+ * Basic usage - automatically uses component or story description:
99
+ * ```tsx
100
+ * export const MyStory: Story = {
101
+ * parameters: defineDocsParam({
102
+ * description: {
103
+ * story: 'This description will be shown in the card'
104
+ * }
105
+ * }),
106
+ * decorators: [withStoryCard()]
107
+ * }
108
+ * ```
109
+ *
110
+ * @example
111
+ * With custom content:
112
+ * ```tsx
113
+ * export const MyStory: Story = {
114
+ * decorators: [
115
+ * withStoryCard({
116
+ * content: <p>This is a custom message displayed in the card.</p>
117
+ * })
118
+ * ]
119
+ * }
120
+ * ```
121
+ *
122
+ * @example
123
+ * With title and status:
124
+ * ```tsx
125
+ * export const MyStory: Story = {
126
+ * decorators: [
127
+ * withStoryCard({
128
+ * title: 'Important Notice',
129
+ * status: 'warn',
130
+ * content: <p>Please review this carefully.</p>
131
+ * })
132
+ * ]
133
+ * }
134
+ * ```
135
+ *
136
+ * @example
137
+ * Multiple cards:
138
+ * ```tsx
139
+ * export const MyStory: Story = {
140
+ * decorators: [
141
+ * withStoryCard({ title: 'First Card', status: 'info' }),
142
+ * withStoryCard({ title: 'Second Card', status: 'warn' })
143
+ * ]
144
+ * }
145
+ * ```
146
+ *
147
+ * @remarks
148
+ * - The card will not render if both `content` and `title` are missing.
149
+ * - If `content` is not provided, it will automatically use the story description,
150
+ * or fall back to the component description.
151
+ * - Cards are collected and displayed in the order they are defined in the decorators array.
152
+ */
153
+ function withStoryCard({ title, content: contentProp, ...rest } = {}) {
154
+ return (Story, { parameters, viewMode }) => {
155
+ if (viewMode === "docs") return /* @__PURE__ */ jsx(Story, {});
156
+ const content = contentProp ?? parameters.docs?.description?.story ?? parameters.docs?.description?.component;
157
+ if (!content && !title) return /* @__PURE__ */ jsx(Story, {});
158
+ return /* @__PURE__ */ jsx(StoryCardContainerWrapper, {
159
+ Story,
160
+ content,
161
+ title,
162
+ ...rest
163
+ });
164
+ };
165
+ }
166
+ function StoryCardContainerWrapper({ Story, ...props }) {
167
+ const context = useContext(StoryCardContext);
168
+ const collector = /* @__PURE__ */ jsx(StoryCardCollector, {
169
+ Story,
170
+ ...props
171
+ });
172
+ if (context === null) return /* @__PURE__ */ jsx(StoryCardContainer, { children: collector });
173
+ return collector;
174
+ }
175
+ function StoryCardContainer({ children }) {
176
+ const [cards, setCards] = useState([]);
177
+ const contextValue = useMemo(() => ({
178
+ addCard(card) {
179
+ const id = generateKey("story-card");
180
+ setCards((cards$1) => [...cards$1, {
181
+ ...card,
182
+ id
183
+ }]);
184
+ return id;
185
+ },
186
+ removeCard(id) {
187
+ setCards((cards$1) => cards$1.filter((card) => card.id !== id));
188
+ }
189
+ }), []);
190
+ return /* @__PURE__ */ jsx(StoryCardContext.Provider, {
191
+ value: contextValue,
192
+ children: /* @__PURE__ */ jsxs("div", {
193
+ className: "flex flex-col gap-2",
194
+ children: [cards.map(({ id, status, className, content, title }) => /* @__PURE__ */ jsxs("section", {
195
+ className: storyCardTheme({ status }, className),
196
+ children: [title && /* @__PURE__ */ jsx("h2", {
197
+ className: "text-lg font-bold",
198
+ children: title
199
+ }), content]
200
+ }, id)), children]
201
+ })
202
+ });
203
+ }
204
+ const storyCardTheme = (state, className) => {
205
+ const defaultClassName = storyCardVariants(state);
206
+ if (!className) return defaultClassName;
207
+ return twMerge(defaultClassName, typeof className === "function" ? className({
208
+ ...state,
209
+ defaultClassName
210
+ }) : className);
211
+ };
212
+ const storyCardVariants = cva("flex flex-col gap-1 py-3 px-4 rounded text-black dark:text-gray-100", {
213
+ variants: { status: {
214
+ error: "bg-red-100 dark:bg-red-900",
215
+ warn: "bg-yellow-100 dark:bg-yellow-900",
216
+ info: "bg-sky-100 dark:bg-sky-900"
217
+ } },
218
+ defaultVariants: { status: "info" }
219
+ });
220
+ function StoryCardCollector({ Story, title, status, className, content }) {
221
+ const context = useContext(StoryCardContext);
222
+ const cardIdRef = useRef(null);
223
+ useLayoutEffect(() => {
224
+ if (cardIdRef.current === null) cardIdRef.current = context.addCard({
225
+ title,
226
+ status,
227
+ className,
228
+ content
229
+ });
230
+ return () => {
231
+ if (cardIdRef.current !== null) {
232
+ context.removeCard(cardIdRef.current);
233
+ cardIdRef.current = null;
234
+ }
235
+ };
236
+ }, []);
237
+ return /* @__PURE__ */ jsx(Story, {});
238
+ }
239
+ const StoryCardContext = createContext(null);
240
+
70
241
  //#endregion
71
242
  //#region src/parameters/define_actions_param.ts
72
243
  /**
@@ -248,4 +419,4 @@ function whenRunningInTest(decoratorOrHandler) {
248
419
  }
249
420
 
250
421
  //#endregion
251
- export { ShowHtml, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineTestParam, defineViewportParam, showDocSource, whenRunningInTest };
422
+ export { ShowHtml, defineActionsParam, defineBackgroundsParam, defineDocsParam, defineLayoutParam, defineParameters, defineTestParam, defineViewportParam, showDocSource, whenRunningInTest, withStoryCard };
@@ -1,3 +1,4 @@
1
+
1
2
  //#region src/manager/brand_title.d.ts
2
3
  interface BrandTitleOptions {
3
4
  /**
@@ -1,3 +1,4 @@
1
+
1
2
  //#region src/manager/brand_title.ts
2
3
  /**
3
4
  * Creates a brand title element for the Storybook manager UI.
@@ -1,3 +1,4 @@
1
+
1
2
  import { TagBadgeParameters } from "storybook-addon-tag-badges/manager-helpers";
2
3
  import { Args, Meta as Meta$1, StoryObj as StoryObj$1 } from "@storybook/react-vite";
3
4
 
@@ -6,7 +7,7 @@ type TagBadgeParameter = TagBadgeParameters[0];
6
7
  /**
7
8
  * Type representing the names of predefined tags used in Storybook stories.
8
9
  */
9
- type TagNames = 'editor' | 'new' | 'beta' | 'props' | 'deprecated' | 'outdated' | 'danger' | 'todo' | 'code-only' | 'snapshot' | 'unit' | 'integration' | 'keyboard' | 'internal';
10
+ type TagNames = 'editor' | 'new' | 'beta' | 'props' | 'deprecated' | 'outdated' | 'danger' | 'todo' | 'code-only' | 'snapshot' | 'unit' | 'integration' | 'keyboard' | 'internal' | 'usecase';
10
11
  /**
11
12
  * Configuration for story tag badges that appear in the Storybook sidebar.
12
13
  * Each badge is associated with a specific tag and displays an emoji with a tooltip.
@@ -1,3 +1,4 @@
1
+
1
2
  import { defaultConfig } from "storybook-addon-tag-badges/manager-helpers";
2
3
 
3
4
  //#region src/storybook-addon-tag-badges/tag_badges.ts
@@ -1,3 +1,4 @@
1
+
1
2
  import { DocsContextProps } from "@storybook/addon-docs/blocks";
2
3
  import { PropsWithChildren } from "react";
3
4
  import { ThemeVars } from "storybook/theming";
@@ -1,3 +1,4 @@
1
+
1
2
  import { DARK_MODE_EVENT_NAME, useDarkMode } from "@storybook-community/storybook-dark-mode";
2
3
  import { DocsContainer } from "@storybook/addon-docs/blocks";
3
4
  import { useEffect, useState } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repobuddy/storybook",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "Storybook repo buddy",
5
5
  "keywords": [
6
6
  "storybook",
@@ -38,44 +38,46 @@
38
38
  "./storybook-dark-mode": {
39
39
  "types": "./esm/storybook-dark-mode/index.d.ts",
40
40
  "default": "./esm/storybook-dark-mode/index.js"
41
- }
41
+ },
42
+ "./styles.css": "./styles.css"
42
43
  },
43
44
  "files": [
44
45
  "cjs",
45
46
  "esm",
46
47
  "src",
48
+ "styles.css",
47
49
  "!**/*.{spec,test,unit,accept,integrate,system,perf,stress,study,stories}.*",
48
50
  "!**/*.mdx"
49
51
  ],
50
52
  "dependencies": {
51
53
  "@just-web/css": "^0.7.0",
52
54
  "@repobuddy/test": "^1.0.0",
53
- "@storybook/icons": "^2.0.1",
54
- "htmlfy": "^1.0.0"
55
+ "class-variance-authority": "^0.7.1",
56
+ "htmlfy": "^1.0.0",
57
+ "tailwind-merge": "^3.4.0"
55
58
  },
56
59
  "devDependencies": {
57
60
  "@repobuddy/vitest": "^2.0.0",
58
61
  "@storybook-community/storybook-dark-mode": "^7.0.2",
59
62
  "@storybook/addon-docs": "^10.0.7",
60
- "@storybook/addon-themes": "^10.0.7",
61
63
  "@storybook/addon-vitest": "^10.0.7",
62
64
  "@storybook/react-vite": "^10.0.7",
63
65
  "@tailwindcss/cli": "^4.1.17",
64
66
  "@tailwindcss/vite": "^4.1.17",
65
- "@vitest/browser": "^4.0.9",
66
- "@vitest/browser-playwright": "^4.0.9",
67
- "@vitest/coverage-v8": "^4.0.9",
67
+ "@vitest/browser": "^4.0.16",
68
+ "@vitest/browser-playwright": "^4.0.16",
69
+ "@vitest/coverage-v8": "^4.0.16",
68
70
  "dedent": "^1.7.0",
69
- "ncp": "^2.0.0",
71
+ "npm-run-all2": "^8.0.4",
70
72
  "react": "^19.2.0",
71
73
  "react-dom": "^19.2.0",
72
74
  "rimraf": "^6.1.0",
73
75
  "storybook": "^10.0.8",
74
76
  "storybook-addon-tag-badges": "^3.0.2",
75
77
  "tailwindcss": "^4.1.17",
76
- "tsdown": "^0.16.6",
77
- "vite": "^7.2.2",
78
- "vitest": "^4.0.9"
78
+ "tsdown": "^0.18.0",
79
+ "vite": "^7.3.0",
80
+ "vitest": "^4.0.16"
79
81
  },
80
82
  "peerDependencies": {
81
83
  "@storybook-community/storybook-dark-mode": "^7.0.0",
@@ -91,7 +93,9 @@
91
93
  }
92
94
  },
93
95
  "scripts": {
94
- "build": "tsdown",
96
+ "build": "run-p build:*",
97
+ "build:code": "tsdown",
98
+ "build:css": "tailwindcss -i ./tailwind.css -o ./styles.css",
95
99
  "clean": "rimraf .turbo coverage cjs esm storybook-static *.tsbuildinfo",
96
100
  "coverage": "vitest run --coverage",
97
101
  "sb": "storybook dev -p 6006",
@@ -100,6 +104,7 @@
100
104
  "test:dark": "vitest run --config=vitest.config.dark.ts",
101
105
  "test:light": "vitest run --config=vitest.config.light.ts",
102
106
  "test:theme": "vitest run --config=vitest.config.theme.ts",
107
+ "test:types": "tsc --noEmit",
103
108
  "w": "vitest"
104
109
  }
105
110
  }
package/readme.md CHANGED
@@ -4,9 +4,11 @@ Your repository buddy for Storybook.
4
4
 
5
5
  > [!NOTE]
6
6
  >
7
- > This package supports Storybook 9.x from version `1.0.0`.
7
+ > For Storybook 10, please use version `2.x`.
8
8
  >
9
- > For Storybook 8.x, please use `0.x` version.
9
+ > For Storybook 9, please use version `1.x`.
10
+ >
11
+ > For Storybook 8.x, please use version `0.x`.
10
12
 
11
13
  ## Install
12
14
 
@@ -14,6 +16,8 @@ Your repository buddy for Storybook.
14
16
  pnpm add -D @repobuddy/storybook
15
17
  ```
16
18
 
19
+ If you use the components in the library, import `@repobuddy/storybook/styles.css`.
20
+
17
21
  ## Features
18
22
 
19
23
  ### Typed Parameters
@@ -0,0 +1,236 @@
1
+ import { cva } from 'class-variance-authority'
2
+ import type { ClassValue } from 'class-variance-authority/types'
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ComponentType,
11
+ type ReactNode
12
+ } from 'react'
13
+ import type { DecoratorFunction, Renderer } from 'storybook/internal/csf'
14
+ import { twMerge } from 'tailwind-merge'
15
+ import { generateKey } from '../utils/generate_key'
16
+
17
+ export type StoryCardProps = {
18
+ /**
19
+ * Optional title displayed as a heading in the card.
20
+ * Can be any React node (string, JSX, etc.).
21
+ */
22
+ title?: ReactNode | undefined
23
+ /**
24
+ * Visual status of the card, affecting its background color.
25
+ * - `'error'`: Red background (bg-red-100 dark:bg-red-900)
26
+ * - `'warn'`: Yellow background (bg-yellow-100 dark:bg-yellow-900)
27
+ * - `'info'`: Blue background (bg-sky-100 dark:bg-sky-900) - default
28
+ */
29
+ status?: 'error' | 'warn' | 'info' | undefined
30
+ /**
31
+ * Additional CSS classes or a function to compute classes.
32
+ *
33
+ * If a string is provided, it will be merged with the default classes.
34
+ * If a function is provided, it receives the card state and default className,
35
+ * and should return the final className string.
36
+ */
37
+ className?:
38
+ | ((state: Pick<StoryCardProps, 'status'> & { defaultClassName: string }) => string)
39
+ | ClassValue
40
+ | undefined
41
+ /**
42
+ * Content to display in the card body.
43
+ * Can be any React node (string, JSX, etc.).
44
+ *
45
+ * If not provided, the decorator will automatically use:
46
+ * 1. Story description (`parameters.docs.description.story`)
47
+ * 2. Component description (`parameters.docs.description.component`)
48
+ * 3. Nothing (card won't render if no content and no title)
49
+ */
50
+ content?: ReactNode | undefined
51
+ }
52
+
53
+ /**
54
+ * A decorator that adds a card section to display additional information about the story.
55
+ *
56
+ * The card is automatically hidden when the story is shown in docs mode.
57
+ * Multiple decorators can be chained together,
58
+ * and all cards will be collected and displayed above the story content.
59
+ *
60
+ * @returns A Storybook decorator function.
61
+ *
62
+ * @example
63
+ * Basic usage - automatically uses component or story description:
64
+ * ```tsx
65
+ * export const MyStory: Story = {
66
+ * parameters: defineDocsParam({
67
+ * description: {
68
+ * story: 'This description will be shown in the card'
69
+ * }
70
+ * }),
71
+ * decorators: [withStoryCard()]
72
+ * }
73
+ * ```
74
+ *
75
+ * @example
76
+ * With custom content:
77
+ * ```tsx
78
+ * export const MyStory: Story = {
79
+ * decorators: [
80
+ * withStoryCard({
81
+ * content: <p>This is a custom message displayed in the card.</p>
82
+ * })
83
+ * ]
84
+ * }
85
+ * ```
86
+ *
87
+ * @example
88
+ * With title and status:
89
+ * ```tsx
90
+ * export const MyStory: Story = {
91
+ * decorators: [
92
+ * withStoryCard({
93
+ * title: 'Important Notice',
94
+ * status: 'warn',
95
+ * content: <p>Please review this carefully.</p>
96
+ * })
97
+ * ]
98
+ * }
99
+ * ```
100
+ *
101
+ * @example
102
+ * Multiple cards:
103
+ * ```tsx
104
+ * export const MyStory: Story = {
105
+ * decorators: [
106
+ * withStoryCard({ title: 'First Card', status: 'info' }),
107
+ * withStoryCard({ title: 'Second Card', status: 'warn' })
108
+ * ]
109
+ * }
110
+ * ```
111
+ *
112
+ * @remarks
113
+ * - The card will not render if both `content` and `title` are missing.
114
+ * - If `content` is not provided, it will automatically use the story description,
115
+ * or fall back to the component description.
116
+ * - Cards are collected and displayed in the order they are defined in the decorators array.
117
+ */
118
+ export function withStoryCard<TRenderer extends Renderer = Renderer>({
119
+ title,
120
+ content: contentProp,
121
+ ...rest
122
+ }: StoryCardProps = {}): DecoratorFunction<TRenderer> {
123
+ return (Story, { parameters, viewMode }) => {
124
+ if (viewMode === 'docs') return <Story />
125
+
126
+ const content = contentProp ?? parameters.docs?.description?.story ?? parameters.docs?.description?.component
127
+ if (!content && !title) return <Story />
128
+
129
+ return <StoryCardContainerWrapper Story={Story} content={content} title={title} {...rest} />
130
+ }
131
+ }
132
+
133
+ interface StoryCardContainerWrapperProps extends StoryCardProps {
134
+ Story: ComponentType
135
+ }
136
+
137
+ function StoryCardContainerWrapper({ Story, ...props }: StoryCardContainerWrapperProps) {
138
+ const context = useContext(StoryCardContext)
139
+ const collector = <StoryCardCollector Story={Story} {...props} />
140
+
141
+ if (context === null) {
142
+ return <StoryCardContainer>{collector}</StoryCardContainer>
143
+ }
144
+
145
+ return collector
146
+ }
147
+
148
+ function StoryCardContainer({ children }: { children: ReactNode }) {
149
+ const [cards, setCards] = useState<StoryCardWithId[]>([])
150
+
151
+ const contextValue: StoryCardContextValue = useMemo(
152
+ () => ({
153
+ addCard(card) {
154
+ const id = generateKey('story-card')
155
+ setCards((cards) => [...cards, { ...card, id }])
156
+ return id
157
+ },
158
+ removeCard(id) {
159
+ setCards((cards) => cards.filter((card) => card.id !== id))
160
+ }
161
+ }),
162
+ []
163
+ )
164
+
165
+ return (
166
+ <StoryCardContext.Provider value={contextValue}>
167
+ <div className="flex flex-col gap-2">
168
+ {cards.map(({ id, status, className, content, title }) => (
169
+ <section key={id} className={storyCardTheme({ status }, className)}>
170
+ {title && <h2 className="text-lg font-bold">{title}</h2>}
171
+ {content}
172
+ </section>
173
+ ))}
174
+ {children}
175
+ </div>
176
+ </StoryCardContext.Provider>
177
+ )
178
+ }
179
+
180
+ type StoryCardWithId = StoryCardProps & { id: string }
181
+
182
+ const storyCardTheme = (state: Pick<StoryCardProps, 'status'>, className: StoryCardProps['className']) => {
183
+ const defaultClassName = storyCardVariants(state)
184
+ if (!className) return defaultClassName
185
+ return twMerge(
186
+ defaultClassName,
187
+ typeof className === 'function' ? className({ ...state, defaultClassName }) : className
188
+ )
189
+ }
190
+
191
+ const storyCardVariants = cva('flex flex-col gap-1 py-3 px-4 rounded text-black dark:text-gray-100', {
192
+ variants: {
193
+ status: {
194
+ error: 'bg-red-100 dark:bg-red-900',
195
+ warn: 'bg-yellow-100 dark:bg-yellow-900',
196
+ info: 'bg-sky-100 dark:bg-sky-900'
197
+ }
198
+ },
199
+ defaultVariants: {
200
+ status: 'info'
201
+ }
202
+ })
203
+
204
+ interface StoryCardCollectorProps extends StoryCardProps {
205
+ Story: ComponentType
206
+ }
207
+
208
+ function StoryCardCollector({ Story, title, status, className, content }: StoryCardCollectorProps) {
209
+ // StoryCardCollector is an internal component. Context is guaranteed to be not null by `StoryCardContainer`.
210
+ const context = useContext(StoryCardContext)!
211
+ const cardIdRef = useRef<string | null>(null)
212
+
213
+ // Collect this card once into the collection
214
+ useLayoutEffect(() => {
215
+ // Only add if not already added (handles Strict Mode double-render)
216
+ if (cardIdRef.current === null) {
217
+ cardIdRef.current = context.addCard({ title, status, className, content })
218
+ }
219
+
220
+ return () => {
221
+ if (cardIdRef.current !== null) {
222
+ context.removeCard(cardIdRef.current)
223
+ cardIdRef.current = null
224
+ }
225
+ }
226
+ }, [])
227
+
228
+ return <Story />
229
+ }
230
+
231
+ interface StoryCardContextValue {
232
+ addCard: (card: StoryCardProps) => string
233
+ removeCard: (id: string) => void
234
+ }
235
+
236
+ const StoryCardContext = createContext<StoryCardContextValue | null>(null)
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from '@repobuddy/test'
2
2
  export * from './components/show_html.tsx'
3
3
  export * from './decorators/show_doc_source.tsx'
4
+ export * from './decorators/with_story_card.tsx'
4
5
  export * from './parameters/define_actions_param.ts'
5
6
  export * from './parameters/define_backgrounds_param.ts'
6
7
  export * from './parameters/define_docs_param.ts'
@@ -22,6 +22,7 @@ export type TagNames =
22
22
  | 'integration'
23
23
  | 'keyboard'
24
24
  | 'internal'
25
+ | 'usecase'
25
26
 
26
27
  /**
27
28
  * Configuration for story tag badges that appear in the Storybook sidebar.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Generates a key for React collections, falling back to a simple counter-based ID if crypto.randomUUID is unavailable.
3
+ * crypto.randomUUID() requires a secure context (HTTPS, localhost, or 127.0.0.1).
4
+ *
5
+ * This can be moved to `@just-web` in the future.
6
+ */
7
+ export function generateKey(prefix?: string | undefined): string {
8
+ const randomId =
9
+ typeof crypto !== 'undefined' && crypto.randomUUID
10
+ ? crypto.randomUUID()
11
+ : `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
12
+
13
+ return prefix ? `${prefix}-${randomId}` : randomId
14
+ }
package/styles.css ADDED
@@ -0,0 +1,344 @@
1
+ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
2
+ @layer properties;
3
+ @layer theme, base, components, utilities;
4
+ @layer theme {
5
+ :root, :host {
6
+ --color-red-100: oklch(93.6% 0.032 17.717);
7
+ --color-red-800: oklch(44.4% 0.177 26.899);
8
+ --color-red-900: oklch(39.6% 0.141 25.723);
9
+ --color-amber-300: oklch(87.9% 0.169 91.605);
10
+ --color-amber-900: oklch(41.4% 0.112 45.904);
11
+ --color-yellow-100: oklch(97.3% 0.071 103.193);
12
+ --color-yellow-900: oklch(42.1% 0.095 57.708);
13
+ --color-green-200: oklch(92.5% 0.084 155.995);
14
+ --color-green-800: oklch(44.8% 0.119 151.328);
15
+ --color-sky-100: oklch(95.1% 0.026 236.824);
16
+ --color-sky-500: oklch(68.5% 0.169 237.323);
17
+ --color-sky-900: oklch(39.1% 0.09 240.876);
18
+ --color-blue-500: oklch(62.3% 0.214 259.815);
19
+ --color-rose-400: oklch(71.2% 0.194 13.428);
20
+ --color-rose-900: oklch(41% 0.159 10.272);
21
+ --color-gray-100: oklch(96.7% 0.003 264.542);
22
+ --color-gray-500: oklch(55.1% 0.027 264.364);
23
+ --color-black: #000;
24
+ --color-white: #fff;
25
+ --spacing: 0.25rem;
26
+ --text-lg: 1.125rem;
27
+ --text-lg--line-height: calc(1.75 / 1.125);
28
+ --font-weight-extralight: 200;
29
+ --font-weight-bold: 700;
30
+ --font-weight-extrabold: 800;
31
+ }
32
+ }
33
+ @layer utilities {
34
+ .static {
35
+ position: static;
36
+ }
37
+ .container {
38
+ width: 100%;
39
+ @media (width >= 40rem) {
40
+ max-width: 40rem;
41
+ }
42
+ @media (width >= 48rem) {
43
+ max-width: 48rem;
44
+ }
45
+ @media (width >= 64rem) {
46
+ max-width: 64rem;
47
+ }
48
+ @media (width >= 80rem) {
49
+ max-width: 80rem;
50
+ }
51
+ @media (width >= 96rem) {
52
+ max-width: 96rem;
53
+ }
54
+ }
55
+ .flex {
56
+ display: flex;
57
+ }
58
+ .hidden {
59
+ display: none;
60
+ }
61
+ .inline {
62
+ display: inline;
63
+ }
64
+ .transform {
65
+ transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
66
+ }
67
+ .flex-col {
68
+ flex-direction: column;
69
+ }
70
+ .gap-1 {
71
+ gap: calc(var(--spacing) * 1);
72
+ }
73
+ .gap-2 {
74
+ gap: calc(var(--spacing) * 2);
75
+ }
76
+ .gap-4 {
77
+ gap: calc(var(--spacing) * 4);
78
+ }
79
+ .rounded {
80
+ border-radius: 0.25rem;
81
+ }
82
+ .border {
83
+ border-style: var(--tw-border-style);
84
+ border-width: 1px;
85
+ }
86
+ .border-2 {
87
+ border-style: var(--tw-border-style);
88
+ border-width: 2px;
89
+ }
90
+ .border-blue-500 {
91
+ border-color: var(--color-blue-500);
92
+ }
93
+ .bg-amber-300 {
94
+ background-color: var(--color-amber-300);
95
+ }
96
+ .bg-black {
97
+ background-color: var(--color-black);
98
+ }
99
+ .bg-gray-100 {
100
+ background-color: var(--color-gray-100);
101
+ }
102
+ .bg-green-200 {
103
+ background-color: var(--color-green-200);
104
+ }
105
+ .bg-green-800 {
106
+ background-color: var(--color-green-800);
107
+ }
108
+ .bg-red-100 {
109
+ background-color: var(--color-red-100);
110
+ }
111
+ .bg-red-800 {
112
+ background-color: var(--color-red-800);
113
+ }
114
+ .bg-rose-400 {
115
+ background-color: var(--color-rose-400);
116
+ }
117
+ .bg-sky-100 {
118
+ background-color: var(--color-sky-100);
119
+ }
120
+ .bg-sky-500 {
121
+ background-color: var(--color-sky-500);
122
+ }
123
+ .bg-white {
124
+ background-color: var(--color-white);
125
+ }
126
+ .bg-yellow-100 {
127
+ background-color: var(--color-yellow-100);
128
+ }
129
+ .p-2 {
130
+ padding: calc(var(--spacing) * 2);
131
+ }
132
+ .p-4 {
133
+ padding: calc(var(--spacing) * 4);
134
+ }
135
+ .px-4 {
136
+ padding-inline: calc(var(--spacing) * 4);
137
+ }
138
+ .py-3 {
139
+ padding-block: calc(var(--spacing) * 3);
140
+ }
141
+ .text-lg {
142
+ font-size: var(--text-lg);
143
+ line-height: var(--tw-leading, var(--text-lg--line-height));
144
+ }
145
+ .font-bold {
146
+ --tw-font-weight: var(--font-weight-bold);
147
+ font-weight: var(--font-weight-bold);
148
+ }
149
+ .font-extrabold {
150
+ --tw-font-weight: var(--font-weight-extrabold);
151
+ font-weight: var(--font-weight-extrabold);
152
+ }
153
+ .font-extralight {
154
+ --tw-font-weight: var(--font-weight-extralight);
155
+ font-weight: var(--font-weight-extralight);
156
+ }
157
+ .text-black {
158
+ color: var(--color-black);
159
+ }
160
+ .text-white {
161
+ color: var(--color-white);
162
+ }
163
+ .shadow-lg {
164
+ --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
165
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
166
+ }
167
+ .dark\:bg-amber-900 {
168
+ &:where(.dark, .dark *) {
169
+ background-color: var(--color-amber-900);
170
+ }
171
+ }
172
+ .dark\:bg-black {
173
+ &:where(.dark, .dark *) {
174
+ background-color: var(--color-black);
175
+ }
176
+ }
177
+ .dark\:bg-gray-500 {
178
+ &:where(.dark, .dark *) {
179
+ background-color: var(--color-gray-500);
180
+ }
181
+ }
182
+ .dark\:bg-green-800 {
183
+ &:where(.dark, .dark *) {
184
+ background-color: var(--color-green-800);
185
+ }
186
+ }
187
+ .dark\:bg-red-900 {
188
+ &:where(.dark, .dark *) {
189
+ background-color: var(--color-red-900);
190
+ }
191
+ }
192
+ .dark\:bg-rose-900 {
193
+ &:where(.dark, .dark *) {
194
+ background-color: var(--color-rose-900);
195
+ }
196
+ }
197
+ .dark\:bg-sky-900 {
198
+ &:where(.dark, .dark *) {
199
+ background-color: var(--color-sky-900);
200
+ }
201
+ }
202
+ .dark\:bg-yellow-900 {
203
+ &:where(.dark, .dark *) {
204
+ background-color: var(--color-yellow-900);
205
+ }
206
+ }
207
+ .dark\:font-extrabold {
208
+ &:where(.dark, .dark *) {
209
+ --tw-font-weight: var(--font-weight-extrabold);
210
+ font-weight: var(--font-weight-extrabold);
211
+ }
212
+ }
213
+ .dark\:text-gray-100 {
214
+ &:where(.dark, .dark *) {
215
+ color: var(--color-gray-100);
216
+ }
217
+ }
218
+ .dark\:text-white {
219
+ &:where(.dark, .dark *) {
220
+ color: var(--color-white);
221
+ }
222
+ }
223
+ }
224
+ @property --tw-rotate-x {
225
+ syntax: "*";
226
+ inherits: false;
227
+ }
228
+ @property --tw-rotate-y {
229
+ syntax: "*";
230
+ inherits: false;
231
+ }
232
+ @property --tw-rotate-z {
233
+ syntax: "*";
234
+ inherits: false;
235
+ }
236
+ @property --tw-skew-x {
237
+ syntax: "*";
238
+ inherits: false;
239
+ }
240
+ @property --tw-skew-y {
241
+ syntax: "*";
242
+ inherits: false;
243
+ }
244
+ @property --tw-border-style {
245
+ syntax: "*";
246
+ inherits: false;
247
+ initial-value: solid;
248
+ }
249
+ @property --tw-font-weight {
250
+ syntax: "*";
251
+ inherits: false;
252
+ }
253
+ @property --tw-shadow {
254
+ syntax: "*";
255
+ inherits: false;
256
+ initial-value: 0 0 #0000;
257
+ }
258
+ @property --tw-shadow-color {
259
+ syntax: "*";
260
+ inherits: false;
261
+ }
262
+ @property --tw-shadow-alpha {
263
+ syntax: "<percentage>";
264
+ inherits: false;
265
+ initial-value: 100%;
266
+ }
267
+ @property --tw-inset-shadow {
268
+ syntax: "*";
269
+ inherits: false;
270
+ initial-value: 0 0 #0000;
271
+ }
272
+ @property --tw-inset-shadow-color {
273
+ syntax: "*";
274
+ inherits: false;
275
+ }
276
+ @property --tw-inset-shadow-alpha {
277
+ syntax: "<percentage>";
278
+ inherits: false;
279
+ initial-value: 100%;
280
+ }
281
+ @property --tw-ring-color {
282
+ syntax: "*";
283
+ inherits: false;
284
+ }
285
+ @property --tw-ring-shadow {
286
+ syntax: "*";
287
+ inherits: false;
288
+ initial-value: 0 0 #0000;
289
+ }
290
+ @property --tw-inset-ring-color {
291
+ syntax: "*";
292
+ inherits: false;
293
+ }
294
+ @property --tw-inset-ring-shadow {
295
+ syntax: "*";
296
+ inherits: false;
297
+ initial-value: 0 0 #0000;
298
+ }
299
+ @property --tw-ring-inset {
300
+ syntax: "*";
301
+ inherits: false;
302
+ }
303
+ @property --tw-ring-offset-width {
304
+ syntax: "<length>";
305
+ inherits: false;
306
+ initial-value: 0px;
307
+ }
308
+ @property --tw-ring-offset-color {
309
+ syntax: "*";
310
+ inherits: false;
311
+ initial-value: #fff;
312
+ }
313
+ @property --tw-ring-offset-shadow {
314
+ syntax: "*";
315
+ inherits: false;
316
+ initial-value: 0 0 #0000;
317
+ }
318
+ @layer properties {
319
+ @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
320
+ *, ::before, ::after, ::backdrop {
321
+ --tw-rotate-x: initial;
322
+ --tw-rotate-y: initial;
323
+ --tw-rotate-z: initial;
324
+ --tw-skew-x: initial;
325
+ --tw-skew-y: initial;
326
+ --tw-border-style: solid;
327
+ --tw-font-weight: initial;
328
+ --tw-shadow: 0 0 #0000;
329
+ --tw-shadow-color: initial;
330
+ --tw-shadow-alpha: 100%;
331
+ --tw-inset-shadow: 0 0 #0000;
332
+ --tw-inset-shadow-color: initial;
333
+ --tw-inset-shadow-alpha: 100%;
334
+ --tw-ring-color: initial;
335
+ --tw-ring-shadow: 0 0 #0000;
336
+ --tw-inset-ring-color: initial;
337
+ --tw-inset-ring-shadow: 0 0 #0000;
338
+ --tw-ring-inset: initial;
339
+ --tw-ring-offset-width: 0px;
340
+ --tw-ring-offset-color: #fff;
341
+ --tw-ring-offset-shadow: 0 0 #0000;
342
+ }
343
+ }
344
+ }
@@ -1,3 +0,0 @@
1
- console.info('variants.index.ts.load')
2
-
3
- export const empty = {}
@@ -1,3 +0,0 @@
1
- {
2
- "type": "module"
3
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * User specifies `@repobuddy/storybook/variants/addon` in the `.storybook/main.ts`.
3
- *
4
- * Storybook loads it on the server side.
5
- *
6
- * It provides a default export (or in CJS, `module.exports =`) that contains:
7
- *
8
- * - `managerEntries` a function that returns an array of strings on where to load the manager (on the client side)
9
- * - `previewAnnotations` a function that returns an array of strings on where to load the preview (on the client side)
10
- * - `experimental_serverChannel` a function that handles the server-side channel
11
- */
12
-
13
- console.info('variants.preset.js.load')
14
-
15
- // export const managerEntries = ['@storybook-community/storybook-dark-mode/manager']
16
- // const previewAnnotations = (entry = []) => [...entry, require.resolve('@storybook-community/storybook-dark-mode')]
17
- // const previewAnnotations = [ require.resolve('@storybook-community/storybook-dark-mode')]
18
-
19
- // module.exports = {
20
- // managerEntries,
21
- // // previewAnnotations
22
- // }