Phase 0: backend + frontend scaffold, single-binary build
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s

Implements the Pansy v1 scaffold (epic #20, phase 0).

#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
   gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
   with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
   runner, full initial schema (users, sessions, gardens, garden_shares,
   garden_objects, plants, plantings), domain structs + sentinel errors,
   graceful shutdown.

#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
   Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
   body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
   responsive app shell with stub pages for all five routes.

#3 Single binary: //go:embed of the web build with a committed placeholder,
   SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
   /api and missing assets), Makefile (web/build/dev/test), README quickstart
   + env var table.

Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 14:52:05 -04:00
co-authored by Claude Opus 4.8
parent 76d18da542
commit 91da9ff945
36 changed files with 4578 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
/** Placeholder page scaffolding used until each feature issue fills these in. */
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
return (
<section>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-2 text-sm text-muted">
{children ?? 'Placeholder — this page arrives in a later issue.'}
</p>
</section>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { Link, Outlet } from '@tanstack/react-router'
import { cn } from '@/lib/cn'
const navLinks = [
{ to: '/gardens', label: 'Gardens' },
{ to: '/plants', label: 'Plants' },
] as const
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
export function AppShell() {
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
🌱 pansy
</Link>
<div className="flex flex-1 items-center gap-1">
{navLinks.map((l) => (
<Link
key={l.to}
to={l.to}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg',
)}
activeProps={{ className: 'bg-border/60 text-fg' }}
>
{l.label}
</Link>
))}
</div>
<Link
to="/login"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
>
Sign in
</Link>
</nav>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
<Outlet />
</main>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
// Typed fetch wrapper for the pansy JSON API under /api/v1.
//
// Every non-2xx response throws an ApiError carrying the HTTP status and the
// parsed response body. That contract matters beyond this issue: the optimistic
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
const BASE = '/api/v1'
/** Error thrown for any non-2xx response (or a network failure, status 0). */
export class ApiError extends Error {
readonly status: number
/** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
readonly body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
/** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
get isConflict(): boolean {
return this.status === 409
}
/** No authenticated session; callers typically redirect to /login. */
get isUnauthorized(): boolean {
return this.status === 401
}
}
type ParamValue = string | number | boolean | undefined | null
export type Params = Record<string, ParamValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
/** JSON request body; serialized and sent with a JSON content-type. */
body?: unknown
/** Query-string parameters; undefined/null/'' entries are omitted. */
params?: Params
signal?: AbortSignal
}
function buildUrl(path: string, params?: Params): string {
const url = BASE + path
if (!params) return url
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue
qs.set(k, String(v))
}
const s = qs.toString()
return s ? `${url}?${s}` : url
}
async function parseBody(res: Response): Promise<unknown> {
if (res.status === 204) return null
const text = await res.text()
if (!text) return null
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
try {
return JSON.parse(text)
} catch {
return text
}
}
return text
}
function messageFrom(body: unknown, status: number): string {
if (body && typeof body === 'object') {
const err = (body as { error?: { message?: string } }).error
if (err?.message) return err.message
const msg = (body as { message?: string }).message
if (msg) return msg
}
return `Request failed (${status})`
}
/** Perform a request against /api/v1 and return the parsed JSON body as T. */
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, params, signal } = opts
let res: Response
try {
res = await fetch(buildUrl(path, params), {
method,
signal,
credentials: 'same-origin', // send the HttpOnly session cookie
headers: {
accept: 'application/json',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err
throw new ApiError('Cannot reach the pansy server.', 0, null)
}
const payload = await parseBody(res)
if (!res.ok) {
throw new ApiError(messageFrom(payload, res.status), res.status, payload)
}
return payload as T
}
/** Convenience verbs over apiFetch. */
export const api = {
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'DELETE' }),
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** Merge conditional class names, resolving Tailwind conflicts (last wins). */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
import './styles/index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
},
})
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Missing #root element')
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)
+13
View File
@@ -0,0 +1,13 @@
import { getRouteApi } from '@tanstack/react-router'
import { PageStub } from '@/components/PageStub'
const routeApi = getRouteApi('/gardens/$gardenId')
export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
return (
<PageStub title={`Garden ${gardenId}`}>
The field editor (canvas, palette, plops) arrives across issues #9#15.
</PageStub>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function GardensPage() {
return <PageStub title="Gardens">The garden list and create flow arrive in issue #8.</PageStub>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function LoginPage() {
return <PageStub title="Sign in">Login form and SSO button arrive in issue #6.</PageStub>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function PlantsPage() {
return <PageStub title="Plants">The plant catalog arrives in issue #13.</PageStub>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function RegisterPage() {
return <PageStub title="Create account">Registration form arrives in issue #6.</PageStub>
}
+54
View File
@@ -0,0 +1,54 @@
import {
createRootRoute,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
import { AppShell } from '@/components/layout/AppShell'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PlantsPage } from '@/pages/PlantsPage'
const rootRoute = createRootRoute({ component: AppShell })
// The root path has no page of its own yet; send it to the gardens list. A real
// auth-aware landing/redirect lands with the route guard in issue #6.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
beforeLoad: () => {
throw redirect({ to: '/gardens' })
},
})
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
const gardenEditorRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'gardens/$gardenId',
component: GardenEditorPage,
})
const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', component: PlantsPage })
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
registerRoute,
gardensRoute,
gardenEditorRoute,
plantsRoute,
])
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
+46
View File
@@ -0,0 +1,46 @@
@import 'tailwindcss';
/* Garden-planner theme tokens. Each --color-* becomes a Tailwind utility
(bg-*, text-*, border-*). Light by default; dark via prefers-color-scheme. */
@theme {
--color-bg: #f8faf7;
--color-surface: #ffffff;
--color-border: #e3e8e0;
--color-muted: #61705d;
--color-fg: #1c2419;
--color-accent: #3f8f4f;
--color-accent-strong: #2f7a3e;
--color-accent-contrast: #ffffff;
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
background-color: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
}
/* Dark theme: override the same tokens so utilities recolor automatically. */
@media (prefers-color-scheme: dark) {
@theme {
--color-bg: #10140f;
--color-surface: #171c15;
--color-border: #2a3226;
--color-muted: #8a967f;
--color-fg: #e7ede2;
--color-accent: #5bb06a;
--color-accent-strong: #6fc07d;
--color-accent-contrast: #0c1109;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />