Initial Commit
This commit is contained in:
49
apps/testbed/src/client/components/layout/console-panel.md
Normal file
49
apps/testbed/src/client/components/layout/console-panel.md
Normal 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.
|
||||
@@ -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 = {};
|
||||
73
apps/testbed/src/client/components/layout/console-panel.tsx
Normal file
73
apps/testbed/src/client/components/layout/console-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/testbed/src/client/components/layout/header.md
Normal file
45
apps/testbed/src/client/components/layout/header.md
Normal 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.
|
||||
15
apps/testbed/src/client/components/layout/header.stories.tsx
Normal file
15
apps/testbed/src/client/components/layout/header.stories.tsx
Normal 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 = {};
|
||||
20
apps/testbed/src/client/components/layout/header.tsx
Normal file
20
apps/testbed/src/client/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/testbed/src/client/components/layout/index.ts
Normal file
5
apps/testbed/src/client/components/layout/index.ts
Normal 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";
|
||||
47
apps/testbed/src/client/components/layout/locale-switcher.md
Normal file
47
apps/testbed/src/client/components/layout/locale-switcher.md
Normal 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.
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
43
apps/testbed/src/client/components/layout/sidebar.md
Normal file
43
apps/testbed/src/client/components/layout/sidebar.md
Normal 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.
|
||||
@@ -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 = {};
|
||||
41
apps/testbed/src/client/components/layout/sidebar.tsx
Normal file
41
apps/testbed/src/client/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/testbed/src/client/components/layout/theme-toggle.md
Normal file
48
apps/testbed/src/client/components/layout/theme-toggle.md
Normal 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.
|
||||
@@ -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 = {};
|
||||
32
apps/testbed/src/client/components/layout/theme-toggle.tsx
Normal file
32
apps/testbed/src/client/components/layout/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user