@fohte/storybook-addon 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -17,8 +17,13 @@ pnpm add -D @fohte/storybook-addon
17
17
 
18
18
  # Peer dependencies
19
19
  pnpm add -D storybook vitest
20
+
21
+ # Peer dependencies for ./vitest-plugin only
22
+ pnpm add -D @storybook/addon-vitest @storycap-testrun/browser @vitest/browser-playwright
20
23
  ```
21
24
 
25
+ `@vitest/browser-playwright` pins its own `vitest` peer to its exact version (e.g. `4.1.10` requires `vitest@4.1.10` precisely), even though this package's own `peerDependencies` range for both is the looser `^4.0.0`. Keep the two installed at the same version.
26
+
22
27
  ## Usage
23
28
 
24
29
  Add the addon to `.storybook/main.ts`:
@@ -69,11 +74,49 @@ initialize({
69
74
  })
70
75
  ```
71
76
 
77
+ ## Vitest plugin (`./vitest-plugin`)
78
+
79
+ Builds a Vitest browser-mode project that runs Storybook stories through [`@storybook/addon-vitest`](https://storybook.js.org/docs/writing-tests/integrations/vitest-addon) and captures a screenshot of each with [`@storycap-testrun/browser`](https://github.com/reg-viz/storycap-testrun), patched to shorten storycap's hardcoded 500ms network-idle wait to 100ms and to fix a tiling bug in its fullPage capture (`clip` staying anchored to the top of the viewport instead of following the browser's clamped scroll position, which duplicated content and cut off the bottom of any story taller than the viewport).
80
+
81
+ ```ts
82
+ import path from 'node:path'
83
+ import { fileURLToPath } from 'node:url'
84
+
85
+ import { createStorybookProject } from '@fohte/storybook-addon/vitest-plugin'
86
+ import { defineConfig } from 'vitest/config'
87
+
88
+ const rootDir = path.dirname(fileURLToPath(import.meta.url))
89
+
90
+ export default defineConfig({
91
+ test: {
92
+ projects: [
93
+ createStorybookProject({
94
+ name: 'storybook',
95
+ rootDir,
96
+ viewport: { width: 1280, height: 800 },
97
+ screenshotsSubdir: 'desktop',
98
+ setupFiles: ['./.storybook/vitest.setup.ts'],
99
+ }),
100
+ ],
101
+ },
102
+ })
103
+ ```
104
+
105
+ Screenshots for a project land in `<rootDir>/__screenshots__/<screenshotsSubdir>` — downstream tooling that consumes these images depends on this exact path, so treat it as a stable contract rather than an implementation detail.
106
+
107
+ `storycapNetworkIdle` and `storycapFullPageStitch` are also exported on their own, for building a project without `createStorybookProject`. `storycapFullPageStitch` must be placed after storycap's own plugin in the `plugins` array — it works by overriding the `__storycap_takeScreenshot` command storycap registers, and Vite resolves conflicting plugin `config()` keys in plugin order, later wins.
108
+
109
+ Your `setupFiles` entry needs an `afterEach` that calls storycap's own `screenshot()` — see [`@storycap-testrun/browser`'s "Setup Screenshot Capture"](https://github.com/reg-viz/storycap-testrun/tree/main/packages/browser#2-setup-screenshot-capture) for the exact shape. Don't call `setProjectAnnotations()` there, and don't even mention that identifier in a comment: `@storybook/addon-vitest` decides whether to inject this package's checks by a plain substring search over the setup file's source text, so its mere presence silently disables every check in the project, with no error.
110
+
111
+ ### Check failures don't block the screenshot
112
+
113
+ This package's checks run inside `@storybook/addon-vitest`'s own render phase, as part of the generated test body — before your `setupFiles`' `afterEach` (the one that calls `screenshot()`) ever runs. A failing check throws there, but Vitest still runs every registered `afterEach` after a test regardless of pass or fail, so the screenshot capture always follows, against whatever the DOM looked like when the check failed. Verified against a story with a deliberate overflow: the check failed, and `screenshot()` still produced a non-blank image showing the actual overflowing content.
114
+
72
115
  ## Addon `afterEach` ordering
73
116
 
74
117
  Storybook runs multiple addons' `afterEach` hooks serially, in the **reverse** of the order they're listed in `main.ts`'s `addons` array — the addon listed last runs its `afterEach` first. The consuming app's own `preview.ts`/`preview.tsx` `afterEach` runs before every addon's `afterEach`. If one hook throws, the remaining ones are skipped (fail-fast).
75
118
 
76
- This matters once a screenshot-capturing addon is added: to keep "capture first, then let this package's checks fail the test" working, list that addon **after** `@fohte/storybook-addon` in the `addons` array.
119
+ This doesn't apply to `./vitest-plugin`'s screenshot capture: it isn't a Storybook addon, so it never sits in the `addons` array at all see "Check failures don't block the screenshot" above for how that ordering actually works.
77
120
 
78
121
  ## Development
79
122
 
@@ -0,0 +1,33 @@
1
+ import type { BrowserCommand, BrowserCommandContext } from 'vitest/node';
2
+ type Viewport = {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ type Page = BrowserCommandContext['page'];
7
+ type ScreenshotOptions = NonNullable<Parameters<Page['screenshot']>[0]>;
8
+ interface TakeScreenshotOptions {
9
+ fullPage?: boolean;
10
+ omitBackground?: ScreenshotOptions['omitBackground'];
11
+ scale?: ScreenshotOptions['scale'];
12
+ type?: ScreenshotOptions['type'];
13
+ }
14
+ export declare function computeChunkClip(requestedScrollY: number, actualScrollY: number, viewportHeight: number, scrollHeight: number): {
15
+ clipYOffset: number;
16
+ chunkHeight: number;
17
+ };
18
+ export declare function storycapFullPageStitch(options?: {
19
+ viewport?: Viewport;
20
+ }): {
21
+ name: string;
22
+ config(): {
23
+ test: {
24
+ browser: {
25
+ commands: {
26
+ __storycap_takeScreenshot: BrowserCommand<[string, TakeScreenshotOptions], string>;
27
+ };
28
+ };
29
+ };
30
+ };
31
+ };
32
+ export {};
33
+ //# sourceMappingURL=storycap-fullpage-stitch.d.ts.map
@@ -0,0 +1,170 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ const IFRAME_SELECTOR = 'iframe[data-vitest]';
4
+ // @storycap-testrun/browser's own fullPage tiling (packages/browser/src/vitest-plugin/index.ts,
5
+ // `captureFullPage`) always clips each tile starting at the iframe's bounding-box
6
+ // top, on the assumption that `scrollTo(0, requestedScrollY)` always lands
7
+ // exactly at `requestedScrollY`. The browser clamps the scroll position once
8
+ // it exceeds `scrollHeight - viewportHeight`, so whenever the page height
9
+ // isn't an exact multiple of the viewport height, the last tile re-captures
10
+ // already-seen content instead of the page's true bottom, which is never
11
+ // captured. Comparing the requested position against what the browser
12
+ // actually applied gives the pixel offset the clip rect needs to shift down
13
+ // by to land on unseen content.
14
+ export function computeChunkClip(requestedScrollY, actualScrollY, viewportHeight, scrollHeight) {
15
+ const clipYOffset = requestedScrollY - actualScrollY;
16
+ const chunkHeight = Math.min(viewportHeight - clipYOffset, scrollHeight - requestedScrollY);
17
+ return { clipYOffset, chunkHeight };
18
+ }
19
+ async function scrollIframeTo(ctx, top) {
20
+ // `{ behavior: 'instant' }` overrides any `scroll-behavior: smooth` set by
21
+ // the story's own CSS. Without it, a smooth-scrolling story would still be
22
+ // mid-animation when the position is read back below, making the reported
23
+ // scroll position (and therefore the capture) non-deterministic.
24
+ return ctx.iframe.locator('body').evaluate((body, scrollTop) => {
25
+ const view = body.ownerDocument.defaultView;
26
+ view?.scrollTo({ top: scrollTop, left: 0, behavior: 'instant' });
27
+ return view?.scrollY ?? 0;
28
+ }, top);
29
+ }
30
+ async function getIframeBoundingBox(ctx) {
31
+ const box = await ctx.page.locator(IFRAME_SELECTOR).boundingBox();
32
+ if (box)
33
+ return box;
34
+ // eslint-disable-next-line no-restricted-syntax -- interop boundary: BrowserCommand contract signals failure by rejecting
35
+ throw new Error('Could not determine iframe position for screenshot');
36
+ }
37
+ function screenshotOptionsFrom(options) {
38
+ return {
39
+ animations: 'disabled',
40
+ caret: 'hide',
41
+ // Playwright's own default ('device') scales the returned buffer by
42
+ // deviceScaleFactor, but captureFullPage's canvas below is sized in CSS
43
+ // pixels — pin 'css' so a chunk's pixel dimensions always match the
44
+ // canvas region it gets drawn into.
45
+ scale: options.scale ?? 'css',
46
+ ...(options.omitBackground != null && {
47
+ omitBackground: options.omitBackground,
48
+ }),
49
+ ...(options.type != null && { type: options.type }),
50
+ };
51
+ }
52
+ async function captureTiles(ctx, viewport, scrollHeight, options) {
53
+ // The iframe element's position on the outer page doesn't move when its
54
+ // own inner content scrolls, so this only needs to be read once per
55
+ // capture instead of once per tile.
56
+ const iframeBox = await getIframeBoundingBox(ctx);
57
+ const images = [];
58
+ const heights = [];
59
+ for (let requestedScrollY = 0; requestedScrollY < scrollHeight; requestedScrollY += viewport.height) {
60
+ const actualScrollY = await scrollIframeTo(ctx, requestedScrollY);
61
+ const { clipYOffset, chunkHeight } = computeChunkClip(requestedScrollY, actualScrollY, viewport.height, scrollHeight);
62
+ const chunkBuffer = await ctx.page.screenshot({
63
+ clip: {
64
+ x: iframeBox.x,
65
+ y: iframeBox.y + clipYOffset,
66
+ width: iframeBox.width,
67
+ height: chunkHeight,
68
+ },
69
+ ...screenshotOptionsFrom(options),
70
+ });
71
+ images.push(Buffer.from(chunkBuffer).toString('base64'));
72
+ heights.push(chunkHeight);
73
+ }
74
+ const mimeType = options.type === 'jpeg' ? 'image/jpeg' : 'image/png';
75
+ const stitchedBase64 = await ctx.page.evaluate(async ({ images: chunkImages, width, heights: chunkHeights, mimeType: mime, }) => {
76
+ const totalHeight = chunkHeights.reduce((sum, h) => sum + h, 0);
77
+ const canvas = document.createElement('canvas');
78
+ canvas.width = width;
79
+ canvas.height = totalHeight;
80
+ const canvasContext = canvas.getContext('2d');
81
+ let y = 0;
82
+ for (let i = 0; i < chunkImages.length; i++) {
83
+ const img = new Image();
84
+ img.src = `data:${mime};base64,${chunkImages[i] ?? ''}`;
85
+ await new Promise((resolve, reject) => {
86
+ img.onload = () => {
87
+ resolve();
88
+ };
89
+ img.onerror = () => {
90
+ reject(new Error(`failed to decode screenshot chunk ${i.toString()}`));
91
+ };
92
+ });
93
+ canvasContext?.drawImage(img, 0, y);
94
+ y += chunkHeights[i] ?? 0;
95
+ }
96
+ return canvas.toDataURL(mime).split(',')[1] ?? '';
97
+ }, { images, width: viewport.width, heights, mimeType });
98
+ return Buffer.from(stitchedBase64, 'base64');
99
+ }
100
+ // Vitest's browser mode reuses one iframe per test file, so a scroll
101
+ // position left behind by a failed capture would bleed into whichever
102
+ // story runs next in the same file — reset it whether captureTiles()
103
+ // succeeds or rejects.
104
+ function captureFullPage(ctx, viewport, scrollHeight, options) {
105
+ return captureTiles(ctx, viewport, scrollHeight, options).finally(() => scrollIframeTo(ctx, 0));
106
+ }
107
+ function takeScreenshot(configuredViewport) {
108
+ return async (ctx, filepath, options) => {
109
+ const viewport = configuredViewport ??
110
+ ctx.page.viewportSize() ?? { width: 1280, height: 720 };
111
+ let buffer;
112
+ if (options.fullPage === false) {
113
+ await ctx.page.evaluate(() => {
114
+ window.scrollTo(0, 0);
115
+ });
116
+ const iframeBox = await getIframeBoundingBox(ctx);
117
+ buffer = Buffer.from(await ctx.page.screenshot({
118
+ clip: iframeBox,
119
+ ...screenshotOptionsFrom(options),
120
+ }));
121
+ }
122
+ else {
123
+ const scrollHeight = await ctx.iframe
124
+ .locator('body')
125
+ .evaluate((body) => Math.max(body.scrollHeight, body.ownerDocument.documentElement.scrollHeight));
126
+ buffer =
127
+ scrollHeight > viewport.height
128
+ ? await captureFullPage(ctx, viewport, scrollHeight, options)
129
+ : await ctx.iframe
130
+ .locator('body')
131
+ .screenshot(screenshotOptionsFrom(options));
132
+ }
133
+ await fs.mkdir(path.dirname(filepath), { recursive: true });
134
+ await fs.writeFile(filepath, buffer);
135
+ return Buffer.from(buffer).toString('base64');
136
+ };
137
+ }
138
+ // Replaces @storycap-testrun/browser's `__storycap_takeScreenshot` command
139
+ // (registered by the `storycap` plugin passed to `createStorybookProject`,
140
+ // via the same `config()` → `test.browser.commands` shape used below) with
141
+ // a from-scratch reimplementation that fixes the fullPage tiling bug
142
+ // described above. Vite merges plugin `config()`
143
+ // hooks in plugin order, with later plugins overriding matching keys — so
144
+ // this must be placed after the `storycap` plugin in `createStorybookProject`'s
145
+ // `plugins` array for the override to take effect. `resolveScreenshotFilepath`,
146
+ // `__storycap_prepareViewport` and `__storycap_restoreViewport` are untouched,
147
+ // since this only supplies the `__storycap_takeScreenshot` key.
148
+ //
149
+ // No upstream issue tracks this bug. Drop this plugin (and its wiring in
150
+ // vitest-plugin.ts) once @storycap-testrun/browser's own `captureFullPage`
151
+ // (packages/browser/src/vitest-plugin/index.ts) accounts for `scrollTo`
152
+ // clamping the requested position instead of assuming it always lands
153
+ // exactly where requested.
154
+ export function storycapFullPageStitch(options = {}) {
155
+ return {
156
+ name: 'storycap-fullpage-stitch-fix',
157
+ config() {
158
+ return {
159
+ test: {
160
+ browser: {
161
+ commands: {
162
+ __storycap_takeScreenshot: takeScreenshot(options.viewport),
163
+ },
164
+ },
165
+ },
166
+ };
167
+ },
168
+ };
169
+ }
170
+ //# sourceMappingURL=storycap-fullpage-stitch.js.map
@@ -0,0 +1,55 @@
1
+ export { storycapFullPageStitch } from '#storycap-fullpage-stitch.js';
2
+ export declare const storycapNetworkIdle: {
3
+ name: string;
4
+ transform(code: string, id: string): string | null;
5
+ };
6
+ export interface CreateStorybookProjectOptions {
7
+ /** Project name, as shown by `vitest --project <name>`. */
8
+ name: string;
9
+ /**
10
+ * Absolute path to the app root that the default `.storybook` config dir
11
+ * and the screenshot output dir are resolved against — typically
12
+ * `path.dirname(fileURLToPath(import.meta.url))` from the consumer's own
13
+ * vitest.config.ts.
14
+ */
15
+ rootDir: string;
16
+ viewport: {
17
+ width: number;
18
+ height: number;
19
+ };
20
+ /**
21
+ * Screenshots for this project are written to
22
+ * `<rootDir>/__screenshots__/<screenshotsSubdir>`. Downstream tooling that
23
+ * consumes these images depends on this exact path, so treat it as a
24
+ * stable contract rather than an implementation detail.
25
+ */
26
+ screenshotsSubdir: string;
27
+ setupFiles: string[];
28
+ excludeTags?: string[];
29
+ /**
30
+ * `test.maxWorkers`, applied only when `process.env.CI` is set — Vitest
31
+ * reads `maxWorkers` per-project rather than falling back to the root
32
+ * config. Screenshot capture is I/O-bound (network-idle wait, font
33
+ * loading, CDP metric polling), so this can exceed the CI runner's vCPU
34
+ * count, but the right number depends on the runner in use — tune it per
35
+ * consumer rather than trusting this default.
36
+ */
37
+ ciMaxWorkers?: number;
38
+ }
39
+ export declare function createStorybookProject({ name, rootDir, viewport, screenshotsSubdir, setupFiles, excludeTags, ciMaxWorkers, }: CreateStorybookProjectOptions): {
40
+ plugins: any[];
41
+ test: {
42
+ browser: {
43
+ enabled: boolean;
44
+ provider: import("vitest/node").BrowserProviderOption<import("@vitest/browser-playwright").PlaywrightProviderOptions>;
45
+ headless: boolean;
46
+ instances: {
47
+ browser: "chromium";
48
+ }[];
49
+ };
50
+ setupFiles: string[];
51
+ maxWorkers?: number;
52
+ name: string;
53
+ };
54
+ };
55
+ //# sourceMappingURL=vitest-plugin.d.ts.map
@@ -0,0 +1,97 @@
1
+ import path from 'node:path';
2
+ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
3
+ import storycap from '@storycap-testrun/browser/vitest-plugin';
4
+ import { playwright } from '@vitest/browser-playwright';
5
+ import { storycapFullPageStitch } from '#storycap-fullpage-stitch.js';
6
+ export { storycapFullPageStitch } from '#storycap-fullpage-stitch.js';
7
+ // @storycap-testrun/browser ships a bundled .d.ts with its own copy of vite's
8
+ // `Plugin` type, so it's structurally identical but nominally unrelated to
9
+ // ours — cast to sidestep the resulting "unrelated types" error. Typing this
10
+ // as `Plugin` (instead of `any`) reintroduces a cascading "exactOptionalPropertyTypes"
11
+ // mismatch between vite's own `Plugin` and rollup's, so this stays `any`.
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
13
+ function asPlugin(plugin) {
14
+ return plugin;
15
+ }
16
+ // @storycap-testrun/browser waits for 500ms of network silence before every
17
+ // capture and exposes no option to shorten it, so every story pays that flat
18
+ // half second per viewport. Only the floor moves: the window still restarts on
19
+ // each resource load, and `document.fonts.ready` plus the metrics-stability
20
+ // poll that follow it are untouched.
21
+ const NETWORK_IDLE_MS = 100;
22
+ // The module arrives here either as the shipped `dist/index.mjs` or as an
23
+ // esbuild pre-bundle, which reformats the minified source but keeps the literal.
24
+ const NETWORK_IDLE_DEFAULT = /=\s*500\s*\)\s*=>\s*new Promise\(/;
25
+ // A Vite plugin that patches @storycap-testrun/browser's own minified
26
+ // source. If a version bump moves the 500ms literal (or removes it), this
27
+ // throws at config-load time instead of silently leaving the 500ms wait in
28
+ // place — the alternative (staying quiet) would just make every capture
29
+ // slower with no visible symptom.
30
+ export const storycapNetworkIdle = {
31
+ name: 'storycap-network-idle',
32
+ transform(code, id) {
33
+ if (!id.includes('@storycap-testrun') ||
34
+ !code.includes('PerformanceObserver')) {
35
+ return null;
36
+ }
37
+ const patched = code.replace(NETWORK_IDLE_DEFAULT, (match) => match.replace('500', String(NETWORK_IDLE_MS)));
38
+ if (patched === code) {
39
+ // This throw fails config loading itself, before any test runs.
40
+ // eslint-disable-next-line no-restricted-syntax -- Vite plugin transform hook contract: throwing is how a plugin aborts config loading
41
+ throw new Error(`storycap-network-idle: no 500ms network-idle default found in ${id}. Drop this plugin if @storycap-testrun made the wait configurable, otherwise re-derive the pattern.`);
42
+ }
43
+ return patched;
44
+ },
45
+ };
46
+ export function createStorybookProject({ name, rootDir, viewport, screenshotsSubdir, setupFiles, excludeTags = [], ciMaxWorkers = 8, }) {
47
+ const configDir = path.join(rootDir, '.storybook');
48
+ const screenshotsDir = path.join(rootDir, '__screenshots__', screenshotsSubdir);
49
+ const maxWorkers = process.env['CI'] != null ? ciMaxWorkers : undefined;
50
+ const plugins = [
51
+ storycapNetworkIdle,
52
+ storybookTest({
53
+ configDir,
54
+ tags: { exclude: excludeTags },
55
+ }),
56
+ asPlugin(storycap({
57
+ viewport,
58
+ output: { dir: screenshotsDir },
59
+ })),
60
+ storycapFullPageStitch({ viewport }),
61
+ ];
62
+ // storycapFullPageStitch's own comment explains why plugin order is what
63
+ // makes it an override of storycap's `__storycap_takeScreenshot` — check
64
+ // that order here (by plugin name, since storycap's own plugin type is
65
+ // cast to `any` above) instead of leaving a reorder to silently resurrect
66
+ // the fullPage tiling bug it fixes.
67
+ const storycapIndex = plugins.findIndex((p) => p.name === 'vitest:screenshot');
68
+ const fullPageStitchIndex = plugins.findIndex((p) => p.name === 'storycap-fullpage-stitch-fix');
69
+ if (fullPageStitchIndex < storycapIndex) {
70
+ // eslint-disable-next-line no-restricted-syntax -- config-load-time invariant check, mirrors storycapNetworkIdle's fail-fast pattern above
71
+ throw new Error('storycapFullPageStitch must be registered after storycap in the plugins array for its __storycap_takeScreenshot override to take effect');
72
+ }
73
+ // storybookTest() below returns a Promise that starts loading a real
74
+ // Storybook config from `configDir` as soon as it's constructed — nothing
75
+ // but Vite's own plugin container ever awaits that Promise, so calling
76
+ // this factory directly against a synthetic `rootDir` in a unit test
77
+ // surfaces it as an unhandled rejection instead of a useful assertion.
78
+ // This branch is covered by running against a real Storybook config instead.
79
+ return {
80
+ plugins,
81
+ test: {
82
+ name,
83
+ ...(maxWorkers !== undefined && { maxWorkers }),
84
+ browser: {
85
+ enabled: true,
86
+ // Pin the browser's timezone so time-dependent stories (calendar
87
+ // "now" indicators, relative timestamps) render identically
88
+ // regardless of the host machine's local timezone.
89
+ provider: playwright({ contextOptions: { timezoneId: 'Asia/Tokyo' } }),
90
+ headless: true,
91
+ instances: [{ browser: 'chromium' }],
92
+ },
93
+ setupFiles,
94
+ },
95
+ };
96
+ }
97
+ //# sourceMappingURL=vitest-plugin.js.map
package/package.json CHANGED
@@ -12,6 +12,10 @@
12
12
  "./preview": {
13
13
  "types": "./lib/preview.d.ts",
14
14
  "default": "./lib/preview.js"
15
+ },
16
+ "./vitest-plugin": {
17
+ "types": "./lib/vitest-plugin.d.ts",
18
+ "default": "./lib/vitest-plugin.js"
15
19
  }
16
20
  },
17
21
  "imports": {
@@ -27,31 +31,48 @@
27
31
  "registry": "https://registry.npmjs.org/"
28
32
  },
29
33
  "peerDependencies": {
34
+ "@storybook/addon-vitest": "^10.0.0",
35
+ "@storycap-testrun/browser": "^2.0.0",
36
+ "@vitest/browser-playwright": "^4.0.0",
30
37
  "storybook": "^10.0.0",
31
38
  "vitest": "^4.0.0"
32
39
  },
40
+ "peerDependenciesMeta": {
41
+ "@storybook/addon-vitest": {
42
+ "optional": true
43
+ },
44
+ "@storycap-testrun/browser": {
45
+ "optional": true
46
+ },
47
+ "@vitest/browser-playwright": {
48
+ "optional": true
49
+ }
50
+ },
33
51
  "dependencies": {
34
52
  "neverthrow": "8.2.0"
35
53
  },
36
54
  "devDependencies": {
37
- "@commitlint/cli": "21.2.1",
55
+ "@commitlint/cli": "21.2.2",
38
56
  "@eslint/eslintrc": "3.3.6",
39
- "@fohte/eslint-config": "0.4.0",
57
+ "@fohte/eslint-config": "0.4.1",
40
58
  "@ninoseki/eslint-plugin-neverthrow": "0.2.0",
59
+ "@storybook/addon-vitest": "10.5.8",
60
+ "@storycap-testrun/browser": "2.1.1",
41
61
  "@tsconfig/node-lts": "24.0.0",
42
62
  "@tsconfig/strictest": "2.0.8",
43
63
  "@types/jsdom": "30.0.0",
44
64
  "@types/node": "24.13.3",
65
+ "@vitest/browser-playwright": "4.1.10",
45
66
  "concurrently": "10.0.4",
46
67
  "eslint": "10.6.0",
47
68
  "jsdom": "30.0.1",
48
69
  "prettier": "3.9.6",
49
70
  "rimraf": "6.1.3",
50
- "storybook": "10.5.7",
71
+ "storybook": "10.5.8",
51
72
  "typescript": "6.0.3",
52
73
  "vitest": "4.1.10"
53
74
  },
54
- "version": "0.1.2",
75
+ "version": "0.1.4",
55
76
  "scripts": {
56
77
  "clean": "rimraf lib tsconfig.tsbuildinfo",
57
78
  "prebuild": "pnpm run clean",