Move website directory

This commit is contained in:
Aaron Po
2026-04-27 16:00:11 -04:00
parent 189bce040b
commit 5a21589029
58 changed files with 0 additions and 0 deletions

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,68 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import FormField from '../app/components/forms/FormField';
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
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',
docs: {
description: {
component: formFieldDescription,
},
},
},
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,69 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import Navbar from '../app/components/Navbar';
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
const meta = {
title: 'Navigation/Navbar',
component: Navbar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: navbarDescription,
},
},
},
} 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,52 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import SubmitButton from '../app/components/forms/SubmitButton';
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
const meta = {
title: 'Forms/SubmitButton',
component: SubmitButton,
tags: ['autodocs'],
args: {
idleText: 'Save changes',
submittingText: 'Saving changes',
isSubmitting: false,
},
parameters: {
layout: 'centered',
docs: {
description: {
component: submitButtonDescription,
},
},
},
} 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,157 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { biergartenThemes } from '../app/lib/themes';
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
function ThemeSwatch({ label, className }: { label: string; className: string }) {
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
}
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
return (
<div
className="rounded-box p-3 text-sm font-medium"
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
>
{label}
</div>
);
}
function TextTokenSample({
label,
background,
text,
}: {
label: string;
background: string;
text: string;
}) {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
Secondary copy, placeholders, and helper text.
</p>
</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>
{/* Core palette */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Core
</p>
<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>
{/* Status tokens */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Status
</p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
<ThemeSwatch label="Info" className="bg-info text-info-content" />
<ThemeSwatch label="Success" className="bg-success text-success-content" />
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
<ThemeSwatch label="Error" className="bg-error text-error-content" />
</div>
</div>
{/* Content tokens (custom) */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Content
</p>
<div className="grid gap-2 sm:grid-cols-3">
<CssVarSwatch
label="Surface"
bg="--color-surface"
color="--color-surface-content"
/>
<TextTokenSample
label="Muted on Base"
background="--color-base-100"
text="--color-muted"
/>
<CssVarSwatch
label="Highlight"
bg="--color-highlight"
color="--color-highlight-content"
/>
</div>
<div className="mt-2">
<TextTokenSample
label="Muted on Surface"
background="--color-surface"
text="--color-muted"
/>
</div>
</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-success alert-soft">
<span>Semantic tokens stay stable while the atmosphere changes.</span>
</div>
</div>
</section>
);
}
const meta = {
title: 'Themes/Biergarten Themes',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: themesDescription,
},
},
},
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,74 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, within } from 'storybook/test';
import ToastProvider from '../app/components/toast/ToastProvider';
import {
dismissToasts,
showErrorToast,
showInfoToast,
showSuccessToast,
} from '../app/components/toast/toast';
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
function ToastDemo() {
return (
<div className="space-y-4">
<ToastProvider />
<div className="card border border-base-300 bg-base-100 shadow-md">
<div className="card-body">
<h2 className="card-title">Toast demo</h2>
<p className="text-sm text-base-content/70">
Use these actions to preview toast styles.
</p>
<div className="flex flex-wrap gap-2">
<button
className="btn btn-success btn-sm"
onClick={() => showSuccessToast('Saved successfully')}
>
Success
</button>
<button
className="btn btn-error btn-sm"
onClick={() => showErrorToast('Something went wrong')}
>
Error
</button>
<button
className="btn btn-info btn-sm"
onClick={() => showInfoToast('Heads up: check your email')}
>
Info
</button>
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
Dismiss all
</button>
</div>
</div>
</div>
</div>
);
}
const meta = {
title: 'Feedback/Toast',
component: ToastDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: toastDescription,
},
},
},
} satisfies Meta<typeof ToastDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /success/i }));
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
},
};