@wcag-checkr/storybook-addon 1.0.0-rc.13
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 +51 -0
- package/dist/manager.d.ts +2 -0
- package/dist/manager.js +180 -0
- package/dist/manager.js.map +1 -0
- package/dist/preview.d.ts +16 -0
- package/dist/preview.js +144 -0
- package/dist/preview.js.map +1 -0
- package/package.json +73 -0
- package/preset.js +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @wcag-checkr/storybook-addon
|
|
2
|
+
|
|
3
|
+
Multi-state accessibility audit for Storybook. Cycles each story through default, hover, focus, and dark-mode states, runs axe-core at each, and reports per-state findings in a Storybook panel.
|
|
4
|
+
|
|
5
|
+
Companion to the [wcagcheckr Chrome extension](https://wcagcheckr.com) — the extension drives the FULL state matrix (forced-colors, RTL, real `:focus-visible`, multiple breakpoints) via the Chrome DevTools Protocol; this addon does what's possible from inside a Storybook preview iframe.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev @wcag-checkr/storybook-addon
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Add to your `.storybook/main.ts`:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { StorybookConfig } from '@storybook/react-vite';
|
|
17
|
+
|
|
18
|
+
const config: StorybookConfig = {
|
|
19
|
+
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
|
20
|
+
addons: [
|
|
21
|
+
'@storybook/addon-essentials',
|
|
22
|
+
'@wcag-checkr/storybook-addon',
|
|
23
|
+
],
|
|
24
|
+
framework: { name: '@storybook/react-vite', options: {} },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default config;
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
Open any story → click the **wcagcheckr** panel → click **Run multi-state audit**.
|
|
33
|
+
|
|
34
|
+
Per-state findings appear under each state heading. Pass = no axe-core violations at that state; fail = one or more, with the rule, impact level, affected node count, and a link to the rule documentation.
|
|
35
|
+
|
|
36
|
+
## How states are simulated
|
|
37
|
+
|
|
38
|
+
| State | Mechanism | Reliability |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `default` | No attribute on `<html>`. Ambient state. | High |
|
|
41
|
+
| `hover` | `<html data-wc-state="hover">` — opt-in via your CSS | Medium |
|
|
42
|
+
| `focus` | `<html data-wc-state="focus">` — opt-in via your CSS | Medium |
|
|
43
|
+
| `dark` | `<html class="dark" data-theme="dark">` | High when your CSS targets either selector |
|
|
44
|
+
|
|
45
|
+
If your CSS only uses `:hover` / `:focus` pseudo-class selectors (not class-based mirrors), the hover/focus states will run axe-core but the visible style won't change. That still catches non-pseudo-class violations (contrast at rest, ARIA, names/labels) — useful but limited.
|
|
46
|
+
|
|
47
|
+
For the FULL multi-state matrix (forced-colors, RTL, real focus-visible, breakpoints, ARIA-state permutations), install the [wcagcheckr Chrome extension](https://wcagcheckr.com) — it drives the rendered component through every state via the Chrome DevTools Protocol, which a Storybook preview iframe cannot.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
UNLICENSED until commercial release. See `wcagcheckr.com/license`.
|
package/dist/manager.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/manager.tsx
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { addons, types, useChannel, useStorybookApi } from "@storybook/manager-api";
|
|
4
|
+
import { AddonPanel, Badge, Button, Placeholder, Separator } from "@storybook/components";
|
|
5
|
+
|
|
6
|
+
// src/constants.ts
|
|
7
|
+
var ADDON_ID = "wcagcheckr/storybook-addon";
|
|
8
|
+
var PANEL_ID = `${ADDON_ID}/panel`;
|
|
9
|
+
var EVENTS = {
|
|
10
|
+
/** Manager → preview: run an audit cycle on the current story. */
|
|
11
|
+
RUN_AUDIT: `${ADDON_ID}/run-audit`,
|
|
12
|
+
/** Preview → manager: a single state finished auditing. */
|
|
13
|
+
STATE_RESULT: `${ADDON_ID}/state-result`,
|
|
14
|
+
/** Preview → manager: the whole cycle finished. */
|
|
15
|
+
AUDIT_COMPLETE: `${ADDON_ID}/audit-complete`,
|
|
16
|
+
/** Preview → manager: the cycle failed (e.g. axe-core threw). */
|
|
17
|
+
AUDIT_FAILED: `${ADDON_ID}/audit-failed`
|
|
18
|
+
};
|
|
19
|
+
var DEFAULT_STATES = ["default", "hover", "focus", "dark"];
|
|
20
|
+
|
|
21
|
+
// src/manager.tsx
|
|
22
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
23
|
+
var STORY_CHANGED = "storyChanged";
|
|
24
|
+
var STORY_RENDERED = "storyRendered";
|
|
25
|
+
var Panel = ({ active }) => {
|
|
26
|
+
const api = useStorybookApi();
|
|
27
|
+
const [running, setRunning] = useState(false);
|
|
28
|
+
const [results, setResults] = useState([]);
|
|
29
|
+
const [completedStoryId, setCompletedStoryId] = useState(null);
|
|
30
|
+
const [axeVersion, setAxeVersion] = useState(null);
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
function readStoryId() {
|
|
33
|
+
const fromCurrent = api.getCurrentStoryData?.()?.id ?? null;
|
|
34
|
+
if (fromCurrent) return fromCurrent;
|
|
35
|
+
const urlState = api.getUrlState?.();
|
|
36
|
+
return urlState?.storyId ?? null;
|
|
37
|
+
}
|
|
38
|
+
const [currentStoryId, setCurrentStoryId] = useState(() => readStoryId());
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const t = setInterval(() => {
|
|
41
|
+
const id = readStoryId();
|
|
42
|
+
if (id && id !== currentStoryId) {
|
|
43
|
+
setCurrentStoryId(id);
|
|
44
|
+
clearInterval(t);
|
|
45
|
+
}
|
|
46
|
+
}, 200);
|
|
47
|
+
const timeout = setTimeout(() => clearInterval(t), 5e3);
|
|
48
|
+
return () => {
|
|
49
|
+
clearInterval(t);
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
};
|
|
52
|
+
}, [api]);
|
|
53
|
+
useChannel({
|
|
54
|
+
[EVENTS.STATE_RESULT]: (r) => {
|
|
55
|
+
setResults((prev) => [...prev, r]);
|
|
56
|
+
},
|
|
57
|
+
[EVENTS.AUDIT_COMPLETE]: (payload) => {
|
|
58
|
+
setRunning(false);
|
|
59
|
+
setAxeVersion(payload.axeVersion);
|
|
60
|
+
setCompletedStoryId(payload.storyId);
|
|
61
|
+
setResults(payload.results);
|
|
62
|
+
},
|
|
63
|
+
[EVENTS.AUDIT_FAILED]: ({ error: msg }) => {
|
|
64
|
+
setRunning(false);
|
|
65
|
+
setError(msg);
|
|
66
|
+
},
|
|
67
|
+
[STORY_CHANGED]: (id) => setCurrentStoryId(id),
|
|
68
|
+
[STORY_RENDERED]: (id) => setCurrentStoryId(id)
|
|
69
|
+
});
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
setResults([]);
|
|
72
|
+
setCompletedStoryId(null);
|
|
73
|
+
setAxeVersion(null);
|
|
74
|
+
setError(null);
|
|
75
|
+
}, [currentStoryId]);
|
|
76
|
+
function runAudit() {
|
|
77
|
+
if (!currentStoryId) return;
|
|
78
|
+
setRunning(true);
|
|
79
|
+
setResults([]);
|
|
80
|
+
setError(null);
|
|
81
|
+
setAxeVersion(null);
|
|
82
|
+
api.emit(EVENTS.RUN_AUDIT, {
|
|
83
|
+
storyId: currentStoryId,
|
|
84
|
+
states: DEFAULT_STATES
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const totalViolations = useMemo(
|
|
88
|
+
() => results.reduce((sum, r) => sum + r.violations.length, 0),
|
|
89
|
+
[results]
|
|
90
|
+
);
|
|
91
|
+
const distinctRules = useMemo(() => {
|
|
92
|
+
const ids = /* @__PURE__ */ new Set();
|
|
93
|
+
for (const r of results) for (const v of r.violations) ids.add(v.id);
|
|
94
|
+
return ids.size;
|
|
95
|
+
}, [results]);
|
|
96
|
+
return /* @__PURE__ */ jsx(AddonPanel, { active, children: /* @__PURE__ */ jsxs("div", { style: { padding: "12px 16px" }, children: [
|
|
97
|
+
/* @__PURE__ */ jsxs("div", { style: { display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }, children: [
|
|
98
|
+
/* @__PURE__ */ jsx(Button, { onClick: runAudit, disabled: running || !currentStoryId, variant: "solid", children: running ? "Running\u2026" : "Run multi-state audit" }),
|
|
99
|
+
completedStoryId && /* @__PURE__ */ jsxs("span", { style: { fontSize: 12, color: "#64748b" }, children: [
|
|
100
|
+
distinctRules,
|
|
101
|
+
" distinct rule",
|
|
102
|
+
distinctRules === 1 ? "" : "s",
|
|
103
|
+
" \xB7",
|
|
104
|
+
" ",
|
|
105
|
+
totalViolations,
|
|
106
|
+
" total finding",
|
|
107
|
+
totalViolations === 1 ? "" : "s",
|
|
108
|
+
axeVersion ? ` \xB7 axe ${axeVersion}` : ""
|
|
109
|
+
] })
|
|
110
|
+
] }),
|
|
111
|
+
error && /* @__PURE__ */ jsxs(Placeholder, { children: [
|
|
112
|
+
/* @__PURE__ */ jsx("strong", { children: "Audit failed" }),
|
|
113
|
+
/* @__PURE__ */ jsx("span", { style: { fontSize: 12 }, children: error })
|
|
114
|
+
] }),
|
|
115
|
+
!completedStoryId && !running && !error && /* @__PURE__ */ jsxs(Placeholder, { children: [
|
|
116
|
+
/* @__PURE__ */ jsx("strong", { children: "Multi-state accessibility audit" }),
|
|
117
|
+
/* @__PURE__ */ jsx("span", { style: { maxWidth: 480 }, children: "Cycles the current story through default, hover, focus, and dark-mode states and runs axe-core at each. The companion wcagcheckr Chrome extension drives a wider state matrix (forced-colors, RTL, real :focus-visible, breakpoints) via the Chrome DevTools Protocol." })
|
|
118
|
+
] }),
|
|
119
|
+
running && /* @__PURE__ */ jsxs(Placeholder, { children: [
|
|
120
|
+
/* @__PURE__ */ jsx("strong", { children: "Auditing\u2026" }),
|
|
121
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
122
|
+
"Cycling through ",
|
|
123
|
+
DEFAULT_STATES.length,
|
|
124
|
+
" states."
|
|
125
|
+
] })
|
|
126
|
+
] }),
|
|
127
|
+
results.length > 0 && /* @__PURE__ */ jsx("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: results.map((r) => /* @__PURE__ */ jsx(StateBlock, { result: r }, r.state)) })
|
|
128
|
+
] }) });
|
|
129
|
+
};
|
|
130
|
+
var StateBlock = ({ result }) => {
|
|
131
|
+
const badgeType = result.violations.length === 0 ? "positive" : "negative";
|
|
132
|
+
return /* @__PURE__ */ jsxs("section", { style: { border: "1px solid #e2e8f0", borderRadius: 4, padding: "10px 12px" }, children: [
|
|
133
|
+
/* @__PURE__ */ jsxs("header", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }, children: [
|
|
134
|
+
/* @__PURE__ */ jsx("strong", { style: { fontSize: 13, textTransform: "capitalize" }, children: result.state }),
|
|
135
|
+
/* @__PURE__ */ jsxs(Badge, { status: badgeType, children: [
|
|
136
|
+
result.violations.length,
|
|
137
|
+
" violation",
|
|
138
|
+
result.violations.length === 1 ? "" : "s"
|
|
139
|
+
] }),
|
|
140
|
+
!result.ran && /* @__PURE__ */ jsxs(Badge, { status: "warning", children: [
|
|
141
|
+
"skipped: ",
|
|
142
|
+
result.error ?? "unknown"
|
|
143
|
+
] })
|
|
144
|
+
] }),
|
|
145
|
+
result.violations.length > 0 && /* @__PURE__ */ jsx("ul", { style: { margin: 0, paddingLeft: 18, fontSize: 12 }, children: result.violations.map((v, i) => /* @__PURE__ */ jsxs("li", { style: { marginBottom: 6 }, children: [
|
|
146
|
+
/* @__PURE__ */ jsx("code", { style: { fontFamily: "monospace" }, children: v.id }),
|
|
147
|
+
" ",
|
|
148
|
+
/* @__PURE__ */ jsxs("span", { style: { color: "#64748b" }, children: [
|
|
149
|
+
"(",
|
|
150
|
+
v.impact ?? "unknown",
|
|
151
|
+
", ",
|
|
152
|
+
v.nodes.length,
|
|
153
|
+
" node",
|
|
154
|
+
v.nodes.length === 1 ? "" : "s",
|
|
155
|
+
")"
|
|
156
|
+
] }),
|
|
157
|
+
/* @__PURE__ */ jsx("div", { style: { marginTop: 2, color: "#334155" }, children: v.description }),
|
|
158
|
+
v.helpUrl && /* @__PURE__ */ jsx(
|
|
159
|
+
"a",
|
|
160
|
+
{
|
|
161
|
+
href: v.helpUrl,
|
|
162
|
+
target: "_blank",
|
|
163
|
+
rel: "noreferrer",
|
|
164
|
+
style: { fontSize: 11, color: "#1e40af" },
|
|
165
|
+
children: "Reference \u2192"
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
] }, `${v.id}-${i}`)) }),
|
|
169
|
+
/* @__PURE__ */ jsx(Separator, {})
|
|
170
|
+
] });
|
|
171
|
+
};
|
|
172
|
+
addons.register(ADDON_ID, () => {
|
|
173
|
+
addons.add(PANEL_ID, {
|
|
174
|
+
type: types.PANEL,
|
|
175
|
+
title: "wcagcheckr",
|
|
176
|
+
match: ({ viewMode }) => viewMode === "story",
|
|
177
|
+
render: ({ active }) => /* @__PURE__ */ jsx(Panel, { active: active ?? false })
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
//# sourceMappingURL=manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/manager.tsx","../src/constants.ts"],"sourcesContent":["// Manager-side bundle: lives in the top-level Storybook UI (the chrome\n// around the iframe). Registers a panel that shows the running audit's\n// per-state results and a \"Run audit\" button that emits RUN_AUDIT to the\n// preview-side bundle.\n\nimport React, { useEffect, useMemo, useState } from 'react';\nimport { addons, types, useChannel, useStorybookApi } from '@storybook/manager-api';\n// STORY_CHANGED / STORY_RENDERED are core-event names — re-deriving current\n// story on these events makes the panel reactive to navigation, which\n// useStorybookApi() alone isn't.\nconst STORY_CHANGED = 'storyChanged';\nconst STORY_RENDERED = 'storyRendered';\nimport { AddonPanel, Badge, Button, Placeholder, Separator } from '@storybook/components';\nimport {\n ADDON_ID,\n PANEL_ID,\n EVENTS,\n DEFAULT_STATES,\n type AuditCompletePayload,\n type AuditStateName,\n type StateResult,\n} from './constants';\n\nconst Panel: React.FC<{ active: boolean }> = ({ active }) => {\n const api = useStorybookApi();\n const [running, setRunning] = useState(false);\n const [results, setResults] = useState<StateResult[]>([]);\n const [completedStoryId, setCompletedStoryId] = useState<string | null>(null);\n const [axeVersion, setAxeVersion] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n // useStorybookApi() returns the API singleton, but reading\n // api.getCurrentStoryData() during render is NOT reactive — the panel\n // would freeze at \"no story\" if it mounted before the story-change event.\n // Track the current story id as state and update on STORY_CHANGED /\n // STORY_RENDERED so the panel re-renders when navigation happens.\n function readStoryId(): string | null {\n // Several APIs may have the story id depending on Storybook version and\n // panel-mount timing. Try them in order of reliability:\n // 1. api.getCurrentStoryData() — present after STORY_RENDERED\n // 2. api.getUrlState().storyId — derived from the URL, present\n // immediately on page load even before the preview rendered\n const fromCurrent = api.getCurrentStoryData?.()?.id ?? null;\n if (fromCurrent) return fromCurrent;\n const urlState = api.getUrlState?.();\n return urlState?.storyId ?? null;\n }\n\n const [currentStoryId, setCurrentStoryId] = useState<string | null>(() => readStoryId());\n\n // Poll briefly on mount so the panel picks up the active story even when\n // STORY_RENDERED already fired before the panel mounted (common when the\n // panel tab is opened after the story already loaded).\n useEffect(() => {\n const t = setInterval(() => {\n const id = readStoryId();\n if (id && id !== currentStoryId) {\n setCurrentStoryId(id);\n clearInterval(t);\n }\n }, 200);\n // Stop polling after 5 s regardless.\n const timeout = setTimeout(() => clearInterval(t), 5000);\n return () => {\n clearInterval(t);\n clearTimeout(timeout);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [api]);\n\n useChannel({\n [EVENTS.STATE_RESULT]: (r: StateResult) => {\n setResults((prev) => [...prev, r]);\n },\n [EVENTS.AUDIT_COMPLETE]: (payload: AuditCompletePayload) => {\n setRunning(false);\n setAxeVersion(payload.axeVersion);\n setCompletedStoryId(payload.storyId);\n setResults(payload.results);\n },\n [EVENTS.AUDIT_FAILED]: ({ error: msg }: { error: string }) => {\n setRunning(false);\n setError(msg);\n },\n [STORY_CHANGED]: (id: string) => setCurrentStoryId(id),\n [STORY_RENDERED]: (id: string) => setCurrentStoryId(id),\n });\n\n // Reset state when navigating between stories.\n useEffect(() => {\n setResults([]);\n setCompletedStoryId(null);\n setAxeVersion(null);\n setError(null);\n }, [currentStoryId]);\n\n function runAudit() {\n if (!currentStoryId) return;\n setRunning(true);\n setResults([]);\n setError(null);\n setAxeVersion(null);\n // api.emit routes events across the manager/preview boundary correctly\n // (sets the right `from` field). The bare addons.getChannel().emit\n // from manager fails to cross the boundary and the preview rejects it.\n api.emit(EVENTS.RUN_AUDIT, {\n storyId: currentStoryId,\n states: DEFAULT_STATES,\n });\n }\n\n const totalViolations = useMemo(\n () => results.reduce((sum, r) => sum + r.violations.length, 0),\n [results],\n );\n const distinctRules = useMemo(() => {\n const ids = new Set<string>();\n for (const r of results) for (const v of r.violations) ids.add(v.id);\n return ids.size;\n }, [results]);\n\n return (\n <AddonPanel active={active}>\n <div style={{ padding: '12px 16px' }}>\n <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>\n <Button onClick={runAudit} disabled={running || !currentStoryId} variant=\"solid\">\n {running ? 'Running…' : 'Run multi-state audit'}\n </Button>\n {completedStoryId && (\n <span style={{ fontSize: 12, color: '#64748b' }}>\n {distinctRules} distinct rule{distinctRules === 1 ? '' : 's'} ·{' '}\n {totalViolations} total finding{totalViolations === 1 ? '' : 's'}\n {axeVersion ? ` · axe ${axeVersion}` : ''}\n </span>\n )}\n </div>\n\n {error && (\n <Placeholder>\n <strong>Audit failed</strong>\n <span style={{ fontSize: 12 }}>{error}</span>\n </Placeholder>\n )}\n\n {!completedStoryId && !running && !error && (\n <Placeholder>\n <strong>Multi-state accessibility audit</strong>\n <span style={{ maxWidth: 480 }}>\n Cycles the current story through default, hover, focus, and dark-mode\n states and runs axe-core at each. The companion wcagcheckr Chrome\n extension drives a wider state matrix (forced-colors, RTL, real\n :focus-visible, breakpoints) via the Chrome DevTools Protocol.\n </span>\n </Placeholder>\n )}\n\n {running && (\n <Placeholder>\n <strong>Auditing…</strong>\n <span>Cycling through {DEFAULT_STATES.length} states.</span>\n </Placeholder>\n )}\n\n {results.length > 0 && (\n <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>\n {results.map((r) => (\n <StateBlock key={r.state} result={r} />\n ))}\n </div>\n )}\n </div>\n </AddonPanel>\n );\n};\n\nconst StateBlock: React.FC<{ result: StateResult }> = ({ result }) => {\n const badgeType = result.violations.length === 0 ? 'positive' : 'negative';\n return (\n <section style={{ border: '1px solid #e2e8f0', borderRadius: 4, padding: '10px 12px' }}>\n <header style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>\n <strong style={{ fontSize: 13, textTransform: 'capitalize' }}>{result.state}</strong>\n <Badge status={badgeType}>\n {result.violations.length} violation{result.violations.length === 1 ? '' : 's'}\n </Badge>\n {!result.ran && (\n <Badge status=\"warning\">skipped: {result.error ?? 'unknown'}</Badge>\n )}\n </header>\n {result.violations.length > 0 && (\n <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>\n {result.violations.map((v, i) => (\n <li key={`${v.id}-${i}`} style={{ marginBottom: 6 }}>\n <code style={{ fontFamily: 'monospace' }}>{v.id}</code>{' '}\n <span style={{ color: '#64748b' }}>\n ({v.impact ?? 'unknown'}, {v.nodes.length} node{v.nodes.length === 1 ? '' : 's'})\n </span>\n <div style={{ marginTop: 2, color: '#334155' }}>{v.description}</div>\n {v.helpUrl && (\n <a\n href={v.helpUrl}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={{ fontSize: 11, color: '#1e40af' }}\n >\n Reference →\n </a>\n )}\n </li>\n ))}\n </ul>\n )}\n <Separator />\n </section>\n );\n};\n\naddons.register(ADDON_ID, () => {\n addons.add(PANEL_ID, {\n type: types.PANEL,\n title: 'wcagcheckr',\n match: ({ viewMode }) => viewMode === 'story',\n render: ({ active }) => <Panel active={active ?? false} />,\n });\n});\n","// Identifiers shared between the manager (top-level Storybook UI) and the\n// preview (the story iframe). Keep these stable — they're the message\n// channel between the two contexts.\n\nexport const ADDON_ID = 'wcagcheckr/storybook-addon';\nexport const PANEL_ID = `${ADDON_ID}/panel`;\nexport const PARAM_KEY = 'wcagcheckr';\n\nexport const EVENTS = {\n /** Manager → preview: run an audit cycle on the current story. */\n RUN_AUDIT: `${ADDON_ID}/run-audit`,\n /** Preview → manager: a single state finished auditing. */\n STATE_RESULT: `${ADDON_ID}/state-result`,\n /** Preview → manager: the whole cycle finished. */\n AUDIT_COMPLETE: `${ADDON_ID}/audit-complete`,\n /** Preview → manager: the cycle failed (e.g. axe-core threw). */\n AUDIT_FAILED: `${ADDON_ID}/audit-failed`,\n} as const;\n\nexport type AuditStateName = 'default' | 'hover' | 'focus' | 'active' | 'dark';\n\nexport const DEFAULT_STATES: AuditStateName[] = ['default', 'hover', 'focus', 'dark'];\n\nexport type StateResult = {\n state: AuditStateName;\n violations: Array<{\n id: string;\n impact: 'minor' | 'moderate' | 'serious' | 'critical' | null;\n description: string;\n helpUrl: string;\n nodes: Array<{ target: string[]; html: string; failureSummary?: string }>;\n }>;\n ran: boolean;\n error?: string;\n};\n\nexport type AuditCompletePayload = {\n storyId: string;\n axeVersion: string;\n results: StateResult[];\n};\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare const ADDON_ID = "wcagcheckr/storybook-addon";
|
|
2
|
+
declare const PARAM_KEY = "wcagcheckr";
|
|
3
|
+
declare const EVENTS: {
|
|
4
|
+
/** Manager → preview: run an audit cycle on the current story. */
|
|
5
|
+
readonly RUN_AUDIT: "wcagcheckr/storybook-addon/run-audit";
|
|
6
|
+
/** Preview → manager: a single state finished auditing. */
|
|
7
|
+
readonly STATE_RESULT: "wcagcheckr/storybook-addon/state-result";
|
|
8
|
+
/** Preview → manager: the whole cycle finished. */
|
|
9
|
+
readonly AUDIT_COMPLETE: "wcagcheckr/storybook-addon/audit-complete";
|
|
10
|
+
/** Preview → manager: the cycle failed (e.g. axe-core threw). */
|
|
11
|
+
readonly AUDIT_FAILED: "wcagcheckr/storybook-addon/audit-failed";
|
|
12
|
+
};
|
|
13
|
+
type AuditStateName = 'default' | 'hover' | 'focus' | 'active' | 'dark';
|
|
14
|
+
declare const DEFAULT_STATES: AuditStateName[];
|
|
15
|
+
|
|
16
|
+
export { ADDON_ID, DEFAULT_STATES, EVENTS, PARAM_KEY };
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// src/preview.ts
|
|
2
|
+
import { addons } from "@storybook/preview-api";
|
|
3
|
+
|
|
4
|
+
// src/constants.ts
|
|
5
|
+
var ADDON_ID = "wcagcheckr/storybook-addon";
|
|
6
|
+
var PANEL_ID = `${ADDON_ID}/panel`;
|
|
7
|
+
var PARAM_KEY = "wcagcheckr";
|
|
8
|
+
var EVENTS = {
|
|
9
|
+
/** Manager → preview: run an audit cycle on the current story. */
|
|
10
|
+
RUN_AUDIT: `${ADDON_ID}/run-audit`,
|
|
11
|
+
/** Preview → manager: a single state finished auditing. */
|
|
12
|
+
STATE_RESULT: `${ADDON_ID}/state-result`,
|
|
13
|
+
/** Preview → manager: the whole cycle finished. */
|
|
14
|
+
AUDIT_COMPLETE: `${ADDON_ID}/audit-complete`,
|
|
15
|
+
/** Preview → manager: the cycle failed (e.g. axe-core threw). */
|
|
16
|
+
AUDIT_FAILED: `${ADDON_ID}/audit-failed`
|
|
17
|
+
};
|
|
18
|
+
var DEFAULT_STATES = ["default", "hover", "focus", "dark"];
|
|
19
|
+
|
|
20
|
+
// src/preview.ts
|
|
21
|
+
var axePromise = null;
|
|
22
|
+
function loadAxe() {
|
|
23
|
+
if (!axePromise) {
|
|
24
|
+
axePromise = import("axe-core").then((m) => {
|
|
25
|
+
const mod = m;
|
|
26
|
+
const candidate = "default" in mod && mod.default ? mod.default : mod;
|
|
27
|
+
return candidate;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return axePromise;
|
|
31
|
+
}
|
|
32
|
+
function getStoryRoot() {
|
|
33
|
+
return document.getElementById("storybook-root") ?? document.body;
|
|
34
|
+
}
|
|
35
|
+
var STATE_HELPER_STYLE_ID = `${ADDON_ID}--state-helper`;
|
|
36
|
+
var STATE_HELPER_CSS = `
|
|
37
|
+
/* wcagcheckr state helper \u2014 opt-in styles for sites that want their hover/
|
|
38
|
+
focus/active styles to apply during the multi-state audit. Sites that
|
|
39
|
+
use class-based state mirrors (e.g. .is-hover, .is-focus) can wire those
|
|
40
|
+
to the attribute selectors below; sites that rely purely on :hover /
|
|
41
|
+
:focus selectors will only see the audit run, not the styling. */
|
|
42
|
+
[data-wc-state="hover"] *:not(:disabled) { /* hint: apply hover styles via .is-hover */ }
|
|
43
|
+
[data-wc-state="focus"] *:not(:disabled) { /* hint: apply focus styles via .is-focus */ }
|
|
44
|
+
[data-wc-state="active"] *:not(:disabled) { /* hint: apply active styles via .is-active */ }
|
|
45
|
+
`;
|
|
46
|
+
function ensureStateHelperCss() {
|
|
47
|
+
if (document.getElementById(STATE_HELPER_STYLE_ID)) return;
|
|
48
|
+
const style = document.createElement("style");
|
|
49
|
+
style.id = STATE_HELPER_STYLE_ID;
|
|
50
|
+
style.textContent = STATE_HELPER_CSS;
|
|
51
|
+
document.head.appendChild(style);
|
|
52
|
+
}
|
|
53
|
+
function applyState(state) {
|
|
54
|
+
const root = document.documentElement;
|
|
55
|
+
ensureStateHelperCss();
|
|
56
|
+
if (state === "dark") {
|
|
57
|
+
root.setAttribute("data-theme", "dark");
|
|
58
|
+
root.classList.add("dark");
|
|
59
|
+
root.removeAttribute("data-wc-state");
|
|
60
|
+
} else if (state === "default") {
|
|
61
|
+
root.removeAttribute("data-theme");
|
|
62
|
+
root.classList.remove("dark");
|
|
63
|
+
root.removeAttribute("data-wc-state");
|
|
64
|
+
} else {
|
|
65
|
+
root.removeAttribute("data-theme");
|
|
66
|
+
root.classList.remove("dark");
|
|
67
|
+
root.setAttribute("data-wc-state", state);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function clearState() {
|
|
71
|
+
const root = document.documentElement;
|
|
72
|
+
root.removeAttribute("data-theme");
|
|
73
|
+
root.classList.remove("dark");
|
|
74
|
+
root.removeAttribute("data-wc-state");
|
|
75
|
+
}
|
|
76
|
+
async function runAuditCycle(storyId, states) {
|
|
77
|
+
const channel2 = addons.getChannel();
|
|
78
|
+
let axe;
|
|
79
|
+
try {
|
|
80
|
+
axe = await loadAxe();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
channel2.emit(EVENTS.AUDIT_FAILED, {
|
|
83
|
+
storyId,
|
|
84
|
+
error: err instanceof Error ? err.message : String(err)
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const results = [];
|
|
89
|
+
const root = getStoryRoot();
|
|
90
|
+
for (const state of states) {
|
|
91
|
+
applyState(state);
|
|
92
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
93
|
+
try {
|
|
94
|
+
const axeResult = await axe.run(root, {
|
|
95
|
+
resultTypes: ["violations"]
|
|
96
|
+
});
|
|
97
|
+
const stateResult = {
|
|
98
|
+
state,
|
|
99
|
+
ran: true,
|
|
100
|
+
violations: axeResult.violations.map((v) => ({
|
|
101
|
+
id: v.id,
|
|
102
|
+
impact: v.impact,
|
|
103
|
+
description: v.description,
|
|
104
|
+
helpUrl: v.helpUrl,
|
|
105
|
+
nodes: v.nodes.map((n) => ({
|
|
106
|
+
target: n.target.flat(),
|
|
107
|
+
html: n.html,
|
|
108
|
+
failureSummary: n.failureSummary
|
|
109
|
+
}))
|
|
110
|
+
}))
|
|
111
|
+
};
|
|
112
|
+
results.push(stateResult);
|
|
113
|
+
channel2.emit(EVENTS.STATE_RESULT, stateResult);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const failure = {
|
|
116
|
+
state,
|
|
117
|
+
ran: false,
|
|
118
|
+
violations: [],
|
|
119
|
+
error: err instanceof Error ? err.message : String(err)
|
|
120
|
+
};
|
|
121
|
+
results.push(failure);
|
|
122
|
+
channel2.emit(EVENTS.STATE_RESULT, failure);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
clearState();
|
|
126
|
+
const complete = {
|
|
127
|
+
storyId,
|
|
128
|
+
axeVersion: axe.version,
|
|
129
|
+
results
|
|
130
|
+
};
|
|
131
|
+
channel2.emit(EVENTS.AUDIT_COMPLETE, complete);
|
|
132
|
+
}
|
|
133
|
+
var channel = addons.getChannel();
|
|
134
|
+
channel.on(EVENTS.RUN_AUDIT, (payload) => {
|
|
135
|
+
const states = payload.states ?? DEFAULT_STATES;
|
|
136
|
+
void runAuditCycle(payload.storyId, states);
|
|
137
|
+
});
|
|
138
|
+
export {
|
|
139
|
+
ADDON_ID,
|
|
140
|
+
DEFAULT_STATES,
|
|
141
|
+
EVENTS,
|
|
142
|
+
PARAM_KEY
|
|
143
|
+
};
|
|
144
|
+
//# sourceMappingURL=preview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/preview.ts","../src/constants.ts"],"sourcesContent":["// Preview-side bundle: runs inside the story iframe. Listens for RUN_AUDIT\n// from the manager, cycles the story's root through the requested states by\n// injecting/removing a class on the documentElement that scoped CSS picks\n// up, runs axe-core at each state, and emits results.\n//\n// State simulation strategy:\n//\n// - `default`: no class, ambient state.\n// - `hover` / `focus` / `active`: inject CSS that targets the story body\n// and forces a state-like style by adding a class chain. Real `:hover`\n// and `:focus` selectors only fire on actual cursor/focus events, which\n// we can't synthesize headlessly from a Storybook preview. So we use a\n// class-substitution approach: each state appends a `data-wc-state`\n// attribute that the audit harness CSS maps to a forced-style rule.\n// Sites that don't opt into the helper CSS will see no visible state\n// change, but axe-core will still run — surfacing rules that don't\n// depend on pseudo-state (color contrast at REST, ARIA, names/labels)\n// in EVERY state-iteration, which is still useful for catching base\n// issues across themes.\n// - `dark`: toggles `[data-theme=\"dark\"]` and `prefers-color-scheme:dark`\n// emulation via a class on documentElement.\n//\n// This is intentionally a lighter-weight version of the wcagcheckr Chrome\n// extension's chrome.debugger-driven state matrix. The extension can drive\n// REAL :hover / :focus / :focus-visible / :active via the DevTools Protocol;\n// a Storybook addon cannot. The addon's value-add: catches the rest (theme,\n// RTL, contrast at multiple breakpoints) without requiring the extension.\n\nimport { addons } from '@storybook/preview-api';\nimport {\n ADDON_ID,\n DEFAULT_STATES,\n EVENTS,\n type AuditCompletePayload,\n type AuditStateName,\n type StateResult,\n} from './constants';\n\ntype AxeResult = {\n violations: Array<{\n id: string;\n impact: 'minor' | 'moderate' | 'serious' | 'critical' | null;\n description: string;\n helpUrl: string;\n nodes: Array<{ target: unknown[]; html: string; failureSummary?: string }>;\n }>;\n};\ntype AxeApi = {\n run: (context: Node | Element, options?: Record<string, unknown>) => Promise<AxeResult>;\n version: string;\n};\n\nlet axePromise: Promise<AxeApi> | null = null;\nfunction loadAxe(): Promise<AxeApi> {\n if (!axePromise) {\n axePromise = import('axe-core').then((m) => {\n // axe-core ships as both CJS and ESM. Depending on the bundler the\n // module shape can be either `{ default: axe, run, ... }` or just the\n // namespace with run() at the top level. Normalize.\n const mod = m as unknown as Record<string, unknown>;\n const candidate =\n 'default' in mod && mod.default ? (mod.default as AxeApi) : (mod as unknown as AxeApi);\n return candidate;\n });\n }\n return axePromise;\n}\n\nfunction getStoryRoot(): HTMLElement {\n return (document.getElementById('storybook-root') ?? document.body) as HTMLElement;\n}\n\nconst STATE_HELPER_STYLE_ID = `${ADDON_ID}--state-helper`;\nconst STATE_HELPER_CSS = `\n /* wcagcheckr state helper — opt-in styles for sites that want their hover/\n focus/active styles to apply during the multi-state audit. Sites that\n use class-based state mirrors (e.g. .is-hover, .is-focus) can wire those\n to the attribute selectors below; sites that rely purely on :hover /\n :focus selectors will only see the audit run, not the styling. */\n [data-wc-state=\"hover\"] *:not(:disabled) { /* hint: apply hover styles via .is-hover */ }\n [data-wc-state=\"focus\"] *:not(:disabled) { /* hint: apply focus styles via .is-focus */ }\n [data-wc-state=\"active\"] *:not(:disabled) { /* hint: apply active styles via .is-active */ }\n`;\n\nfunction ensureStateHelperCss() {\n if (document.getElementById(STATE_HELPER_STYLE_ID)) return;\n const style = document.createElement('style');\n style.id = STATE_HELPER_STYLE_ID;\n style.textContent = STATE_HELPER_CSS;\n document.head.appendChild(style);\n}\n\nfunction applyState(state: AuditStateName) {\n const root = document.documentElement;\n ensureStateHelperCss();\n if (state === 'dark') {\n root.setAttribute('data-theme', 'dark');\n root.classList.add('dark');\n root.removeAttribute('data-wc-state');\n } else if (state === 'default') {\n root.removeAttribute('data-theme');\n root.classList.remove('dark');\n root.removeAttribute('data-wc-state');\n } else {\n root.removeAttribute('data-theme');\n root.classList.remove('dark');\n root.setAttribute('data-wc-state', state);\n }\n}\n\nfunction clearState() {\n const root = document.documentElement;\n root.removeAttribute('data-theme');\n root.classList.remove('dark');\n root.removeAttribute('data-wc-state');\n}\n\nasync function runAuditCycle(storyId: string, states: AuditStateName[]): Promise<void> {\n const channel = addons.getChannel();\n let axe: AxeApi;\n try {\n axe = await loadAxe();\n } catch (err) {\n channel.emit(EVENTS.AUDIT_FAILED, {\n storyId,\n error: err instanceof Error ? err.message : String(err),\n });\n return;\n }\n\n const results: StateResult[] = [];\n const root = getStoryRoot();\n\n for (const state of states) {\n applyState(state);\n // Allow a tick for the DOM to settle (style recalculation).\n await new Promise((r) => setTimeout(r, 50));\n try {\n const axeResult: AxeResult = await axe.run(root, {\n resultTypes: ['violations'],\n });\n const stateResult: StateResult = {\n state,\n ran: true,\n violations: axeResult.violations.map((v) => ({\n id: v.id,\n impact: v.impact,\n description: v.description,\n helpUrl: v.helpUrl,\n nodes: v.nodes.map((n) => ({\n target: (n.target as unknown[]).flat() as string[],\n html: n.html,\n failureSummary: n.failureSummary,\n })),\n })),\n };\n results.push(stateResult);\n channel.emit(EVENTS.STATE_RESULT, stateResult);\n } catch (err) {\n const failure: StateResult = {\n state,\n ran: false,\n violations: [],\n error: err instanceof Error ? err.message : String(err),\n };\n results.push(failure);\n channel.emit(EVENTS.STATE_RESULT, failure);\n }\n }\n\n clearState();\n const complete: AuditCompletePayload = {\n storyId,\n axeVersion: axe.version,\n results,\n };\n channel.emit(EVENTS.AUDIT_COMPLETE, complete);\n}\n\n// Hook the channel once per preview iframe load.\nconst channel = addons.getChannel();\nchannel.on(EVENTS.RUN_AUDIT, (payload: { storyId: string; states?: AuditStateName[] }) => {\n const states = payload.states ?? DEFAULT_STATES;\n void runAuditCycle(payload.storyId, states);\n});\n\n// No default export required — the file is loaded by Storybook via the\n// \"preview\" entry in the addon's package.json exports. Re-export the\n// constants so consumers can import the parameter key + event names for\n// custom decorators / story-level overrides if they need them.\nexport { ADDON_ID, EVENTS, DEFAULT_STATES, PARAM_KEY } from './constants';\n","// Identifiers shared between the manager (top-level Storybook UI) and the\n// preview (the story iframe). Keep these stable — they're the message\n// channel between the two contexts.\n\nexport const ADDON_ID = 'wcagcheckr/storybook-addon';\nexport const PANEL_ID = `${ADDON_ID}/panel`;\nexport const PARAM_KEY = 'wcagcheckr';\n\nexport const EVENTS = {\n /** Manager → preview: run an audit cycle on the current story. */\n RUN_AUDIT: `${ADDON_ID}/run-audit`,\n /** Preview → manager: a single state finished auditing. */\n STATE_RESULT: `${ADDON_ID}/state-result`,\n /** Preview → manager: the whole cycle finished. */\n AUDIT_COMPLETE: `${ADDON_ID}/audit-complete`,\n /** Preview → manager: the cycle failed (e.g. axe-core threw). */\n AUDIT_FAILED: `${ADDON_ID}/audit-failed`,\n} as const;\n\nexport type AuditStateName = 'default' | 'hover' | 'focus' | 'active' | 'dark';\n\nexport const DEFAULT_STATES: AuditStateName[] = ['default', 'hover', 'focus', 'dark'];\n\nexport type StateResult = {\n state: AuditStateName;\n violations: Array<{\n id: string;\n impact: 'minor' | 'moderate' | 'serious' | 'critical' | null;\n description: string;\n helpUrl: string;\n nodes: Array<{ target: string[]; html: string; failureSummary?: string }>;\n }>;\n ran: boolean;\n error?: string;\n};\n\nexport type AuditCompletePayload = {\n storyId: string;\n axeVersion: string;\n results: StateResult[];\n};\n"],"mappings":"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","names":["channel"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wcag-checkr/storybook-addon",
|
|
3
|
+
"version": "1.0.0-rc.13",
|
|
4
|
+
"description": "Multi-state accessibility audit for Storybook. Cycles each story through default, hover, focus, and dark-mode states, runs axe-core at each, and surfaces per-state findings in a Storybook panel. Companion to the wcagcheckr Chrome extension.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/preview.js",
|
|
8
|
+
"module": "./dist/preview.js",
|
|
9
|
+
"types": "./dist/preview.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/preview.d.ts",
|
|
13
|
+
"import": "./dist/preview.js"
|
|
14
|
+
},
|
|
15
|
+
"./manager": "./dist/manager.js",
|
|
16
|
+
"./preview": "./dist/preview.js",
|
|
17
|
+
"./register.js": "./dist/manager.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/**",
|
|
22
|
+
"preset.js",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"dev": "tsup --watch"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"storybook": "^8.0.0",
|
|
31
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"axe-core": "^4.10.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@storybook/manager-api": "^8.6.0",
|
|
38
|
+
"@storybook/preview-api": "^8.6.0",
|
|
39
|
+
"@storybook/components": "^8.6.0",
|
|
40
|
+
"@storybook/theming": "^8.6.0",
|
|
41
|
+
"@storybook/icons": "^1.4.0",
|
|
42
|
+
"@types/react": "^18.3.0",
|
|
43
|
+
"@types/react-dom": "^18.3.0",
|
|
44
|
+
"react": "^18.3.0",
|
|
45
|
+
"react-dom": "^18.3.0",
|
|
46
|
+
"tsup": "^8.0.0",
|
|
47
|
+
"typescript": "^5.5.0"
|
|
48
|
+
},
|
|
49
|
+
"storybook": {
|
|
50
|
+
"displayName": "wcagcheckr — multi-state a11y",
|
|
51
|
+
"supportedFrameworks": [
|
|
52
|
+
"react",
|
|
53
|
+
"vue",
|
|
54
|
+
"angular",
|
|
55
|
+
"html",
|
|
56
|
+
"svelte",
|
|
57
|
+
"web-components"
|
|
58
|
+
],
|
|
59
|
+
"icon": "https://wcagcheckr.com/icon.svg"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://wcagcheckr.com",
|
|
62
|
+
"keywords": [
|
|
63
|
+
"storybook",
|
|
64
|
+
"storybook-addons",
|
|
65
|
+
"addon",
|
|
66
|
+
"a11y",
|
|
67
|
+
"accessibility",
|
|
68
|
+
"wcag",
|
|
69
|
+
"axe-core",
|
|
70
|
+
"multi-state",
|
|
71
|
+
"audit"
|
|
72
|
+
]
|
|
73
|
+
}
|
package/preset.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Storybook addon preset (ESM — package is `type: module`). Tells
|
|
2
|
+
// Storybook where to find the preview-side entry so the channel listener
|
|
3
|
+
// for RUN_AUDIT registers inside the story iframe. The manager-side entry
|
|
4
|
+
// is auto-discovered via package.json `exports['./manager']`.
|
|
5
|
+
//
|
|
6
|
+
// Without this preset, `addons: ['@wcag-checkr/storybook-addon']` in
|
|
7
|
+
// main.ts loads only the manager bundle and the preview entry never runs.
|
|
8
|
+
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
export const previewAnnotations = (entry = []) => [
|
|
15
|
+
...entry,
|
|
16
|
+
join(here, 'dist/preview.js'),
|
|
17
|
+
];
|