@fohte/storybook-addon 0.0.0 → 0.1.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/README.md +78 -28
- package/lib/checks/check.d.ts +6 -0
- package/lib/checks/check.js +9 -0
- package/lib/checks/external-resource-check.d.ts +3 -0
- package/lib/checks/external-resource-check.js +32 -0
- package/lib/checks/overflow-check.d.ts +6 -0
- package/lib/checks/overflow-check.js +148 -0
- package/lib/checks/unhandled-api-request-check.d.ts +7 -0
- package/lib/checks/unhandled-api-request-check.js +34 -0
- package/lib/errors.d.ts +4 -0
- package/lib/errors.js +30 -0
- package/lib/preview.d.ts +7 -0
- package/lib/preview.js +45 -0
- package/package.json +65 -8
package/README.md
CHANGED
|
@@ -1,45 +1,95 @@
|
|
|
1
1
|
# @fohte/storybook-addon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
@fohte's personal Storybook addons.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Runs three checks right after each story renders, and fails the story's test when one trips:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **overflow-check** — flags elements whose content is clipped by their container (scrollWidth > clientWidth), which usually means a silent layout bug.
|
|
8
|
+
- **external-resource-check** — flags stories that load a non-same-origin resource (font, image, stylesheet), which makes VRT captures non-deterministic.
|
|
9
|
+
- **unhandled-api-request-check** — flags stories that hit an API endpoint with no MSW handler, which would otherwise render with MSW's error response instead of failing.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
It also injects CSS that hides the text-input caret and collapses all animations/transitions to their end state, keeping screenshots deterministic.
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
1. Configure OIDC trusted publishing for the package name `@fohte/storybook-addon`
|
|
13
|
-
2. Enable secure, token-less publishing from CI/CD workflows
|
|
14
|
-
3. Establish provenance for packages published under this name
|
|
13
|
+
## Install
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add -D @fohte/storybook-addon
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Peer dependencies
|
|
19
|
+
pnpm add -D storybook vitest
|
|
20
|
+
```
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## Usage
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
Add the addon to `.storybook/main.ts`:
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
26
|
+
```ts
|
|
27
|
+
import type { StorybookConfig } from '@storybook/react-vite'
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const config: StorybookConfig = {
|
|
30
|
+
addons: ['@fohte/storybook-addon'],
|
|
31
|
+
}
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
33
|
+
export default config
|
|
34
|
+
```
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
This wires up `overflow-check` and `external-resource-check` — no further setup needed.
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
- [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
|
|
41
|
-
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
|
38
|
+
### overflow-check
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
To exempt a selector across every story (e.g. a component whose oversized hit target never visibly clips anything), call `configureOverflowCheck()` once in `.storybook/preview.ts`. It's additive with a story's own `parameters.overflowCheck.ignoreSelectors` — both apply, since Storybook merges story-level parameters by replacing arrays rather than deep-merging them, and a story-level `ignoreSelectors` would otherwise silently drop the global list:
|
|
44
41
|
|
|
45
|
-
|
|
42
|
+
```ts
|
|
43
|
+
import { configureOverflowCheck } from '@fohte/storybook-addon/preview'
|
|
44
|
+
|
|
45
|
+
configureOverflowCheck({ ignoreSelectors: ['[data-slot="checkbox"]'] })
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### unhandled-api-request-check
|
|
49
|
+
|
|
50
|
+
This check needs to know which paths count as "API requests" (that's app-specific), and needs to be fed unhandled requests from your MSW setup. Configure it once and call `reportUnhandledApiRequest()` from `onUnhandledRequest` in `.storybook/preview.ts`:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import {
|
|
54
|
+
configureUnhandledApiRequestCheck,
|
|
55
|
+
reportUnhandledApiRequest,
|
|
56
|
+
} from '@fohte/storybook-addon/preview'
|
|
57
|
+
import { initialize } from 'msw-storybook-addon'
|
|
58
|
+
|
|
59
|
+
configureUnhandledApiRequestCheck({ pathPrefixes: ['/api/'] })
|
|
60
|
+
|
|
61
|
+
initialize({
|
|
62
|
+
onUnhandledRequest: ({ url }, print) => {
|
|
63
|
+
if (reportUnhandledApiRequest(url)) {
|
|
64
|
+
print.error()
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Addon `afterEach` ordering
|
|
71
|
+
|
|
72
|
+
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).
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
### Setup
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pnpm install
|
|
82
|
+
pnpm test
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Scripts
|
|
86
|
+
|
|
87
|
+
- `pnpm test` - Run type checking and unit tests
|
|
88
|
+
- `pnpm test:type` - Type-check without emitting
|
|
89
|
+
- `pnpm test:unit` - Run unit tests (vitest)
|
|
90
|
+
- `pnpm build` - Compile TypeScript to `lib/`
|
|
91
|
+
- `pnpm lint` - Run ESLint
|
|
92
|
+
|
|
93
|
+
### Release process
|
|
94
|
+
|
|
95
|
+
This project uses [release-please](https://github.com/googleapis/release-please). Merging a PR whose title follows [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, `feat!:`/`fix!:` for breaking changes) into `main` updates a Release PR; merging that Release PR tags a GitHub release and publishes to npm.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { throwIfNotEmpty } from '#checks/check.js';
|
|
2
|
+
// A story that loads a non-same-origin http(s) resource (e.g. a remote
|
|
3
|
+
// avatar image) races the capture against that request's completion over the
|
|
4
|
+
// real network, so the same story can rasterize differently between runs.
|
|
5
|
+
// Failing the test surfaces this instead of letting it show up as unstable
|
|
6
|
+
// screenshot diffs; fix stories by inlining the resource as a data URI.
|
|
7
|
+
const externalResourceUrls = [];
|
|
8
|
+
// Entries older than this are from a previous story and must not be
|
|
9
|
+
// attributed to the one currently rendering.
|
|
10
|
+
let resetAt = 0;
|
|
11
|
+
function isExternalResourceUrl(url) {
|
|
12
|
+
const { protocol, origin } = new URL(url);
|
|
13
|
+
return ((protocol === 'http:' || protocol === 'https:') &&
|
|
14
|
+
origin !== window.location.origin);
|
|
15
|
+
}
|
|
16
|
+
new PerformanceObserver((list) => {
|
|
17
|
+
for (const entry of list.getEntries()) {
|
|
18
|
+
if (entry.startTime >= resetAt && isExternalResourceUrl(entry.name)) {
|
|
19
|
+
externalResourceUrls.push(entry.name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}).observe({ type: 'resource', buffered: true });
|
|
23
|
+
export const externalResourceCheck = {
|
|
24
|
+
reset: () => {
|
|
25
|
+
resetAt = performance.now();
|
|
26
|
+
externalResourceUrls.length = 0;
|
|
27
|
+
},
|
|
28
|
+
assert: () => {
|
|
29
|
+
throwIfNotEmpty(externalResourceUrls, 'Story loaded non-same-origin resource(s), which makes VRT captures flaky');
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
//# sourceMappingURL=external-resource-check.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { throwIfNotEmpty } from '#checks/check.js';
|
|
2
|
+
function describeElement(el) {
|
|
3
|
+
const id = el.id ? `#${el.id}` : '';
|
|
4
|
+
const classAttr = el.getAttribute('class');
|
|
5
|
+
const classes = classAttr !== null && classAttr !== ''
|
|
6
|
+
? `.${classAttr.trim().split(/\s+/).join('.')}`
|
|
7
|
+
: '';
|
|
8
|
+
return `${el.tagName.toLowerCase()}${id}${classes}`;
|
|
9
|
+
}
|
|
10
|
+
// `text-overflow: ellipsis` is excluded because it's a deliberate "this is
|
|
11
|
+
// cut off, here's more" affordance (e.g. Tailwind's `truncate`), not a
|
|
12
|
+
// silent, undiscoverable clip — exactly the class of intentional truncation
|
|
13
|
+
// this check isn't meant to flag.
|
|
14
|
+
//
|
|
15
|
+
// `overflow-x: visible` elements are NOT excluded, even though such an
|
|
16
|
+
// element doesn't clip its own content: in a real app, some clipping
|
|
17
|
+
// ancestor above it usually reports the same overflow independently, but a
|
|
18
|
+
// story has no such ancestor above its scan root — an `overflow-x: visible`
|
|
19
|
+
// element may be the only place a story's overflow is ever caught.
|
|
20
|
+
function isIntentionalTruncation(cs) {
|
|
21
|
+
return cs.textOverflow === 'ellipsis';
|
|
22
|
+
}
|
|
23
|
+
// The standard visually-hidden a11y technique (e.g. Tailwind's `sr-only`)
|
|
24
|
+
// shrinks an element to a 1x1px box and clips it on purpose, to keep it
|
|
25
|
+
// readable by screen readers while invisible to sighted users. A 1x1px box
|
|
26
|
+
// can never show meaningfully clipped content to a sighted user either way,
|
|
27
|
+
// so this is a safe, general skip rather than a per-story exclusion.
|
|
28
|
+
function isVisuallyHidden(el) {
|
|
29
|
+
return el.clientWidth <= 1 && el.clientHeight <= 1;
|
|
30
|
+
}
|
|
31
|
+
// A single overflowing element is picked up again by every clipping
|
|
32
|
+
// ancestor above it, up to the scan root (per
|
|
33
|
+
// https://www.w3.org/TR/cssom-view-1/#scrolling-area, unclipped overflow
|
|
34
|
+
// keeps bubbling upward), so one bug would otherwise read as N separate
|
|
35
|
+
// findings. Group ancestor-descendant reports into the topmost ancestor's
|
|
36
|
+
// group instead of dropping any of them — the chain itself is a hint for
|
|
37
|
+
// where the fix belongs, since the root cause (e.g. a parent flex layout)
|
|
38
|
+
// isn't always the innermost element. `root.querySelectorAll` yields
|
|
39
|
+
// elements in document order, so an element's ancestors are always seen
|
|
40
|
+
// (and can become its group) before it is.
|
|
41
|
+
function groupByAncestor(entries) {
|
|
42
|
+
const groups = [];
|
|
43
|
+
for (const { el, description } of entries) {
|
|
44
|
+
const group = groups.find((g) => g.root.contains(el));
|
|
45
|
+
if (group) {
|
|
46
|
+
group.descriptions.push(description);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
groups.push({ root: el, descriptions: [description] });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return groups.map((g) => g.descriptions.length === 1
|
|
53
|
+
? g.descriptions.join('')
|
|
54
|
+
: `${String(g.descriptions.length)} chained overflows (same root cause, outermost first):\n ${g.descriptions.join('\n ')}`);
|
|
55
|
+
}
|
|
56
|
+
// el.scrollWidth > el.clientWidth means the element's content doesn't fit
|
|
57
|
+
// inside its own padding box (clientWidth) — i.e. some of it is clipped and
|
|
58
|
+
// invisible.
|
|
59
|
+
//
|
|
60
|
+
// This only measures overflow on the inline-end (right, in LTR) side: the
|
|
61
|
+
// CSSOM "scrolling area" a scrollable box exposes never extends past its
|
|
62
|
+
// inline-start edge, so content overflowing to the *left* is invisible to
|
|
63
|
+
// this check (https://www.w3.org/TR/cssom-view-1/#scrolling-area).
|
|
64
|
+
// `position: fixed` elements are invisible to it too — they escape the
|
|
65
|
+
// containing block chain, so an off-screen fixed element never shows up in
|
|
66
|
+
// any ancestor's scrollWidth.
|
|
67
|
+
function findOverflows(root, ignoreSelectors) {
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const el of root.querySelectorAll('*')) {
|
|
70
|
+
if (el.scrollWidth <= el.clientWidth)
|
|
71
|
+
continue;
|
|
72
|
+
if (isVisuallyHidden(el))
|
|
73
|
+
continue;
|
|
74
|
+
if (ignoreSelectors.some((selector) => el.matches(selector)))
|
|
75
|
+
continue;
|
|
76
|
+
if (isIntentionalTruncation(getComputedStyle(el)))
|
|
77
|
+
continue;
|
|
78
|
+
const overflowPx = el.scrollWidth - el.clientWidth;
|
|
79
|
+
entries.push({
|
|
80
|
+
el,
|
|
81
|
+
description: `${describeElement(el)}: scrollWidth=${String(el.scrollWidth)} > clientWidth=${String(el.clientWidth)} (+${String(overflowPx)}px)`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return groupByAncestor(entries);
|
|
85
|
+
}
|
|
86
|
+
// Exempted everywhere, not per-story: configured once by the consuming app
|
|
87
|
+
// for markup that recurs across many stories (e.g. a component whose
|
|
88
|
+
// oversized hit target never visibly clips anything). Kept separate from
|
|
89
|
+
// parameters.overflowCheck.ignoreSelectors — which storybook's
|
|
90
|
+
// combineParameters replaces wholesale rather than merges — so a story
|
|
91
|
+
// setting its own ignoreSelectors can't silently drop this list.
|
|
92
|
+
let globalIgnoreSelectors = [];
|
|
93
|
+
export function configureOverflowCheck(options) {
|
|
94
|
+
globalIgnoreSelectors = options.ignoreSelectors;
|
|
95
|
+
}
|
|
96
|
+
function overflowCheckParameters(storyParameters) {
|
|
97
|
+
if (typeof storyParameters !== 'object' || storyParameters === null) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (!('overflowCheck' in storyParameters))
|
|
101
|
+
return undefined;
|
|
102
|
+
const { overflowCheck } = storyParameters;
|
|
103
|
+
if (typeof overflowCheck !== 'object' || overflowCheck === null) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return overflowCheck;
|
|
107
|
+
}
|
|
108
|
+
function isDisabled(overflowCheck) {
|
|
109
|
+
return (overflowCheck !== undefined &&
|
|
110
|
+
'disable' in overflowCheck &&
|
|
111
|
+
overflowCheck.disable === true);
|
|
112
|
+
}
|
|
113
|
+
// Narrower than `overflowCheck.disable`: exempt one intentionally-overflowing
|
|
114
|
+
// element (e.g. a chip row using `overflow-x-auto`, or a fixed-scrollbar
|
|
115
|
+
// sizing artifact) by CSS selector via the story's own parameters, matching
|
|
116
|
+
// only the element itself — not its descendants — so the rest of the story
|
|
117
|
+
// (including anything nested inside the matched element) still gets checked.
|
|
118
|
+
function ignoreSelectorsOf(overflowCheck) {
|
|
119
|
+
if (overflowCheck === undefined)
|
|
120
|
+
return [];
|
|
121
|
+
if (!('ignoreSelectors' in overflowCheck))
|
|
122
|
+
return [];
|
|
123
|
+
const { ignoreSelectors } = overflowCheck;
|
|
124
|
+
if (!Array.isArray(ignoreSelectors))
|
|
125
|
+
return [];
|
|
126
|
+
return ignoreSelectors.filter((s) => typeof s === 'string');
|
|
127
|
+
}
|
|
128
|
+
export const overflowCheck = {
|
|
129
|
+
reset: () => { },
|
|
130
|
+
assert: (storyParameters, canvasElement) => {
|
|
131
|
+
const params = overflowCheckParameters(storyParameters);
|
|
132
|
+
if (isDisabled(params))
|
|
133
|
+
return;
|
|
134
|
+
if (canvasElement == null) {
|
|
135
|
+
// Real Storybook runs always pass canvasElement (it's the story's
|
|
136
|
+
// mount container); this only triggers a caller invoking assert()
|
|
137
|
+
// directly without one. Skip rather than fail, since there is no root
|
|
138
|
+
// to scan.
|
|
139
|
+
console.warn('[overflow-check] no canvasElement in story context; skipping overflow scan');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
throwIfNotEmpty(findOverflows(canvasElement, [
|
|
143
|
+
...globalIgnoreSelectors,
|
|
144
|
+
...ignoreSelectorsOf(params),
|
|
145
|
+
]), 'Story has element(s) overflowing their container (clipped and invisible)');
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=overflow-check.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StorybookCheck } from '#checks/check.js';
|
|
2
|
+
export declare function configureUnhandledApiRequestCheck(options: {
|
|
3
|
+
pathPrefixes: string[];
|
|
4
|
+
}): void;
|
|
5
|
+
export declare function reportUnhandledApiRequest(url: string): boolean;
|
|
6
|
+
export declare const unhandledApiRequestCheck: StorybookCheck;
|
|
7
|
+
//# sourceMappingURL=unhandled-api-request-check.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { throwIfNotEmpty } from '#checks/check.js';
|
|
2
|
+
// Populated by reportUnhandledApiRequest(), which the consuming app's MSW
|
|
3
|
+
// `onUnhandledRequest` callback calls — a story hitting an API endpoint with
|
|
4
|
+
// no MSW handler gets MSW's error response instead of real data, so the
|
|
5
|
+
// screenshot captures a broken UI state without failing otherwise.
|
|
6
|
+
const unhandledApiRequestUrls = [];
|
|
7
|
+
// Which paths count as "API requests" is app-specific (tq uses `/api/`, a
|
|
8
|
+
// future app might use a different prefix or none), so it's configured once
|
|
9
|
+
// by the consuming app rather than hardcoded here.
|
|
10
|
+
let pathPrefixes = [];
|
|
11
|
+
export function configureUnhandledApiRequestCheck(options) {
|
|
12
|
+
pathPrefixes = options.pathPrefixes;
|
|
13
|
+
}
|
|
14
|
+
// Returns whether the request was recorded, so the caller can decide whether
|
|
15
|
+
// to also print MSW's own error for it.
|
|
16
|
+
export function reportUnhandledApiRequest(url) {
|
|
17
|
+
const parsed = new URL(url);
|
|
18
|
+
if (parsed.origin !== window.location.origin)
|
|
19
|
+
return false;
|
|
20
|
+
if (!pathPrefixes.some((prefix) => parsed.pathname.startsWith(prefix))) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
unhandledApiRequestUrls.push(parsed.pathname);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
export const unhandledApiRequestCheck = {
|
|
27
|
+
reset: () => {
|
|
28
|
+
unhandledApiRequestUrls.length = 0;
|
|
29
|
+
},
|
|
30
|
+
assert: () => {
|
|
31
|
+
throwIfNotEmpty(unhandledApiRequestUrls, 'Story made unhandled API request(s); add an MSW handler for');
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=unhandled-api-request-check.js.map
|
package/lib/errors.d.ts
ADDED
package/lib/errors.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Wrap a caught external error before re-throwing, so it carries a
|
|
2
|
+
// domain-meaningful message while preserving the original via `cause`.
|
|
3
|
+
// Subclass per interop boundary — `name` is derived automatically, so no
|
|
4
|
+
// constructor override is needed. The try and the throw this requires each
|
|
5
|
+
// need their own eslint-disable-next-line comment, since @fohte/eslint-config's
|
|
6
|
+
// errorHandling bans ThrowStatement and TryStatement as separate selectors:
|
|
7
|
+
//
|
|
8
|
+
// export class TaskStorePersistenceError extends BoundaryError {}
|
|
9
|
+
//
|
|
10
|
+
// // eslint-disable-next-line no-restricted-syntax -- interop boundary
|
|
11
|
+
// try {
|
|
12
|
+
// ...
|
|
13
|
+
// } catch (caughtErr) {
|
|
14
|
+
// const wrapped = new TaskStorePersistenceError('failed to save', caughtErr)
|
|
15
|
+
// // eslint-disable-next-line no-restricted-syntax -- interop boundary
|
|
16
|
+
// throw wrapped
|
|
17
|
+
// }
|
|
18
|
+
//
|
|
19
|
+
// To report a wrapped error under a stable fingerprint (for Sentry
|
|
20
|
+
// grouping) without changing control flow, call `captureWithFingerprint`
|
|
21
|
+
// (from `@fohte/service-kit/observability`, see src/bootstrap.ts — only
|
|
22
|
+
// generated when `error_tracking` or `is_web_app` is enabled) right before
|
|
23
|
+
// re-throwing.
|
|
24
|
+
export class BoundaryError extends Error {
|
|
25
|
+
constructor(message, cause) {
|
|
26
|
+
super(message, { cause });
|
|
27
|
+
this.name = new.target.name;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=errors.js.map
|
package/lib/preview.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AfterEach, BeforeEach, Parameters, WebRenderer } from 'storybook/internal/types';
|
|
2
|
+
export { configureOverflowCheck } from '#checks/overflow-check.js';
|
|
3
|
+
export { configureUnhandledApiRequestCheck, reportUnhandledApiRequest, } from '#checks/unhandled-api-request-check.js';
|
|
4
|
+
export declare const parameters: Parameters;
|
|
5
|
+
export declare const beforeEach: BeforeEach<WebRenderer>;
|
|
6
|
+
export declare const afterEach: AfterEach<WebRenderer>;
|
|
7
|
+
//# sourceMappingURL=preview.d.ts.map
|
package/lib/preview.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { externalResourceCheck } from '#checks/external-resource-check.js';
|
|
2
|
+
import { overflowCheck } from '#checks/overflow-check.js';
|
|
3
|
+
import { unhandledApiRequestCheck } from '#checks/unhandled-api-request-check.js';
|
|
4
|
+
export { configureOverflowCheck } from '#checks/overflow-check.js';
|
|
5
|
+
export { configureUnhandledApiRequestCheck, reportUnhandledApiRequest, } from '#checks/unhandled-api-request-check.js';
|
|
6
|
+
function injectStyle(css) {
|
|
7
|
+
const style = document.createElement('style');
|
|
8
|
+
style.textContent = css;
|
|
9
|
+
document.head.appendChild(style);
|
|
10
|
+
}
|
|
11
|
+
// The native text-input caret blinks on an OS timer, so a captured frame of a
|
|
12
|
+
// focused input/contenteditable is on or off at random — same content,
|
|
13
|
+
// different pixels between runs. Hiding it keeps captures deterministic
|
|
14
|
+
// without touching application code.
|
|
15
|
+
injectStyle('input, textarea, [contenteditable] { caret-color: transparent !important; }');
|
|
16
|
+
// CSS animations/transitions (popup open/close fades, zooms, spinners, ...)
|
|
17
|
+
// capture at whatever frame happens to be on screen when the screenshot
|
|
18
|
+
// fires, so the same story rasterizes differently between runs even though
|
|
19
|
+
// nothing about it actually changed. Forcing zero duration collapses every
|
|
20
|
+
// animation/transition to its end state instantly, keeping captures
|
|
21
|
+
// deterministic without touching application code.
|
|
22
|
+
injectStyle(`
|
|
23
|
+
*, *::before, *::after {
|
|
24
|
+
animation-duration: 0s !important;
|
|
25
|
+
animation-delay: 0s !important;
|
|
26
|
+
transition-duration: 0s !important;
|
|
27
|
+
transition-delay: 0s !important;
|
|
28
|
+
}
|
|
29
|
+
`);
|
|
30
|
+
const checks = [
|
|
31
|
+
externalResourceCheck,
|
|
32
|
+
unhandledApiRequestCheck,
|
|
33
|
+
overflowCheck,
|
|
34
|
+
];
|
|
35
|
+
export const parameters = {};
|
|
36
|
+
export const beforeEach = () => {
|
|
37
|
+
for (const check of checks)
|
|
38
|
+
check.reset();
|
|
39
|
+
};
|
|
40
|
+
export const afterEach = (context) => {
|
|
41
|
+
for (const check of checks) {
|
|
42
|
+
check.assert(context.parameters, context.canvasElement);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=preview.js.map
|
package/package.json
CHANGED
|
@@ -1,10 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fohte/storybook-addon",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
"description": "@fohte's personal Storybook addons",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "Hayato Kawai <fohte.hk@gmail.com>",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/fohte/storybook-addon.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
"./preview": {
|
|
13
|
+
"types": "./lib/preview.d.ts",
|
|
14
|
+
"default": "./lib/preview.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"imports": {
|
|
18
|
+
"#*.js": "./lib/*.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"!lib/**/*.map",
|
|
23
|
+
"!lib/**/*.test.*"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"storybook": "^10.0.0",
|
|
31
|
+
"vitest": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"neverthrow": "8.2.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@commitlint/cli": "21.2.1",
|
|
38
|
+
"@eslint/eslintrc": "3.3.6",
|
|
39
|
+
"@fohte/eslint-config": "0.4.0",
|
|
40
|
+
"@ninoseki/eslint-plugin-neverthrow": "0.2.0",
|
|
41
|
+
"@tsconfig/node-lts": "24.0.0",
|
|
42
|
+
"@tsconfig/strictest": "2.0.8",
|
|
43
|
+
"@types/jsdom": "30.0.0",
|
|
44
|
+
"@types/node": "24.13.3",
|
|
45
|
+
"concurrently": "10.0.4",
|
|
46
|
+
"eslint": "10.6.0",
|
|
47
|
+
"jsdom": "30.0.1",
|
|
48
|
+
"prettier": "3.9.6",
|
|
49
|
+
"rimraf": "6.1.3",
|
|
50
|
+
"storybook": "10.5.7",
|
|
51
|
+
"typescript": "6.0.3",
|
|
52
|
+
"vitest": "4.1.10"
|
|
53
|
+
},
|
|
54
|
+
"version": "0.1.1",
|
|
55
|
+
"scripts": {
|
|
56
|
+
"clean": "rimraf lib tsconfig.tsbuildinfo",
|
|
57
|
+
"prebuild": "pnpm run clean",
|
|
58
|
+
"build": "tsc --project tsconfig.build.json",
|
|
59
|
+
"lint": "eslint .",
|
|
60
|
+
"format:eslint": "eslint --fix .",
|
|
61
|
+
"format:prettier": "prettier --write .",
|
|
62
|
+
"format": "conc -m 1 pnpm:format:eslint pnpm:format:prettier",
|
|
63
|
+
"test": "conc pnpm:test:type pnpm:test:unit",
|
|
64
|
+
"test:type": "tsc --noEmit",
|
|
65
|
+
"test:unit": "vitest run"
|
|
66
|
+
}
|
|
67
|
+
}
|