Phase 0: repo skeleton, image, compose stacks, doctor, migration runner, frontend shell

New repo for the Packet Station rebuild (Python 3.12, FastAPI, PostgreSQL 16,
React, VisualKit, MFilesKit, Docker Compose on docker-1). Nothing is migrated
from the M: install.

- CLAUDE.md with the safety rules, README, CHANGELOG, docs (BRIEF, PLAN,
  SECURITY rewritten for the server with the requirement ID table, DEPLOY,
  stubs for FEED, POLICY, SCHEMA, GO-LIVE, PAPER-FALLBACK).
- packetstation CLI: app, worker (heartbeat stub), migrate, doctor, version,
  healthcheck. Settings from the environment with secret files. Stack guard
  (test never points at VISUAL production). Security headers and CSP.
  Trusted proxy resolved by name. JSON logs with redaction.
- Migration runner and 0001_base: settings, append-only audit_events with
  hash chain, ps_notify, grants for ps_app.
- Multi-stage Dockerfile (ODBC Driver 18, PESCO libraries from the Gitea
  index only), compose base, proxy overlay for npm_default, CI overlay,
  postgres initdb roles, env examples, NPM proxy host notes, Gitea Actions.
- Frontend: Vite, React, TypeScript, the PESCO dark theme ported with
  self-hosted fonts, placeholder sign-in and home.
- Verified on docker-1: image builds, CI stack comes up healthy, doctor
  database checks pass, 77 server tests pass against PostgreSQL.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-15 23:08:47 -06:00
co-authored by Claude Fable 5.1
commit 0b663bb2b2
91 changed files with 11307 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
coverage/
+1
View File
@@ -0,0 +1 @@
save-exact=true
+26
View File
@@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
);
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0d161b" />
<title>PESCO Packet Station</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4535
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "packetstation-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json",
"test": "vitest run"
},
"dependencies": {
"@fontsource/barlow-condensed": "5.3.0",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans": "5.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.18.4"
},
"devDependencies": {
"@eslint/js": "9.39.5",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "6.10.0",
"@testing-library/react": "16.3.3",
"@types/node": "22.20.5",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.7.0",
"eslint": "9.39.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.26",
"globals": "16.5.0",
"jsdom": "26.1.0",
"typescript": "5.9.3",
"typescript-eslint": "8.71.2",
"vite": "6.4.4",
"vitest": "4.1.12"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { mockFetch } from "./test/fetchMock";
import { AppRoutes } from "./App";
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<AppRoutes />
</MemoryRouter>,
);
}
describe("routes", () => {
it("home shows the top bar and the version", async () => {
mockFetch({ "/api/version": { body: { version: "0.0.1" } }, "/healthz": { body: "ok" } });
renderAt("/");
expect(screen.getByRole("img", { name: "PESCO" })).toBeInTheDocument();
expect(await screen.findByText("0.0.1")).toBeInTheDocument();
});
it("sign-in shows both tabs with disabled inputs", () => {
renderAt("/signin");
expect(screen.getByRole("tab", { name: "Domain password" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "PIN" })).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeDisabled();
expect(screen.getByText(/phase 1/)).toBeInTheDocument();
});
it("unknown paths show the 404 page", () => {
renderAt("/no/such/page");
expect(screen.getByRole("heading", { name: "Page not found" })).toBeInTheDocument();
});
});
+26
View File
@@ -0,0 +1,26 @@
import { BrowserRouter, Route, Routes } from "react-router";
import { Layout } from "./components/Layout";
import { HomePage } from "./pages/HomePage";
import { NotFoundPage } from "./pages/NotFoundPage";
import { SignInPage } from "./pages/SignInPage";
/** Route table. Exported apart from the router so tests can mount it in a MemoryRouter. */
export function AppRoutes() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="signin" element={<SignInPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { mockFetch } from "../test/fetchMock";
import { ApiError, REQUESTED_WITH, api, request } from "./client";
describe("api client", () => {
it("returns parsed JSON on success", async () => {
mockFetch({ "/api/thing": { body: { a: 1 } } });
await expect(api.get<{ a: number }>("/api/thing")).resolves.toEqual({ a: 1 });
});
it("sends same-origin requests with credentials and no CSRF header on GET", async () => {
const fetch = mockFetch({ "/api/thing": { body: {} } });
await api.get("/api/thing");
const init = fetch.mock.calls[0]?.[1];
expect(init?.credentials).toBe("include");
expect(init?.mode).toBe("same-origin");
expect((init?.headers as Record<string, string>)["X-Requested-With"]).toBeUndefined();
});
it("sends X-Requested-With and a JSON body on state-changing requests", async () => {
const fetch = mockFetch({ "/api/thing": { status: 201, body: { id: 7 } } });
await expect(api.post("/api/thing", { name: "x" })).resolves.toEqual({ id: 7 });
const init = fetch.mock.calls[0]?.[1];
const headers = init?.headers as Record<string, string>;
expect(init?.method).toBe("POST");
expect(headers["X-Requested-With"]).toBe(REQUESTED_WITH);
expect(headers["Content-Type"]).toBe("application/json");
expect(init?.body).toBe(JSON.stringify({ name: "x" }));
});
it("throws ApiError with status, body and the FastAPI detail as message", async () => {
mockFetch({ "/api/locked": { status: 409, body: { detail: "Packet is locked by J. Smith" } } });
const err = await request("/api/locked", { method: "DELETE" }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
const apiErr = err as ApiError;
expect(apiErr.status).toBe(409);
expect(apiErr.body).toEqual({ detail: "Packet is locked by J. Smith" });
expect(apiErr.message).toBe("Packet is locked by J. Smith");
});
it("keeps a plain-text error body", async () => {
mockFetch({ "/api/boom": { status: 502, body: "Bad gateway" } });
await expect(api.get("/api/boom")).rejects.toMatchObject({ status: 502, body: "Bad gateway" });
});
it("handles an empty error body", async () => {
mockFetch({ "/api/gone": { status: 500 } });
await expect(api.get("/api/gone")).rejects.toMatchObject({ status: 500, body: null, message: "Request failed (500)" });
});
it("turns a network failure into ApiError status 0", async () => {
mockFetch({ "/api/down": new TypeError("Failed to fetch") });
await expect(api.get("/api/down")).rejects.toMatchObject({ name: "ApiError", status: 0 });
});
it("returns null for 204", async () => {
mockFetch({ "/api/thing": { status: 204 } });
await expect(api.del("/api/thing")).resolves.toBeNull();
});
it("refuses absolute and protocol-relative URLs", async () => {
mockFetch({});
await expect(api.get("https://evil.example/api")).rejects.toThrow(TypeError);
await expect(api.get("//evil.example/api")).rejects.toThrow(TypeError);
});
});
+102
View File
@@ -0,0 +1,102 @@
// Small fetch wrapper for the Packet Station API.
// Same origin only. Cookies ride along. JSON in and out.
/** Thrown for any response that is not 2xx. Holds the status and the parsed body. */
export class ApiError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, body: unknown, message?: string) {
super(message ?? ApiError.describe(status, body));
this.name = "ApiError";
this.status = status;
this.body = body;
}
/** Prefer the server's own message ({"detail": ...} from FastAPI), else the status. */
private static describe(status: number, body: unknown): string {
if (body && typeof body === "object" && "detail" in body) {
const detail = (body as { detail: unknown }).detail;
if (typeof detail === "string") return detail;
}
if (typeof body === "string" && body.trim() !== "") return body.trim().slice(0, 200);
return status === 0 ? "Network error" : `Request failed (${status})`;
}
}
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export interface RequestOptions {
method?: Method;
/** Sent as JSON. FormData and Blob are sent as they are. */
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
const SAFE_METHODS: ReadonlySet<Method> = new Set<Method>(["GET"]);
/** The server checks this header on state-changing requests as a CSRF guard. */
export const REQUESTED_WITH = "PacketStation";
/** Paths must be same-origin and absolute, like "/api/version". */
function assertSameOrigin(path: string): void {
if (!path.startsWith("/") || path.startsWith("//")) {
throw new TypeError(`API path must start with a single "/": ${path}`);
}
}
async function readBody(res: Response): Promise<unknown> {
if (res.status === 204 || res.status === 205) return null;
const text = await res.text();
if (text === "") return null;
const type = res.headers.get("content-type") ?? "";
if (type.includes("json")) {
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
return text;
}
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
assertSameOrigin(path);
const method: Method = opts.method ?? "GET";
const headers: Record<string, string> = { Accept: "application/json", ...opts.headers };
if (!SAFE_METHODS.has(method)) headers["X-Requested-With"] = REQUESTED_WITH;
let body: BodyInit | undefined;
if (opts.body instanceof FormData || opts.body instanceof Blob) {
body = opts.body;
} else if (opts.body !== undefined) {
body = JSON.stringify(opts.body);
headers["Content-Type"] = "application/json";
}
const init: RequestInit = { method, headers, credentials: "include", mode: "same-origin" };
if (body !== undefined) init.body = body;
if (opts.signal) init.signal = opts.signal;
let res: Response;
try {
res = await fetch(path, init);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
throw new ApiError(0, null, "Network error. Check the connection and try again.");
}
const parsed = await readBody(res);
if (!res.ok) throw new ApiError(res.status, parsed);
return parsed as T;
}
export const api = {
get: <T>(path: string, signal?: AbortSignal) =>
request<T>(path, signal ? { signal } : {}),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: "POST", body }),
put: <T>(path: string, body?: unknown) => request<T>(path, { method: "PUT", body }),
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+16
View File
@@ -0,0 +1,16 @@
import { Outlet } from "react-router";
import { TopBar } from "./TopBar";
/** Page frame: the top bar, then the routed page inside main.wrap. */
export function Layout() {
return (
<>
<TopBar>
<span className="proto">Rebuild preview</span>
</TopBar>
<main className="wrap" id="main">
<Outlet />
</main>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import logo from "../assets/logo.png";
interface TopBarProps {
/** Right-hand side: the signed-in user, role switcher and so on. */
children?: ReactNode;
}
/** Sticky shop header. Same markup classes as the old page's header.top. */
export function TopBar({ children }: TopBarProps) {
return (
<header className="top">
<div className="top-in">
<Link className="brand" to="/" aria-label="Packet Station home">
<img alt="PESCO" src={logo} width={119} height={34} />
<div className="nm">
Packet <span>Station</span>
</div>
</Link>
<div className="sp" />
{children ? <div className="who">{children}</div> : null}
</div>
</header>
);
}
@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { mockFetch } from "../test/fetchMock";
import { parseVersion } from "../lib/version";
import { VersionStatus } from "./VersionStatus";
describe("VersionStatus", () => {
it("shows the server build version and a healthy API", async () => {
mockFetch({
"/api/version": { body: { version: "0.1.0", build: "sha-abc1234" } },
"/healthz": { body: { status: "ok" } },
});
render(<VersionStatus />);
expect(await screen.findByText("0.1.0")).toBeInTheDocument();
expect(screen.getByText("sha-abc1234")).toBeInTheDocument();
expect(await screen.findByText("Up")).toBeInTheDocument();
});
it("shows the error when the API is down", async () => {
mockFetch({
"/api/version": { status: 503, body: { detail: "Database unavailable" } },
"/healthz": { status: 503, body: { detail: "Database unavailable" } },
});
render(<VersionStatus />);
expect(await screen.findByText("Down")).toBeInTheDocument();
expect(await screen.findAllByText("Database unavailable")).toHaveLength(2);
expect(screen.getByTestId("kpi-version")).toHaveClass("hot");
});
});
describe("parseVersion", () => {
it("accepts a plain string and odd shapes", () => {
expect(parseVersion("1.2.3")).toEqual({ version: "1.2.3" });
expect(parseVersion({ build: "sha-1" })).toEqual({ version: "sha-1" });
expect(parseVersion({ version: "1.0", commit: "abc" })).toEqual({ version: "1.0", build: "abc" });
expect(parseVersion(null)).toEqual({ version: "unknown" });
});
});
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { ApiError } from "../api/client";
import {
FRONTEND_BUILD,
fetchHealth,
fetchVersion,
type HealthInfo,
type VersionInfo,
} from "../lib/version";
type Load<T> = { state: "loading" } | { state: "ok"; data: T } | { state: "error"; message: string };
function errorText(err: unknown): string {
if (err instanceof ApiError) return err.message;
if (err instanceof Error) return err.message;
return "Unknown error";
}
/** KPI tiles for the server build, the API health and the frontend build. */
export function VersionStatus() {
const [version, setVersion] = useState<Load<VersionInfo>>({ state: "loading" });
const [health, setHealth] = useState<Load<HealthInfo>>({ state: "loading" });
useEffect(() => {
const ctl = new AbortController();
const aborted = () => ctl.signal.aborted;
fetchVersion(ctl.signal)
.then((data) => !aborted() && setVersion({ state: "ok", data }))
.catch((err: unknown) => !aborted() && setVersion({ state: "error", message: errorText(err) }));
fetchHealth(ctl.signal)
.then((data) => !aborted() && setHealth({ state: "ok", data }))
.catch((err: unknown) => !aborted() && setHealth({ state: "error", message: errorText(err) }));
return () => ctl.abort();
}, []);
const healthOk = health.state === "ok" && health.data.ok;
return (
<div className="kpis" aria-live="polite">
<div className={version.state === "error" ? "kpi hot" : "kpi"} data-testid="kpi-version">
<div className="n mono">
{version.state === "ok" ? version.data.version : version.state === "loading" ? "..." : "?"}
</div>
<div className="l">Server build</div>
{version.state === "ok" && version.data.build ? (
<div className="small muted mono">{version.data.build}</div>
) : null}
{version.state === "error" ? <div className="small muted">{version.message}</div> : null}
</div>
<div className={healthOk ? "kpi" : health.state === "loading" ? "kpi zero" : "kpi hot"} data-testid="kpi-health">
<div className="n">
<span className={`pill ${healthOk ? "st-ok" : health.state === "loading" ? "st-wait" : "st-bad"}`}>
{healthOk ? "Up" : health.state === "loading" ? "Checking" : "Down"}
</span>
</div>
<div className="l">API health</div>
{health.state === "ok" ? <div className="small muted">{health.data.status}</div> : null}
{health.state === "error" ? <div className="small muted">{health.message}</div> : null}
</div>
<div className="kpi zero">
<div className="n mono">{FRONTEND_BUILD}</div>
<div className="l">Frontend build</div>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
// Build version and API health. The server owns both endpoints.
// The parsers are lenient on purpose: the server shape may grow, and a missing field must not break the page.
import { api, request } from "../api/client";
export interface VersionInfo {
/** Release or build version, for example "0.1.0" or "sha-abc1234". */
version: string;
/** Optional extra build detail, such as the commit or the PS_BUILD label. */
build?: string;
}
export interface HealthInfo {
ok: boolean;
/** Short status text from the server, or the error message. */
status: string;
}
function str(v: unknown): string | undefined {
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
}
export function parseVersion(raw: unknown): VersionInfo {
if (typeof raw === "string") return { version: str(raw) ?? "unknown" };
if (raw && typeof raw === "object") {
const o = raw as Record<string, unknown>;
const version = str(o.version) ?? str(o.build) ?? "unknown";
const build = str(o.build) ?? str(o.commit) ?? str(o.sha);
return build && build !== version ? { version, build } : { version };
}
return { version: "unknown" };
}
export async function fetchVersion(signal?: AbortSignal): Promise<VersionInfo> {
return parseVersion(await api.get<unknown>("/api/version", signal));
}
export async function fetchHealth(signal?: AbortSignal): Promise<HealthInfo> {
try {
const raw = await request<unknown>("/healthz", signal ? { signal } : {});
let status = "ok";
if (typeof raw === "string") status = str(raw) ?? status;
else if (raw && typeof raw === "object") status = str((raw as Record<string, unknown>).status) ?? status;
return { ok: true, status };
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { ok: false, status: err instanceof Error ? err.message : "unreachable" };
}
}
/** The version the frontend bundle was built with, if the build set it. */
export const FRONTEND_BUILD: string = str(import.meta.env.VITE_PS_BUILD) ?? "dev";
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./theme/fonts";
import "./theme/theme.css";
import App from "./App";
const root = document.getElementById("root");
if (!root) throw new Error("Missing #root element");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+25
View File
@@ -0,0 +1,25 @@
import { Link } from "react-router";
import { VersionStatus } from "../components/VersionStatus";
/** Placeholder home. Shows the build and the API health until the real home lands in phase 2. */
export function HomePage() {
return (
<>
<div className="phead">
<div>
<div className="eyebrow">Phase 0 shell</div>
<h1>Packet Station</h1>
</div>
<div className="pacts">
<Link className="btn pri" to="/signin">
Sign in
</Link>
</div>
</div>
<VersionStatus />
<div className="callout">
<b>Skeleton build.</b> Boards, packets and sign-in arrive in the next phases.
</div>
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Link, useLocation } from "react-router";
export function NotFoundPage() {
const { pathname } = useLocation();
return (
<section className="sec">
<div className="eyebrow">404</div>
<h1>Page not found</h1>
<div className="callout warn">
Nothing lives at <b className="mono">{pathname}</b>.
</div>
<Link className="btn" to="/">
Back to home
</Link>
</section>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from "react";
type Mode = "password" | "pin";
const TABS: ReadonlyArray<{ id: Mode; label: string }> = [
{ id: "password", label: "Domain password" },
{ id: "pin", label: "PIN" },
];
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "Clear", "0", "Back"] as const;
/** Placeholder sign-in. Both tabs are drawn, every input is disabled until phase 1 wires the server. */
export function SignInPage() {
const [mode, setMode] = useState<Mode>("password");
return (
<div className="auth">
<div className="auth-card">
<div>
<div className="eyebrow">PESCO Packet Station</div>
<h1>Sign in</h1>
</div>
<div className="tabs static" role="tablist" aria-label="Sign-in method">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`tab-${t.id}`}
aria-selected={mode === t.id}
aria-controls={`panel-${t.id}`}
onClick={() => setMode(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="callout">
Sign-in arrives in <b>phase 1</b>. The fields below are disabled.
</div>
{mode === "password" ? (
<form className="auth-form" id="panel-password" role="tabpanel" aria-labelledby="tab-password" onSubmit={(e) => e.preventDefault()}>
<label className="f">
User name
<input className="in" name="username" autoComplete="username" disabled />
</label>
<label className="f">
Password
<input className="in" name="password" type="password" autoComplete="current-password" disabled />
</label>
<button className="btn pri" type="submit" disabled>
Sign in
</button>
</form>
) : (
<form className="auth-form" id="panel-pin" role="tabpanel" aria-labelledby="tab-pin" onSubmit={(e) => e.preventDefault()}>
<label className="f">
Employee ID
<input className="in" name="employee" inputMode="numeric" disabled />
</label>
<label className="f">
PIN
<input className="in big" name="pin" type="password" inputMode="numeric" disabled />
</label>
<div className="keypad" aria-label="PIN keypad">
{KEYS.map((k) => (
<button key={k} type="button" disabled>
{k}
</button>
))}
</div>
<button className="btn pri" type="submit" disabled>
Sign in
</button>
</form>
)}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { vi } from "vitest";
export type Route = { status?: number; body?: unknown; contentType?: string } | Error;
/** Stubs global fetch. Routes are keyed by path. Unknown paths answer 404. */
export function mockFetch(routes: Record<string, Route>) {
const fn = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
const path = typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url;
const r = routes[path];
if (r instanceof Error) throw r;
const status = r?.status ?? (r ? 200 : 404);
const body = r?.body;
const isText = typeof body === "string";
const contentType = r?.contentType ?? (isText ? "text/plain" : "application/json");
const text = body === undefined ? "" : isText ? body : JSON.stringify(body);
return new Response(status === 204 ? null : text, { status, headers: { "content-type": contentType } });
});
vi.stubGlobal("fetch", fn);
return fn;
}
+9
View File
@@ -0,0 +1,9 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
+13
View File
@@ -0,0 +1,13 @@
// Self-hosted fonts. The old page pulled these exact families and weights from Google Fonts.
// The npm packages ship the woff2 files, and Vite copies them into dist with hashed names.
// Only the Latin subset is loaded to keep the bundle small.
import "@fontsource/barlow-condensed/latin-500.css";
import "@fontsource/barlow-condensed/latin-600.css";
import "@fontsource/barlow-condensed/latin-700.css";
import "@fontsource/ibm-plex-sans/latin-400.css";
import "@fontsource/ibm-plex-sans/latin-500.css";
import "@fontsource/ibm-plex-sans/latin-600.css";
import "@fontsource/ibm-plex-sans/latin-700.css";
import "@fontsource/ibm-plex-mono/latin-400.css";
import "@fontsource/ibm-plex-mono/latin-500.css";
import "@fontsource/ibm-plex-mono/latin-600.css";
+241
View File
@@ -0,0 +1,241 @@
/*
PESCO dark theme. Ported from the old build's src/app.src.html (PacketStation repo).
Values are copied as they were so trained users see the same look.
Each block notes the old selectors it came from.
Fonts: the old page loaded Barlow Condensed, IBM Plex Sans and IBM Plex Mono from Google Fonts.
Here the same families are self-hosted from the @fontsource npm packages (see src/theme/fonts.ts).
The fallback stacks are the old ones, so the page still reads right if a font file fails.
*/
/* From :root. */
:root {
--bg: #0d161b;
--panel: #1a252b;
--panel2: #162931;
--line: #26363e;
--line2: #33474f;
--cyan: #49c0de;
--pink: #e5589f;
--amber: #e7b33c;
--green: #5fc98a;
--red: #ef6b6b;
--white: #ffffff;
--near: #eff5f8;
--body: #a9bac3;
--muted: #77878f;
--warnbg: #221b28;
/* Shades the old page wrote inline. Named here so components never hard-code them. */
--on-cyan: #06222b;
--on-pink: #2a0717;
--on-green: #06210f;
--field: #0f1b20;
--col: #111d23;
--sel: #123643;
--sheet: #13222a;
--top-from: #0f2129;
--scrim: rgba(3, 8, 10, 0.66);
--f-disp: "Barlow Condensed", "Arial Narrow", Arial, sans-serif;
--f-body: "IBM Plex Sans", Helvetica, Arial, sans-serif;
--f-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
--toph: 64px;
color-scheme: dark;
}
/* From *, [hidden], html, body, form controls, :focus-visible, reduced motion, .wrap. */
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { background: var(--bg); }
body { color: var(--body); font-family: var(--f-body); font-size: 15px; line-height: 1.45; margin: 0; }
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
.wrap { padding-inline: 16px; padding-block: 14px 48px; max-width: 1500px; margin: 0 auto; }
/* Top bar. From .top, .top-in, .brand, .brand img, .brand .nm, .roles, .top .sp, .who, .proto. */
.top { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 30; background: linear-gradient(180deg, var(--top-from), var(--bg)); border-bottom: 1px solid var(--line); padding-inline: 16px; padding-block: 10px; }
.top-in { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; max-width: 1500px; margin: 0 auto; }
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; color: inherit; text-decoration: none; }
.brand img { height: 34px; width: auto; }
.brand .nm { font-family: var(--f-disp); font-weight: 600; font-size: 22px; letter-spacing: .06em; color: var(--near); text-transform: uppercase; white-space: nowrap; }
.brand .nm span { color: var(--cyan); }
.roles { display: flex; gap: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 4px; flex-wrap: wrap; }
.roles button { border: 0; background: transparent; padding: 8px 14px; border-radius: 7px; font-weight: 600; font-size: 14px; color: var(--body); cursor: pointer; min-height: 40px; }
.roles button[aria-pressed="true"] { background: var(--cyan); color: var(--on-cyan); }
.top .sp { flex: 1; }
.who { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.who select { background: var(--panel); border: 1px solid var(--line2); border-radius: 8px; padding: 8px 10px; color: var(--near); font-weight: 600; min-height: 40px; }
.proto { font-family: var(--f-mono); font-size: 11px; letter-spacing: .08em; color: var(--amber); border: 1px solid #5a4720; border-radius: 6px; padding: 3px 7px; text-transform: uppercase; }
/* Type. From .eyebrow, h1, h2, h3, .mono, .muted, .small, .sr, .lnk. */
.eyebrow { font-family: var(--f-mono); font-size: 11.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--cyan); }
h1, h2, h3 { color: var(--near); margin: 0; text-wrap: balance; }
h1 { font-family: var(--f-disp); font-weight: 700; font-size: 34px; letter-spacing: .02em; line-height: 1.05; }
h2 { font-family: var(--f-disp); font-weight: 600; font-size: 22px; letter-spacing: .04em; text-transform: uppercase; }
h3 { font-size: 15px; font-weight: 600; }
.mono { font-family: var(--f-mono); font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.lnk { all: unset; cursor: pointer; color: var(--cyan); text-decoration: underline; text-underline-offset: 3px; font-weight: 600; }
.lnk:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
/* KPI tiles. From .kpis, .kpi, button.kpi, .kpi.on, .kpi .n, .kpi .l, .kpi.hot, .kpi.zero, .phead, .pacts. */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-block: 12px 20px; }
.kpi { background: var(--panel); border: 1px solid var(--line); border-top: 3px solid var(--cyan); border-radius: 10px; padding: 10px 16px 12px; text-align: left; color: inherit; font: inherit; display: block; width: 100%; }
button.kpi { cursor: pointer; position: relative; transition: border-color .15s, background .15s; }
button.kpi:hover { border-color: var(--cyan); border-top-color: var(--cyan); }
button.kpi::after { content: "Tap to show these \2193"; position: absolute; top: 10px; right: 12px; font-size: 11px; color: var(--muted); opacity: 0; transition: opacity .15s; }
button.kpi:hover::after, button.kpi:focus-visible::after { opacity: 1; }
.kpi.on { background: var(--sel); border-color: var(--cyan); }
.kpi.on::after { content: "Showing only these, tap to clear"; opacity: 1; color: var(--cyan); }
.kpi .n { font-family: var(--f-disp); font-weight: 700; font-size: 44px; line-height: 1; color: var(--white); font-variant-numeric: tabular-nums; }
.kpi .n small { font-size: 20px; color: var(--muted); font-weight: 600; }
.kpi .l { font-size: 12.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--cyan); margin-top: 6px; }
.kpi.hot { border-top-color: var(--pink); }
.kpi.hot .l { color: var(--pink); }
.kpi.zero { border-top-color: var(--line2); }
.kpi.zero .n, .kpi.zero .l { color: var(--muted); }
.phead { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; margin-top: 4px; }
.pacts { display: flex; gap: 8px; flex-wrap: wrap; }
/* Sections, boards, cards, bars. From .sec, .sec-h, .board, .col, .col-h, .empty, .card, .bar, .panel. */
.sec { margin-block: 8px 26px; }
.sec-h { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.sec-h .ct { font-family: var(--f-mono); font-size: 13px; color: var(--muted); }
.board { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
.col { background: var(--col); border: 1px solid var(--line); border-radius: 12px; padding: 12px; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.col-h { display: flex; justify-content: space-between; align-items: center; font-family: var(--f-disp); font-size: 17px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--near); }
.col-h .c { font-family: var(--f-mono); font-size: 13px; color: var(--muted); }
.empty { border: 1px dashed var(--line2); border-radius: 10px; padding: 16px; color: var(--muted); font-size: 13.5px; text-align: center; }
.empty.slim { padding: 12px 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; text-align: left; width: 100%; color: inherit; font: inherit; transition: border-color .15s; }
button.card, a.card { cursor: pointer; text-decoration: none; }
button.card:hover, a.card:hover { border-color: var(--cyan); }
.card .r1 { display: flex; justify-content: space-between; gap: 6px 8px; align-items: center; flex-wrap: wrap; }
.card .r1 .pill { margin-left: auto; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.card .job { font-family: var(--f-disp); font-weight: 700; font-size: 20px; color: var(--white); letter-spacing: .03em; }
.card .tt { color: var(--near); font-weight: 500; }
.card .meta { display: flex; gap: 6px 14px; flex-wrap: wrap; font-size: 12.5px; color: var(--muted); }
.card .meta b { color: var(--body); font-weight: 600; }
.bar { height: 6px; border-radius: 3px; background: var(--bg); overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--cyan); }
.bar i.over { background: var(--pink); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
.panel h3 { margin-bottom: 8px; }
/* Pills and status colours. From .pill, .pill::before, .st-*. */
.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; border-radius: 999px; padding: 3px 10px; white-space: nowrap; border: 1px solid currentColor; }
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.st-draft { color: #9fb1ba; }
.st-warehouse { color: var(--amber); }
.st-ready { color: var(--cyan); }
.st-assigned { color: #8fa6ff; }
.st-in_progress { color: var(--green); }
.st-review { color: var(--pink); }
.st-qc { color: #b99cff; }
.st-complete { color: #c6d4da; }
.st-filed { color: var(--muted); }
/* New in the rebuild. Health and error states for the shell. */
.st-ok { color: var(--green); }
.st-bad { color: var(--red); }
.st-wait { color: var(--muted); }
/* Buttons and fields. From .btn and its variants, label.f, input.in, select.in, textarea.in, .in.big, .frow, .ckl. */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 9px; padding: 10px 18px; font-weight: 700; font-size: 15px; border: 1px solid var(--line2); background: var(--panel); color: var(--near); cursor: pointer; min-height: 46px; letter-spacing: .02em; text-decoration: none; }
.btn:hover { border-color: var(--cyan); }
.btn.pri { background: var(--cyan); border-color: var(--cyan); color: var(--on-cyan); }
.btn.pink { background: var(--pink); border-color: var(--pink); color: var(--on-pink); }
.btn.ghost { background: transparent; }
.btn.sm { min-height: 36px; padding: 6px 12px; font-size: 13.5px; }
.btn.dim { opacity: .6; }
.btn.rejb { border-color: var(--red); color: var(--red); background: transparent; }
.btn.voidbtn { border-color: var(--pink); color: var(--pink); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn svg { vertical-align: -4px; }
label.f { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); min-width: 0; }
input.in, select.in, textarea.in { background: var(--field); border: 1px solid var(--line2); border-radius: 8px; padding: 10px 12px; color: var(--near); font-size: 15px; min-height: 44px; width: 100%; text-transform: none; letter-spacing: 0; font-weight: 500; }
input.in:focus, select.in:focus, textarea.in:focus { border-color: var(--cyan); outline: none; }
input.in:disabled, select.in:disabled, textarea.in:disabled { opacity: .55; cursor: not-allowed; }
textarea.in { min-height: 80px; resize: vertical; }
.in.big { font-size: 22px; min-height: 56px; font-family: var(--f-mono); letter-spacing: .08em; }
.frow { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.ckl { display: flex; gap: 10px; align-items: center; font-weight: 600; color: var(--near); min-height: 46px; cursor: pointer; }
.ckl input { width: 24px; height: 24px; accent-color: var(--green); }
/* Chips and segmented control. From .chips, .chip, .seg, .uchip, .rchip. */
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { display: inline-flex; gap: 8px; align-items: center; border: 1px solid var(--line2); border-radius: 999px; padding: 8px 14px; cursor: pointer; color: var(--near); background: var(--panel); font-size: 14px; min-height: 42px; }
.chip[aria-pressed="true"] { border-color: var(--cyan); background: var(--sel); }
.seg { display: inline-flex; border: 1px solid var(--line2); border-radius: 10px; overflow: hidden; }
.seg button { background: var(--panel); color: var(--near); border: 0; padding: 8px 14px; min-height: 44px; cursor: pointer; font-size: 13.5px; }
.seg button + button { border-left: 1px solid var(--line2); }
.seg button[aria-pressed="true"], .seg button[aria-selected="true"] { background: var(--sel); color: var(--cyan); font-weight: 700; }
.uchip { display: inline-flex; gap: 8px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px; color: var(--near); font-size: 13px; }
.uchip .mono { color: var(--muted); }
.rchip { display: inline-block; line-height: 1.5; font-size: 11.5px; font-weight: 700; letter-spacing: .04em; border-radius: 6px; padding: 2px 8px; background: #14262e; color: var(--cyan); border: 1px solid #23414c; }
.rchip.supervisor { color: var(--pink); border-color: #5a2a44; background: #22131b; }
.rchip.lead { color: var(--green); border-color: #24503a; background: #10221a; }
.rchip.admin { color: var(--amber); border-color: #5a4720; background: #211c10; }
/* Callouts and notices. From .callout, .callout.warn, .callout b, .viewonly, .back. */
.callout { border-left: 3px solid var(--cyan); background: var(--panel); border-radius: 0 10px 10px 0; padding: 10px 14px; font-size: 14px; margin-block: 10px; }
.callout.warn { border-left-color: var(--pink); background: var(--warnbg); }
.callout b { color: var(--white); }
.viewonly { display: flex; gap: 10px; align-items: flex-start; border: 1px dashed var(--line2); border-radius: 10px; padding: 10px 14px; margin: 8px 0; font-size: 14px; color: var(--body); background: rgba(255, 255, 255, .02); }
.viewonly b { color: var(--near); }
.back { all: unset; cursor: pointer; color: var(--cyan); font-weight: 600; font-size: 14px; display: inline-flex; gap: 6px; align-items: center; padding-block: 6px; }
/* Tabs. From .tabs, .tabs button, .tabs .cnt, .pmtabs (static variant). */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); overflow-x: auto; margin-bottom: 14px; position: sticky; top: var(--toph); z-index: 15; background: var(--bg); }
.tabs button { border: 0; background: transparent; color: var(--muted); font-weight: 600; font-size: 15px; padding: 12px 16px; border-bottom: 3px solid transparent; cursor: pointer; white-space: nowrap; min-height: 48px; }
.tabs button[aria-selected="true"] { color: var(--near); border-bottom-color: var(--cyan); }
.tabs .cnt { font-family: var(--f-mono); font-size: 12px; color: var(--cyan); margin-left: 6px; }
.tabs.static { position: static; }
/* Tables. From .tw, table, th, td, td.num, td .sub. Wrap a table in .tw for sideways scroll. */
.tw { overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th { background: var(--panel2); text-align: left; font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 700; padding: 9px 10px; white-space: nowrap; }
td { border-top: 1px solid var(--line); padding: 9px 10px; vertical-align: middle; color: var(--near); }
td.num, th.num { text-align: right; font-family: var(--f-mono); font-variant-numeric: tabular-nums; }
td .sub { color: var(--muted); font-size: 12.5px; }
/* Sheet, modal and toast. From .scrim, .sheet, .toast. */
.scrim { position: fixed; inset: 0; background: var(--scrim); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; overscroll-behavior: contain; }
.sheet { background: var(--sheet); border: 1px solid var(--line2); border-radius: 16px; width: min(760px, 100%); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); overflow: auto; padding: 18px; display: flex; flex-direction: column; gap: 14px; box-shadow: 0 18px 60px rgba(0, 0, 0, .55); margin: auto; }
.sheet-acts { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
.toast { position: fixed; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); background: var(--near); color: var(--bg); font-weight: 600; padding: 10px 18px; border-radius: 10px; z-index: 60; box-shadow: 0 6px 20px rgba(0, 0, 0, .4); max-width: calc(100% - 32px); }
/* Sign-in card and PIN keypad. From .auth, .auth-card, .auth-form, .keypad. */
.auth { display: grid; place-items: center; padding-block: 5vh 40px; }
.auth-card { width: min(440px, 100%); background: var(--panel); border: 1px solid var(--line); border-top: 3px solid var(--cyan); border-radius: 14px; padding: 24px; display: flex; flex-direction: column; gap: 14px; }
.auth-form { display: flex; flex-direction: column; gap: 12px; }
.keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.keypad button { min-height: 60px; border-radius: 10px; border: 1px solid var(--line2); background: var(--field); color: var(--near); font-family: var(--f-mono); font-size: 22px; font-weight: 600; cursor: pointer; }
.keypad button:active { background: var(--sel); }
.keypad button:disabled { opacity: .45; cursor: not-allowed; }
/* Lock screen placeholder. From body.blur-hidden #main and #guard .scrim. The guard sits above every sheet. */
.blur-hidden .wrap { filter: blur(18px); }
.scrim.lock { z-index: 90; }
.lock .auth-card { border-top-color: var(--amber); }
/* Motion, opt-in through a .motion class on body. From .motion .scrim, .motion .sheet, .motion .toast and their keyframes. */
@keyframes mfade { from { opacity: 0; } to { opacity: 1; } }
@keyframes msheet { from { opacity: 0; transform: translateY(18px) scale(.985); } to { opacity: 1; transform: none; } }
@keyframes mtoast { from { opacity: 0; transform: translate(-50%, 14px); } to { opacity: 1; transform: translate(-50%, 0); } }
.motion .scrim { animation: mfade .16s ease-out; }
.motion .sheet { animation: msheet .24s cubic-bezier(.2, .7, .2, 1); }
.motion .toast { animation: mtoast .22s cubic-bezier(.2, .7, .2, 1); }
/* Narrow screens. From the old @media (max-width:700px) and (max-width:640px) blocks. */
@media (max-width: 700px) {
.tw table { min-width: 620px; }
.tabs { top: 0; position: static; }
}
@media (max-width: 640px) {
h1 { font-size: 28px; }
.kpi .n { font-size: 38px; }
.brand .nm { font-size: 18px; }
.phead h1 { font-size: 26px; }
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Set by the image build (PS_BUILD). Absent in local dev. */
readonly VITE_PS_BUILD?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"isolatedModules": true,
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// The API runs on uvicorn at 127.0.0.1:8000 in development.
const API = "http://127.0.0.1:8000";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true,
// CSP is default-src 'self'. Never inline assets as data: URIs.
assetsInlineLimit: 0,
sourcemap: false,
},
server: {
proxy: {
"/api": { target: API, changeOrigin: false },
"/_blob": { target: API, changeOrigin: false },
"/healthz": { target: API, changeOrigin: false },
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
css: false,
},
});