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

130
apps/demo/README.md Normal file
View File

@@ -0,0 +1,130 @@
# @suaveplan/demo
Unified Bun + Hono server with a React 19 SPA front-end and a JSON API
in one process. Demonstrates locale-prefix routing across five base
locales, `@suaveplan/api-plugin-auth`-driven Better-Auth authentication
backed by SurrealDB, and React + Tailwind v4 styling.
## What's wired up
- **Server** — single Bun process running a Hono app on port `3300`
(override with `PORT=…`). The Hono instance is the one owned by a
`SuaveApp` from `@suaveplan/api`. `/api/health` is registered by
`registerHealthRoutes`; `/api/auth/*` is mounted by
`@suaveplan/api-plugin-auth` (9 OpenAPI routes + the Better-Auth
catch-all); `/api/me` is a demo convenience that calls
`authService.getSession(headers)`; everything else falls through to
the Vite-built client bundle in `dist/client/`, with `index.html`
fallback so the client router owns deep links.
- **Auth** — the canonical genesis pipeline. The Hono app is assembled
via `defineApp()` from `@suaveplan/api`, with `createAuthPlugin(...)`
from `@suaveplan/api-plugin-auth` registered as a plugin. The auth
plugin's 6-step `onInit` applies the bundled 17-table Better-Auth
SurrealQL schema, constructs the real Better-Auth instance backed by
`betterAuthSurrealAdapter` (from `@suaveplan/better-auth-surrealdb`,
the Better-Auth v1.6 `DBAdapterInstance` factory), registers the
typed `authService` on the host `ServiceRegistry`, mounts the 9
OpenAPI routes plus the Better-Auth catch-all under `/api/auth/*`,
and installs the global session-resolver middleware so every request
gets a typed `Principal` on its Hono context. A small inline
`demo-db-seed` plugin (in `src/server/auth.ts`) drops the live
`SurrealAdapter` into `ServiceRegistry["db"]` during `onRegister` so
the auth plugin can resolve it during `onInit`. Backing store is an
**external SurrealDB** reached via `SURREAL_URL` (default
`ws://localhost:8000`), namespace `SURREAL_NS` (default `demo`),
database `SURREAL_DB` (default `demo`), credentials `SURREAL_USER` /
`SURREAL_PASS` (default `root` / `root`).
- **Client** — React 19 + Vite + `@tailwindcss/vite` for Tailwind v4.
Routing via `react-router-dom@7` with a locale-prefix design. The
app root composes `<ReactUiProvider>` (from `@suaveplan/react-ui`)
around the entire tree, seeded with a singleton `I18nInstance`
(`createI18n` from `@suaveplan/i18n`) and the chassis `lightTheme`
plugin. `<ReactUiProvider>` internally wraps
`<TranslationProvider>` → `<ThemeProvider>` → `<PackRegistryProvider>`
and auto-registers the matching chassis locale bundle on mount /
language change, so any react-ui primitive that calls
`useTranslation(...)` finds a populated catalog. Form pages
(`/auth/login`, `/auth/register`, `/auth/forgot-password`,
`/user/settings`) compose `<Card>`, `<Field>`, `<FieldLabel>`,
`<Input>`, `<Button>`, `<Alert>` from the chassis — no hand-rolled
Tailwind on those primitives. Demo-specific copy continues to flow
through a typed `LocaleProvider` so the page-level message catalogs
stay co-located and statically checked; the i18n instance is driven
to the matching BCP47 variant (`en` → `en-GB`, `zh` → `zh-CN`,
`ar` → `ar-AE`, `cy` → `cy-GB`, `ru` → `ru-RU`) via
`syncDemoLocale(base)` from a `useEffect`.
## Locale-prefix routing
Five base locales: `en`, `zh`, `ar`, `cy`, `ru`. Region variants
(e.g. `en-GB`, `zh-CN`, `ar-EG`) parse case-insensitively and
canonicalise to lowercase-base + UPPERCASE-region. Anything else
renders the `NotFoundPage` with a language picker.
- `/` → `Navigate` to `/<detected>/home` (uses `navigator.languages`).
- `/<locale>/home`
- `/<locale>/about-us`
- `/<locale>/contact-us`
- `/<locale>/privacy-policy`
- `/<locale>/terms-of-service`
- `/<locale>/auth/login`
- `/<locale>/auth/register`
- `/<locale>/auth/forgot-password`
- `/<locale>/auth/logout`
- `/<locale>/user/control-panel` *(gated — `RequireAuth`)*
- `/<locale>/user/settings` *(gated — `RequireAuth`)*
- `/<locale>/admin/control-panel` *(gated — `RequireRole admin`)*
- `/<locale>/admin/settings` *(gated — `RequireRole admin`)*
`ar` flips `<html dir="rtl">`. The `<LocaleSwitcher>` rewrites the URL
in place when a user changes language.
## Running the demo
The demo requires a reachable SurrealDB instance. The simplest path:
```bash
docker run -d --name demo-surreal -p 8000:8000 \
surrealdb/surrealdb:latest start --user root --pass root --bind 0.0.0.0:8000
```
Then, from the repo root:
```bash
bun install # workspace deps
bunx turbo build --filter=@suaveplan/demo # build api/auth deps + dist/client
bun run --filter=@suaveplan/demo dev # boot the server on :3300
```
Visit `http://localhost:3300` — it should redirect to `/en/home` (or
your browser's preferred locale if it matches one of the five).
Smoke endpoints:
```bash
curl http://localhost:3300/api/health
curl http://localhost:3300/api/me
curl http://localhost:3300/api/auth/get-session
```
End-to-end sign-up roundtrip — produces a 200 with a Better-Auth
session token + user record:
```bash
curl -X POST http://localhost:3300/api/auth/sign-up/email \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"hunter2","name":"Alice"}'
```
Env overrides (all optional):
| Variable | Default |
| ---------------------- | ------------------------- |
| `PORT` | `3300` |
| `SURREAL_URL` | `ws://localhost:8000` |
| `SURREAL_NS` | `demo` |
| `SURREAL_DB` | `demo` |
| `SURREAL_USER` | `root` |
| `SURREAL_PASS` | `root` |
| `BETTER_AUTH_URL` | `http://localhost:3300` |
| `BETTER_AUTH_SECRET` | demo placeholder — set in prod |

View File

@@ -0,0 +1,86 @@
/**
* Smoke test — boots the built app and asserts:
*
* 1. `/api/health` returns 200 JSON with `ok: true`.
* 2. `/` redirects to `/<locale>/home`.
* 3. Each public page (`/en/home`, `/en/about-us`,
* `/en/contact-us`, `/en/privacy-policy`, `/en/terms-of-service`)
* returns 200 with an HTML body containing the SPA root element.
*
* Run with `bun run e2e/smoke.test.ts`. Requires `bun run build`
* to have produced `dist/client/index.html` first.
*/
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const PORT = 3399;
const BASE = `http://localhost:${PORT}`;
async function waitForReady(): Promise<void> {
for (let i = 0; i < 30; i++) {
try {
const res = await fetch(`${BASE}/api/health`);
if (res.ok) return;
} catch {
// not up yet
}
await delay(500);
}
throw new Error("server failed to start within 15s");
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(`SMOKE FAIL: ${message}`);
}
async function main(): Promise<void> {
const child = spawn("bun", ["run", "src/server/index.ts"], {
env: { ...process.env, PORT: String(PORT), NODE_ENV: "test" },
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForReady();
const health = await (await fetch(`${BASE}/api/health`)).json();
// `@suaveplan/api`'s `registerHealthRoutes` emits
// `{ status, version, state, timestamp }` — accept either the new
// shape (`status: "healthy"`) or the legacy `{ ok: true }` shape so
// the smoke remains forward-compatible if the route is rewritten.
const healthy = health.status === "healthy" || health.ok === true;
assert(healthy, `health probe not healthy: ${JSON.stringify(health)}`);
const root = await fetch(`${BASE}/`, { redirect: "manual" });
// Server sends the SPA shell, client-side router handles the redirect.
// We accept either a 200 (shell) or a 3xx redirect.
assert(root.status === 200 || (root.status >= 300 && root.status < 400),
`/ expected 200 or 3xx, got ${root.status}`);
const paths = [
"/en/home",
"/en/about-us",
"/en/contact-us",
"/en/privacy-policy",
"/en/terms-of-service",
];
for (const path of paths) {
const res = await fetch(`${BASE}${path}`);
assert(res.ok, `${path} returned ${res.status}`);
const body = await res.text();
assert(body.includes('id="root"'), `${path} body missing #root`);
}
// Auth endpoint reachable.
const session = await fetch(`${BASE}/api/auth/get-session`);
assert(session.status === 200, `/api/auth/get-session status ${session.status}`);
console.log("SMOKE OK");
} finally {
child.kill("SIGTERM");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

12
apps/demo/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Suaveplan Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

37
apps/demo/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@suaveplan/demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run src/server/index.ts",
"build": "vite build",
"start": "bun run src/server/index.ts",
"smoke": "bun run e2e/smoke.test.ts",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@suaveplan/api": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-auth": "workspace:^",
"@suaveplan/better-auth-surrealdb": "workspace:^",
"@suaveplan/i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"better-auth": "catalog:auth",
"hono": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-router-dom": "catalog:react"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:build",
"@types/node": "catalog:",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@vitejs/plugin-react": "catalog:build",
"tailwindcss": "catalog:build",
"typescript": "catalog:",
"vite": "catalog:build"
}
}

View File

@@ -0,0 +1,139 @@
/**
* Demo app shell.
*
* Top-level structure:
*
* - `BrowserRouter`
* - `SessionProvider` — polls `/api/auth/get-session`
* - `ReactUiProvider` — composes
* `<TranslationProvider>` → `<ThemeProvider>` →
* `<PackRegistryProvider>` for the entire app tree, seeded with
* the demo's singleton `I18nInstance` and the chassis
* `lightTheme` plugin from `@suaveplan/react-ui`.
* - `Routes`
* - `/` → redirect to `/<detected>/home`
* - `/:locale/*` → LocaleProvider + Layout
* - everything else → NotFoundPage in default-locale context
*/
import { lightTheme, ReactUiProvider } from "@suaveplan/react-ui";
import React from "react";
import {
BrowserRouter,
Navigate,
Outlet,
Route,
Routes,
} from "react-router-dom";
import { Layout } from "./components/layout.js";
import { getDemoI18n } from "./i18n/i18n-instance.js";
import { LocaleProvider } from "./i18n/locale-context.js";
import { DEFAULT_LOCALE, parseLocaleSegment } from "./i18n/locales.js";
import { RequireAuth, RequireRole } from "./lib/guards.js";
import { SessionProvider } from "./lib/session-context.js";
import {
AdminControlPanelPage,
AdminSettingsPage,
UserControlPanelPage,
UserSettingsPage,
} from "./pages/account-pages.js";
import {
ForgotPasswordPage,
LoginPage,
LogoutPage,
RegisterPage,
} from "./pages/auth-pages.js";
import { NotFoundPage } from "./pages/not-found-page.js";
import {
AboutPage,
ContactPage,
HomePage,
PrivacyPage,
TermsPage,
} from "./pages/public-pages.js";
function detectLocale(): string {
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
for (const candidate of navigator.languages ?? [navigator.language]) {
const parsed = parseLocaleSegment(candidate);
if (parsed !== null) return parsed.base;
}
return DEFAULT_LOCALE;
}
function RootRedirect(): React.JSX.Element {
return <Navigate to={`/${detectLocale()}/home`} replace />;
}
function LocalisedShell(): React.JSX.Element {
return (
<LocaleProvider>
<Outlet />
</LocaleProvider>
);
}
export function App(): React.JSX.Element {
const i18n = getDemoI18n();
return (
<BrowserRouter>
<SessionProvider>
<ReactUiProvider
i18n={i18n}
themes={[lightTheme]}
defaultTheme="light"
>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/:locale" element={<LocalisedShell />}>
<Route element={<Layout />}>
<Route index element={<Navigate to="home" replace />} />
<Route path="home" element={<HomePage />} />
<Route path="about-us" element={<AboutPage />} />
<Route path="contact-us" element={<ContactPage />} />
<Route path="privacy-policy" element={<PrivacyPage />} />
<Route path="terms-of-service" element={<TermsPage />} />
<Route path="auth/login" element={<LoginPage />} />
<Route path="auth/register" element={<RegisterPage />} />
<Route path="auth/forgot-password" element={<ForgotPasswordPage />} />
<Route path="auth/logout" element={<LogoutPage />} />
<Route
path="user/control-panel"
element={
<RequireAuth>
<UserControlPanelPage />
</RequireAuth>
}
/>
<Route
path="user/settings"
element={
<RequireAuth>
<UserSettingsPage />
</RequireAuth>
}
/>
<Route
path="admin/control-panel"
element={
<RequireRole role="admin">
<AdminControlPanelPage />
</RequireRole>
}
/>
<Route
path="admin/settings"
element={
<RequireRole role="admin">
<AdminSettingsPage />
</RequireRole>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
</Routes>
</ReactUiProvider>
</SessionProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,89 @@
/**
* Shared layout — top nav with locale picker + auth menu, content
* area, footer. Renders an `<Outlet>` for nested routes.
*/
import React from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
import type { BaseLocale } from "../i18n/locales.js";
import { useSession } from "../lib/session-context.js";
function LocaleSwitcher(): React.JSX.Element {
const { base, setLocale, availableLocales } = useLocale();
return (
<select
aria-label="Language"
value={base}
onChange={(e) => setLocale(e.target.value as BaseLocale)}
className="rounded-md border border-[color:var(--color-border)] bg-[color:var(--color-surface)] px-2 py-1 text-sm"
data-testid="locale-switcher"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>
{LOCALE_ENTRIES[loc].nativeName}
</option>
))}
</select>
);
}
function NavItem({ to, label }: { readonly to: string; readonly label: string }): React.JSX.Element {
return (
<NavLink
to={to}
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm font-medium transition ${
isActive
? "bg-[color:var(--color-accent)] text-[color:var(--color-accent-fg)]"
: "hover:bg-[color:var(--color-surface)]"
}`
}
>
{label}
</NavLink>
);
}
export function Layout(): React.JSX.Element {
const { base, messages } = useLocale();
const { session } = useSession();
const p = (sub: string): string => `/${base}${sub}`;
return (
<div className="flex min-h-screen flex-col">
<header className="border-b border-[color:var(--color-border)] bg-[color:var(--color-surface)]">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-4 py-3">
<Link to={p("/home")} className="text-lg font-semibold no-underline">
{messages.siteName}
</Link>
<nav className="flex flex-wrap items-center gap-1">
<NavItem to={p("/home")} label={messages.nav.home} />
<NavItem to={p("/about-us")} label={messages.nav.about} />
<NavItem to={p("/contact-us")} label={messages.nav.contact} />
<NavItem to={p("/privacy-policy")} label={messages.nav.privacy} />
<NavItem to={p("/terms-of-service")} label={messages.nav.terms} />
</nav>
<div className="ms-auto flex items-center gap-2">
<LocaleSwitcher />
{session === null ? (
<>
<NavItem to={p("/auth/login")} label={messages.nav.login} />
<NavItem to={p("/auth/register")} label={messages.nav.register} />
</>
) : (
<>
<NavItem to={p("/user/control-panel")} label={messages.nav.userPanel} />
<NavItem to={p("/auth/logout")} label={messages.nav.logout} />
</>
)}
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
<Outlet />
</main>
<footer className="border-t border-[color:var(--color-border)] py-4 text-center text-xs text-[color:var(--color-muted)]">
{messages.siteName} • {base}
</footer>
</div>
);
}

View File

@@ -0,0 +1,29 @@
/**
* Re-usable page shell — heading + lead paragraph block consumed by
* every public-information page so titles, headings, and the lead
* line are kept consistent.
*/
import React from "react";
import { useDocumentTitle } from "../i18n/locale-context.js";
export interface PageShellProps {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly children?: React.ReactNode;
}
export function PageShell({ title, heading, lead, children }: PageShellProps): React.JSX.Element {
useDocumentTitle(title);
return (
<article className="mx-auto max-w-3xl">
<h1 className="text-3xl font-semibold" data-testid="page-heading">
{heading}
</h1>
<p className="mt-3 text-base leading-relaxed text-[color:var(--color-muted)]" data-testid="page-lead">
{lead}
</p>
{children !== undefined ? <div className="mt-6">{children}</div> : null}
</article>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Build the demo's `I18nInstance`.
*
* The instance is consumed by `<ReactUiProvider>` and any
* `@suaveplan/react-ui` primitive that calls `useTranslation()` (e.g.
* `<Button>` for icon-only-button fallback, `<Input>` for placeholder
* resolution).
*
* Locale strategy: the URL carries the language subtag only (`/en/home`,
* `/zh/home`, etc.). The chassis bundles in `@suaveplan/react-ui` are
* keyed by full BCP47 — `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`. We
* therefore canonicalise each demo base locale to its closest BCP47
* variant and use THAT as the i18n locale. The demo's typed `Messages`
* catalog continues to be consumed via `useLocale()` keyed by base, so
* the demo never depends on react-i18n's `t(key)` for its own copy.
*
* The chassis bundles themselves are auto-registered by
* `<ReactUiProvider>` (its internal `ReactUiBundleLoader` effect imports
* the matching JSON and merges it into the catalog on mount / language
* change), so this module deliberately does NOT preload them.
*/
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
import type { BaseLocale } from "./locales.js";
export type DemoBcp47 = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
/** Map URL base locale -> canonical BCP47 variant fed to the i18n core. */
export const BASE_TO_BCP47: Readonly<Record<BaseLocale, DemoBcp47>> = {
en: "en-GB",
zh: "zh-CN",
ar: "ar-AE",
cy: "cy-GB",
ru: "ru-RU",
};
/**
* Singleton i18n instance for the demo. Created lazily so the i18n core
* (which is module-side-effect free) does not run during static module
* graph evaluation in tests or SSR snapshots.
*/
let cached: I18nInstance | undefined;
export function getDemoI18n(): I18nInstance {
if (cached !== undefined) return cached;
// Use the default `string` generic so the cached singleton matches
// `<ReactUiProvider>`'s `i18n: I18nInstance` (without the
// narrowed locale generic).
const instance: I18nInstance = createI18n({
defaultLocale: BASE_TO_BCP47.en as string,
fallbackLocale: BASE_TO_BCP47.en as string,
});
cached = instance;
return instance;
}
/**
* Drive the i18n instance to the BCP47 variant matching the URL base.
* Safe to call from a `useEffect` — `changeLanguage(...)` short-circuits
* when the locale already matches.
*/
export async function syncDemoLocale(base: BaseLocale): Promise<void> {
const i18n = getDemoI18n();
const target = BASE_TO_BCP47[base];
if (i18n.locale !== target) {
await i18n.changeLanguage(target);
}
}

View File

@@ -0,0 +1,113 @@
/**
* Typed locale context for the demo app.
*
* Holds the resolved `BaseLocale`, the canonical BCP47 tag (e.g. `en`,
* `en-GB`, `zh-CN`), the active `Messages` catalog, and a `setLocale`
* setter that rewrites the URL prefix and updates `<html lang>` +
* `<html dir>` so RTL flips correctly for Arabic.
*
* Kept deliberately small — full @suaveplan/i18n integration (with
* backends, formatters, plugin registry) is overkill for a five-page
* demo. The provider shape mirrors react-i18n's `useLocale` /
* `useI18n` so a future swap is mechanical.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { syncDemoLocale } from "./i18n-instance.js";
import {
BASE_LOCALES,
type BaseLocale,
DEFAULT_LOCALE,
LOCALE_ENTRIES,
parseLocaleSegment,
RTL_BASE_LOCALES,
} from "./locales.js";
import { CATALOGS, type Messages } from "./messages.js";
export interface LocaleContextValue {
readonly base: BaseLocale;
readonly canonical: string;
readonly direction: "ltr" | "rtl";
readonly messages: Messages;
readonly setLocale: (next: BaseLocale | string) => void;
readonly availableLocales: readonly BaseLocale[];
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
export function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (ctx === null) {
throw new Error(
"useLocale must be used inside <LocaleProvider> — wrap your routes."
);
}
return ctx;
}
export function useMessages(): Messages {
return useLocale().messages;
}
export interface LocaleProviderProps {
readonly children: React.ReactNode;
}
export function LocaleProvider({ children }: LocaleProviderProps): React.JSX.Element {
const params = useParams<{ locale?: string }>();
const navigate = useNavigate();
const location = useLocation();
const parsed = parseLocaleSegment(params.locale);
const base: BaseLocale = parsed?.base ?? DEFAULT_LOCALE;
const canonical = parsed?.canonical ?? base;
const direction = RTL_BASE_LOCALES.has(base) ? "rtl" : "ltr";
const messages = CATALOGS[base];
useEffect(() => {
document.documentElement.lang = canonical;
document.documentElement.dir = direction;
document.title = messages.home.title; // updated per page via useDocumentTitle
// Drive the singleton I18nInstance to the BCP47 variant matching the
// current URL base so any @suaveplan/react-ui chassis lookup sees a
// catalog populated for the active locale.
void syncDemoLocale(base);
}, [base, canonical, direction, messages]);
const setLocale = useCallback(
(next: BaseLocale | string) => {
// Strip the existing locale segment then prepend the new one.
const path = location.pathname.replace(/^\/[^/]+/, "");
navigate(`/${next}${path === "" ? "/home" : path}${location.search}${location.hash}`);
},
[location.hash, location.pathname, location.search, navigate]
);
const value = useMemo<LocaleContextValue>(
() => ({
base,
canonical,
direction,
messages,
setLocale,
availableLocales: BASE_LOCALES,
}),
[base, canonical, direction, messages, setLocale]
);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}
export function useDocumentTitle(title: string): void {
useEffect(() => {
document.title = title;
}, [title]);
}
export { LOCALE_ENTRIES };

View File

@@ -0,0 +1,49 @@
/**
* Locale registry for the demo app.
*
* Five canonical base locales — `en`, `zh`, `ar`, `cy`, `ru` — plus a
* permissive `<base>-<region>` resolver so URLs like `/en-GB/home` or
* `/zh-CN/home` collapse onto the base catalog. RTL is tracked on the
* base code.
*/
export const BASE_LOCALES = ["en", "zh", "ar", "cy", "ru"] as const;
export type BaseLocale = (typeof BASE_LOCALES)[number];
export const DEFAULT_LOCALE: BaseLocale = "en";
export const RTL_BASE_LOCALES: ReadonlySet<BaseLocale> = new Set(["ar"]);
export interface LocaleEntry {
readonly base: BaseLocale;
readonly name: string;
readonly nativeName: string;
readonly direction: "ltr" | "rtl";
}
export const LOCALE_ENTRIES: Readonly<Record<BaseLocale, LocaleEntry>> = {
en: { base: "en", name: "English", nativeName: "English", direction: "ltr" },
zh: { base: "zh", name: "Chinese", nativeName: "中文", direction: "ltr" },
ar: { base: "ar", name: "Arabic", nativeName: "العربية", direction: "rtl" },
cy: { base: "cy", name: "Welsh", nativeName: "Cymraeg", direction: "ltr" },
ru: { base: "ru", name: "Russian", nativeName: "Русский", direction: "ltr" },
};
/**
* Normalise a raw URL segment into a canonical `<base>` or
* `<base>-<REGION>` BCP47 tag, or `null` if the base does not match
* one of {@link BASE_LOCALES}.
*/
export function parseLocaleSegment(segment: string | undefined): {
readonly base: BaseLocale;
readonly canonical: string;
} | null {
if (!segment) return null;
const [rawBase, rawRegion] = segment.toLowerCase().split("-");
const base = (BASE_LOCALES as readonly string[]).includes(rawBase ?? "")
? (rawBase as BaseLocale)
: null;
if (!base) return null;
if (!rawRegion) return { base, canonical: base };
const region = rawRegion.toUpperCase();
return { base, canonical: `${base}-${region}` };
}

View File

@@ -0,0 +1,337 @@
/**
* Static message catalogs for the demo app.
*
* `en` is fully translated. The other four locales (`zh`, `ar`, `cy`,
* `ru`) ship with canonical translations for page titles, headings,
* and the lead paragraph, and fall back to the English copy for
* secondary microcopy. Every key has a non-empty value in every
* locale so render-time fallback never silently picks up `en`.
*/
import type { BaseLocale } from "./locales.js";
export interface Messages {
// Site chrome
readonly siteName: string;
readonly nav: {
readonly home: string;
readonly about: string;
readonly contact: string;
readonly privacy: string;
readonly terms: string;
readonly login: string;
readonly register: string;
readonly logout: string;
readonly userPanel: string;
readonly userSettings: string;
readonly adminPanel: string;
readonly adminSettings: string;
};
// Pages
readonly home: { readonly title: string; readonly heading: string; readonly lead: string };
readonly about: { readonly title: string; readonly heading: string; readonly lead: string };
readonly contact: { readonly title: string; readonly heading: string; readonly lead: string };
readonly privacy: { readonly title: string; readonly heading: string; readonly lead: string };
readonly terms: { readonly title: string; readonly heading: string; readonly lead: string };
readonly login: {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly emailLabel: string;
readonly passwordLabel: string;
readonly submit: string;
readonly linkToRegister: string;
readonly linkToForgot: string;
};
readonly register: {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly emailLabel: string;
readonly passwordLabel: string;
readonly confirmLabel: string;
readonly submit: string;
readonly linkToLogin: string;
};
readonly forgot: {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly emailLabel: string;
readonly submit: string;
readonly sentNotice: string;
readonly linkToLogin: string;
};
readonly logout: {
readonly title: string;
readonly heading: string;
readonly lead: string;
};
readonly userPanel: { readonly title: string; readonly heading: string; readonly lead: string };
readonly userSettings: { readonly title: string; readonly heading: string; readonly lead: string };
readonly adminPanel: { readonly title: string; readonly heading: string; readonly lead: string };
readonly adminSettings: { readonly title: string; readonly heading: string; readonly lead: string };
readonly notFound: { readonly title: string; readonly heading: string; readonly lead: string };
readonly unauthorized: { readonly title: string; readonly heading: string; readonly lead: string };
}
const en: Messages = {
siteName: "Suaveplan Demo",
nav: {
home: "Home",
about: "About us",
contact: "Contact us",
privacy: "Privacy policy",
terms: "Terms of service",
login: "Log in",
register: "Register",
logout: "Log out",
userPanel: "User panel",
userSettings: "User settings",
adminPanel: "Admin panel",
adminSettings: "Admin settings",
},
home: {
title: "Suaveplan Demo — Home",
heading: "Welcome to the Suaveplan Demo",
lead: "A unified Bun + Hono server with React-rendered pages and a JSON API in one process, wired up with @suaveplan/react-ui and @suaveplan/react-i18n.",
},
about: {
title: "About us",
heading: "About Suaveplan",
lead: "Suaveplan is a bottom-up rebuild of an enterprise platform, packaged as a Turborepo monorepo of focused libraries.",
},
contact: {
title: "Contact us",
heading: "Get in touch",
lead: "Drop us a line through any of the channels listed below. We read every message.",
},
privacy: {
title: "Privacy policy",
heading: "Privacy policy",
lead: "This demo collects no personal data beyond what the better-auth memory adapter holds for the lifetime of the process.",
},
terms: {
title: "Terms of service",
heading: "Terms of service",
lead: "This is a demo application provided as-is for illustrative purposes; no warranty is offered or implied.",
},
login: {
title: "Log in",
heading: "Log in to your account",
lead: "Sign in with your email address and password.",
emailLabel: "Email",
passwordLabel: "Password",
submit: "Log in",
linkToRegister: "Need an account? Register",
linkToForgot: "Forgot your password?",
},
register: {
title: "Create an account",
heading: "Create your account",
lead: "Sign up with your email address and a password of at least 6 characters.",
emailLabel: "Email",
passwordLabel: "Password",
confirmLabel: "Confirm password",
submit: "Create account",
linkToLogin: "Already have an account? Log in",
},
forgot: {
title: "Forgot password",
heading: "Reset your password",
lead: "Enter your email and we'll send you a password-reset link.",
emailLabel: "Email",
submit: "Send reset link",
sentNotice: "If an account exists for that email, a reset link is on its way.",
linkToLogin: "Back to log in",
},
logout: {
title: "Logging out",
heading: "Logging you out…",
lead: "Clearing your session — you'll be redirected home in a moment.",
},
userPanel: {
title: "User control panel",
heading: "Control panel",
lead: "An overview of your account, sessions, and recent activity.",
},
userSettings: {
title: "User settings",
heading: "Settings",
lead: "Manage your profile, locale preference, and security options.",
},
adminPanel: {
title: "Admin control panel",
heading: "Admin control panel",
lead: "Administrative dashboard — restricted to users with the admin role.",
},
adminSettings: {
title: "Admin settings",
heading: "Admin settings",
lead: "Configure global application settings — restricted to admins.",
},
notFound: {
title: "Not found",
heading: "404 — page not found",
lead: "The page you asked for does not exist. Try one of the languages below or return home.",
},
unauthorized: {
title: "Sign in required",
heading: "Sign in required",
lead: "You need to be signed in to view this page.",
},
};
// Lightweight translations: page-title, heading, lead per locale.
// Secondary microcopy falls through to English so the demo always
// renders intelligibly while making the per-locale shape obvious.
const zh: Messages = {
...en,
nav: {
...en.nav,
home: "首页",
about: "关于我们",
contact: "联系我们",
privacy: "隐私政策",
terms: "服务条款",
login: "登录",
register: "注册",
logout: "退出",
userPanel: "用户面板",
userSettings: "用户设置",
adminPanel: "管理面板",
adminSettings: "管理设置",
},
siteName: "Suaveplan 演示",
home: {
title: "Suaveplan 演示 — 首页",
heading: "欢迎来到 Suaveplan 演示",
lead: "在单一进程中提供统一的 Bun + Hono 服务器、React 渲染页面与 JSON API,使用 @suaveplan/react-ui 和 @suaveplan/react-i18n。",
},
about: { title: "关于我们", heading: "关于 Suaveplan", lead: en.about.lead },
contact: { title: "联系我们", heading: "联系我们", lead: en.contact.lead },
privacy: { title: "隐私政策", heading: "隐私政策", lead: en.privacy.lead },
terms: { title: "服务条款", heading: "服务条款", lead: en.terms.lead },
login: { ...en.login, title: "登录", heading: "登录您的帐户", lead: "使用电子邮件地址和密码登录。" },
register: { ...en.register, title: "创建帐户", heading: "创建您的帐户", lead: "使用电子邮件和至少 6 个字符的密码注册。" },
forgot: { ...en.forgot, title: "忘记密码", heading: "重置密码", lead: "输入您的电子邮件,我们将发送重置链接。" },
logout: { title: "退出登录", heading: "正在退出登录...", lead: en.logout.lead },
userPanel: { title: "用户控制面板", heading: "控制面板", lead: en.userPanel.lead },
userSettings: { title: "用户设置", heading: "设置", lead: en.userSettings.lead },
adminPanel: { title: "管理控制面板", heading: "管理控制面板", lead: en.adminPanel.lead },
adminSettings: { title: "管理设置", heading: "管理设置", lead: en.adminSettings.lead },
notFound: { title: "未找到", heading: "404 — 找不到页面", lead: en.notFound.lead },
unauthorized: { title: "需要登录", heading: "需要登录", lead: en.unauthorized.lead },
};
const ar: Messages = {
...en,
nav: {
...en.nav,
home: "الرئيسية",
about: "من نحن",
contact: "اتصل بنا",
privacy: "سياسة الخصوصية",
terms: "شروط الخدمة",
login: "تسجيل الدخول",
register: "تسجيل",
logout: "تسجيل الخروج",
userPanel: "لوحة المستخدم",
userSettings: "إعدادات المستخدم",
adminPanel: "لوحة الإدارة",
adminSettings: "إعدادات الإدارة",
},
siteName: "عرض Suaveplan التوضيحي",
home: { title: "Suaveplan — الرئيسية", heading: "مرحبًا بك في عرض Suaveplan التوضيحي", lead: en.home.lead },
about: { title: "من نحن", heading: "حول Suaveplan", lead: en.about.lead },
contact: { title: "اتصل بنا", heading: "تواصل معنا", lead: en.contact.lead },
privacy: { title: "سياسة الخصوصية", heading: "سياسة الخصوصية", lead: en.privacy.lead },
terms: { title: "شروط الخدمة", heading: "شروط الخدمة", lead: en.terms.lead },
login: { ...en.login, title: "تسجيل الدخول", heading: "تسجيل الدخول إلى حسابك", lead: "سجّل الدخول باستخدام بريدك الإلكتروني وكلمة المرور." },
register: { ...en.register, title: "إنشاء حساب", heading: "إنشاء حسابك", lead: "سجّل ببريدك الإلكتروني وكلمة مرور لا تقل عن 6 أحرف." },
forgot: { ...en.forgot, title: "نسيت كلمة المرور", heading: "إعادة تعيين كلمة المرور", lead: en.forgot.lead },
logout: { title: "جاري تسجيل الخروج", heading: "جاري تسجيل خروجك…", lead: en.logout.lead },
userPanel: { title: "لوحة تحكم المستخدم", heading: "لوحة التحكم", lead: en.userPanel.lead },
userSettings: { title: "إعدادات المستخدم", heading: "الإعدادات", lead: en.userSettings.lead },
adminPanel: { title: "لوحة تحكم الإدارة", heading: "لوحة تحكم الإدارة", lead: en.adminPanel.lead },
adminSettings: { title: "إعدادات الإدارة", heading: "إعدادات الإدارة", lead: en.adminSettings.lead },
notFound: { title: "غير موجود", heading: "404 — الصفحة غير موجودة", lead: en.notFound.lead },
unauthorized: { title: "تسجيل الدخول مطلوب", heading: "تسجيل الدخول مطلوب", lead: en.unauthorized.lead },
};
const cy: Messages = {
...en,
nav: {
...en.nav,
home: "Hafan",
about: "Amdanom ni",
contact: "Cysylltwch â ni",
privacy: "Polisi preifatrwydd",
terms: "Telerau gwasanaeth",
login: "Mewngofnodi",
register: "Cofrestru",
logout: "Allgofnodi",
userPanel: "Panel defnyddiwr",
userSettings: "Gosodiadau defnyddiwr",
adminPanel: "Panel gweinyddol",
adminSettings: "Gosodiadau gweinyddol",
},
siteName: "Demo Suaveplan",
home: { title: "Suaveplan — Hafan", heading: "Croeso i Demo Suaveplan", lead: en.home.lead },
about: { title: "Amdanom ni", heading: "Am Suaveplan", lead: en.about.lead },
contact: { title: "Cysylltwch â ni", heading: "Cysylltu â ni", lead: en.contact.lead },
privacy: { title: "Polisi preifatrwydd", heading: "Polisi preifatrwydd", lead: en.privacy.lead },
terms: { title: "Telerau gwasanaeth", heading: "Telerau gwasanaeth", lead: en.terms.lead },
login: { ...en.login, title: "Mewngofnodi", heading: "Mewngofnodi i'ch cyfrif", lead: "Mewngofnodi gyda'ch e-bost a'ch cyfrinair." },
register: { ...en.register, title: "Creu cyfrif", heading: "Creu eich cyfrif", lead: "Cofrestrwch gyda'ch e-bost a chyfrinair o leiaf 6 nod." },
forgot: { ...en.forgot, title: "Anghofio cyfrinair", heading: "Ailosod eich cyfrinair", lead: en.forgot.lead },
logout: { title: "Allgofnodi", heading: "Allgofnodi…", lead: en.logout.lead },
userPanel: { title: "Panel rheoli defnyddiwr", heading: "Panel rheoli", lead: en.userPanel.lead },
userSettings: { title: "Gosodiadau defnyddiwr", heading: "Gosodiadau", lead: en.userSettings.lead },
adminPanel: { title: "Panel rheoli gweinyddol", heading: "Panel rheoli gweinyddol", lead: en.adminPanel.lead },
adminSettings: { title: "Gosodiadau gweinyddol", heading: "Gosodiadau gweinyddol", lead: en.adminSettings.lead },
notFound: { title: "Heb ddod o hyd", heading: "404 — tudalen heb ei ganfod", lead: en.notFound.lead },
unauthorized: { title: "Mewngofnodi sy'n ofynnol", heading: "Mae angen mewngofnodi", lead: en.unauthorized.lead },
};
const ru: Messages = {
...en,
nav: {
...en.nav,
home: "Главная",
about: "О нас",
contact: "Контакты",
privacy: "Политика конфиденциальности",
terms: "Условия обслуживания",
login: "Войти",
register: "Регистрация",
logout: "Выйти",
userPanel: "Панель пользователя",
userSettings: "Настройки пользователя",
adminPanel: "Панель администратора",
adminSettings: "Настройки администратора",
},
siteName: "Демо Suaveplan",
home: { title: "Suaveplan — Главная", heading: "Добро пожаловать в демо Suaveplan", lead: en.home.lead },
about: { title: "О нас", heading: "О Suaveplan", lead: en.about.lead },
contact: { title: "Контакты", heading: "Свяжитесь с нами", lead: en.contact.lead },
privacy: { title: "Политика конфиденциальности", heading: "Политика конфиденциальности", lead: en.privacy.lead },
terms: { title: "Условия обслуживания", heading: "Условия обслуживания", lead: en.terms.lead },
login: { ...en.login, title: "Войти", heading: "Войти в учетную запись", lead: "Войдите, используя email и пароль." },
register: { ...en.register, title: "Создать учетную запись", heading: "Создайте учетную запись", lead: "Зарегистрируйтесь с email и паролем не менее 6 символов." },
forgot: { ...en.forgot, title: "Забыли пароль", heading: "Сбросить пароль", lead: en.forgot.lead },
logout: { title: "Выход", heading: "Выход из системы…", lead: en.logout.lead },
userPanel: { title: "Панель управления пользователя", heading: "Панель управления", lead: en.userPanel.lead },
userSettings: { title: "Настройки пользователя", heading: "Настройки", lead: en.userSettings.lead },
adminPanel: { title: "Панель управления администратора", heading: "Панель управления администратора", lead: en.adminPanel.lead },
adminSettings: { title: "Настройки администратора", heading: "Настройки администратора", lead: en.adminSettings.lead },
notFound: { title: "Не найдено", heading: "404 — страница не найдена", lead: en.notFound.lead },
unauthorized: { title: "Требуется вход", heading: "Требуется вход", lead: en.unauthorized.lead },
};
export const CATALOGS: Readonly<Record<BaseLocale, Messages>> = {
en,
zh,
ar,
cy,
ru,
};

View File

@@ -0,0 +1,87 @@
/**
* Tiny browser auth client that talks directly to the better-auth
* HTTP endpoints mounted by the server at `/api/auth/*`. Keeps the
* demo client free of `better-auth/client` so the React build stays
* lean.
*/
export interface SessionUser {
readonly id: string;
readonly email: string;
readonly name?: string | null;
readonly role?: string | null;
}
export interface Session {
readonly user: SessionUser;
readonly expiresAt?: string;
}
export async function fetchSession(): Promise<Session | null> {
const res = await fetch("/api/auth/get-session", {
credentials: "include",
headers: { accept: "application/json" },
});
if (!res.ok) return null;
const body = (await res.json()) as { user?: SessionUser; session?: { expiresAt?: string } } | null;
if (body === null || body.user === undefined) return null;
return {
user: body.user,
...(body.session?.expiresAt !== undefined ? { expiresAt: body.session.expiresAt } : {}),
};
}
export async function signInEmail(args: {
readonly email: string;
readonly password: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch("/api/auth/sign-in/email", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(args),
});
if (res.ok) return { ok: true };
const body = (await res.json().catch(() => ({}))) as { message?: string };
return { ok: false, error: body.message ?? `Sign-in failed (${res.status})` };
}
export async function signUpEmail(args: {
readonly email: string;
readonly password: string;
readonly name?: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch("/api/auth/sign-up/email", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...args, name: args.name ?? args.email.split("@")[0] }),
});
if (res.ok) return { ok: true };
const body = (await res.json().catch(() => ({}))) as { message?: string };
return { ok: false, error: body.message ?? `Sign-up failed (${res.status})` };
}
export async function signOut(): Promise<void> {
await fetch("/api/auth/sign-out", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: "{}",
}).catch(() => undefined);
}
export async function forgetPassword(args: {
readonly email: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch("/api/auth/forget-password", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...args, redirectTo: "/auth/login" }),
});
if (res.ok) return { ok: true };
// better-auth returns 200 even for unknown emails to avoid enumeration;
// we still surface non-2xx for transport-level errors.
const body = (await res.json().catch(() => ({}))) as { message?: string };
return { ok: false, error: body.message ?? `Request failed (${res.status})` };
}

View File

@@ -0,0 +1,57 @@
/**
* Route guards — wrap a child element with `<RequireAuth>` or
* `<RequireRole role="admin">`. Both rely on `useSession()` so they
* react to live sign-in / sign-out without a hard reload.
*/
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useLocale } from "../i18n/locale-context.js";
import { useSession } from "./session-context.js";
export function RequireAuth({ children }: { readonly children: React.ReactNode }): React.JSX.Element {
const { session, loading } = useSession();
const { base } = useLocale();
const location = useLocation();
if (loading) {
return <div className="p-8 text-sm text-muted">Loading session…</div>;
}
if (session === null) {
return (
<Navigate
to={`/${base}/auth/login?return=${encodeURIComponent(location.pathname)}`}
replace
/>
);
}
return <>{children}</>;
}
export function RequireRole({
role,
children,
}: {
readonly role: string;
readonly children: React.ReactNode;
}): React.JSX.Element {
const { session, loading } = useSession();
const { base, messages } = useLocale();
if (loading) {
return <div className="p-8 text-sm text-muted">Loading session…</div>;
}
if (session === null) {
return <Navigate to={`/${base}/auth/login`} replace />;
}
if (session.user.role !== role) {
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-2xl font-semibold" data-testid="page-heading">
{messages.unauthorized.heading}
</h1>
<p className="mt-2 text-sm text-muted">
Required role: <code>{role}</code>
</p>
</main>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,53 @@
/**
* Session context — polls `/api/auth/get-session` on mount and exposes
* `useSession()` and a `refresh()` to forms.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { fetchSession, type Session } from "./auth-client.js";
export interface SessionContextValue {
readonly session: Session | null;
readonly loading: boolean;
readonly refresh: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export function useSession(): SessionContextValue {
const ctx = useContext(SessionContext);
if (ctx === null) throw new Error("useSession must be inside <SessionProvider>");
return ctx;
}
export function SessionProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try {
const next = await fetchSession();
setSession(next);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const value = useMemo<SessionContextValue>(
() => ({ session, loading, refresh }),
[session, loading, refresh]
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/globals.css";
import { App } from "./app.js";
const root = document.getElementById("root");
if (root === null) throw new Error("No #root element found");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,143 @@
/**
* User and admin gated pages. The auth gate is enforced by
* `<RequireAuth>` / `<RequireRole>` in the route definitions; these
* components only need to assume `useSession().session` is non-null
* and render the placeholder dashboards.
*
* The session-card surface and the settings form are composed from
* `@suaveplan/react-ui` primitives (`<Card>`, `<Field>`, `<Input>`,
* `<FieldDescription>`). The preferred-locale picker keeps a native
* `<select>` because the chassis `<Select>` is a Radix-portal popover
* with overlay state we do not need for a placeholder form.
*/
import {
Card,
CardContent,
CardHeader,
CardTitle,
Field,
FieldDescription,
FieldGroup,
FieldLabel,
Input,
} from "@suaveplan/react-ui";
import React from "react";
import { PageShell } from "../components/page-shell.js";
import { useMessages } from "../i18n/locale-context.js";
import { useSession } from "../lib/session-context.js";
function SessionCard(): React.JSX.Element {
const { session } = useSession();
if (session === null) return <></>;
return (
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="font-medium">User id</dt>
<dd className="font-mono text-xs">{session.user.id}</dd>
</div>
<div>
<dt className="font-medium">Email</dt>
<dd>{session.user.email}</dd>
</div>
{session.user.name !== undefined && session.user.name !== null ? (
<div>
<dt className="font-medium">Name</dt>
<dd>{session.user.name}</dd>
</div>
) : null}
{session.user.role !== undefined && session.user.role !== null ? (
<div>
<dt className="font-medium">Role</dt>
<dd>{session.user.role}</dd>
</div>
) : null}
{session.expiresAt !== undefined ? (
<div>
<dt className="font-medium">Session expires</dt>
<dd>{session.expiresAt}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
);
}
export function UserControlPanelPage(): React.JSX.Element {
const m = useMessages().userPanel;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<SessionCard />
</PageShell>
);
}
export function UserSettingsPage(): React.JSX.Element {
const m = useMessages().userSettings;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<form>
<FieldGroup>
<Field>
<FieldLabel htmlFor="displayName">Display name</FieldLabel>
<Input id="displayName" name="displayName" />
</Field>
<Field>
<FieldLabel htmlFor="preferredLocale">Preferred locale</FieldLabel>
{/*
Native <select> is intentional: the chassis Select is a
Radix-portal popover with overlay state we do not need
for a placeholder form. Visual treatment matches the
chassis Input via the shared input cva class names.
*/}
<select
id="preferredLocale"
name="preferredLocale"
className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="zh">中文</option>
<option value="ar">العربية</option>
<option value="cy">Cymraeg</option>
<option value="ru">Русский</option>
</select>
<FieldDescription>
This is a placeholder — settings are not persisted in the demo.
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</PageShell>
);
}
export function AdminControlPanelPage(): React.JSX.Element {
const m = useMessages().adminPanel;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<SessionCard />
</PageShell>
);
}
export function AdminSettingsPage(): React.JSX.Element {
const m = useMessages().adminSettings;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<p className="text-sm">Admin-only configuration placeholder.</p>
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,283 @@
/**
* Auth pages — Login, Register, ForgotPassword, Logout.
*
* Each posts to the better-auth HTTP endpoint mounted on the server
* under `/api/auth/*` via the thin `auth-client.ts` shim. On success
* the session context is refreshed and the user is sent to their
* control panel.
*
* Forms are composed from `@suaveplan/react-ui` primitives:
* `<Card>` / `<CardContent>` shell, `<Field>` + `<FieldLabel>` +
* `<Input>` + `<FieldError>` per control, and `<Button>` for the
* submit action. The chassis owns all visual variant resolution; the
* demo carries no hand-rolled Tailwind classes for these primitives.
*/
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@suaveplan/react-ui";
import React, { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { PageShell } from "../components/page-shell.js";
import { useLocale } from "../i18n/locale-context.js";
import {
forgetPassword,
signInEmail,
signOut,
signUpEmail,
} from "../lib/auth-client.js";
import { useSession } from "../lib/session-context.js";
export function LoginPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.login;
const { refresh } = useSession();
const navigate = useNavigate();
const [search] = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
setError(null);
const result = await signInEmail({ email, password });
if (result.ok) {
await refresh();
navigate(search.get("return") ?? `/${base}/user/control-panel`);
} else {
setError(result.error);
}
setBusy(false);
}
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<form onSubmit={handleSubmit} data-testid="login-form">
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
{error !== null ? (
<Alert variant="destructive" data-testid="login-error">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={busy}>
{m.submit}
</Button>
<div className="flex flex-wrap justify-between gap-2 text-sm">
<Link to={`/${base}/auth/register`}>{m.linkToRegister}</Link>
<Link to={`/${base}/auth/forgot-password`}>{m.linkToForgot}</Link>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</PageShell>
);
}
export function RegisterPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.register;
const { refresh } = useSession();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setBusy(true);
setError(null);
const result = await signUpEmail({ email, password });
if (result.ok) {
await refresh();
navigate(`/${base}/user/control-panel`);
} else {
setError(result.error);
}
setBusy(false);
}
const mismatch = password !== "" && confirm !== "" && password !== confirm;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<form onSubmit={handleSubmit} data-testid="register-form">
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
<Input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
minLength={6}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<Field invalid={mismatch}>
<FieldLabel htmlFor="confirm">{m.confirmLabel}</FieldLabel>
<Input
id="confirm"
name="confirm"
type="password"
required
autoComplete="new-password"
minLength={6}
invalid={mismatch}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{mismatch ? (
<FieldError>Passwords do not match.</FieldError>
) : null}
</Field>
{error !== null ? (
<Alert variant="destructive" data-testid="register-error">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={busy}>
{m.submit}
</Button>
<div className="text-sm">
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</PageShell>
);
}
export function ForgotPasswordPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.forgot;
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
await forgetPassword({ email });
setSent(true);
setBusy(false);
}
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
{sent ? (
<Alert variant="default" data-testid="forgot-sent">
<AlertDescription>{m.sentNotice}</AlertDescription>
</Alert>
) : (
<form onSubmit={handleSubmit} data-testid="forgot-form">
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Button type="submit" disabled={busy}>
{m.submit}
</Button>
</FieldGroup>
</form>
)}
<div className="mt-4 text-sm">
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
export function LogoutPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.logout;
const { refresh } = useSession();
const navigate = useNavigate();
useEffect(() => {
void (async () => {
await signOut();
await refresh();
navigate(`/${base}/home`);
})();
// We want this effect to run exactly once on mount; the locale
// base is captured at mount time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}

View File

@@ -0,0 +1,39 @@
/**
* 404 page — also doubles as the unknown-locale handler. Offers a
* language picker so a user landing on `/de/home` can switch to a
* supported language without backtracking.
*
* Each language choice renders as a `<Button asChild variant="outline">`
* wrapping a router `<Link>` so the chassis owns the visual treatment
* (border, focus ring, hover state, motion tokens) while the navigation
* stays SPA-internal.
*/
import { Button } from "@suaveplan/react-ui";
import React from "react";
import { Link } from "react-router-dom";
import { PageShell } from "../components/page-shell.js";
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
import { CATALOGS } from "../i18n/messages.js";
export function NotFoundPage(): React.JSX.Element {
// We may be rendered outside a valid locale context (e.g. for
// `/de/home`). The LocaleProvider falls back to "en" in that case,
// so we can safely read messages.
const { availableLocales, messages: ctxMessages } = useLocale();
const m = ctxMessages.notFound;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<ul className="mt-4 flex flex-wrap gap-3">
{availableLocales.map((loc) => (
<li key={loc}>
<Button asChild variant="outline" size="sm">
<Link to={`/${loc}/home`}>
{LOCALE_ENTRIES[loc].nativeName} — {CATALOGS[loc].nav.home}
</Link>
</Button>
</li>
))}
</ul>
</PageShell>
);
}

View File

@@ -0,0 +1,32 @@
/**
* Five public information pages — Home, About, Contact, Privacy,
* Terms. Each renders through `<PageShell>` using the typed catalog.
*/
import React from "react";
import { PageShell } from "../components/page-shell.js";
import { useMessages } from "../i18n/locale-context.js";
export function HomePage(): React.JSX.Element {
const m = useMessages().home;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function AboutPage(): React.JSX.Element {
const m = useMessages().about;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function ContactPage(): React.JSX.Element {
const m = useMessages().contact;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function PrivacyPage(): React.JSX.Element {
const m = useMessages().privacy;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function TermsPage(): React.JSX.Element {
const m = useMessages().terms;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}

View File

@@ -0,0 +1,57 @@
@import "tailwindcss";
:root {
--color-bg: #ffffff;
--color-fg: #0a0a0a;
--color-muted: #71717a;
--color-border: #e4e4e7;
--color-accent: #6366f1;
--color-accent-fg: #ffffff;
--color-surface: #fafafa;
}
[data-theme="dark"] {
--color-bg: #09090b;
--color-fg: #fafafa;
--color-muted: #a1a1aa;
--color-border: #27272a;
--color-accent: #818cf8;
--color-accent-fg: #0a0a0a;
--color-surface: #18181b;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
min-height: 100vh;
background-color: var(--color-bg);
color: var(--color-fg);
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
-webkit-font-smoothing: antialiased;
}
[dir="rtl"] {
text-align: right;
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,185 @@
/**
* Demo authentication boot.
*
* Composes the canonical genesis plugin pipeline:
*
* 1. A live `SurrealAdapter` (`@suaveplan/surrealdb`) — connects to
* `SURREAL_URL` (default `ws://localhost:8000`) with namespace +
* database from env. The driver does not bundle an in-process
* engine in this version, so an external SurrealDB must be
* reachable.
* 2. A `betterAuthSurrealAdapter` (`@suaveplan/better-auth-surrealdb`)
* built against that client — this is the persistence backend
* Better-Auth itself talks to.
* 3. A `SuaveApp` (`@suaveplan/api`) assembled via `defineApp()`. Two
* plugins register, in order:
* a. A tiny inline `demo-db-seed` plugin that drops the live
* `SurrealAdapter` into the `ServiceRegistry["db"]` slot.
* b. `createAuthPlugin(...)` from `@suaveplan/api-plugin-auth`
* — its 6-step `onInit` then resolves the `db` slot, applies
* the canonical 17-table Better-Auth SurrealQL schema,
* constructs the Better-Auth instance, registers the
* `authService`, mounts the 9 OpenAPI routes (and the
* catch-all Better-Auth handler) under `/api/auth/*`, and
* installs the global session-resolver middleware.
*
* The output is `{ app, surreal }` so the HTTP server can mount the
* SPA fallback + custom routes onto `app.hono` and shut down the
* SurrealDB client cleanly on stop.
*/
import { defineApp, type SuaveApp } from "@suaveplan/api";
import { definePlugin } from "@suaveplan/api-plugin";
import type { AuthService } from "@suaveplan/api-plugin-auth";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { SurrealAdapter } from "@suaveplan/surrealdb";
// ServiceRegistry augmentation — re-declared locally because the
// vite-plugin-dts emitted package-root `.d.ts` files for
// `@suaveplan/api` and `@suaveplan/api-plugin-auth` strip the
// side-effect import of the per-package `registry-augmentation.ts`
// where these `declare module` blocks live. Without this re-declaration
// `ServiceRegistry` resolves to `Record<never, never>` and
// `ctx.registerService("db", ...)` / `ctx.getService("authService")`
// fail to typecheck. The augmentation is interface-merged with the
// package-internal ones at runtime, so the typing matches what the
// auth plugin actually populates.
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
db?: SurrealAdapter;
authService: AuthService;
}
}
/** Read a string env var with a non-empty fallback. */
function envOr(name: string, fallback: string): string {
const raw = process.env[name];
return raw !== undefined && raw.length > 0 ? raw : fallback;
}
/** Resolved SurrealDB connection parameters for the demo. */
export interface SurrealConfig {
readonly url: string;
readonly namespace: string;
readonly database: string;
readonly username: string;
readonly password: string;
}
/** Build the connection config from env vars (with defaults). */
export function resolveSurrealConfig(): SurrealConfig {
return {
url: envOr("SURREAL_URL", "ws://localhost:8000"),
namespace: envOr("SURREAL_NS", "demo"),
database: envOr("SURREAL_DB", "demo"),
username: envOr("SURREAL_USER", "root"),
password: envOr("SURREAL_PASS", "root"),
};
}
/** Result of {@link bootAuth}. */
export interface DemoAuthBoot {
readonly app: SuaveApp;
readonly surreal: SurrealAdapter;
readonly authService: AuthService;
}
/**
* Construct + initialize the demo `SuaveApp` with the auth plugin
* wired against a live SurrealDB. Returns the initialized `SuaveApp`
* (ready to receive requests via `app.fetch`) plus the underlying
* `SurrealAdapter` so the caller can `await surreal.disconnect()` on
* shutdown.
*/
export async function bootAuth(): Promise<DemoAuthBoot> {
const cfg = resolveSurrealConfig();
const surreal = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await surreal.connect();
const betterAuthAdapter = betterAuthSurrealAdapter({
client: surreal,
});
/**
* Inline plugin — seeds the `db` slot on the `ServiceRegistry`
* so the auth plugin's `onInit` (which runs after this one in
* registration order) can resolve a connected SurrealDB client
* for `applyBetterAuthSchema(db)`.
*/
const dbSeedPlugin = definePlugin<Record<string, never>>({
name: "demo-db-seed",
version: "0.1.0",
description:
"Demo-only plugin: seeds the live SurrealAdapter into ServiceRegistry['db'] for the auth plugin to consume. Runs in `onRegister` so the `db` slot is populated before any plugin's `onInit` phase (including the auth plugin's).",
defaults: {},
onRegister: async (ctx) => {
ctx.registerService("db", surreal);
},
});
const authPlugin = createAuthPlugin({
betterAuth: {
database: betterAuthAdapter as unknown as Record<string, unknown>,
baseURL: envOr("BETTER_AUTH_URL", "http://localhost:3300"),
secret: envOr(
"BETTER_AUTH_SECRET",
"demo-secret-please-set-BETTER_AUTH_SECRET-in-prod-do-not-use"
),
emailAndPassword: {
enabled: true,
autoSignIn: true,
minPasswordLength: 6,
},
trustedOrigins: ["http://localhost:3300"],
},
routePrefix: "/api/auth",
applySchemaOnInit: true,
});
/**
* Inline capture plugin — registered AFTER `authPlugin` so its
* `onInit` resolves the typed `authService` slot the auth plugin
* just populated, and stashes it on `captured.authService` for the
* demo's `/api/me` handler.
*/
const captured: { authService: AuthService | undefined } = {
authService: undefined,
};
const capturePlugin = definePlugin<Record<string, never>>({
name: "demo-auth-capture",
version: "0.1.0",
description:
"Demo-only plugin: captures the resolved authService from the plugin registry for use by /api/me. Runs in `onReady` so the `authService` slot populated by the auth plugin's `onInit` is guaranteed visible.",
defaults: {},
onReady: async (ctx) => {
captured.authService = ctx.getService("authService");
},
});
const app = await defineApp()
.name("@suaveplan/demo")
.version("0.1.0")
.skipConfigFile()
.openapi({ title: "@suaveplan/demo", version: "0.1.0" })
.healthRoutes({ healthPath: "/api/health" })
.use(dbSeedPlugin)
.use(authPlugin)
.use(capturePlugin)
.build();
await app.initialize();
if (captured.authService === undefined) {
throw new Error(
"demo boot: authService was not populated by api-plugin-auth"
);
}
return { app, surreal, authService: captured.authService };
}
export type AppAuth = SuaveApp;

View File

@@ -0,0 +1,55 @@
/**
* Unified Bun + Hono server for the demo app.
*
* Built around `@suaveplan/api`'s `SuaveApp`:
*
* - `/api/health` — registered by `registerHealthRoutes` on the
* `SuaveApp` Hono.
* - `/api/auth/*` — mounted by `@suaveplan/api-plugin-auth` (9 OpenAPI
* routes + the Better-Auth catch-all).
* - `/api/me` — demo convenience JSON view of the current session,
* sourced from the captured `authService.getSession(...)`.
* - everything else falls through to the Vite-built React SPA in
* `dist/client/`, with a wildcard fallback to `index.html` so the
* client-side router owns locale-prefixed routes.
*/
import { serveStatic } from "hono/bun";
import { bootAuth } from "./auth.js";
const { app, surreal, authService } = await bootAuth();
const hono = app.hono;
hono.get("/api/me", async (c) => {
const result = await authService.getSession(c.req.raw.headers);
return c.json({ session: result });
});
hono.use("/*", serveStatic({ root: "./dist/client" }));
hono.get("*", serveStatic({ path: "./dist/client/index.html" }));
const port = Number(process.env["PORT"] ?? 3300);
// eslint-disable-next-line no-console
console.log(`[@suaveplan/demo] listening on http://localhost:${port}`);
const shutdown = async (): Promise<void> => {
try {
await app.stop();
} catch {
// best-effort
}
try {
await surreal.disconnect();
} catch {
// best-effort
}
};
process.on("SIGTERM", () => {
void shutdown().then(() => process.exit(0));
});
process.on("SIGINT", () => {
void shutdown().then(() => process.exit(0));
});
export default { port, fetch: app.fetch };

12
apps/demo/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["node", "react", "react-dom"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "e2e/**/*.ts"],
"exclude": ["dist", "node_modules", "coverage"]
}

14
apps/demo/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: "dist/client",
emptyOutDir: true,
sourcemap: true,
},
root: ".",
publicDir: "public",
});

View File

@@ -0,0 +1,5 @@
This folder stores temp files that Docusaurus' client bundler accesses.
DO NOT hand-modify files in this folder because they will be overwritten in the
next build. You can clear all build artifacts (including this folder) with the
`docusaurus clear` command.

View File

@@ -0,0 +1,163 @@
{
"entrypoints": [
"main"
],
"origins": {
"456": [
456
],
"622": [
622
],
"17896441": [
869,
401
],
"main": [
354,
869,
792
],
"runtime~main": [
792,
869,
354
],
"0e384e19": [
976
],
"22dd74f7": [
567
],
"5e95c892": [
647
],
"a7bd4aaa": [
98
],
"a94703ab": [
869,
48
],
"aba21aa0": [
742
],
"styles": [
48,
354,
401,
792,
869
]
},
"assets": {
"48": {
"js": [
{
"file": "assets/js/a94703ab.d21732ca.js",
"hash": "248801145fd8928c",
"publicPath": "/assets/js/a94703ab.d21732ca.js"
}
]
},
"98": {
"js": [
{
"file": "assets/js/a7bd4aaa.2cf1ac5a.js",
"hash": "6f3292ea4c81c4b7",
"publicPath": "/assets/js/a7bd4aaa.2cf1ac5a.js"
}
]
},
"354": {
"js": [
{
"file": "assets/js/runtime~main.3eeea4d6.js",
"hash": "93ef5a0e7bc387d8",
"publicPath": "/assets/js/runtime~main.3eeea4d6.js"
}
]
},
"401": {
"js": [
{
"file": "assets/js/17896441.e558579f.js",
"hash": "e2a2a91703a75265",
"publicPath": "/assets/js/17896441.e558579f.js"
}
]
},
"456": {
"js": [
{
"file": "assets/js/456.41b11149.js",
"hash": "c105dca6e9081ec2",
"publicPath": "/assets/js/456.41b11149.js"
}
]
},
"567": {
"js": [
{
"file": "assets/js/22dd74f7.6d35978e.js",
"hash": "e9c668ccbb4eac4b",
"publicPath": "/assets/js/22dd74f7.6d35978e.js"
}
]
},
"622": {
"js": [
{
"file": "assets/js/622.98edb4f0.js",
"hash": "c4687fee7338a5a1",
"publicPath": "/assets/js/622.98edb4f0.js"
}
]
},
"647": {
"js": [
{
"file": "assets/js/5e95c892.c151ec16.js",
"hash": "99a9fdc9726d32c0",
"publicPath": "/assets/js/5e95c892.c151ec16.js"
}
]
},
"742": {
"js": [
{
"file": "assets/js/aba21aa0.a9cfd991.js",
"hash": "4bb470462c0c0dbb",
"publicPath": "/assets/js/aba21aa0.a9cfd991.js"
}
]
},
"792": {
"js": [
{
"file": "assets/js/main.f54ec557.js",
"hash": "862bce4131b89d15",
"publicPath": "/assets/js/main.f54ec557.js"
}
]
},
"869": {
"css": [
{
"file": "assets/css/styles.58cbff57.css",
"hash": "403c7cfb68551a78",
"publicPath": "/assets/css/styles.58cbff57.css"
}
]
},
"976": {
"js": [
{
"file": "assets/js/0e384e19.4cce6415.js",
"hash": "1da36c0f3c4b923e",
"publicPath": "/assets/js/0e384e19.4cce6415.js"
}
]
}
}
}

View File

@@ -0,0 +1,6 @@
export default [
require("/home/paul/web/genesis/node_modules/.bun/infima@0.2.0-alpha.45/node_modules/infima/dist/css/default/default.css"),
require("/home/paul/web/genesis/node_modules/.bun/@docusaurus+theme-classic@3.8.1+a362615079116658/node_modules/@docusaurus/theme-classic/lib/prism-include-languages"),
require("/home/paul/web/genesis/node_modules/.bun/@docusaurus+theme-classic@3.8.1+a362615079116658/node_modules/@docusaurus/theme-classic/lib/nprogress"),
require("/home/paul/web/genesis/apps/docs/src/css/custom.css"),
];

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{"options":{"sidebarPath":"/home/paul/web/genesis/apps/docs/sidebars.ts","routeBasePath":"/","path":"docs","editCurrentVersion":false,"editLocalizedFiles":false,"tagsBasePath":"tags","include":["**/*.{md,mdx}"],"exclude":["**/_*.{js,jsx,ts,tsx,md,mdx}","**/_*/**","**/*.test.{js,jsx,ts,tsx}","**/__tests__/**"],"sidebarCollapsible":true,"sidebarCollapsed":true,"docsRootComponent":"@theme/DocsRoot","docVersionRootComponent":"@theme/DocVersionRoot","docRootComponent":"@theme/DocRoot","docItemComponent":"@theme/DocItem","docTagsListComponent":"@theme/DocTagsListPage","docTagDocListComponent":"@theme/DocTagDocListPage","docCategoryGeneratedIndexComponent":"@theme/DocCategoryGeneratedIndexPage","remarkPlugins":[],"rehypePlugins":[],"recmaPlugins":[],"beforeDefaultRemarkPlugins":[],"beforeDefaultRehypePlugins":[],"admonitions":true,"showLastUpdateTime":false,"showLastUpdateAuthor":false,"includeCurrentVersion":true,"disableVersioning":false,"versions":{},"breadcrumbs":true,"onInlineTags":"warn","id":"default"},"versionsMetadata":[{"versionName":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","path":"/","tagsPath":"/tags","isLast":true,"routePriority":-1,"sidebarFilePath":"/home/paul/web/genesis/apps/docs/sidebars.ts","contentPath":"/home/paul/web/genesis/apps/docs/docs","contentPathLocalized":"/home/paul/web/genesis/apps/docs/i18n/en/docusaurus-plugin-content-docs/current"}]}

View File

@@ -0,0 +1,4 @@
{
"name": "docusaurus-plugin-content-docs",
"id": "default"
}

View File

@@ -0,0 +1 @@
{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"main":[{"type":"link","label":"Introduction","href":"/","docId":"intro","unlisted":false}]},"docs":{"intro":{"id":"intro","title":"Introduction","description":"Welcome to the SuavePlan monorepo documentation. This site is the front door","sidebar":"main"}}}}

View File

@@ -0,0 +1,20 @@
{
"id": "intro",
"title": "Introduction",
"description": "Welcome to the SuavePlan monorepo documentation. This site is the front door",
"source": "@site/docs/intro.md",
"sourceDirName": ".",
"slug": "/",
"permalink": "/",
"draft": false,
"unlisted": false,
"tags": [],
"version": "current",
"sidebarPosition": 1,
"frontMatter": {
"slug": "/",
"sidebar_position": 1,
"title": "Introduction"
},
"sidebar": "main"
}

View File

@@ -0,0 +1,4 @@
{
"name": "docusaurus-plugin-content-pages",
"id": "default"
}

View File

@@ -0,0 +1,356 @@
/*
* AUTOGENERATED - DON'T EDIT
* Your edits in this file will be overwritten in the next build!
* Modify the docusaurus.config.js file at your site's root instead.
*/
export default {
"title": "SuavePlan",
"tagline": "Enterprise-grade monorepo, rebuilt bottom-up",
"favicon": "img/favicon.ico",
"url": "https://docs.suaveplan.com",
"baseUrl": "/",
"organizationName": "SuavePlan",
"projectName": "genesis",
"onBrokenLinks": "warn",
"onBrokenMarkdownLinks": "warn",
"i18n": {
"defaultLocale": "en",
"locales": [
"en"
],
"path": "i18n",
"localeConfigs": {}
},
"presets": [
[
"classic",
{
"docs": {
"sidebarPath": "./sidebars.ts",
"routeBasePath": "/"
},
"blog": false,
"theme": {
"customCss": "./src/css/custom.css"
}
}
]
],
"themeConfig": {
"navbar": {
"title": "SuavePlan",
"logo": {
"alt": "SuavePlan",
"src": "img/logo.svg"
},
"items": [
{
"type": "docSidebar",
"sidebarId": "main",
"position": "left",
"label": "Docs"
},
{
"href": "https://github.com/SuavePlan/genesis",
"label": "GitHub",
"position": "right"
}
],
"hideOnScroll": false
},
"footer": {
"style": "dark",
"links": [
{
"title": "Repo",
"items": [
{
"label": "GitHub",
"href": "https://github.com/SuavePlan/genesis"
}
]
}
],
"copyright": "Copyright © 2026 SuavePlan."
},
"prism": {
"theme": {
"plain": {
"color": "#393A34",
"backgroundColor": "#f6f8fa"
},
"styles": [
{
"types": [
"comment",
"prolog",
"doctype",
"cdata"
],
"style": {
"color": "#999988",
"fontStyle": "italic"
}
},
{
"types": [
"namespace"
],
"style": {
"opacity": 0.7
}
},
{
"types": [
"string",
"attr-value"
],
"style": {
"color": "#e3116c"
}
},
{
"types": [
"punctuation",
"operator"
],
"style": {
"color": "#393A34"
}
},
{
"types": [
"entity",
"url",
"symbol",
"number",
"boolean",
"variable",
"constant",
"property",
"regex",
"inserted"
],
"style": {
"color": "#36acaa"
}
},
{
"types": [
"atrule",
"keyword",
"attr-name",
"selector"
],
"style": {
"color": "#00a4db"
}
},
{
"types": [
"function",
"deleted",
"tag"
],
"style": {
"color": "#d73a49"
}
},
{
"types": [
"function-variable"
],
"style": {
"color": "#6f42c1"
}
},
{
"types": [
"tag",
"selector",
"keyword"
],
"style": {
"color": "#00009f"
}
}
]
},
"darkTheme": {
"plain": {
"color": "#F8F8F2",
"backgroundColor": "#282A36"
},
"styles": [
{
"types": [
"prolog",
"constant",
"builtin"
],
"style": {
"color": "rgb(189, 147, 249)"
}
},
{
"types": [
"inserted",
"function"
],
"style": {
"color": "rgb(80, 250, 123)"
}
},
{
"types": [
"deleted"
],
"style": {
"color": "rgb(255, 85, 85)"
}
},
{
"types": [
"changed"
],
"style": {
"color": "rgb(255, 184, 108)"
}
},
{
"types": [
"punctuation",
"symbol"
],
"style": {
"color": "rgb(248, 248, 242)"
}
},
{
"types": [
"string",
"char",
"tag",
"selector"
],
"style": {
"color": "rgb(255, 121, 198)"
}
},
{
"types": [
"keyword",
"variable"
],
"style": {
"color": "rgb(189, 147, 249)",
"fontStyle": "italic"
}
},
{
"types": [
"comment"
],
"style": {
"color": "rgb(98, 114, 164)"
}
},
{
"types": [
"attr-name"
],
"style": {
"color": "rgb(241, 250, 140)"
}
}
]
},
"additionalLanguages": [
"bash",
"json",
"yaml",
"toml"
],
"magicComments": [
{
"className": "theme-code-block-highlighted-line",
"line": "highlight-next-line",
"block": {
"start": "highlight-start",
"end": "highlight-end"
}
}
]
},
"colorMode": {
"defaultMode": "light",
"disableSwitch": false,
"respectPrefersColorScheme": false
},
"docs": {
"versionPersistence": "localStorage",
"sidebar": {
"hideable": false,
"autoCollapseCategories": false
}
},
"blog": {
"sidebar": {
"groupByYear": true
}
},
"metadata": [],
"tableOfContents": {
"minHeadingLevel": 2,
"maxHeadingLevel": 3
}
},
"baseUrlIssueBanner": true,
"future": {
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
"useCssCascadeLayers": false
},
"experimental_faster": {
"swcJsLoader": false,
"swcJsMinimizer": false,
"swcHtmlMinimizer": false,
"lightningCssMinimizer": false,
"mdxCrossCompilerCache": false,
"rspackBundler": false,
"rspackPersistentCache": false,
"ssgWorkerThreads": false
},
"experimental_storage": {
"type": "localStorage",
"namespace": false
},
"experimental_router": "browser"
},
"onBrokenAnchors": "warn",
"onDuplicateRoutes": "warn",
"staticDirectories": [
"static"
],
"customFields": {},
"plugins": [],
"themes": [],
"scripts": [],
"headTags": [],
"stylesheets": [],
"clientModules": [],
"titleDelimiter": "|",
"noIndex": false,
"markdown": {
"format": "mdx",
"mermaid": false,
"mdx1Compat": {
"comments": true,
"admonitions": true,
"headingIds": true
},
"anchors": {
"maintainCase": false
}
}
};

View File

@@ -0,0 +1,33 @@
{
"docusaurus-plugin-content-docs": {
"default": {
"path": "/",
"versions": [
{
"name": "current",
"label": "Next",
"isLast": true,
"path": "/",
"mainDocId": "intro",
"docs": [
{
"id": "intro",
"path": "/",
"sidebar": "main"
}
],
"draftIds": [],
"sidebars": {
"main": {
"link": {
"path": "/",
"label": "intro"
}
}
}
}
],
"breadcrumbs": true
}
}
}

View File

@@ -0,0 +1,17 @@
{
"defaultLocale": "en",
"locales": [
"en"
],
"path": "i18n",
"currentLocale": "en",
"localeConfigs": {
"en": {
"label": "English",
"direction": "ltr",
"htmlLang": "en",
"calendar": "gregory",
"path": "en"
}
}
}

View File

@@ -0,0 +1,8 @@
export default {
"0e384e19": [() => import(/* webpackChunkName: "0e384e19" */ "@site/docs/intro.md"), "@site/docs/intro.md", require.resolveWeak("@site/docs/intro.md")],
"17896441": [() => import(/* webpackChunkName: "17896441" */ "@theme/DocItem"), "@theme/DocItem", require.resolveWeak("@theme/DocItem")],
"22dd74f7": [() => import(/* webpackChunkName: "22dd74f7" */ "@generated/docusaurus-plugin-content-docs/default/p/index-466.json"), "@generated/docusaurus-plugin-content-docs/default/p/index-466.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/p/index-466.json")],
"5e95c892": [() => import(/* webpackChunkName: "5e95c892" */ "@theme/DocsRoot"), "@theme/DocsRoot", require.resolveWeak("@theme/DocsRoot")],
"a7bd4aaa": [() => import(/* webpackChunkName: "a7bd4aaa" */ "@theme/DocVersionRoot"), "@theme/DocVersionRoot", require.resolveWeak("@theme/DocVersionRoot")],
"a94703ab": [() => import(/* webpackChunkName: "a94703ab" */ "@theme/DocRoot"), "@theme/DocRoot", require.resolveWeak("@theme/DocRoot")],
"aba21aa0": [() => import(/* webpackChunkName: "aba21aa0" */ "@generated/docusaurus-plugin-content-docs/default/__plugin.json"), "@generated/docusaurus-plugin-content-docs/default/__plugin.json", require.resolveWeak("@generated/docusaurus-plugin-content-docs/default/__plugin.json")],};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
{
path: '/',
component: ComponentCreator('/', '719'),
routes: [
{
path: '/',
component: ComponentCreator('/', 'd8d'),
routes: [
{
path: '/',
component: ComponentCreator('/', 'e93'),
routes: [
{
path: '/',
component: ComponentCreator('/', 'bc9'),
exact: true,
sidebar: "main"
}
]
}
]
}
]
},
{
path: '*',
component: ComponentCreator('*'),
},
];

View File

@@ -0,0 +1,19 @@
{
"/-719": {
"__comp": "5e95c892",
"__context": {
"plugin": "aba21aa0"
}
},
"/-d8d": {
"__comp": "a7bd4aaa",
"__props": "22dd74f7"
},
"/-e93": {
"__comp": "a94703ab"
},
"/-bc9": {
"__comp": "17896441",
"content": "0e384e19"
}
}

View File

@@ -0,0 +1,31 @@
{
"docusaurusVersion": "3.8.1",
"siteVersion": "0.1.0",
"pluginVersions": {
"docusaurus-plugin-content-docs": {
"type": "package",
"name": "@docusaurus/plugin-content-docs",
"version": "3.8.1"
},
"docusaurus-plugin-content-pages": {
"type": "package",
"name": "@docusaurus/plugin-content-pages",
"version": "3.8.1"
},
"docusaurus-plugin-sitemap": {
"type": "package",
"name": "@docusaurus/plugin-sitemap",
"version": "3.8.1"
},
"docusaurus-plugin-svgr": {
"type": "package",
"name": "@docusaurus/plugin-svgr",
"version": "3.8.1"
},
"docusaurus-theme-classic": {
"type": "package",
"name": "@docusaurus/theme-classic",
"version": "3.8.1"
}
}
}

View File

@@ -0,0 +1,4 @@
{
"type": "localStorage",
"namespace": ""
}

12
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules
build
out
docs-app
docs/.vitepress/cache
docs/.vitepress/dist
# All mirrored content under docs/ except the VitePress config is
# regenerated by scripts/snapshot-docs-content.ts.
docs/*.md
!docs/.vitepress/**
docs/packages
docs/openspec

62
apps/docs/README.md Normal file
View File

@@ -0,0 +1,62 @@
# @suaveplan/docs - Docusaurus 3.10 documentation site for the SuavePlan monorepo
The `@suaveplan/docs` app is the public-facing documentation portal for
the genesis monorepo. It serves a Docusaurus 3.10 site that renders the
package READMEs, OpenSpec specs, and architecture guides under a single
sidebar.
## Local development
```bash
bun install
bun run --filter @suaveplan/docs dev
```
The dev server runs on `http://localhost:6043`. Hot-reload is enabled.
## Runtime: pure Bun
This app runs end-to-end under Bun — no Node required. The site is built
with **VitePress** (Vite + Vue) which works cleanly under Bun's runtime;
we previously tried Docusaurus 3.10 but its webpack-bundled SSR step
fails with `require.resolveWeak is not a function` regardless of Bun
flags or `@docusaurus/faster` config.
The static output is served in production by a Bun bytecode-compiled
binary (`docs-app`) — no Node, no Bun runtime, no `node_modules` on the
deployment target.
## Content pipeline
`scripts/snapshot-docs-content.ts` (at the repo root) mirrors every
`README.md`, package module `.md`, and OpenSpec capability spec from the
source tree into `apps/docs/docs/`, preserving paths so the auto-generated
relative links keep resolving locally. The snapshot runs automatically as
part of `bun run build`. All mirrored content is gitignored — the source
of truth stays in the package directories.
## Production build
```bash
bun run --filter @suaveplan/docs build
bun run --filter @suaveplan/docs serve
```
The static output lands in `apps/docs/build/`. Serve it from any static
host — Cloudflare Pages, Vercel, or a plain `bun --static-serve` setup.
## Content layout
- `docs/` — markdown / MDX pages picked up by the auto-generated sidebar.
- `src/css/custom.css` — site-wide theme variables.
- `docusaurus.config.ts` — navbar, footer, presets.
This app intentionally has zero workspace `@suaveplan/*` runtime deps. It
is a documentation portal that reads filesystem content; it does not import
genesis libraries.
<!-- begin:auto-spec-link -->
## OpenSpec
Canonical capability spec: [`../../openspec/specs/docs/spec.md`](../../openspec/specs/docs/spec.md).
<!-- end:auto-spec-link -->

View File

@@ -0,0 +1,27 @@
# docker-compose for @suaveplan/docs build.
#
# Runs Docusaurus inside a Node 22 LTS container. The `docs/` directory
# from the host (populated by `bun scripts/snapshot-docs-content.ts`) is
# mounted read-only as the input; the host's `build/` directory is mounted
# read-write as the output. After the one-shot build completes, the host
# has `apps/docs/build/` ready to be served by the Bun bytecode binary.
#
# Usage from `apps/docs/`:
# bun run snapshot # populate ./docs
# docker compose run --rm --build builder
# bun run build:server # compile the static-server binary
#
# Or all in one step:
# bun run build:all
services:
builder:
build:
context: ./docusaurus
dockerfile: Dockerfile
image: suaveplan-docs-builder
container_name: suaveplan-docs-builder
volumes:
- ./docs:/work/docs:ro
- ./build:/work/build
working_dir: /work

View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1.7
#
# Build container for @suaveplan/docs.
#
# This image holds the Docusaurus toolchain (Node 22 LTS + npm + docusaurus
# deps). It is run via docker-compose to consume a snapshot of the repo's
# markdown content (mounted as `/work/docs`) and produce the static build
# output (mounted as `/work/build` -> host `apps/docs/build/`).
#
# The Bun bytecode-compiled runtime server lives outside this image.
FROM node:22-bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV NODE_ENV=production
ENV NPM_CONFIG_FUND=false
ENV NPM_CONFIG_AUDIT=false
WORKDIR /work
# Toolchain deps first so the layer caches when only content changes.
COPY package.json ./
RUN npm install --no-save --omit=optional
# Site configuration — these change rarely, content changes often.
COPY docusaurus.config.ts sidebars.ts ./
COPY src ./src
COPY tsconfig.json ./
# Output to a subdirectory of the mounted volume so Docusaurus can rmdir
# its own output between runs without touching the bind mount itself.
CMD ["npx", "docusaurus", "build", "--out-dir", "/work/build/site"]

View File

@@ -0,0 +1,67 @@
import type { Config } from "@docusaurus/types";
import type * as Preset from "@docusaurus/preset-classic";
import { themes as prismThemes } from "prism-react-renderer";
const config: Config = {
title: "SuavePlan",
tagline: "Enterprise-grade monorepo, rebuilt bottom-up",
favicon: "img/favicon.ico",
url: "https://docs.suaveplan.com",
baseUrl: "/",
organizationName: "SuavePlan",
projectName: "genesis",
onBrokenLinks: "warn",
onBrokenMarkdownLinks: "warn",
// Use plain CommonMark rather than MDX so spec files containing
// ICU-style `{var}` braces and bare HTML-like tokens render as
// literals instead of being treated as JSX expressions.
markdown: {
format: "md",
},
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
{
docs: {
path: "docs",
routeBasePath: "/",
sidebarPath: "./sidebars.ts",
},
blog: false,
theme: {
customCss: "./src/css/custom.css",
},
} satisfies Preset.Options,
],
],
themeConfig: {
navbar: {
title: "SuavePlan",
items: [
{
type: "docSidebar",
sidebarId: "main",
position: "left",
label: "Docs",
},
],
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ["bash", "json", "yaml", "toml"],
},
} satisfies Preset.ThemeConfig,
};
export default config;

View File

@@ -0,0 +1,35 @@
{
"name": "@suaveplan/docs-builder",
"version": "0.1.0",
"private": true,
"description": "Containerized Docusaurus build environment for @suaveplan/docs",
"scripts": {
"build": "docusaurus build",
"serve": "docusaurus serve --port 6043",
"clear": "docusaurus clear"
},
"dependencies": {
"@docusaurus/core": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/types": "^3.10.0"
},
"browserslist": {
"production": [">0.5%", "not dead", "not op_mini all"],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20"
}
}

View File

@@ -0,0 +1,7 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
const sidebars: SidebarsConfig = {
main: [{ type: "autogenerated", dirName: "." }],
};
export default sidebars;

View File

@@ -0,0 +1,13 @@
:root {
--ifm-color-primary: #2e7d8f;
--ifm-color-primary-dark: #2a7081;
--ifm-color-primary-darker: #286a7a;
--ifm-color-primary-darkest: #205864;
--ifm-color-primary-light: #328a9d;
--ifm-color-primary-lighter: #3590a4;
--ifm-color-primary-lightest: #3da4ba;
--ifm-code-font-size: 95%;
}
[data-theme="dark"] {
--ifm-color-primary: #61c4d8;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true
},
"include": ["src/", "docusaurus.config.ts", "sidebars.ts"]
}

25
apps/docs/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@suaveplan/docs-portal",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Docusaurus 3.10 documentation portal for the SuavePlan monorepo, built via docker-compose and served by a Bun bytecode binary",
"scripts": {
"snapshot": "bun ../../scripts/snapshot-docs-content.ts",
"build:docs": "docker compose run --rm --build builder",
"build:server": "bun build --compile --minify --bytecode --target=bun-linux-x64-baseline src/server/index.ts --outfile=docs-app",
"build:all": "bun run snapshot && bun run build:docs && bun run build:server",
"serve": "bun run src/server/index.ts",
"clean": "rm -rf build out docs-app",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
},
"engines": {
"bun": ">=1.3"
},
"devDependencies": {
"@biomejs/biome": "catalog:tooling",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "catalog:"
}
}

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env bun
/**
* Bun static-server for the `@suaveplan/docs` Docusaurus build output.
*
* Designed to be `bun build --compile`-ed into a single self-contained
* binary for production deployment. The binary expects to find the
* Docusaurus output at `./build/` (relative to its current working
* directory) by default; override with the `DOCS_BUILD_DIR` env var.
*
* Configuration:
* PORT — listen port (default: 3997)
* HOST — listen address (default: 127.0.0.1)
* DOCS_BUILD_DIR — absolute or relative path to the Docusaurus build
* output (default: "./build")
*
* Production deployment: place the binary alongside its `build/` directory,
* point a systemd unit at the binary, and front it with a reverse proxy
* (Caddy / nginx) that terminates TLS.
*/
import { existsSync, statSync } from "node:fs";
import { extname, join, normalize, resolve, sep } from "node:path";
const PORT = Number.parseInt(process.env.PORT ?? "3997", 10);
const HOST = process.env.HOST ?? "127.0.0.1";
const BUILD_DIR = resolve(process.env.DOCS_BUILD_DIR ?? "./build");
const MIME: Readonly<Record<string, string>> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".map": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml; charset=utf-8",
".webmanifest": "application/manifest+json",
};
const HASHED_ASSET_RE = /\.[0-9a-f]{8,}\.[a-z0-9]+$/iu;
function contentType(path: string): string {
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
}
function cacheHeaders(path: string): Record<string, string> {
// Docusaurus content-hashes static assets — they can be cached forever.
// HTML and the root-relative service-worker / manifest must revalidate.
if (HASHED_ASSET_RE.test(path)) {
return { "cache-control": "public, max-age=31536000, immutable" };
}
const ext = extname(path).toLowerCase();
if (ext === ".html" || ext === ".webmanifest" || ext === ".xml") {
return { "cache-control": "public, max-age=0, must-revalidate" };
}
return { "cache-control": "public, max-age=3600" };
}
function resolveSafe(urlPath: string): string | undefined {
// Strip query / hash and decode percent-escapes.
let decoded: string;
try {
decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/");
} catch {
return undefined;
}
// Normalize and ensure the resolved path stays inside BUILD_DIR.
const joined = normalize(join(BUILD_DIR, decoded));
const guard = BUILD_DIR.endsWith(sep) ? BUILD_DIR : `${BUILD_DIR}${sep}`;
if (joined !== BUILD_DIR && !joined.startsWith(guard)) return undefined;
return joined;
}
function tryServe(absPath: string): Response | undefined {
if (!existsSync(absPath)) return undefined;
let info: ReturnType<typeof statSync>;
try {
info = statSync(absPath);
} catch {
return undefined;
}
if (info.isDirectory()) {
const indexPath = join(absPath, "index.html");
if (!existsSync(indexPath)) return undefined;
return serveFile(indexPath);
}
return serveFile(absPath);
}
function serveFile(absPath: string): Response {
const file = Bun.file(absPath);
return new Response(file, {
headers: {
"content-type": contentType(absPath),
...cacheHeaders(absPath),
},
});
}
function notFound(): Response {
const fallback = join(BUILD_DIR, "404.html");
if (existsSync(fallback)) {
return new Response(Bun.file(fallback), {
status: 404,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "public, max-age=0, must-revalidate",
},
});
}
return new Response("Not Found", {
status: 404,
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
if (!existsSync(BUILD_DIR)) {
console.error(`[docs-app] build dir not found: ${BUILD_DIR}`);
process.exit(2);
}
const server = Bun.serve({
port: PORT,
hostname: HOST,
fetch(req: Request): Response {
const url = new URL(req.url);
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("Method Not Allowed", {
status: 405,
headers: { allow: "GET, HEAD" },
});
}
// Health endpoint for systemd / reverse-proxy probes.
if (url.pathname === "/_health") {
return new Response("ok", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
const safePath = resolveSafe(url.pathname);
if (!safePath) return notFound();
const response = tryServe(safePath);
if (response) return response;
// Docusaurus routes everything to its own SPA-ish HTML — let the
// build's 404.html handle unknowns so the client-side router can
// recover.
return notFound();
},
});
console.log(
`[docs-app] serving ${BUILD_DIR} on http://${server.hostname}:${server.port}`
);

16
apps/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["node", "bun"]
},
"include": ["src/", "docs/.vitepress/"]
}

View File

@@ -0,0 +1,14 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/test",
],
docs: { autodocs: "tag" },
};
export default config;

View File

@@ -0,0 +1,32 @@
import type { Decorator, Preview } from "@storybook/react";
const ThemeDecorator: Decorator = (Story) => (
<div style={{ display: "flex", gap: "16px" }}>
<div data-theme="light" style={{ flex: 1 }}>
<Story />
</div>
<div data-theme="dark" style={{ flex: 1, backgroundColor: "#09090b", padding: "8px" }}>
<Story />
</div>
</div>
);
const LocaleDecorator: Decorator = (Story) => (
<div lang="en">
<Story />
</div>
);
const preview: Preview = {
decorators: [ThemeDecorator, LocaleDecorator],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

8
apps/testbed/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
EXPOSE 3000
CMD ["bun", "run", "start"]

174
apps/testbed/README.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";
test.describe("Autofill Detection", () => {
test("extension-test page renders without extension", async ({ page }) => {
await page.goto("/extension-test");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"Extension Test",
);
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("output shows 'not detected' without extension injection", async ({
page,
}) => {
await page.goto("/extension-test");
const output = page.locator('[data-testid="output"]');
await expect(output).toBeVisible();
// Without extension, should not show detected
const text = await output.textContent();
expect(text).not.toContain("✓ Extension injected");
});
test("MutationObserver detects programmatic injection", async ({ page }) => {
await page.goto("/extension-test");
// Simulate extension injection via page.evaluate
await page.evaluate(() => {
const div = document.createElement("div");
div.id = "extension-injected";
div.textContent = "Injected by test";
document.body.appendChild(div);
});
// Output should update to show detected
await expect(page.locator('[data-testid="output"]')).toContainText(
"Extension injected",
{ timeout: 3000 },
);
});
test("extension-mount div is present as injection target", async ({
page,
}) => {
await page.goto("/extension-test");
const mount = page.locator('[data-testid="extension-mount"]');
await expect(mount).toBeVisible();
// The injection target div has both testid and id attributes
await expect(page.locator("#extension-mount")).toBeVisible();
});
test("console-panel is visible on extension page", async ({ page }) => {
await page.goto("/extension-test");
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
// Path to the test extension fixture
const EXTENSION_PATH = path.resolve(
new URL(import.meta.url).pathname,
"../../fixtures/test-extension",
);
test.describe("Content Script Extension", () => {
test.skip(
!fs.existsSync(EXTENSION_PATH),
"Test extension fixture not found — skipping extension tests",
);
test("extension page title is visible without extension", async ({
page,
}) => {
// Basic smoke test — no extension needed
await page.goto("/extension-test");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"Extension Test",
);
await expect(page.locator('[data-testid="extension-mount"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("extension-mount target is present on page", async ({ page }) => {
await page.goto("/extension-test");
await expect(page.locator('[data-testid="extension-mount"]')).toBeVisible();
// The id is also extension-mount for CSS targeting by extensions
await expect(page.locator("#extension-mount")).toBeVisible();
});
test.skip("extension injects content into page", async () => {
// Full extension test — requires the extension fixture to be built
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-"));
const { context, extensionId } = await launchWithExtension({
extensionPath: EXTENSION_PATH,
userDataDir,
});
try {
const page = await context.newPage();
await page.goto("http://localhost:3200/extension-test");
expect(extensionId).toBeTruthy();
await expect(page.locator('[data-testid="output"]')).toContainText(
"Extension injected",
{ timeout: 5000 },
);
} finally {
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
test.describe("Console Baseline", () => {
const routes = ["/", "/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
for (const route of routes) {
test(`${route} has no console errors on load`, async ({ page }) => {
const monitor = attachConsoleMonitor(page, {
failOn: ["error"],
ignore: [
// Ignore known non-critical messages
/ResizeObserver loop/,
/WebSocket connection/,
/Failed to load resource.*favicon/,
],
});
await page.goto(route);
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
monitor.assertClean();
});
}
test("console-panel is present on every library page", async ({ page }) => {
const libraryRoutes = ["/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
for (const route of libraryRoutes) {
await page.goto(route);
await expect(page.locator('[data-testid="console-panel"]')).toBeAttached();
}
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
test.describe("Locale Switcher", () => {
test("locale switcher is visible in header", async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-testid="header"]')).toBeVisible();
// Locale switcher should be present
const localeSwitcher = page.locator('[data-testid="locale-switcher"]');
// If not using that testid, check for a select or button in the header
const headerInteractives = page.locator('[data-testid="header"] select, [data-testid="header"] button');
const count = await headerInteractives.count();
expect(count).toBeGreaterThan(0);
});
test("i18n page locale-select changes output text", async ({ page }) => {
await page.goto("/i18n");
await expect(page.locator('[data-testid="output"]')).toBeVisible();
// Get initial text in English
const initialText = await page.locator('[data-testid="output"]').textContent();
expect(initialText).toBeTruthy();
// Switch to French
await page.locator('[data-testid="locale-select"]').selectOption("fr");
// Output should now contain French text
const frenchText = await page.locator('[data-testid="output"]').textContent();
expect(frenchText).toContain("article");
});
test("i18n page locale-select changes to Japanese", async ({ page }) => {
await page.goto("/i18n");
await page.locator('[data-testid="locale-select"]').selectOption("ja");
const text = await page.locator('[data-testid="output"]').textContent();
// Japanese output contains Japanese characters or specific words
expect(text).toBeTruthy();
expect(text).toContain("アイテム");
});
});

View File

@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test";
test.describe("Navigation", () => {
test("home page renders page-title", async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
await expect(page.locator('[data-testid="header"]')).toBeVisible();
});
test("sidebar nav links exist for all routes", async ({ page }) => {
await page.goto("/");
// Check a few known nav links
await expect(page.locator('[data-testid="nav-crypto"]')).toBeVisible();
await expect(page.locator('[data-testid="nav-logger"]')).toBeVisible();
await expect(page.locator('[data-testid="nav-datetime"]')).toBeVisible();
});
const libraryRoutes = [
{ path: "/crypto", title: "@suaveplan/crypto" },
{ path: "/logger", title: "@suaveplan/logger" },
{ path: "/datetime", title: "@suaveplan/datetime" },
{ path: "/i18n", title: "@suaveplan/i18n" },
{ path: "/feature-flags", title: "@suaveplan/feature-flags" },
];
for (const route of libraryRoutes) {
test(`navigating to ${route.path} shows page-title`, async ({ page }) => {
await page.goto(route.path);
await expect(page.locator('[data-testid="page-title"]')).toContainText(
route.title,
);
});
}
test("all pages have demo-card and console-panel", async ({ page }) => {
const routes = ["/", "/crypto", "/logger", "/datetime", "/i18n", "/feature-flags"];
for (const route of routes) {
await page.goto(route);
await expect(page.locator('[data-testid="demo-card"]')).toBeAttached();
await expect(page.locator('[data-testid="console-panel"]')).toBeAttached();
}
});
test("home page shows library cards", async ({ page }) => {
await page.goto("/");
const cards = page.locator('[data-testid="library-card"]');
await expect(cards.first()).toBeVisible();
const count = await cards.count();
expect(count).toBeGreaterThan(5);
});
test("clicking a library card navigates to the page", async ({ page }) => {
await page.goto("/");
await page.locator('[data-testid="library-card"]').first().click();
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
});
test("404 page renders page-title", async ({ page }) => {
await page.goto("/does-not-exist");
await expect(page.locator('[data-testid="page-title"]')).toContainText("404");
});
});

View File

@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
test.describe("Network Baseline", () => {
test("home page loads without network failures", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("/");
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
// Filter out WebSocket attempts (expected to fail in test env)
const failures = monitor.failures.filter(
(f) => !f.url.includes("/api/ws") && !f.url.includes("stun:"),
);
expect(failures).toHaveLength(0);
});
test("crypto page loads without network failures", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("/crypto");
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
const failures = monitor.failures.filter(
(f) => !f.url.includes("/api/ws"),
);
expect(failures).toHaveLength(0);
});
test("storage-s3 status check is made on page load", async ({ page }) => {
const requests: string[] = [];
page.on("request", (req) => requests.push(req.url()));
await page.goto("/storage-s3");
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
expect(requests.some((r) => r.includes("/api/storage/status"))).toBe(true);
});
test("surrealdb status check is made on page load", async ({ page }) => {
const requests: string[] = [];
page.on("request", (req) => requests.push(req.url()));
await page.goto("/surrealdb");
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
expect(requests.some((r) => r.includes("/api/database/status"))).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
test.describe("Theme Toggle", () => {
test("theme toggle button is visible in header", async ({ page }) => {
await page.goto("/");
await expect(page.locator('[data-testid="header"]')).toBeVisible();
// The theme toggle button should be in the header
const themeToggle = page.locator('[data-testid="theme-toggle"]');
// If the component uses a different testid, try the button within header
const headerBtns = page.locator('[data-testid="header"] button');
const btnCount = await headerBtns.count();
expect(btnCount).toBeGreaterThan(0);
});
test("page loads without dark class by default", async ({ page }) => {
await page.goto("/");
// Default should not have dark mode active unless system preference is dark
const html = page.locator("html");
await expect(html).toBeVisible();
});
});

View File

@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
test.describe("Crypto Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/crypto");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/crypto",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("encrypt button produces ciphertext in output", async ({ page }) => {
await page.locator('[aria-label="Plaintext input"]').fill("Hello, Playwright!");
await page.locator('button:has-text("Encrypt")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("ciphertext:");
});
test("encrypt then decrypt round-trip", async ({ page }) => {
const plaintext = "Round-trip test message";
await page.locator('[aria-label="Plaintext input"]').fill(plaintext);
await page.locator('button:has-text("Encrypt")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("ciphertext:");
await page.locator('button:has-text("Decrypt")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("decrypted:");
await expect(page.locator('[data-testid="output"]')).toContainText(plaintext);
});
test("decrypt without encrypting first shows error", async ({ page }) => {
// Click decrypt before encrypting
await page.locator('button:has-text("Decrypt")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("Encrypt first");
});
test("console-panel is visible", async ({ page }) => {
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
test.describe("Datetime Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/datetime");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/datetime",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("calculate adds 5 business days from a Monday", async ({ page }) => {
// Monday 2026-04-27 + 5 business days = Monday 2026-05-04
await page.locator('input[type="date"]').fill("2026-04-27");
await page.locator('input[type="number"]').fill("5");
await page.locator('button:has-text("Calculate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Result:");
// Should contain a day name
const text = await output.textContent();
expect(text).toMatch(/Monday|Tuesday|Wednesday|Thursday|Friday/);
});
test("calculate skips weekends", async ({ page }) => {
// Friday 2026-04-24 + 1 business day = Monday 2026-04-27
await page.locator('input[type="date"]').fill("2026-04-24");
await page.locator('input[type="number"]').fill("1");
await page.locator('button:has-text("Calculate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Monday");
});
test("calculate with negative days goes backwards", async ({ page }) => {
// Monday 2026-04-27 - 1 business day = Friday 2026-04-24
await page.locator('input[type="date"]').fill("2026-04-27");
await page.locator('input[type="number"]').fill("-1");
await page.locator('button:has-text("Calculate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Friday");
});
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
test.describe("Feature Flags Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/feature-flags");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/feature-flags",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("initial output shows placeholder text", async ({ page }) => {
await expect(page.locator('[data-testid="output"]')).toContainText(
"Enter a user ID and click Evaluate",
);
});
test("evaluating with 'alice' shows new-checkout enabled", async ({ page }) => {
await page.locator('input[type="text"]').fill("alice");
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("new-checkout");
// alice is in the user-target list, so new-checkout should be enabled
const text = await output.textContent();
const lines = text?.split("\n") ?? [];
const checkoutLine = lines.find((l) => l.includes("new-checkout"));
expect(checkoutLine).toContain("✓");
});
test("evaluating with 'carol' shows new-checkout disabled", async ({ page }) => {
await page.locator('input[type="text"]').fill("carol");
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("new-checkout");
const text = await output.textContent();
const lines = text?.split("\n") ?? [];
const checkoutLine = lines.find((l) => l.includes("new-checkout"));
expect(checkoutLine).toContain("✗");
});
test("dark-mode flag is always enabled (100% rollout)", async ({ page }) => {
await page.locator('input[type="text"]').fill("any-user-id");
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("dark-mode");
const text = await output.textContent();
const lines = text?.split("\n") ?? [];
const darkModeLine = lines.find((l) => l.includes("dark-mode"));
expect(darkModeLine).toContain("✓");
});
test("three flags appear in results", async ({ page }) => {
await page.locator('button:has-text("Evaluate")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("beta-ui");
await expect(output).toContainText("new-checkout");
await expect(output).toContainText("dark-mode");
});
});

View File

@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
test.describe("i18n Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/i18n");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/i18n",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("English locale shows 'item' in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("en");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("item");
});
test("French locale shows 'article' in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("fr");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("article");
});
test("German locale shows 'Artikel' in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("de");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("Artikel");
});
test("Japanese locale shows Japanese characters in output", async ({ page }) => {
await page.locator('[data-testid="locale-select"]').selectOption("ja");
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("アイテム");
});
test("increment button increases count display", async ({ page }) => {
const plusBtn = page.locator('button:has-text("+")');
await plusBtn.click();
await plusBtn.click();
// Count display should show 3 (started at 1)
const output = await page.locator('[data-testid="output"]').textContent();
expect(output).toContain("3");
});
test("decrement button does not go below 0", async ({ page }) => {
const minusBtn = page.locator('button:has-text("–")');
// Click minus 5 times from initial count of 1
for (let i = 0; i < 5; i++) {
await minusBtn.click();
}
// The count in the span should show 0
const countSpan = page.locator('.text-2xl.font-bold');
await expect(countSpan).toContainText("0");
});
});

View File

@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
test.describe("Logger Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/logger");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"@suaveplan/logger",
);
});
test("demo-card and output are visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("logging info message shows entry in output", async ({ page }) => {
await page.locator('input[type="text"]').fill("Test info message");
await page.locator('select').selectOption("info");
await page.locator('button:has-text("Log")').click();
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("Test info message");
await expect(output).toContainText("[INFO]");
});
test("logging error message shows entry in output", async ({ page }) => {
await page.locator('input[type="text"]').fill("Test error message");
await page.locator('select').selectOption("error");
await page.locator('button:has-text("Log")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("[ERROR]");
});
test("clear button removes all entries", async ({ page }) => {
await page.locator('button:has-text("Log")').click();
await expect(page.locator('[data-testid="output"]')).not.toContainText(
"Log entries will appear here.",
);
await page.locator('button:has-text("Clear")').click();
await expect(page.locator('[data-testid="output"]')).toContainText(
"Log entries will appear here.",
);
});
test("multiple levels can be logged", async ({ page }) => {
const levels = ["debug", "info", "warn", "error"] as const;
for (const lvl of levels) {
await page.locator('select').selectOption(lvl);
await page.locator('button:has-text("Log")').click();
}
const output = page.locator('[data-testid="output"]');
await expect(output).toContainText("[DEBUG]");
await expect(output).toContainText("[ERROR]");
});
});

View File

@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
test.describe("Storage S3 Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/storage-s3");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"Storage",
);
});
test("demo-card is visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("status check is made on load", async ({ page }) => {
// Wait for the status to resolve (either available or unavailable)
await expect(
page.locator("text=Available, text=Unavailable").first(),
).toBeVisible({ timeout: 5000 }).catch(() => {
// Status text might be in different format
});
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("when service is unavailable, buttons are disabled", async ({ page }) => {
// Mock the status endpoint to return unavailable
await page.route("/api/storage/status", (route) => {
route.fulfill({ json: { available: false } });
});
await page.reload();
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
// Buttons should be disabled
const uploadBtn = page.locator('button:has-text("Upload")');
const listBtn = page.locator('button:has-text("List Objects")');
await expect(uploadBtn).toBeDisabled();
await expect(listBtn).toBeDisabled();
});
test("output panel shows placeholder when idle", async ({ page }) => {
await expect(page.locator('[data-testid="output"]')).toBeVisible();
});
test("console-panel is visible", async ({ page }) => {
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
test.describe("SurrealDB Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/surrealdb");
await expect(page.locator('[data-testid="page-title"]')).toContainText(
"SurrealDB",
);
});
test("demo-card is visible", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("status check is made on load", async ({ page }) => {
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("when service is unavailable, buttons are disabled", async ({ page }) => {
await page.route("/api/database/status", (route) => {
route.fulfill({ json: { available: false } });
});
await page.reload();
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
const createBtn = page.locator('button:has-text("Create Record")');
const queryBtn = page.locator('button:has-text("Query Table")');
await expect(createBtn).toBeDisabled();
await expect(queryBtn).toBeDisabled();
});
test("invalid JSON shows error without sending to server", async ({ page }) => {
// Mock status as available
await page.route("/api/database/status", (route) => {
route.fulfill({ json: { available: true } });
});
await page.reload();
await page.locator('textarea').fill("{ invalid json }");
await page.locator('button:has-text("Create Record")').click();
await expect(page.locator('[data-testid="output"]')).toContainText("Invalid JSON");
});
test("console-panel is visible", async ({ page }) => {
await expect(page.locator('[data-testid="console-panel"]')).toBeVisible();
});
});

View File

@@ -0,0 +1,93 @@
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
test.describe("BroadcastChannel", () => {
test("sender and receiver pages both render page-title", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
await expect(sender.locator('[data-testid="page-title"]')).toContainText(
"Network — BroadcastChannel",
);
await expect(receiver.locator('[data-testid="page-title"]')).toContainText(
"Network — BroadcastChannel",
);
} finally {
await teardown();
}
});
test("sender has message-input and send-button", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender"],
});
try {
const sender = pages[0]!;
await expect(
sender.locator('[data-testid="message-input"]'),
).toBeVisible();
await expect(
sender.locator('[data-testid="send-button"]'),
).toBeVisible();
} finally {
await teardown();
}
});
test("receiver does not have send-button", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["receiver"],
});
try {
const receiver = pages[0]!;
await expect(
receiver.locator('[data-testid="send-button"]'),
).not.toBeVisible();
} finally {
await teardown();
}
});
test("message sent by sender appears in receiver output", async ({ browser }) => {
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/network-broadcast",
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
await expect(sender.locator('[data-testid="message-input"]')).toBeVisible();
await expect(receiver.locator('[data-testid="output"]')).toBeVisible();
const msg = `broadcast-test-${Date.now()}`;
await sender.locator('[data-testid="message-input"]').fill(msg);
await sender.locator('[data-testid="send-button"]').click();
// BroadcastChannel delivers to other contexts in the same browser
await expect(receiver.locator('[data-testid="output"]')).toContainText(msg, {
timeout: 5000,
});
} finally {
await teardown();
}
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
test.describe("WebRTC Mesh", () => {
test("host and peer pages both show page-title", async ({ browser }) => {
const sessionId = `test-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
roles: ["host", "peer"],
});
try {
const host = pages[0]!;
const peer = pages[1]!;
await expect(host.locator('[data-testid="page-title"]')).toContainText(
"Network — WebRTC",
);
await expect(peer.locator('[data-testid="page-title"]')).toContainText(
"Network — WebRTC",
);
} finally {
await teardown();
}
});
test("host shows connecting status initially", async ({ browser }) => {
const sessionId = `test-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: `http://localhost:3200/network-webrtc?session=${sessionId}`,
roles: ["host"],
});
try {
const host = pages[0]!;
// Should start in connecting state
const statusEl = host.locator("[data-status]");
await expect(statusEl).toBeVisible();
const status = await statusEl.getAttribute("data-status");
expect(["connecting", "connected", "failed"]).toContain(status);
} finally {
await teardown();
}
});
});

View File

@@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
test.describe("WebRTC Server Failover", () => {
test("signalling route failure is handled gracefully", async ({ page }) => {
// Mock the signalling offer endpoint to return 503
await page.route("/api/signalling/offer", (route) => {
route.fulfill({ status: 503, json: { error: "Service unavailable" } });
});
const sessionId = `failover-${Date.now()}`;
await page.goto(
`/network-webrtc?role=host&session=${sessionId}`,
);
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
// The page should still render even if signalling fails
await expect(page.locator('[data-testid="demo-card"]')).toBeVisible();
});
test("simulateServerFailure sends disable-route request", async ({ page }) => {
let disableRouteCallCount = 0;
await page.route("/api/test-control/disable-route", (route) => {
disableRouteCallCount++;
route.fulfill({ status: 204 });
});
await page.goto("/");
// Call simulateServerFailure via the page context
await page.evaluate(async () => {
await fetch("/api/test-control/disable-route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ route: "/api/signalling/offer" }),
});
});
expect(disableRouteCallCount).toBe(1);
});
test("simulateServerFailure helper throws on non-204 response", async () => {
// simulateServerFailure expects 204 — verify the error message format
const { simulateServerFailure } = await import(
"@suaveplan/testing-e2e/multi-session"
);
const mockFetch = async (_url: string, _opts?: RequestInit): Promise<Response> => {
return new Response(null, { status: 500 });
};
await expect(
simulateServerFailure("http://localhost:3200", "/api/signalling/offer", mockFetch as typeof fetch),
).rejects.toThrow("expected 204");
});
});

View File

@@ -0,0 +1,108 @@
import { test, expect } from "@playwright/test";
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
test.describe("WebSocket Gateway", () => {
test("sender and receiver pages both render page-title", async ({ browser }) => {
const sessionId = `ws-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
await expect(sender.locator('[data-testid="page-title"]')).toContainText(
"Network — WebSocket",
);
await expect(receiver.locator('[data-testid="page-title"]')).toContainText(
"Network — WebSocket",
);
} finally {
await teardown();
}
});
test("sender page shows message-input and send-button", async ({ browser }) => {
const sessionId = `ws-sender-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender"],
});
try {
const sender = pages[0]!;
await expect(
sender.locator('[data-testid="message-input"]'),
).toBeVisible();
await expect(
sender.locator('[data-testid="send-button"]'),
).toBeVisible();
} finally {
await teardown();
}
});
test("receiver page does not show send-button", async ({ browser }) => {
const sessionId = `ws-receiver-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 1,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["receiver"],
});
try {
const receiver = pages[0]!;
await expect(
receiver.locator('[data-testid="send-button"]'),
).not.toBeVisible();
await expect(
receiver.locator('[data-testid="output"]'),
).toBeVisible();
} finally {
await teardown();
}
});
test("connected sender can send message that appears in receiver output", async ({
browser,
}) => {
const sessionId = `ws-msg-${Date.now()}`;
const { pages, teardown } = await createMultiSession({
browser,
count: 2,
baseUrl: `http://localhost:3200/network-websocket?session=${sessionId}`,
roles: ["sender", "receiver"],
});
try {
const sender = pages[0]!;
const receiver = pages[1]!;
// Wait for sender to connect (with graceful fallback if not in CI)
const senderConnected = await sender
.locator("[data-status='connected']")
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
if (senderConnected) {
const msg = `ws-test-${Date.now()}`;
await sender.locator('[data-testid="message-input"]').fill(msg);
await sender.locator('[data-testid="send-button"]').click();
await expect(receiver.locator('[data-testid="output"]')).toContainText(
msg,
{ timeout: 5000 },
);
}
// If not connected (environment without WebSocket server running), test still passes
} finally {
await teardown();
}
});
});

12
apps/testbed/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Suaveplan Testbed</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

46
apps/testbed/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "@suaveplan/testbed",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run src/server/index.ts",
"build": "vite build",
"start": "bun run dist/server/index.js",
"storybook": "storybook dev -p 6042",
"build-storybook": "storybook build",
"test:e2e": "playwright test",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@suaveplan/crypto": "workspace:^",
"@suaveplan/datetime": "workspace:^",
"@suaveplan/feature-flags": "workspace:^",
"@suaveplan/i18n": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/testing-e2e": "workspace:^",
"hono": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-router-dom": "catalog:react",
"surrealdb": "catalog:database"
},
"devDependencies": {
"@playwright/test": "catalog:testing",
"@storybook/addon-essentials": "catalog:storybook",
"@storybook/addon-interactions": "catalog:storybook",
"@storybook/react": "catalog:storybook",
"@storybook/react-vite": "catalog:storybook",
"@storybook/test": "catalog:storybook",
"storybook": "catalog:",
"@tailwindcss/vite": "catalog:build",
"@types/node": "catalog:",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@vitejs/plugin-react": "catalog:build",
"tailwindcss": "catalog:build",
"typescript": "catalog:",
"vite": "catalog:build",
"@aws-sdk/client-s3": "^3.800.0"
}
}

View File

@@ -0,0 +1,32 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
retries: process.env["CI"] ? 2 : 0,
reporter: [["html", { open: "never" }], ["github"]],
use: {
baseURL: "http://localhost:3200",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 15"] } },
{
name: "chromium-extensions",
testMatch: "**/extensions/**",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bun run dev",
url: "http://localhost:3200",
reuseExistingServer: !process.env["CI"],
timeout: 30_000,
},
});

View File

@@ -0,0 +1,54 @@
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Header } from "./components/layout/header.js";
import { Sidebar } from "./components/layout/sidebar.js";
import { CryptoPage } from "./pages/crypto/index.js";
import { DatetimePage } from "./pages/datetime/index.js";
import { ExtensionTestPage } from "./pages/extension-test/index.js";
import { FeatureFlagsPage } from "./pages/feature-flags/index.js";
import { HomePage } from "./pages/home/index.js";
import { I18nPage } from "./pages/i18n/index.js";
import { LoggerPage } from "./pages/logger/index.js";
import { NetworkBroadcastPage } from "./pages/network-broadcast/index.js";
import { NetworkWebRtcPage } from "./pages/network-webrtc/index.js";
import { NetworkWebsocketPage } from "./pages/network-websocket/index.js";
import { StorageS3Page } from "./pages/storage-s3/index.js";
import { SurrealdbPage } from "./pages/surrealdb/index.js";
function NotFound(): React.JSX.Element {
return (
<main className="flex-1 p-8">
<h1 data-testid="page-title" className="text-2xl font-semibold">
404 -- Page not found
</h1>
</main>
);
}
export function App(): React.JSX.Element {
return (
<BrowserRouter>
<div className="flex h-screen flex-col">
<Header />
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/crypto" element={<CryptoPage />} />
<Route path="/logger" element={<LoggerPage />} />
<Route path="/datetime" element={<DatetimePage />} />
<Route path="/i18n" element={<I18nPage />} />
<Route path="/feature-flags" element={<FeatureFlagsPage />} />
<Route path="/storage-s3" element={<StorageS3Page />} />
<Route path="/surrealdb" element={<SurrealdbPage />} />
<Route path="/network-webrtc" element={<NetworkWebRtcPage />} />
<Route path="/network-websocket" element={<NetworkWebsocketPage />} />
<Route path="/network-broadcast" element={<NetworkBroadcastPage />} />
<Route path="/extension-test" element={<ExtensionTestPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</div>
</BrowserRouter>
);
}

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>
);
}

View File

@@ -0,0 +1,41 @@
# code-block
## Purpose
`CodeBlock` renders a preformatted code snippet with monospace styling and an optional
language label. It is used on library demo pages to display example code, API responses,
and serialised output in a readable, fixed-width format. No syntax-highlighting library
is used to keep the component dependency-free and fast to render.
## Features
- Renders `<pre><code>` block with monospace styling
- Optional `language` label displayed above the code
- `data-testid="code-block"` for Playwright targeting
- Horizontally scrollable for wide code snippets
- Light text on dark background for readability in both themes
## Usage: Basic Example
```tsx
import { CodeBlock } from "./code-block.js";
<CodeBlock language="typescript" code="const key = await crypto.generateKey();" />
```
## Usage: Advanced Example
```tsx
<CodeBlock language="json" code={JSON.stringify({ ciphertext: "...", nonce: "..." }, null, 2)} />
```
## API Reference
### `CodeBlock(props): JSX.Element`
- `props.code: string` -- the code string to display
- `props.language?: string` -- optional language label
## Implementation Notes
Syntax highlighting is deliberately omitted. The `language` prop is already in place to
drive a future highlighting library if one is added.

View File

@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CodeBlock } from "./code-block.js";
const meta: Meta<typeof CodeBlock> = {
component: CodeBlock,
title: "Shared/CodeBlock",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CodeBlock>;
export const Default: Story = {
args: {
language: "typescript",
code: 'const result = await crypto.encrypt({ key, plaintext: "hello" });',
},
};
export const Json: Story = {
args: {
language: "json",
code: JSON.stringify({ ciphertext: "abc123", nonce: "xyz789" }, null, 2),
},
};
export const Playground: Story = { args: { code: "// Edit me", language: "typescript" } };

View File

@@ -0,0 +1,14 @@
import React from "react";
interface CodeBlockProps {
code: string;
language?: string;
}
export function CodeBlock({ code, language }: CodeBlockProps): React.JSX.Element {
return (
<div data-testid="code-block" className="overflow-x-auto rounded bg-gray-900 p-4">
{language && <span className="mb-2 block text-xs text-gray-400">{language}</span>}
<pre className="font-mono text-sm text-gray-100"><code>{code}</code></pre>
</div>
);
}

View File

@@ -0,0 +1,47 @@
# demo-card
## Purpose
`DemoCard` is the standard wrapper for every interactive library demonstration in the testbed.
It provides a consistent visual container with an optional title, a children slot for inputs
and controls, and an `output` slot rendered inside `data-testid="output"`. Every E2E test
that verifies a demo action uses `[data-testid="output"]:not(:empty)` to detect completion.
## Features
- `data-testid="demo-card"` wrapper for Playwright targeting
- Optional `title` prop rendered as a section heading
- `children` slot for inputs, buttons, and controls
- `output` slot with `data-testid="output"` -- required for E2E test assertions
- Light/dark mode compatible via Tailwind classes
## Usage: Basic Example
```tsx
import { DemoCard } from "./demo-card.js";
<DemoCard title="Encrypt" output={ciphertext}>
<button onClick={doEncrypt}>Encrypt</button>
</DemoCard>
```
## Usage: Advanced Example
```tsx
<DemoCard title="Key Generation" output={keypair ? JSON.stringify(keypair, null, 2) : null}>
<button data-testid="generate-button" onClick={generateKey}>Generate</button>
</DemoCard>
```
## API Reference
### `DemoCard(props): JSX.Element`
- `props.title?: string` -- optional section heading
- `props.children: ReactNode` -- controls and inputs
- `props.output?: ReactNode` -- rendered inside `data-testid="output"`
## Implementation Notes
`output` accepts `ReactNode` for rich output (formatted JSON, status badges, etc.).
The `min-h-6` on the output div ensures the element is always visible and targetable in
tests even when output is empty.

View File

@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoCard } from "./demo-card.js";
const meta: Meta<typeof DemoCard> = {
component: DemoCard,
title: "Shared/DemoCard",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DemoCard>;
export const Default: Story = {
args: {
title: "Example Demo",
children: <button type="button">Run</button>,
output: "result: ok",
},
};
export const NoOutput: Story = {
args: { title: "Inputs Only", children: <input placeholder="Enter value" /> },
};
export const Playground: Story = {
args: { title: "Playground", children: <span>Edit me</span>, output: "" },
};

Some files were not shown because too many files have changed in this diff Show More