-
-
-
-
Register
-
Create your Biergarten account
-
+ return (
+
+
+
+
+
Register
+
Create your Biergarten account
+
- {actionData?.error && (
-
-
{actionData.error}
+ {actionData?.error && (
+
+ {actionData.error}
+
+ )}
+
+
+
+
Already have an account?
+
+
+
+ Sign in
+
+
+ ← Back to home
+
+
- )}
-
-
-
-
Already have an account?
-
-
-
- Sign in
-
-
- ← Back to home
-
-
-
+
-
- );
+ );
}
diff --git a/src/Website/app/routes/theme.tsx b/src/Website/app/routes/theme.tsx
index 115e5fd..03edbf6 100644
--- a/src/Website/app/routes/theme.tsx
+++ b/src/Website/app/routes/theme.tsx
@@ -1,158 +1,169 @@
-import { useEffect, useState } from "react";
+import { useEffect, useState } from 'react';
import {
- biergartenThemes,
- defaultThemeName,
- isBiergartenTheme,
- themeStorageKey,
- type ThemeName,
-} from "../lib/themes";
-import type { Route } from "./+types/theme";
+ biergartenThemes,
+ defaultThemeName,
+ isBiergartenTheme,
+ themeStorageKey,
+ type ThemeName,
+} from '../lib/themes';
+import type { Route } from './+types/theme';
export function meta({}: Route.MetaArgs) {
- return [
- { title: "Theme | The Biergarten App" },
- {
- name: "description",
- content: "Theme guide and switcher for The Biergarten App",
- },
- ];
+ return [
+ { title: 'Theme | The Biergarten App' },
+ {
+ name: 'description',
+ content: 'Theme guide and switcher for The Biergarten App',
+ },
+ ];
}
function applyTheme(theme: ThemeName) {
- document.documentElement.setAttribute("data-theme", theme);
- localStorage.setItem(themeStorageKey, theme);
+ document.documentElement.setAttribute('data-theme', theme);
+ localStorage.setItem(themeStorageKey, theme);
}
export default function ThemePage() {
- const [selectedTheme, setSelectedTheme] = useState
(() => {
- if (typeof window === "undefined") {
- return defaultThemeName;
- }
+ const [selectedTheme, setSelectedTheme] = useState(() => {
+ if (typeof window === 'undefined') {
+ return defaultThemeName;
+ }
- const savedTheme = localStorage.getItem(themeStorageKey);
- return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
- });
+ const savedTheme = localStorage.getItem(themeStorageKey);
+ return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
+ });
- useEffect(() => {
- applyTheme(selectedTheme);
- }, [selectedTheme]);
+ useEffect(() => {
+ applyTheme(selectedTheme);
+ }, [selectedTheme]);
- const activeTheme =
- biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
+ const activeTheme =
+ biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
- return (
-
-
-
-
-
Theme Guide
-
- Four themes, four moods — from the sun-bleached clarity of a Weizen afternoon to the
- deep berry dark of a Cassis barrel. Every theme shares the same semantic token
- structure so components stay consistent while the atmosphere shifts completely.
-
-
-
- Active theme: {activeTheme.label} — {activeTheme.vibe}
-
-
-
-
+ return (
+
+
+
+
+
Theme Guide
+
+ Four themes, four moods — from the sun-bleached clarity of a Weizen afternoon
+ to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
+ token structure so components stay consistent while the atmosphere shifts
+ completely.
+
+
+
+ Active theme: {activeTheme.label} — {activeTheme.vibe}
+
+
+
+
-
-
-
Theme switcher
-
Pick a theme and preview it immediately.
+
+
+
Theme switcher
+
Pick a theme and preview it immediately.
-
- {biergartenThemes.map((theme) => {
- const checked = selectedTheme === theme.value;
-
- return (
-
- {
- setSelectedTheme(theme.value);
- applyTheme(theme.value);
- }}
- />
- {theme.label}
-
- );
- })}
-
-
-
+ {biergartenThemes.map((theme) => {
+ const checked = selectedTheme === theme.value;
-
-
-
-
Brand colors
-
-
Primary
-
Secondary
-
Accent
-
Neutral
-
-
-
+ return (
+
+ {
+ setSelectedTheme(theme.value);
+ applyTheme(theme.value);
+ }}
+ />
+ {theme.label}
+
+ );
+ })}
+
+
+
-
-
-
Status colors
-
-
Info
-
Success
-
Warning
-
Error
-
-
-
+
+
+
+
Brand colors
+
+
+ Primary
+
+
+ Secondary
+
+
Accent
+
+ Neutral
+
+
+
+
-
-
-
Core style outline
-
- Warm serif headings paired with clear sans-serif body text
- Rounded, tactile surfaces with subtle depth and grain
- Semantic token usage to keep contrast consistent in both themes
-
-
-
-
+
+
+
Status colors
+
+
Info
+
+ Success
+
+
+ Warning
+
+
Error
+
+
+
-
-
-
Component preview
-
- Primary action
- Secondary action
- Accent action
- Ghost action
-
-
-
- Theme tokens are applied consistently.
-
-
- Use semantic colors over hard-coded color values.
-
-
-
-
-
-
- );
+
+
+
Core style outline
+
+ Warm serif headings paired with clear sans-serif body text
+ Rounded, tactile surfaces with subtle depth and grain
+ Semantic token usage to keep contrast consistent in both themes
+
+
+
+
+
+
+
+
Component preview
+
+ Primary action
+ Secondary action
+ Accent action
+ Ghost action
+
+
+
+ Theme tokens are applied consistently.
+
+
+ Use semantic colors over hard-coded color values.
+
+
+
+
+
+
+ );
}
diff --git a/src/Website/eslint.config.mjs b/src/Website/eslint.config.mjs
index 613a70b..e039543 100644
--- a/src/Website/eslint.config.mjs
+++ b/src/Website/eslint.config.mjs
@@ -1,47 +1,52 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
-import storybook from "eslint-plugin-storybook";
+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";
+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({
- ignores: ["build/**", "node_modules/**", ".react-router/**", "coverage/**"],
-}, {
- files: ["**/*.{ts,tsx}"],
- extends: [
- js.configs.recommended,
- ...tseslint.configs.recommended,
- ...tseslint.configs.stylistic,
- ],
- languageOptions: {
- ecmaVersion: "latest",
- sourceType: "module",
- globals: {
- ...globals.browser,
- ...globals.node,
- },
- parserOptions: {
- ecmaFeatures: {
- jsx: true,
+export default tseslint.config(
+ {
+ ignores: ['build/**', 'node_modules/**', '.react-router/**', 'coverage/**'],
+ },
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ ...tseslint.configs.stylistic,
+ ],
+ languageOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
},
- },
- },
- plugins: {
- "react-hooks": reactHooks,
- },
- rules: {
- ...reactHooks.configs.recommended.rules,
- "no-empty-pattern": "off",
- "@typescript-eslint/no-unused-vars": [
- "warn",
- {
- argsIgnorePattern: "^_",
- varsIgnorePattern: "^_",
- caughtErrorsIgnorePattern: "^_",
+ plugins: {
+ 'react-hooks': reactHooks,
},
- ],
- },
-}, prettierConfig, storybook.configs["flat/recommended"]);
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'no-empty-pattern': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ },
+ ],
+ },
+ },
+ prettierConfig,
+ storybook.configs['flat/recommended'],
+);
diff --git a/src/Website/package.json b/src/Website/package.json
index 6985f32..59deb6a 100644
--- a/src/Website/package.json
+++ b/src/Website/package.json
@@ -1,68 +1,68 @@
{
- "name": "biergarten-website",
- "type": "module",
- "version": "0.0.0",
- "scripts": {
- "dev": "react-router dev",
- "build": "react-router build",
- "start": "NODE_ENV=production node ./build/server/index.js",
- "lint": "eslint .",
- "lint:fix": "eslint . --fix",
- "format": "prettier . --write",
- "format:check": "prettier . --check",
- "typegen": "react-router typegen",
- "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",
- "@hookform/resolvers": "^5.2.2",
- "@react-router/dev": "^7.13.1",
- "@react-router/express": "^7.13.1",
- "@react-router/node": "^7.13.1",
- "iconoir-react": "^7.11.0",
- "isbot": "^5.1.36",
- "react": "^19.2.4",
- "react-dom": "^19.2.4",
- "react-hook-form": "^7.71.2",
- "react-hot-toast": "^2.6.0",
- "react-router": "^7.13.1",
- "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",
- "vitest": "^4.1.0"
- }
+ "name": "biergarten-website",
+ "type": "module",
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "react-router dev",
+ "build": "react-router build",
+ "start": "NODE_ENV=production node ./build/server/index.js",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "format": "prettier . --write",
+ "format:check": "prettier . --check",
+ "typegen": "react-router typegen",
+ "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",
+ "@hookform/resolvers": "^5.2.2",
+ "@react-router/dev": "^7.13.1",
+ "@react-router/express": "^7.13.1",
+ "@react-router/node": "^7.13.1",
+ "iconoir-react": "^7.11.0",
+ "isbot": "^5.1.36",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-hook-form": "^7.71.2",
+ "react-hot-toast": "^2.6.0",
+ "react-router": "^7.13.1",
+ "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",
+ "vitest": "^4.1.0"
+ }
}
diff --git a/src/Website/playwright.storybook.config.ts b/src/Website/playwright.storybook.config.ts
index 9ac326a..3058468 100644
--- a/src/Website/playwright.storybook.config.ts
+++ b/src/Website/playwright.storybook.config.ts
@@ -1,20 +1,20 @@
-import { defineConfig } from "@playwright/test";
+import { defineConfig } from '@playwright/test';
-const port = process.env.STORYBOOK_PORT ?? "6006";
+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,
- },
+ 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,
+ },
});
diff --git a/src/Website/postcss.config.js b/src/Website/postcss.config.js
index c2ddf74..8998e7d 100644
--- a/src/Website/postcss.config.js
+++ b/src/Website/postcss.config.js
@@ -1,5 +1,5 @@
export default {
- plugins: {
- "@tailwindcss/postcss": {},
- },
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
};
diff --git a/src/Website/react-router.config.ts b/src/Website/react-router.config.ts
index e45e273..1dc6cc4 100644
--- a/src/Website/react-router.config.ts
+++ b/src/Website/react-router.config.ts
@@ -1,5 +1,5 @@
-import type { Config } from "@react-router/dev/config";
+import type { Config } from '@react-router/dev/config';
export default {
- ssr: true,
+ ssr: true,
} satisfies Config;
diff --git a/src/Website/stories/Configure.mdx b/src/Website/stories/Configure.mdx
index d4b8e5f..35948c2 100644
--- a/src/Website/stories/Configure.mdx
+++ b/src/Website/stories/Configure.mdx
@@ -1,4 +1,4 @@
-import { Meta } from "@storybook/addon-docs/blocks";
+import { Meta } from '@storybook/addon-docs/blocks';
diff --git a/src/Website/stories/FormField.stories.tsx b/src/Website/stories/FormField.stories.tsx
index 293602b..b35e5b1 100644
--- a/src/Website/stories/FormField.stories.tsx
+++ b/src/Website/stories/FormField.stories.tsx
@@ -1,61 +1,68 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, within } from "storybook/test";
-import FormField from "../app/components/forms/FormField";
+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",
- },
- render: (args) => (
-
-
-
- ),
+ 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) => (
+
+
+
+ ),
} satisfies Meta
;
export default meta;
type Story = StoryObj;
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();
- },
+ 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");
- },
+ 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.",
- },
+ args: {
+ id: 'password',
+ name: 'password',
+ type: 'password',
+ label: 'Password',
+ placeholder: 'Enter a strong password',
+ hint: 'Use 12 or more characters.',
+ },
};
diff --git a/src/Website/stories/Navbar.stories.tsx b/src/Website/stories/Navbar.stories.tsx
index bbc412c..aa9ff02 100644
--- a/src/Website/stories/Navbar.stories.tsx
+++ b/src/Website/stories/Navbar.stories.tsx
@@ -1,62 +1,69 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, userEvent, within } from "storybook/test";
-import Navbar from "../app/components/Navbar";
+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",
- },
+ title: 'Navigation/Navbar',
+ component: Navbar,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: navbarDescription,
+ },
+ },
+ },
} satisfies Meta;
export default meta;
type Story = StoryObj;
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();
- },
+ 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();
- },
+ 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();
- },
+ 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();
+ },
};
diff --git a/src/Website/stories/SubmitButton.stories.tsx b/src/Website/stories/SubmitButton.stories.tsx
index 6649e96..15796da 100644
--- a/src/Website/stories/SubmitButton.stories.tsx
+++ b/src/Website/stories/SubmitButton.stories.tsx
@@ -1,45 +1,52 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, within } from "storybook/test";
-import SubmitButton from "../app/components/forms/SubmitButton";
+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",
- },
+ 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;
export default meta;
type Story = StoryObj;
export const Idle: Story = {
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- await expect(canvas.getByRole("button", { name: /save changes/i })).toBeEnabled();
- },
+ 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();
- },
+ 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",
- },
+ args: {
+ className: 'btn btn-secondary min-w-64',
+ idleText: 'Register user',
+ submittingText: 'Registering user',
+ },
};
diff --git a/src/Website/stories/Themes.stories.tsx b/src/Website/stories/Themes.stories.tsx
index 6fb0d4b..78e9dff 100644
--- a/src/Website/stories/Themes.stories.tsx
+++ b/src/Website/stories/Themes.stories.tsx
@@ -1,67 +1,157 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, within } from "storybook/test";
-import { biergartenThemes } from "../app/lib/themes";
+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 {label}
;
+ return {label}
;
+}
+
+/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
+function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function TextTokenSample({
+ label,
+ background,
+ text,
+}: {
+ label: string;
+ background: string;
+ text: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ Secondary copy, placeholders, and helper text.
+
+
+ );
}
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
- return (
-
-
-
+ return (
+
+
+
-
-
-
-
-
-
+ {/* Core palette */}
+
-
- Primary
- Secondary
- Outline
-
+ {/* Status tokens */}
+
+
+ Status
+
+
+
+
+
+
+
+
-
- Semantic tokens stay stable while the atmosphere changes.
-
-
-
- );
+ {/* Content tokens (custom) */}
+
+
+ Content
+
+
+
+
+
+
+
+
+
+
+
+
+ Primary
+ Secondary
+ Outline
+
+
+
+ Semantic tokens stay stable while the atmosphere changes.
+
+
+
+ );
}
const meta = {
- title: "Themes/Biergarten Themes",
- parameters: {
- layout: "fullscreen",
- },
- tags: ["autodocs"],
- render: () => (
-
- {biergartenThemes.map((theme) => (
-
- ))}
-
- ),
+ title: 'Themes/Biergarten Themes',
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: themesDescription,
+ },
+ },
+ },
+ tags: ['autodocs'],
+ render: () => (
+
+ {biergartenThemes.map((theme) => (
+
+ ))}
+
+ ),
} satisfies Meta;
export default meta;
type Story = StoryObj;
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();
- }
- },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ for (const theme of biergartenThemes) {
+ await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
+ }
+ },
};
diff --git a/src/Website/stories/Toast.stories.tsx b/src/Website/stories/Toast.stories.tsx
index 08cccb0..fc004a7 100644
--- a/src/Website/stories/Toast.stories.tsx
+++ b/src/Website/stories/Toast.stories.tsx
@@ -1,63 +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 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";
+ 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 (
-
-
-
-
-
Toast demo
-
Use these actions to preview toast styles.
-
- showSuccessToast("Saved successfully")}
- >
- Success
-
- showErrorToast("Something went wrong")}
- >
- Error
-
- showInfoToast("Heads up: check your email")}
- >
- Info
-
-
- Dismiss all
-
-
-
+ return (
+
+
+
+
+
Toast demo
+
+ Use these actions to preview toast styles.
+
+
+ showSuccessToast('Saved successfully')}
+ >
+ Success
+
+ showErrorToast('Something went wrong')}
+ >
+ Error
+
+ showInfoToast('Heads up: check your email')}
+ >
+ Info
+
+
+ Dismiss all
+
+
+
+
-
- );
+ );
}
const meta = {
- title: "Feedback/Toast",
- component: ToastDemo,
- tags: ["autodocs"],
+ title: 'Feedback/Toast',
+ component: ToastDemo,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component: toastDescription,
+ },
+ },
+ },
} satisfies Meta
;
export default meta;
type Story = StoryObj;
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();
- },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button', { name: /success/i }));
+ await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
+ },
};
diff --git a/src/Website/tailwind.config.js b/src/Website/tailwind.config.js
index e9ac06b..71c098a 100644
--- a/src/Website/tailwind.config.js
+++ b/src/Website/tailwind.config.js
@@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
- content: ["./app/**/*.{ts,tsx}"],
- theme: {
- extend: {},
- },
- plugins: [require("daisyui")],
+ content: ['./app/**/*.{ts,tsx}'],
+ theme: {
+ extend: {},
+ },
+ plugins: [require('daisyui')],
};
diff --git a/src/Website/tests/playwright/storybook.components.spec.ts b/src/Website/tests/playwright/storybook.components.spec.ts
index 257e13d..0700440 100644
--- a/src/Website/tests/playwright/storybook.components.spec.ts
+++ b/src/Website/tests/playwright/storybook.components.spec.ts
@@ -1,49 +1,49 @@
-import { expect, test } from "@playwright/test";
+import { expect, test } from '@playwright/test';
const themes = [
- "biergarten-lager",
- "biergarten-stout",
- "biergarten-cassis",
- "biergarten-weizen",
+ '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.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(`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 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('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();
- });
+ 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();
+ });
});
diff --git a/src/Website/tsconfig.json b/src/Website/tsconfig.json
index 931afce..84ca439 100644
--- a/src/Website/tsconfig.json
+++ b/src/Website/tsconfig.json
@@ -1,19 +1,19 @@
{
- "compilerOptions": {
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "lib": ["DOM", "DOM.Iterable", "ES2022"],
- "moduleResolution": "bundler",
- "module": "ESNext",
- "noEmit": true,
- "rootDirs": [".", "./.react-router/types"],
- "resolveJsonModule": true,
- "types": ["node", "vite/client"],
- "target": "ES2022",
- "skipLibCheck": true
- },
- "include": ["app", ".react-router/types/**/*", "react-router.config.ts"],
- "exclude": ["node_modules"]
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "moduleResolution": "bundler",
+ "module": "ESNext",
+ "noEmit": true,
+ "rootDirs": [".", "./.react-router/types"],
+ "resolveJsonModule": true,
+ "types": ["node", "vite/client"],
+ "target": "ES2022",
+ "skipLibCheck": true
+ },
+ "include": ["app", ".react-router/types/**/*", "react-router.config.ts"],
+ "exclude": ["node_modules"]
}
diff --git a/src/Website/vite.config.ts b/src/Website/vite.config.ts
index 5ad51f8..44f0ed0 100644
--- a/src/Website/vite.config.ts
+++ b/src/Website/vite.config.ts
@@ -1,47 +1,47 @@
///
-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";
+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));
+ typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const isStorybook =
- process.env.STORYBOOK === "true" || process.argv.some((arg) => arg.includes("storybook"));
+ 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: 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",
- },
+ 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'),
+ }),
],
- },
- setupFiles: [".storybook/vitest.setup.ts"],
- },
- },
- ],
- },
+ test: {
+ name: 'storybook',
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright({}),
+ instances: [
+ {
+ browser: 'chromium',
+ },
+ ],
+ },
+ setupFiles: ['.storybook/vitest.setup.ts'],
+ },
+ },
+ ],
+ },
});
diff --git a/src/Website/vitest.shims.d.ts b/src/Website/vitest.shims.d.ts
index 7782f28..03b1801 100644
--- a/src/Website/vitest.shims.d.ts
+++ b/src/Website/vitest.shims.d.ts
@@ -1 +1 @@
-///
\ No newline at end of file
+///