Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
# console-panel
## Purpose
`ConsolePanel` intercepts `window.console.log`, `warn`, and `error` on mount and renders
each captured message in an in-page log with level badges. It restores the original console
methods on unmount. The panel is mounted on every library page so developers and Playwright
tests can see exactly what console output the library produces during a demo interaction.
This component mirrors exactly what the Playwright `attachConsoleMonitor` captures, making
it easy to reproduce CI failures locally.
## Features
- Intercepts `console.log`, `console.warn`, `console.error` via method replacement
- Restores originals on React unmount (cleanup effect)
- Renders each entry with level badge: [INFO], [WARN], [ERROR]
- Objects serialised via JSON.stringify; primitives via String()
- `data-testid="console-panel"` for Playwright targeting
- Scrollable: max-h-48 with overflow-y-auto
## Usage: Basic Example
```tsx
import { ConsolePanel } from "./console-panel.js";
<ConsolePanel />
```
## Usage: Advanced Example
```tsx
// E2E -- trigger demo and verify console panel shows output
await page.click('[data-testid="encrypt-button"]');
await page.waitForSelector('[data-testid="console-panel"] >> text=encrypt');
```
## API Reference
### `ConsolePanel(): JSX.Element`
No props. Side-effectful: replaces console methods on mount, restores on unmount.
`data-testid="console-panel"`.
## Implementation Notes
Direct method replacement is used rather than a Proxy to avoid infinite recursion
hazards when the console itself tries to log during render. The `originals` ref captures
methods at mount time, ensuring remount (React StrictMode double-invocation) always
restores the methods that were in place when the component first mounted.

View File

@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ConsolePanel } from "./console-panel.js";
const meta: Meta<typeof ConsolePanel> = {
component: ConsolePanel,
title: "Layout/ConsolePanel",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ConsolePanel>;
export const Default: Story = {};
export const WithMessages: Story = {
play: async () => {
console.log("Demo log message");
console.warn("Demo warning");
console.error("Demo error");
},
};
export const Playground: Story = {};

View File

@@ -0,0 +1,73 @@
import React from "react";
import { useEffect, useRef, useState } from "react";
interface ConsoleEntry {
level: "log" | "warn" | "error";
message: string;
timestamp: number;
}
const LEVEL_COLORS: Record<ConsoleEntry["level"], string> = {
log: "text-gray-600 dark:text-gray-400",
warn: "text-yellow-600 dark:text-yellow-400",
error: "text-red-600 dark:text-red-400",
};
const LEVEL_BADGES: Record<ConsoleEntry["level"], string> = {
log: "INFO",
warn: "WARN",
error: "ERROR",
};
export function ConsolePanel(): React.JSX.Element {
const [entries, setEntries] = useState<ConsoleEntry[]>([]);
const originals = useRef<{
log: typeof console.log;
warn: typeof console.warn;
error: typeof console.error;
} | null>(null);
useEffect(() => {
originals.current = { log: console.log, warn: console.warn, error: console.error };
function capture(level: ConsoleEntry["level"]) {
return (...args: unknown[]): void => {
const message = args
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
.join(" ");
setEntries((prev) => [...prev, { level, message, timestamp: Date.now() }]);
originals.current?.[level](...(args as Parameters<typeof console.log>));
};
}
console.log = capture("log");
console.warn = capture("warn");
console.error = capture("error");
return () => {
if (originals.current) {
console.log = originals.current.log;
console.warn = originals.current.warn;
console.error = originals.current.error;
}
};
}, []);
return (
<div
data-testid="console-panel"
className="mt-4 max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-900"
>
{entries.length === 0 ? (
<span className="text-gray-400">No console output</span>
) : (
entries.map((entry, i) => (
<div key={`${entry.timestamp}-${i}`} className={LEVEL_COLORS[entry.level]}>
<span className="mr-2 font-bold">[{LEVEL_BADGES[entry.level]}]</span>
<span>{entry.message}</span>
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
# header
## Purpose
`Header` is the top-level application bar for the testbed. It displays the app title on the
left and renders `LocaleSwitcher` and `ThemeToggle` on the right. It is mounted above the
sidebar/main split in `App` so it is visible on every route.
The `data-testid="header"` attribute is consumed by E2E tests that verify the presence of
header controls on every page load.
## Features
- Fixed height (h-12); does not scroll
- Title on left, controls (LocaleSwitcher + ThemeToggle) on right
- Composes control components -- no inline control logic
- `data-testid="header"` for Playwright
## Usage: Basic Example
```tsx
import { Header } from "./header.js";
<Header />
```
## Usage: Advanced Example
```tsx
// E2E -- verify header present on every route
for (const route of routes) {
await page.goto(route.path);
await expect(page.locator('[data-testid="header"]')).toBeVisible();
}
```
## API Reference
### `Header(): JSX.Element`
No props.
## Implementation Notes
`Header` is a pure layout compositor. All state lives in `LocaleSwitcher` and `ThemeToggle`.
This keeps `Header` trivially testable in Storybook without mock setup.

View File

@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { Header } from "./header.js";
const meta: Meta<typeof Header> = {
component: Header,
title: "Layout/Header",
tags: ["autodocs"],
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof Header>;
export const Default: Story = {};
export const Playground: Story = {};

View File

@@ -0,0 +1,20 @@
import React from "react";
import { LocaleSwitcher } from "./locale-switcher.js";
import { ThemeToggle } from "./theme-toggle.js";
export function Header(): React.JSX.Element {
return (
<header
data-testid="header"
className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-700 dark:bg-gray-900"
>
<span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
Suaveplan Testbed
</span>
<div className="flex items-center gap-2">
<LocaleSwitcher />
<ThemeToggle />
</div>
</header>
);
}

View File

@@ -0,0 +1,5 @@
export { ConsolePanel } from "./console-panel.js";
export { Header } from "./header.js";
export { LocaleSwitcher } from "./locale-switcher.js";
export { Sidebar } from "./sidebar.js";
export { ThemeToggle } from "./theme-toggle.js";

View File

@@ -0,0 +1,47 @@
# locale-switcher
## Purpose
`LocaleSwitcher` renders a `<select>` allowing the user to switch between the four supported
locales (`en`, `fr`, `de`, `ja`). The selected locale is persisted to `localStorage["locale"]`
and applied as a `lang` attribute on `<html>`. On first render it reads the stored preference,
defaulting to `"en"`.
The locale switcher is used by baseline E2E specs to verify every library page renders
correctly in all four locales. It is placed in the `Header` so it is accessible on every route.
## Features
- Supports four locales: en, fr, de, ja
- Persists selection to `localStorage["locale"]`
- Sets `document.documentElement.lang` on every change
- `data-testid="locale-switcher"` for Playwright targeting
- Runtime validation guards against DOM-injected invalid values
## Usage: Basic Example
```tsx
import { LocaleSwitcher } from "./locale-switcher.js";
<LocaleSwitcher />
```
## Usage: Advanced Example
```tsx
// E2E -- switch to French and verify lang attribute
await page.selectOption('[data-testid="locale-switcher"]', "fr");
await page.reload();
expect(await page.getAttribute("html", "lang")).toBe("fr");
```
## API Reference
### `LocaleSwitcher(): JSX.Element`
No props. Reads and writes `localStorage["locale"]`. Sets `document.documentElement.lang`.
## Implementation Notes
The `LOCALES` tuple is `as const` to produce a literal union type. Runtime validation in
`handleChange` checks membership before calling `setLocale`, preventing invalid locale strings
from reaching `localStorage` or i18n providers.

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import { LocaleSwitcher } from "./locale-switcher.js";
const meta: Meta<typeof LocaleSwitcher> = {
component: LocaleSwitcher,
title: "Layout/LocaleSwitcher",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof LocaleSwitcher>;
export const Default: Story = {};
export const Playground: Story = {};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { useEffect, useState } from "react";
const LOCALES = ["en", "fr", "de", "ja"] as const;
type Locale = (typeof LOCALES)[number];
function getInitialLocale(): Locale {
if (typeof localStorage === "undefined") return "en";
const stored = localStorage.getItem("locale");
return (LOCALES as readonly string[]).includes(stored ?? "") ? (stored as Locale) : "en";
}
export function LocaleSwitcher(): React.JSX.Element {
const [locale, setLocale] = useState<Locale>(getInitialLocale);
useEffect(() => {
localStorage.setItem("locale", locale);
document.documentElement.setAttribute("lang", locale);
}, [locale]);
function handleChange(e: React.ChangeEvent<HTMLSelectElement>): void {
const value = e.target.value;
if ((LOCALES as readonly string[]).includes(value)) setLocale(value as Locale);
}
return (
<select
aria-label="Select locale"
data-testid="locale-switcher"
value={locale}
onChange={handleChange}
className="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
>
{LOCALES.map((loc) => (
<option key={loc} value={loc}>{loc.toUpperCase()}</option>
))}
</select>
);
}

View File

@@ -0,0 +1,43 @@
# sidebar
## Purpose
`Sidebar` renders the primary navigation for the testbed as a vertically scrollable nav panel.
Routes are grouped by their `category` field from `routes.ts` and rendered as React Router
`NavLink` elements. Active links receive distinct styling.
Every `data-testid="nav-<path>"` attribute maps directly to a route in the manifest, making
it trivial to enumerate and test all routes programmatically in E2E navigation specs.
## Features
- Groups routes by category, preserving insertion order from `routes.ts`
- Uses React Router `NavLink` with `isActive` callback for active-state styling
- Each nav item has `data-testid="nav-<route-path-without-slash>"` for Playwright
- Scrollable: overflow-y-auto handles large numbers of routes
## Usage: Basic Example
```tsx
import { Sidebar } from "./sidebar.js"; // requires BrowserRouter in tree
```
## Usage: Advanced Example
```tsx
// E2E -- click nav link and assert page title
await page.click('[data-testid="nav-crypto"]');
await page.waitForSelector('[data-testid="page-title"]');
```
## API Reference
### `Sidebar(): JSX.Element`
No props. Reads `routes` from `routes.ts`. Requires `BrowserRouter` or `MemoryRouter` in tree.
## Implementation Notes
Categories are derived via `[...new Set(routes.map(r => r.category))]`. ES2015+ Set preserves
insertion order, so routes appear in the same order as the manifest. Adding routes to
`routes.ts` automatically updates the sidebar.

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "./sidebar.js";
const meta: Meta<typeof Sidebar> = {
component: Sidebar,
title: "Layout/Sidebar",
tags: ["autodocs"],
decorators: [(Story) => <MemoryRouter><Story /></MemoryRouter>],
};
export default meta;
type Story = StoryObj<typeof Sidebar>;
export const Default: Story = {};
export const WithActiveCrypto: Story = {
decorators: [(Story) => <MemoryRouter initialEntries={["/crypto"]}><Story /></MemoryRouter>],
};
export const Playground: Story = {};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { NavLink } from "react-router-dom";
import { routes } from "../../routes.js";
const categories = [...new Set(routes.map((r) => r.category))];
export function Sidebar(): React.JSX.Element {
return (
<nav
data-testid="sidebar"
aria-label="Library navigation"
className="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900"
>
{categories.map((category) => (
<div key={category} className="px-3 py-2">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">
{category}
</p>
{routes
.filter((r) => r.category === category)
.map((route) => (
<NavLink
key={route.path}
to={route.path}
data-testid={`nav-${route.path.replace("/", "")}`}
className={({ isActive }) =>
`block truncate rounded px-2 py-1 text-sm ${
isActive
? "bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`
}
>
{route.label}
</NavLink>
))}
</div>
))}
</nav>
);
}

View File

@@ -0,0 +1,48 @@
# theme-toggle
## Purpose
`ThemeToggle` is a button that toggles between `light` and `dark` visual themes.
It applies the theme by setting a `data-theme` attribute on `<html>` (consumed by CSS
custom properties and Tailwind dark mode variants) and persists the choice to
`localStorage["theme"]`. On first render it reads the stored preference, falling back
to the OS `prefers-color-scheme` media query.
Every E2E test for the testbed's theme feature interacts via `data-testid="theme-toggle"`.
## Features
- Reads initial theme from `localStorage["theme"]` or `prefers-color-scheme`
- Applies `data-theme` attribute to `document.documentElement` on every toggle
- Persists selection to `localStorage` so choice survives page reloads
- Accessible: `aria-label` updates to describe the next action
- `data-testid="theme-toggle"` for deterministic Playwright targeting
## Usage: Basic Example
```tsx
import { ThemeToggle } from "./theme-toggle.js";
<ThemeToggle />
```
## Usage: Advanced Example
```tsx
// E2E -- toggle and verify data-theme attribute persists
await page.click('[data-testid="theme-toggle"]');
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
await page.reload();
expect(await page.getAttribute("html", "data-theme")).toBe("dark");
```
## API Reference
### `ThemeToggle(): JSX.Element`
No props. Reads and writes `localStorage["theme"]`. Sets `document.documentElement.dataset.theme`.
## Implementation Notes
The component does not accept a controlled `theme` prop -- the theme is global document state.
`getInitialTheme` runs synchronously on the first render pass to avoid a flash of the default
theme before the stored preference is applied.

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ThemeToggle } from "./theme-toggle.js";
const meta: Meta<typeof ThemeToggle> = {
component: ThemeToggle,
title: "Layout/ThemeToggle",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ThemeToggle>;
export const Default: Story = {};
export const Playground: Story = {};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useEffect, useState } from "react";
type Theme = "light" | "dark";
function getInitialTheme(): Theme {
if (typeof localStorage === "undefined") return "light";
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeToggle(): React.JSX.Element {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
return (
<button
type="button"
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
data-testid="theme-toggle"
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
className="rounded px-2 py-1 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
{theme === "light" ? "Moon" : "Sun"}
</button>
);
}