add storybook

This commit is contained in:
Aaron Po
2026-03-15 21:18:34 -04:00
parent 9a0eadc514
commit cbaa5bfbca
20 changed files with 2775 additions and 98 deletions

6
.gitignore vendored
View File

@@ -17,7 +17,10 @@
# project-specific build artifacts
/src/Website/build/
/src/Website/storybook-static/
/src/Website/.react-router/
/src/Website/playwright-report/
/src/Website/test-results/
/test-results/
# misc
@@ -495,3 +498,6 @@ FodyWeavers.xsd
.env.dev
.env.test
.env.prod
*storybook.log
storybook-static

View File

@@ -0,0 +1,49 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: [
"../stories/Configure.mdx",
"../stories/SubmitButton.stories.tsx",
"../stories/FormField.stories.tsx",
"../stories/Navbar.stories.tsx",
"../stories/Themes.stories.tsx",
],
addons: [
"@chromatic-com/storybook",
"@storybook/addon-vitest",
"@storybook/addon-a11y",
"@storybook/addon-docs",
"@storybook/addon-onboarding",
],
framework: "@storybook/react-vite",
async viteFinal(config) {
config.plugins = (config.plugins ?? []).filter((plugin) => {
if (!plugin) {
return true;
}
const pluginName = typeof plugin === "object" && "name" in plugin ? plugin.name : "";
return !pluginName.startsWith("react-router");
});
config.build ??= {};
config.build.rollupOptions ??= {};
const previousOnWarn = config.build.rollupOptions.onwarn;
config.build.rollupOptions.onwarn = (warning, warn) => {
if (warning.code === "MODULE_LEVEL_DIRECTIVE") {
return;
}
if (typeof previousOnWarn === "function") {
previousOnWarn(warning, warn);
return;
}
warn(warning);
};
return config;
},
};
export default config;

View File

@@ -0,0 +1,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap"
/>

View File

@@ -0,0 +1,63 @@
import type { Preview } from "@storybook/react-vite";
import { createElement } from "react";
import { MemoryRouter } from "react-router";
import "../app/app.css";
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from "../app/lib/themes";
const preview: Preview = {
globalTypes: {
theme: {
description: "Active Biergarten theme",
toolbar: {
title: "Theme",
icon: "paintbrush",
dynamicTitle: true,
items: biergartenThemes.map((theme) => ({
value: theme.value,
title: theme.label,
})),
},
},
},
initialGlobals: {
theme: defaultThemeName,
},
decorators: [
(Story, context) => {
const theme = isBiergartenTheme(String(context.globals.theme))
? context.globals.theme
: defaultThemeName;
return createElement(
MemoryRouter,
undefined,
createElement(
"div",
{
"data-theme": theme,
className: "bg-base-200 p-6 text-base-content",
},
createElement("div", { className: "mx-auto max-w-7xl" }, createElement(Story)),
),
);
},
],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: "padded",
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: "todo",
},
},
};
export default preview;

View File

@@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -124,21 +124,21 @@ h6,
color-scheme: "dark";
/* Base — blackberry-stained barrel, dark purple-black */
--color-base-100: oklch(13% 0.022 295);
--color-base-200: oklch(17% 0.028 292);
--color-base-300: oklch(22% 0.032 290);
--color-base-100: oklch(13% 0.01 295);
--color-base-200: oklch(17% 0.013 292);
--color-base-300: oklch(22% 0.016 290);
--color-base-content: oklch(90% 0.014 300);
/* Primary — cassis berry purple */
--color-primary: oklch(52% 0.15 295);
--color-primary: oklch(52% 0.07 295);
--color-primary-content: oklch(97% 0.008 295);
/* Secondary — sour cherry */
--color-secondary: oklch(46% 0.11 10);
--color-secondary: oklch(46% 0.05 10);
--color-secondary-content: oklch(97% 0.006 10);
/* Accent — tart lime zest */
--color-accent: oklch(75% 0.1 130);
--color-accent: oklch(75% 0.045 130);
--color-accent-content: oklch(18% 0.04 130);
/* Neutral — deep blackened grape */
@@ -146,19 +146,19 @@ h6,
--color-neutral-content: oklch(88% 0.01 295);
/* Info — muted indigo */
--color-info: oklch(46% 0.065 250);
--color-info: oklch(46% 0.035 250);
--color-info-content: oklch(97% 0.006 250);
/* Success — dark elderberry green */
--color-success: oklch(50% 0.065 145);
--color-success: oklch(50% 0.035 145);
--color-success-content: oklch(97% 0.006 145);
/* Warning — sour apricot */
--color-warning: oklch(70% 0.1 65);
--color-warning: oklch(70% 0.05 65);
--color-warning-content: oklch(18% 0.03 65);
/* Error — kriek red */
--color-error: oklch(50% 0.13 22);
--color-error: oklch(50% 0.055 22);
--color-error-content: oklch(97% 0.006 22);
--radius-selector: 0.5rem;

View File

@@ -0,0 +1,41 @@
export type ThemeName =
| "biergarten-lager"
| "biergarten-stout"
| "biergarten-cassis"
| "biergarten-weizen";
export interface ThemeOption {
value: ThemeName;
label: string;
vibe: string;
}
export const defaultThemeName: ThemeName = "biergarten-lager";
export const themeStorageKey = "biergarten-theme";
export const biergartenThemes: ThemeOption[] = [
{
value: "biergarten-lager",
label: "Biergarten Lager",
vibe: "Muted parchment, mellow amber, daytime beer garden",
},
{
value: "biergarten-stout",
label: "Biergarten Stout",
vibe: "Charred barrel, deep roast, cozy evening cellar",
},
{
value: "biergarten-cassis",
label: "Biergarten Cassis",
vibe: "Blackberry barrel, sour berry dark, vivid night market",
},
{
value: "biergarten-weizen",
label: "Biergarten Weizen",
vibe: "Ultra-light young barley, green undertone, bright spring afternoon",
},
];
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
return biergartenThemes.some((theme) => theme.value === value);
}

View File

@@ -1,37 +1,13 @@
import { useEffect, useState } from "react";
import {
biergartenThemes,
defaultThemeName,
isBiergartenTheme,
themeStorageKey,
type ThemeName,
} from "../lib/themes";
import type { Route } from "./+types/theme";
interface ThemeOption {
value: "biergarten-lager" | "biergarten-stout" | "biergarten-cassis" | "biergarten-weizen";
label: string;
vibe: string;
}
const themeOptions: ThemeOption[] = [
{
value: "biergarten-lager",
label: "Biergarten Lager",
vibe: "Warm parchment, golden amber, daytime beer garden",
},
{
value: "biergarten-stout",
label: "Biergarten Stout",
vibe: "Charred barrel, deep roast, cozy evening cellar",
},
{
value: "biergarten-cassis",
label: "Biergarten Cassis",
vibe: "Blackberry barrel, sour kriek, dark berry night",
},
{
value: "biergarten-weizen",
label: "Biergarten Weizen",
vibe: "Hazy straw wheat, banana-clove, sunny afternoon",
},
];
const storageKey = "biergarten-theme";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Theme | The Biergarten App" },
@@ -42,23 +18,19 @@ export function meta({}: Route.MetaArgs) {
];
}
function isValidTheme(value: string | null): value is ThemeOption["value"] {
return themeOptions.some((theme) => theme.value === value);
}
function applyTheme(theme: ThemeOption["value"]) {
function applyTheme(theme: ThemeName) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(storageKey, theme);
localStorage.setItem(themeStorageKey, theme);
}
export default function ThemePage() {
const [selectedTheme, setSelectedTheme] = useState<ThemeOption["value"]>(() => {
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
if (typeof window === "undefined") {
return "biergarten-lager";
return defaultThemeName;
}
const savedTheme = localStorage.getItem(storageKey);
return isValidTheme(savedTheme) ? savedTheme : "biergarten-lager";
const savedTheme = localStorage.getItem(themeStorageKey);
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
});
useEffect(() => {
@@ -66,7 +38,7 @@ export default function ThemePage() {
}, [selectedTheme]);
const activeTheme =
themeOptions.find((theme) => theme.value === selectedTheme) ?? themeOptions[0];
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
return (
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
@@ -97,7 +69,7 @@ export default function ThemePage() {
role="radiogroup"
aria-label="Theme selector"
>
{themeOptions.map((theme) => {
{biergartenThemes.map((theme) => {
const checked = selectedTheme === theme.value;
return (

View File

@@ -1,14 +1,15 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import js from "@eslint/js";
import prettierConfig from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
export default tseslint.config({
ignores: ["build/**", "node_modules/**", ".react-router/**", "coverage/**"],
},
{
}, {
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
@@ -43,6 +44,4 @@ export default tseslint.config(
},
],
},
},
prettierConfig,
);
}, prettierConfig, storybook.configs["flat/recommended"]);

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,11 @@
"format": "prettier . --write",
"format:check": "prettier . --check",
"typegen": "react-router typegen",
"typecheck": "npm run typegen && tsc -p tsconfig.json"
"typecheck": "npm run typegen && tsc -p tsconfig.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test:storybook": "vitest run --project storybook",
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
@@ -28,23 +32,36 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^10.2.19",
"@storybook/addon-docs": "^10.2.19",
"@storybook/addon-onboarding": "^10.2.19",
"@storybook/addon-vitest": "^10.2.19",
"@storybook/react-vite": "^10.2.19",
"@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"daisyui": "^5.5.19",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-storybook": "^10.2.19",
"globals": "^17.4.0",
"playwright": "^1.58.2",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"storybook": "^10.2.19",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";
const port = process.env.STORYBOOK_PORT ?? "6006";
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: "./tests/playwright",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL,
trace: "on-first-retry",
},
webServer: {
command: `npm run storybook -- --ci --port ${port}`,
url: baseURL,
reuseExistingServer: true,
timeout: 120_000,
},
});

View File

@@ -0,0 +1,41 @@
import { Meta } from "@storybook/addon-docs/blocks";
<Meta title="Docs/Storybook" />
# Biergarten Storybook
This Storybook is scoped to real app UI only:
- `SubmitButton`
- `FormField`
- `Navbar`
- `Themes` gallery
## Theme workflow
Use the toolbar theme switcher to preview all Biergarten themes:
- `biergarten-lager`
- `biergarten-stout`
- `biergarten-cassis`
- `biergarten-weizen`
Stories are rendered inside a decorator that sets `data-theme`, so tokens and components reflect production styling.
## Tests
Two layers are enabled:
1. Story `play` tests (Storybook test runner / Vitest addon)
2. Browser checks with Playwright against Storybook iframe routes
Run:
- `npm run build-storybook -- --test`
- `npm run test:storybook:playwright`
## Rules
- Add stories only for reusable app components.
- Prefer semantic classes (`bg-primary`, `text-base-content`, etc.).
- Keep stories state-focused and minimal.

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import FormField from "../app/components/forms/FormField";
const meta = {
title: "Forms/FormField",
component: FormField,
tags: ["autodocs"],
args: {
id: "email",
name: "email",
type: "email",
label: "Email address",
placeholder: "you@example.com",
hint: "We only use this to manage your account.",
},
parameters: {
layout: "centered",
},
render: (args) => (
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
<FormField {...args} />
</div>
),
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithHint: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
},
};
export const WithError: Story = {
args: {
error: "Please enter a valid email address.",
hint: undefined,
"aria-invalid": true,
defaultValue: "not-an-email",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute("aria-invalid", "true");
},
};
export const PasswordField: Story = {
args: {
id: "password",
name: "password",
type: "password",
label: "Password",
placeholder: "Enter a strong password",
hint: "Use 12 or more characters.",
},
};

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import Navbar from "../app/components/Navbar";
const meta = {
title: "Navigation/Navbar",
component: Navbar,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof Navbar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Guest: Story = {
args: {
auth: null,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("link", { name: /the biergarten app/i })).toBeInTheDocument();
await expect(canvas.getByRole("link", { name: /login/i })).toBeInTheDocument();
await expect(canvas.getByRole("link", { name: /register user/i })).toBeInTheDocument();
},
};
export const Authenticated: Story = {
args: {
auth: {
username: "Hans",
accessToken: "access-token",
refreshToken: "refresh-token",
userAccountId: "user-1",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const userButton = canvas.getByRole("button", { name: /hans/i });
await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton);
await expect(canvas.getByRole("menuitem", { name: /dashboard/i })).toBeInTheDocument();
await expect(canvas.getByRole("menuitem", { name: /logout/i })).toBeInTheDocument();
},
};
export const MobileMenu: Story = {
args: {
auth: null,
},
parameters: {
viewport: {
defaultViewport: "mobile1",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /toggle navigation/i }));
await expect(canvas.getByRole("link", { name: /beer styles/i })).toBeInTheDocument();
},
};

View File

@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import SubmitButton from "../app/components/forms/SubmitButton";
const meta = {
title: "Forms/SubmitButton",
component: SubmitButton,
tags: ["autodocs"],
args: {
idleText: "Save changes",
submittingText: "Saving changes",
isSubmitting: false,
},
parameters: {
layout: "centered",
},
} satisfies Meta<typeof SubmitButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: /save changes/i })).toBeEnabled();
},
};
export const Submitting: Story = {
args: {
isSubmitting: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: /saving changes/i })).toBeDisabled();
},
};
export const CustomWidth: Story = {
args: {
className: "btn btn-secondary min-w-64",
idleText: "Register user",
submittingText: "Registering user",
},
};

View File

@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { biergartenThemes } from "../app/lib/themes";
function ThemeSwatch({ label, className }: { label: string; className: string }) {
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
}
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
return (
<section
data-theme={value}
className="rounded-box border border-base-300 bg-base-100 shadow-lg"
>
<div className="space-y-4 p-5">
<div className="space-y-1">
<h2 className="text-2xl font-bold">{label}</h2>
<p className="text-sm text-base-content/70">{vibe}</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" />
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
</div>
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary btn-sm">Primary</button>
<button className="btn btn-secondary btn-sm">Secondary</button>
<button className="btn btn-outline btn-sm">Outline</button>
</div>
<div role="alert" className="alert alert-info alert-soft">
<span>Semantic tokens stay stable while the atmosphere changes.</span>
</div>
</div>
</section>
);
}
const meta = {
title: "Themes/Biergarten Themes",
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
render: () => (
<div className="grid gap-6 p-6 lg:grid-cols-2">
{biergartenThemes.map((theme) => (
<ThemePanel key={theme.value} {...theme} />
))}
</div>
),
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const Gallery: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
for (const theme of biergartenThemes) {
await expect(canvas.getByRole("heading", { name: theme.label })).toBeInTheDocument();
}
},
};

View File

@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
const themes = [
"biergarten-lager",
"biergarten-stout",
"biergarten-cassis",
"biergarten-weizen",
] as const;
test.describe("storybook component coverage", () => {
for (const theme of themes) {
test(`SubmitButton idle renders in ${theme}`, async ({ page }) => {
await page.goto(`/iframe.html?id=forms-submitbutton--idle&globals=theme:${theme}`);
await expect(page.getByRole("button", { name: /save changes/i })).toBeVisible();
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
});
test(`FormField error renders in ${theme}`, async ({ page }) => {
await page.goto(`/iframe.html?id=forms-formfield--with-error&globals=theme:${theme}`);
await expect(page.getByLabel("Email address")).toBeVisible();
await expect(page.getByText(/valid email address/i)).toBeVisible();
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
});
test(`Navbar guest renders in ${theme}`, async ({ page }) => {
await page.goto(`/iframe.html?id=navigation-navbar--guest&globals=theme:${theme}`);
await expect(page.getByRole("link", { name: /the biergarten app/i })).toBeVisible();
await expect(page.getByRole("link", { name: /^login$/i })).toBeVisible();
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
});
}
test("Navbar authenticated state renders", async ({ page }) => {
await page.goto(
`/iframe.html?id=navigation-navbar--authenticated&globals=theme:biergarten-stout`,
);
await expect(page.getByRole("button", { name: /hans/i })).toBeVisible();
});
test("Theme gallery shows all themes", async ({ page }) => {
await page.goto(
`/iframe.html?id=themes-biergarten-themes--gallery&globals=theme:biergarten-lager`,
);
await expect(page.getByRole("heading", { name: "Biergarten Lager" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Biergarten Stout" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Biergarten Cassis" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Biergarten Weizen" })).toBeVisible();
});
});

View File

@@ -1,9 +1,47 @@
/// <reference types="vitest/config" />
import { reactRouter } from "@react-router/dev/vite";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import { playwright } from "@vitest/browser-playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const dirname =
typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const isStorybook =
process.env.STORYBOOK === "true" || process.argv.some((arg) => arg.includes("storybook"));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
plugins: [reactRouter()],
plugins: isStorybook ? [] : [reactRouter()],
resolve: {
dedupe: ["react", "react-dom"],
},
test: {
projects: [
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, ".storybook"),
}),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [
{
browser: "chromium",
},
],
},
setupFiles: [".storybook/vitest.setup.ts"],
},
},
],
},
});

1
src/Website/vitest.shims.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />