Files
wild-pc/docs/stacks/react-vite.md
Paul Payne 05b28cb584 Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
2026-07-18 22:55:08 -07:00

385 lines
11 KiB
Markdown

# Web Frontends in Wild PC
> **This is a stack — creation-time guidance for writing _new_ frontends.**
> A stack is a template + conventions, not a runtime requirement. `wildpc program create
> --stack react-vite` scaffolds from it and seeds the program's default dev-verb
> commands. An existing frontend adopted with `wildpc program add` doesn't need this
> stack — it declares its own `commands:`. See @docs/registry.md for
> `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage web frontends as wildpc programs.
## Stack
- Build: Vite 6
- Language: TypeScript 5.8 (strict)
- Framework: React 19
- Routing: React Router 7
- Styling: Tailwind CSS 4 (`@tailwindcss/vite` plugin)
- Components: shadcn/ui (new-york style) + Radix UI primitives
- Icons: Lucide React
- Server state: TanStack React Query 5
- Forms: React Hook Form + Zod validation
- Testing: Vitest + Testing Library
- Package manager: pnpm
## Scaffolding a new frontend
```bash
# Create the project
mkdir /data/repos/my-frontend && cd /data/repos/my-frontend
pnpm create vite . --template react-ts
# Core dependencies
pnpm add react-router react-router-dom \
@tanstack/react-query \
tailwindcss @tailwindcss/vite \
class-variance-authority clsx tailwind-merge \
lucide-react sonner zod \
react-hook-form @hookform/resolvers
# Dev dependencies
pnpm add -D vitest jsdom @testing-library/react @testing-library/jest-dom \
@testing-library/user-event
# shadcn/ui
pnpm dlx shadcn@latest init
```
## Vite config
```ts
// vite.config.ts
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
// Wild PC sets VITE_BASE=/ at build time (every frontend serves at its
// subdomain root). Reading it here keeps the bundle.s absolute asset
// URLs resolving at the root — no hand-tuning.
base: process.env.VITE_BASE ?? "/",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})
```
> **Serving behind the gateway.** A static frontend is served at its own subdomain
> — `<name>.<gateway.domain>` — rooted at `/`, so `VITE_BASE` is always `/`. Wild PC's
> `react-vite` build passes `VITE_BASE=/`, and a `vite.config` that reads it (above)
> works unchanged. (Frontends are no longer mounted under a `/<name>/` path.)
## Project layout
```
my-frontend/
├── src/
│ ├── main.tsx # ReactDOM.createRoot mount
│ ├── App.tsx # Root: QueryClientProvider + RouterProvider
│ ├── index.css # Tailwind imports + CSS custom properties
│ ├── router/
│ │ ├── index.tsx # createBrowserRouter
│ │ └── routes.tsx # Route tree
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── services/api/
│ │ ├── client.ts # Typed fetch wrapper
│ │ └── hooks/ # React Query hooks per resource
│ ├── hooks/ # App-level hooks
│ ├── contexts/ # React context providers
│ ├── lib/
│ │ ├── utils.ts # cn() helper
│ │ └── queryClient.ts # Query client config
│ ├── types/ # Shared TypeScript types
│ └── schemas/ # Zod schemas
├── public/ # Static assets (favicon, manifest.json)
├── index.html # SPA entry point
├── package.json
├── vite.config.ts
├── tsconfig.json
├── vitest.config.ts
├── components.json # shadcn/ui config
└── .env # VITE_API_BASE_URL etc.
```
## Build commands
```bash
pnpm run dev # Vite dev server (:5173), HMR
pnpm run build # tsc -b && vite build → dist/
pnpm run preview # Serve production build locally
pnpm run type-check # tsc --noEmit
pnpm run lint # ESLint
pnpm run test # Vitest
pnpm run check # lint + type-check + test
```
The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
## Pinning the node version
Commit a **`.node-version`** (or `.nvmrc`, or `package.json` `engines.node`) at the
project root:
```
24.14.1
```
Wild PC reads this pin and puts the matching node on PATH whenever it runs the
program's node — at build time (`wildpc program build`, incl. builds triggered from
the wildpc web app, which run inside the `wildpc-api` service) and at run time (a
`launcher: node` service's systemd unit). This is why a build works from the web app
even though the service's default PATH deliberately omits nvm's versioned dirs.
The pin is standard, tool-agnostic config — the program stays wildpc-independent; nvm
(and editors/CI) honor the same file. Wild PC resolves it against
`~/.nvm/versions/node` (override with `WILDPC_NODE_VERSIONS_DIR`), newest match wins.
An exact pin (`24.14.1`) matches exactly; a partial (`24`) or a range (`>=24`) matches
the newest installed major. If the pinned version isn't installed, the verb fails loud
with a `nvm install <version>` hint instead of a cryptic `node: not found`. Unpinned →
Wild PC injects no node (it uses whatever ambient node is on PATH, and does not guess).
## Registering as a program
A frontend program has a `build` spec (produces static output). Register it in
`programs/<name>.yaml`, plus a `deployments/<name>.yaml` with `manager: caddy`
(derived **kind: static**) that names the built directory in `root:` — no `run`
block, since Caddy serves the build output directly.
```yaml
# programs/my-frontend.yaml
description: Web dashboard
source: /data/repos/my-frontend
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
```
```yaml
# deployments/my-frontend.yaml (manager: caddy → kind: static)
program: my-frontend
manager: caddy
root: dist # served at my-frontend.<gateway.domain>
```
For production, Caddy serves the build output **in place** from the program's
repo (`<source>/<root>`) — no Node process and no copy into a central directory.
See [Serving with Caddy](#serving-with-caddy) below.
For development with Vite's dev server, use a `manager: systemd` deployment with
the `node` launcher instead:
```yaml
# deployments/my-frontend.yaml (dev — manager: systemd, node launcher → kind: service)
program: my-frontend
manager: systemd
run:
launcher: node
script: dev
package_manager: pnpm
expose:
http:
internal: { port: 5173 }
proxy: true # expose the dev server at my-frontend.<gateway.domain>
```
See @docs/registry.md for the full registry reference.
## Serving with Caddy
For production, the static build output is served by Caddy rather than a Node
process. You do **not** write this block by hand — `wildpc apply` generates it.
The flow:
1. You build the frontend with `wildpc program build <name>` (deploy does **not**
build — it only points Caddy at `dist/`).
2. The Caddyfile generator emits a route per `manager: caddy` deployment (kind
**static**), rooted **in place** at `<source>/<root>` — no copy. Each static
frontend is served at its own subdomain `<name>.<gateway.domain>` (the
dashboard, `wildpc`, is also the target of the `:9000` redirect).
The build is run with `VITE_BASE=/`, so a `vite.config` that reads it (see
[Vite config](#vite-config)) emits root-relative asset URLs. The generated block
(in `~/.wildpc/artifacts/specs/Caddyfile`) is a host matcher inside the
`*.<domain>` site:
```caddyfile
@host_my_frontend host my-frontend.example.com
handle @host_my_frontend {
root * /data/repos/my-frontend/dist
try_files {path} /index.html
file_server
}
```
The `try_files {path} /index.html` directive is essential for SPA routing —
it falls back to `index.html` for any path that doesn't match a static file,
letting React Router handle client-side routes. The subdomain is the program name,
not hand-configured.
## API integration
Frontends talk to wildpc services via environment variables injected at build
time. Vite exposes variables prefixed with `VITE_`:
```bash
# .env
VITE_API_BASE_URL=http://localhost:9001
```
```ts
// src/services/api/client.ts
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:9001"
class ApiClient {
private baseUrl: string
constructor(baseUrl = BASE_URL) {
this.baseUrl = baseUrl
}
async get<T>(path: string): Promise<T> {
const resp = await fetch(`${this.baseUrl}${path}`)
if (!resp.ok) throw new ApiError(resp.status, await resp.text())
return resp.json()
}
// post<T>, put<T>, delete<T>, etc.
}
export const apiClient = new ApiClient()
```
When served behind the wildpc gateway, the API base URL can use the gateway's
proxy paths (e.g., `/central-context/`) instead of direct ports, avoiding CORS.
## React Query setup
```ts
// src/lib/queryClient.ts
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 1,
},
},
})
```
```ts
// src/services/api/hooks/useThings.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "../client"
export function useThings() {
return useQuery({
queryKey: ["things"],
queryFn: () => apiClient.get<Thing[]>("/things"),
})
}
export function useCreateThing() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateThing) => apiClient.post<Thing>("/things", data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["things"] }),
})
}
```
## shadcn/ui setup
Initialize with the `components.json` config:
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
```
Add components as needed:
```bash
pnpm dlx shadcn@latest add button card dialog sidebar
```
Components are copied into `src/components/ui/` as source files you own and can
modify. They use Radix UI primitives underneath, with Tailwind for styling.
## Dark mode
Use CSS custom properties with a `.dark` class on `<html>`:
```css
/* src/index.css */
@import "tailwindcss";
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
/* ... */
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
/* ... */
}
```
Toggle via a React context that persists the preference to `localStorage`.
## Testing
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config"
import path from "path"
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["src/test/setup.ts"],
exclude: ["dist/**"],
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
})
```
```bash
pnpm run test # Single run
pnpm run test:ui # Interactive UI
pnpm run test:coverage # With coverage report
```