From 1550194e13411b6878297ab3058db286d4dd12fd Mon Sep 17 00:00:00 2001 From: David Ficociello Date: Wed, 15 Apr 2026 07:45:39 -0400 Subject: [PATCH 1/2] Squashed 'web/ui/src/shared/' content from commit a5195d4 git-subtree-dir: web/ui/src/shared git-subtree-split: a5195d42958310e8d1cbc14a7d5349d30115b251 --- .gitignore | 3 + ConfirmDialog.tsx | 148 ++++++++++++++++++++++++++++++++++++++++++++++ ErrorBoundary.tsx | 89 ++++++++++++++++++++++++++++ Modal.tsx | 75 +++++++++++++++++++++++ PageHeader.tsx | 67 +++++++++++++++++++++ Pill.tsx | 20 +++++++ README.md | 57 ++++++++++++++++++ api.ts | 56 ++++++++++++++++++ package-lock.json | 43 ++++++++++++++ package.json | 10 ++++ styles.ts | 19 ++++++ utils.ts | 30 ++++++++++ 12 files changed, 617 insertions(+) create mode 100644 .gitignore create mode 100644 ConfirmDialog.tsx create mode 100644 ErrorBoundary.tsx create mode 100644 Modal.tsx create mode 100644 PageHeader.tsx create mode 100644 Pill.tsx create mode 100644 README.md create mode 100644 api.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 styles.ts create mode 100644 utils.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/ConfirmDialog.tsx b/ConfirmDialog.tsx new file mode 100644 index 0000000..4333bec --- /dev/null +++ b/ConfirmDialog.tsx @@ -0,0 +1,148 @@ +import { createContext, useCallback, useContext, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import Modal from "./Modal"; + +interface ConfirmOptions { + title: string; + message: string; + confirmLabel?: string; + /** Color the confirm button red. Defaults to true. */ + danger?: boolean; +} + +type ConfirmFn = (opts: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(null); + +/** + * useConfirm returns a function that opens the shared confirm modal and + * resolves to true/false when the user clicks a button or dismisses. Use + * this instead of `window.confirm()` so every service gets the same + * themed dialog instead of the browser's native one. + * + * Must be called inside a (usually wrapped at the App + * root). Throws if no provider is mounted — this is intentional: a bare + * call in a test or untouched service is a louder failure than a silent + * fallback to window.confirm. + * + * Example: + * + * const confirm = useConfirm(); + * if (await confirm({ + * title: "Delete torrent", + * message: "This cannot be undone.", + * confirmLabel: "Delete", + * })) { + * deleteTorrent.mutate(...); + * } + */ +export function useConfirm(): ConfirmFn { + const fn = useContext(ConfirmContext); + if (!fn) throw new Error("useConfirm must be used within ConfirmProvider"); + return fn; +} + +/** + * ConfirmProvider wraps the React tree and provides the useConfirm() hook. + * Mount once at the App root, above any component that uses useConfirm. + * Rendering the provider also renders the modal itself when open, so there + * is no separate to place. + */ +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [opts, setOpts] = useState(null); + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + const confirm = useCallback( + (options) => + new Promise((resolve) => { + resolveRef.current = resolve; + setOpts(options); + }), + [], + ); + + function close(result: boolean) { + resolveRef.current?.(result); + resolveRef.current = null; + setOpts(null); + } + + const danger = opts?.danger ?? true; + const confirmLabel = + opts?.confirmLabel ?? (danger ? "Delete" : "Confirm"); + + return ( + + {children} + {opts && ( + close(false)} width={400}> +
+

+ {opts.title} +

+

+ {opts.message} +

+
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/ErrorBoundary.tsx b/ErrorBoundary.tsx new file mode 100644 index 0000000..c9fea05 --- /dev/null +++ b/ErrorBoundary.tsx @@ -0,0 +1,89 @@ +import { Component } from "react"; +import type { ReactNode, ErrorInfo } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + resetKey?: string; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidUpdate(prevProps: Props) { + if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) { + this.setState({ hasError: false, error: null }); + } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + + return ( +
+

+ Failed to render +

+

+ {this.state.error?.message ?? "Unknown error"} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/Modal.tsx b/Modal.tsx new file mode 100644 index 0000000..c3361ca --- /dev/null +++ b/Modal.tsx @@ -0,0 +1,75 @@ +import { useEffect } from "react"; +import type { ReactNode, CSSProperties } from "react"; + +interface ModalProps { + onClose: () => void; + children: ReactNode; + /** Width of the inner container. Default: 520. */ + width?: number | string; + maxWidth?: string; + maxHeight?: string; + /** Extra styles merged onto the inner container. */ + innerStyle?: CSSProperties; +} + +/** + * Generic modal shell — backdrop overlay, centered content, Escape-to-close, + * click-outside-to-close. All modals across every Beacon service frontend + * should use this so behaviour stays consistent and escape/click-away logic + * isn't duplicated per service. + * + * Colors come from CSS custom properties (`--color-bg-surface`, + * `--color-border-subtle`, `--shadow-modal`). Each service defines its own + * theme; this component doesn't know or care which. + */ +export default function Modal({ + onClose, + children, + width = 520, + maxWidth = "calc(100vw - 48px)", + maxHeight = "calc(100vh - 80px)", + innerStyle, +}: ModalProps) { + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +} diff --git a/PageHeader.tsx b/PageHeader.tsx new file mode 100644 index 0000000..7f0bd87 --- /dev/null +++ b/PageHeader.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; + +// Inline "external link" icon so this file has no dependency on lucide-react. +// The shared package keeps its dep surface to React only — each consuming +// service already has its own copy of lucide, but we don't want web-shared +// to reach into sibling node_modules for it. +function ExternalLinkIcon() { + return ( + + + + + + ); +} + +interface PageHeaderProps { + title: string; + description: string; + docsUrl?: string; + action?: ReactNode; +} + +export default function PageHeader({ title, description, docsUrl, action }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+

+ {description} + {docsUrl && ( + <> + {" "} + + Learn more + + + )} +

+
+ {action} +
+ ); +} diff --git a/Pill.tsx b/Pill.tsx new file mode 100644 index 0000000..37d9d6e --- /dev/null +++ b/Pill.tsx @@ -0,0 +1,20 @@ +export default function Pill({ ok, labelTrue, labelFalse }: { ok: boolean; labelTrue: string; labelFalse: string }) { + return ( + + {ok ? labelTrue : labelFalse} + + ); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..472a94b --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# web-shared + +Shared React/TypeScript components used by every Beacon service's frontend (Pilot, Prism, Haul, Pulse). + +## Why this exists + +Before this directory, each service either had a duplicated local copy of components like `Modal.tsx` and `ConfirmDialog.tsx` (Pilot/Prism kept drifting toward incidental divergence), or simply didn't have them at all (Haul and Pulse used raw `window.confirm()`, leaking the browser's ugly native dialog into the UX). The "dead torrent delete shows a browser prompt" bug was the symptom that surfaced this class. + +Now there's one canonical source. If you fix a bug or tweak the styling in `Modal.tsx`, every service picks it up on its next frontend build. No manual sync, no drift. + +## How it works + +Each service's `vite.config.ts` adds an alias: + +```ts +resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@beacon-shared": path.resolve(__dirname, "../../../web-shared"), + }, +}, +``` + +And each service's `tsconfig.app.json` mirrors it for type resolution: + +```json +"paths": { + "@": ["./src/*"], + "@beacon-shared/*": ["../../../web-shared/*"] +} +``` + +Then any component in the service imports like: + +```tsx +import Modal from "@beacon-shared/Modal"; +import { useConfirm, ConfirmProvider } from "@beacon-shared/ConfirmDialog"; +``` + +## Contents + +- `Modal.tsx` — Generic modal shell (backdrop, Escape, click-outside, width/height controls). +- `ConfirmDialog.tsx` — `ConfirmProvider` + `useConfirm()` hook. Replaces `window.confirm()` everywhere. + +## Rules for adding components + +1. **Only add things that ≥3 services need** (or will need within the next few commits). This isn't a dumping ground for "it might be useful someday". +2. **No service-specific deps.** Pure React + TypeScript + CSS custom properties. No imports from `@/` paths. +3. **Props must be stable.** Changes here break every service simultaneously — treat it like a public API. +4. **Test before committing.** Run `npm run build` in at least one service that imports the component. +5. **Don't create parallel versions.** If you need a variant, add a prop or make two components, but never fork. + +## Gotchas + +- The alias targets a relative path (`../../../web-shared`). That assumes a service lives at `/web/ui/`. If the directory layout moves, update the aliases. +- Because this isn't an npm package, there's no versioning. Breaking changes ship to every service at once. Make them rarely and intentionally. +- Vitest (used by Pilot's test suite) picks up the alias from `vite.config.ts` automatically. No extra test-config needed. diff --git a/api.ts b/api.ts new file mode 100644 index 0000000..52db590 --- /dev/null +++ b/api.ts @@ -0,0 +1,56 @@ +export class APIError extends Error { + status: number; + detail: string | undefined; + + constructor(status: number, message: string, detail?: string) { + super(message); + this.name = "APIError"; + this.status = status; + this.detail = detail; + } +} + +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const res = await fetch(`/api/v1${path}`, { + ...init, + headers: { + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...(init?.headers ?? {}), + }, + }); + + if (!res.ok) { + let title = res.statusText; + let detail: string | undefined; + try { + const body = (await res.json()) as { + title?: string; + error?: string; + detail?: string; + errors?: { message?: string }[]; + }; + // huma puts the underlying error in errors[0].message, not detail + const firstErr = body.errors?.[0]?.message; + title = firstErr ?? body.detail ?? body.title ?? body.error ?? title; + detail = firstErr ?? body.detail; + } catch { + // ignore parse error, use statusText + } + throw new APIError(res.status, title, detail); + } + + // 204 No Content — never has a body + if (res.status === 204) return undefined as T; + + // 202 Accepted — may or may not have a body + if (res.status === 202) { + const text = await res.text(); + if (!text) return undefined as T; + return JSON.parse(text) as T; + } + + return res.json() as Promise; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4f919b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,43 @@ +{ + "name": "@beacon/web-shared", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@beacon/web-shared", + "version": "0.0.0", + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9bf4cd4 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "@beacon/web-shared", + "version": "0.0.0", + "private": true, + "description": "Shared React components used by every Beacon service frontend (Pilot, Prism, Haul, Pulse). Resolved via Vite alias + tsconfig paths — NOT installed as a package. This package.json exists only so TypeScript can find react's type definitions when type-checking files in this directory (the module-resolution walk-up finds web-shared/node_modules/@types/react). Nothing else uses it.", + "devDependencies": { + "react": "^19.0.0", + "@types/react": "^19.0.0" + } +} diff --git a/styles.ts b/styles.ts new file mode 100644 index 0000000..cfc31eb --- /dev/null +++ b/styles.ts @@ -0,0 +1,19 @@ +import type { CSSProperties } from "react"; + +export const card: CSSProperties = { + background: "var(--color-bg-surface)", + border: "1px solid var(--color-border-subtle)", + borderRadius: 8, + padding: 20, + boxShadow: "var(--shadow-card)", +}; + +export const sectionHeader: CSSProperties = { + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.08em", + textTransform: "uppercase", + color: "var(--color-text-muted)", + marginBottom: 16, + marginTop: 0, +}; diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..c19843a --- /dev/null +++ b/utils.ts @@ -0,0 +1,30 @@ +export function formatBytes(bytes: number): string { + if (bytes <= 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function formatDate(iso: string, includeYear = false): string { + const opts: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }; + if (includeYear) opts.year = "numeric"; + return new Date(iso).toLocaleString(undefined, opts); +} + +export function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} From d2cc3d0460213b1ce6bdcc36e76605f82309cfea Mon Sep 17 00:00:00 2001 From: David Ficociello Date: Wed, 15 Apr 2026 07:46:24 -0400 Subject: [PATCH 2/2] fix: vendor web-shared via git subtree to unblock frontend CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously web/ui/vite.config.ts and tsconfig.app.json resolved @beacon-shared/* via a path relative to a sibling directory outside the pilot repo: "@beacon-shared": "../../../web-shared" That worked on the author's laptop (where the beacon monorepo checkout sits with all four app repos alongside a web-shared dir) but broke every CI run, since GitHub runners only check out pilot and the sibling dir doesn't exist. The consequence was `npm run build` and `npm run test` both dying on `Failed to resolve import "@beacon-shared/ErrorBoundary"` and every dependent test failing. Vendors web-shared into pilot's own tree at web/ui/src/shared/ via `git subtree add --prefix=web/ui/src/shared https://github.com/beacon-stack/web-shared.git main --squash` (the parent commit on this branch), then rewires the Vite alias and the tsconfig path map to point at the vendored copy instead. Why vendoring and not a published npm package: - web-shared ships .tsx source, not compiled JS. Publishing requires a build step (tsup or tsc), an exports map, peerDependencies for react/react-dom, and a GitHub Packages publish flow. That's half a day of work, not 45 minutes. - The existing React singleton hack in vite.config.ts (forcing react and react-dom to the service's own node_modules to avoid shipping two copies) is fragile and would get worse as an installed package — any React-coupled transitive dep in a published web-shared could bypass it. - Keeping the "@beacon-shared/*" alias means zero import changes in any consumer file. Every existing `import X from "@beacon-shared/Y"` still resolves correctly. Upstream bugfix flow: `git subtree pull --prefix=web/ui/src/shared https://github.com/beacon-stack/web-shared.git main --squash`. A fix made in a pilot component can be pushed back upstream with `git subtree push`. Graduates to a real published package when any of these fire: (1) a fourth consumer joins, (2) web-shared needs a React-coupled transitive dep (Radix, Framer Motion, react-hook-form), (3) two consumers need the same component to behave differently, (4) a non-Beacon project wants to consume it. None are true today. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/ui/tsconfig.app.json | 4 ++-- web/ui/vite.config.ts | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/web/ui/tsconfig.app.json b/web/ui/tsconfig.app.json index c40287f..46a5524 100644 --- a/web/ui/tsconfig.app.json +++ b/web/ui/tsconfig.app.json @@ -9,7 +9,7 @@ "skipLibCheck": true, "paths": { "@/*": ["./src/*"], - "@beacon-shared/*": ["../../../web-shared/*"] + "@beacon-shared/*": ["./src/shared/*"] }, "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -24,6 +24,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src", "../../../web-shared/**/*"], + "include": ["src"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] } diff --git a/web/ui/vite.config.ts b/web/ui/vite.config.ts index 62f1d5b..06fdd93 100644 --- a/web/ui/vite.config.ts +++ b/web/ui/vite.config.ts @@ -8,12 +8,15 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), - "@beacon-shared": path.resolve(__dirname, "../../../web-shared"), - // Force React singleton. Without these, web-shared's own - // node_modules/react (installed for TypeScript type resolution) - // gets bundled alongside this service's own react, producing two - // React instances. The first shared hook call then crashes with - // "Cannot read properties of null (reading 'useState')". + // Shared React components are vendored from beacon-stack/web-shared + // via `git subtree add --prefix=web/ui/src/shared` and sync upstream + // bugfixes with `git subtree pull`. See README.md for the canaries + // that would justify graduating to a real package. + "@beacon-shared": path.resolve(__dirname, "./src/shared"), + // Force React singleton. Kept as free insurance even after vendoring — + // if the subtree ever grows a React-coupled transitive dep (Radix, + // Framer Motion, etc.) the dedupe stops being enough and we'll need + // a proper published package. react: path.resolve(__dirname, "node_modules/react"), "react-dom": path.resolve(__dirname, "node_modules/react-dom"), },