diff --git a/Sprint-1-Plan-Part-3.md b/Sprint-1-Plan-Part-3.md
new file mode 100644
index 0000000..c3aba14
--- /dev/null
+++ b/Sprint-1-Plan-Part-3.md
@@ -0,0 +1,474 @@
+# Sprint 1 — Plan, Part 3 ("Spark")
+
+_Continued from [Sprint-1-Plan-Part-2](Sprint-1-Plan-Part-2.md). Covers W4 (frontend), W5 (seed data), W6 (deploy + CI/CD)._
+
+---
+
+### W4 — Frontend
+
+**Goal:** the four humans can see the idea list and post a new idea. PWA manifest is served and "Add to Home Screen" works on iOS Safari + Android Chrome. Service worker registers (stub-only, no caching yet).
+
+**Pre-requisite:** W3 complete (`/api/ideas` works locally against a logged-in proxy session).
+
+**Deliverables:**
+
+1. [`frontend/app/layout.tsx`](../frontend/app/layout.tsx) — root layout, fonts, theme colour, link to manifest.
+2. [`frontend/app/(app)/layout.tsx`](../frontend/app/(app)/layout.tsx) — protected layout: redirects to `/login` if no session.
+3. [`frontend/app/(app)/page.tsx`](../frontend/app/(app)/page.tsx) — `/` redirects to `/ideas`.
+4. [`frontend/app/(app)/ideas/page.tsx`](../frontend/app/(app)/ideas/page.tsx) — list view (server component).
+5. [`frontend/app/(app)/ideas/new/page.tsx`](../frontend/app/(app)/ideas/new/page.tsx) — create form (client component).
+6. [`frontend/app/(app)/ideas/components/idea-list.tsx`](../frontend/app/(app)/ideas/components/idea-list.tsx).
+7. [`frontend/app/(app)/ideas/components/idea-form.tsx`](../frontend/app/(app)/ideas/components/idea-form.tsx).
+8. [`frontend/lib/api.ts`](../frontend/lib/api.ts) — thin fetch wrappers.
+9. [`frontend/public/manifest.json`](../frontend/public/manifest.json).
+10. [`frontend/public/sw.js`](../frontend/public/sw.js) — stub service worker.
+11. [`frontend/lib/sw-register.ts`](../frontend/lib/sw-register.ts) — SW registration on first paint.
+12. App icon set: `icon-192.png`, `icon-512.png`, `apple-touch-icon.png`, `favicon.ico`. Hand-drawn or `imagemagick`-generated single-colour campfire glyph. See [Open Question Q07](Open-Questions.md#q07-pwa-assets-pipeline).
+
+**Acceptance gate:**
+- Local dev: signed-in user can hit `/ideas`, see an empty list, click "+", post an idea, see it appear.
+- `/manifest.json` returns 200 with valid content.
+- DevTools → Application → Manifest shows green checkmarks.
+- iOS Safari "Add to Home Screen" produces an icon labelled "Sparkboard".
+- Android Chrome shows the install prompt.
+- Satisfies the UI half of **A4**, all of **A5**, and the post-login navigation half of **A1**.
+
+**Code sketch — [`frontend/app/(app)/ideas/page.tsx`](../frontend/app/(app)/ideas/page.tsx) (server component):**
+
+```tsx
+import { listIdeas } from "@/lib/api";
+import { IdeaList } from "./components/idea-list";
+import Link from "next/link";
+
+export default async function IdeasPage() {
+ const ideas = await listIdeas();
+
+ return (
+
+
+
Sparkboard
+
+ + New
+
+
+
+
+ );
+}
+```
+
+**Code sketch — [`frontend/lib/api.ts`](../frontend/lib/api.ts):**
+
+```typescript
+import { cookies } from "next/headers";
+
+export type Idea = {
+ id: string;
+ authorId: string;
+ title: string;
+ description: string | null;
+ status: "RAW" | "EXPLORING" | "BUILDING" | "SHIPPED" | "DEAD";
+ createdAt: string;
+ updatedAt: string;
+};
+
+const ORIGIN = process.env.NEXTAUTH_URL ?? "http://localhost:3000";
+
+export async function listIdeas(): Promise {
+ const res = await fetch(`${ORIGIN}/api/backend/api/ideas`, {
+ headers: { cookie: (await cookies()).toString() },
+ cache: "no-store",
+ });
+ if (!res.ok) throw new Error(`listIdeas: ${res.status}`);
+ return res.json();
+}
+
+export async function createIdea(input: { title: string; description?: string }) {
+ const res = await fetch(`${ORIGIN}/api/backend/api/ideas`, {
+ method: "POST",
+ headers: { "content-type": "application/json", cookie: (await cookies()).toString() },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) throw new Error(`createIdea: ${res.status}`);
+ return res.json();
+}
+```
+
+**Code sketch — [`frontend/app/(app)/ideas/components/idea-form.tsx`](../frontend/app/(app)/ideas/components/idea-form.tsx) (client component):**
+
+```tsx
+"use client";
+import { useTransition, useState } from "react";
+import { useRouter } from "next/navigation";
+
+export function IdeaForm() {
+ const router = useRouter();
+ const [pending, start] = useTransition();
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+
+ const submit = (e: React.FormEvent) => {
+ e.preventDefault();
+ start(async () => {
+ const res = await fetch("/api/backend/api/ideas", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ title, description: description || undefined }),
+ });
+ if (res.ok) router.push("/ideas");
+ });
+ };
+
+ return (
+
+ );
+}
+```
+
+**Code sketch — [`frontend/public/manifest.json`](../frontend/public/manifest.json):**
+
+```json
+{
+ "name": "Sparkboard",
+ "short_name": "Sparkboard",
+ "description": "Catch the spark before it fades.",
+ "start_url": "/ideas",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#0a0a0a",
+ "theme_color": "#ea580c",
+ "orientation": "portrait-primary",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
+ ]
+}
+```
+
+**Code sketch — [`frontend/public/sw.js`](../frontend/public/sw.js) (stub, intentionally minimal):**
+
+```javascript
+// Sprint 1 service worker stub.
+// Purpose: register, claim clients, fail closed. NO caching.
+// Real offline caching arrives in Sprint 4 (Ember).
+self.addEventListener("install", e => self.skipWaiting());
+self.addEventListener("activate", e => e.waitUntil(self.clients.claim()));
+self.addEventListener("fetch", () => { /* let the network handle it */ });
+```
+
+**Code sketch — [`frontend/lib/sw-register.ts`](../frontend/lib/sw-register.ts):**
+
+```typescript
+"use client";
+import { useEffect } from "react";
+
+export function ServiceWorkerRegister() {
+ useEffect(() => {
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
+ }
+ }, []);
+ return null;
+}
+```
+
+Wired into `app/layout.tsx` once.
+
+**On protecting the `(app)` route group:**
+NextAuth v5 + `@platesoft/auth`'s middleware re-export handles auth-aware redirects. Sparkboard's `middleware.ts`:
+
+```typescript
+export { default } from "@platesoft/auth/middleware";
+export const config = { matcher: ["/((?!login|api/auth|_next|icons|sw.js|manifest.json).*)"] };
+```
+
+Anything matched and unauthenticated → bounce to `/login`. That's the entire client-side auth check.
+
+---
+
+### W5 — Seed data
+
+**Goal:** local dev and CI environments start with a tiny but useful dataset so the four humans (and the test runner) can see something on day one.
+
+**Pre-requisite:** W2 + W3 + W4 done. The migration system works.
+
+**Deliverables:**
+
+1. [`backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql`](../backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql) — already done in W2.
+2. **No idea seed data in production.** The `ideas` table starts empty. Patrick will post the first idea himself ("Sparkboard exists.") and that becomes the canonical first row.
+3. _Dev-only_ [`backend/src/main/resources/db/migration/dev/`](../backend/src/main/resources/db/migration/dev/) Flyway location, activated with `spring.profiles.active=dev` — seeds 3 example ideas with a fixed `author_id` of `00000000-0000-0000-0000-000000000099` ("Dev User") so the list page is non-empty when the developer boots locally.
+4. Local dev convenience: a `dev-login` button on `/login` that POSTs to a `dev-only` `/api/auth/dev-login` endpoint shipped by plate-auth's `dev` profile (already provided by plate-auth — see [plate-auth Integration Guide §3](https://git.plate-software.de/pplate/plate-auth/wiki/Integration-Guide)). Sparkboard does **not** implement this endpoint; just configures the flag.
+
+**Acceptance gate:**
+- Local backend boot with `spring.profiles.active=dev` populates 3 example ideas.
+- Local backend boot **without** dev profile leaves `ideas` empty.
+- Local frontend boot with `dev` env shows a "Dev login" button alongside "Sign in with Google".
+- Prod boot has neither dev migration nor dev login.
+
+**Code sketch — dev seed migration `db/migration/dev/R__dev_seed_ideas.sql` (Flyway _repeatable_, prefix `R__`):**
+
+```sql
+-- Runs every dev boot if changed. Idempotent via WHERE NOT EXISTS.
+INSERT INTO ideas (id, org_id, author_id, title, description, status, created_at, updated_at)
+SELECT
+ '11111111-1111-1111-1111-111111111111',
+ '00000000-0000-0000-0000-000000000001',
+ '00000000-0000-0000-0000-000000000099',
+ 'Sparkboard exists.',
+ 'The first canonical idea. Burn it down if you want.',
+ 'EXPLORING',
+ now() - interval '3 days',
+ now() - interval '3 days'
+WHERE NOT EXISTS (SELECT 1 FROM ideas WHERE id = '11111111-1111-1111-1111-111111111111');
+
+-- Two more sample ideas elided for brevity.
+```
+
+Activated via `spring.flyway.locations=classpath:db/migration,classpath:db/migration/dev` in `application-dev.yml`.
+
+---
+
+### W6 — Deploy + CI/CD
+
+**Goal:** a push to `main` on `git.plate-software.de/pplate/sparkboard` results in a healthy `https://sparkboard.plate-software.de` within 10 minutes. Wall-clock end-to-end.
+
+**Pre-requisite:** W4 complete (a build that runs locally). All Sprint-0 prereqs done: DNS, frps port 30011, IONOS vhost, TrueNAS dataset, Google OAuth client.
+
+**Deliverables:**
+
+1. [`backend/Dockerfile`](../backend/Dockerfile) — multi-stage, distroless Java 25 runtime.
+2. [`frontend/Dockerfile`](../frontend/Dockerfile) — multi-stage, Next.js standalone build on Node 22.
+3. [`docker-compose.yml`](../docker-compose.yml) — local dev (backend + frontend + postgres).
+4. [`docker-compose.prod.yml`](../docker-compose.prod.yml) — TrueNAS production (adds named volumes, host network for frpc, restart policies).
+5. [`deploy/caddy/Caddyfile`](../deploy/caddy/Caddyfile) — internal reverse proxy on TrueNAS routing `/api/*` → backend, everything else → frontend.
+6. [`deploy/deploy.sh`](../deploy/deploy.sh) — SSH-side script: pull images, run `docker compose -f docker-compose.prod.yml up -d`, prune.
+7. [`deploy/smoke-test.sh`](../deploy/smoke-test.sh) — post-deploy: `curl https://sparkboard.plate-software.de/api/health`, `/login`, `/manifest.json` — all must return 2xx.
+8. [`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml) — on push to `main`: build → push to Gitea registry → SSH-deploy.
+9. IONOS Apache vhost block (out-of-repo, lives on the IONOS server): `sparkboard.plate-software.de` → `http://:30011`.
+10. `frpc.toml` entry on TrueNAS, new section:
+ ```toml
+ [sparkboard]
+ type = "tcp"
+ local_ip = "127.0.0.1"
+ local_port = 8080 # caddy on truenas
+ remote_port = 30011
+ ```
+
+**Acceptance gate:**
+- A merge to `main` triggers `.gitea/workflows/deploy.yml`.
+- The workflow builds both images, pushes to `docker.git.plate-software.de/pplate/sparkboard-backend:` and `sparkboard-frontend:`.
+- The SSH step runs `deploy/deploy.sh` on TrueNAS; the script picks up the new images and recreates the containers.
+- `deploy/smoke-test.sh` runs against `https://sparkboard.plate-software.de` and passes.
+- A real Google sign-in from a phone on cellular reaches `/ideas` and shows an empty list (or the dev seed if dev). End-to-end.
+- Satisfies **A6** and is the final closing gate for **A1**.
+
+**Code sketch — [`backend/Dockerfile`](../backend/Dockerfile):**
+
+```dockerfile
+# syntax=docker/dockerfile:1
+FROM maven:3.9-eclipse-temurin-25 AS build
+WORKDIR /build
+COPY pom.xml .
+RUN mvn -B -e -ntp dependency:go-offline
+COPY src src
+RUN mvn -B -e -ntp -DskipTests package
+
+FROM gcr.io/distroless/java25-debian12:nonroot
+WORKDIR /app
+COPY --from=build /build/target/sparkboard-backend-*.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+```
+
+**Code sketch — [`frontend/Dockerfile`](../frontend/Dockerfile):**
+
+```dockerfile
+# syntax=docker/dockerfile:1
+FROM node:22-alpine AS deps
+WORKDIR /app
+COPY package.json pnpm-lock.yaml ./
+RUN corepack enable && pnpm install --frozen-lockfile
+
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN corepack enable && pnpm build
+
+FROM gcr.io/distroless/nodejs22-debian12:nonroot
+WORKDIR /app
+COPY --from=build /app/.next/standalone /app/
+COPY --from=build /app/.next/static /app/.next/static
+COPY --from=build /app/public /app/public
+EXPOSE 3000
+ENV PORT=3000 HOSTNAME=0.0.0.0
+CMD ["server.js"]
+```
+
+**Code sketch — [`docker-compose.prod.yml`](../docker-compose.prod.yml) (excerpt):**
+
+```yaml
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: sparkboard
+ POSTGRES_USER: ${DB_USER}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ volumes:
+ - /mnt/tank/sparkboard/pgdata:/var/lib/postgresql/data
+ restart: unless-stopped
+
+ backend:
+ image: docker.git.plate-software.de/pplate/sparkboard-backend:${SHA}
+ depends_on: [postgres]
+ environment:
+ SPRING_PROFILES_ACTIVE: prod
+ DB_HOST: postgres
+ DB_USER: ${DB_USER}
+ DB_PASSWORD: ${DB_PASSWORD}
+ PLATE_AUTH_JWT_SECRET: ${PLATE_AUTH_JWT_SECRET}
+ PLATE_AUTH_EXCHANGE_SECRET: ${PLATE_AUTH_EXCHANGE_SECRET}
+ PLATE_AUTH_ALLOWLIST_EMAILS: ${PLATE_AUTH_ALLOWLIST_EMAILS}
+ GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
+ GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
+ restart: unless-stopped
+
+ frontend:
+ image: docker.git.plate-software.de/pplate/sparkboard-frontend:${SHA}
+ depends_on: [backend]
+ environment:
+ NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
+ NEXTAUTH_URL: https://sparkboard.plate-software.de
+ PLATE_AUTH_BACKEND_URL: http://backend:8080
+ PLATE_AUTH_EXCHANGE_SECRET: ${PLATE_AUTH_EXCHANGE_SECRET}
+ GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
+ GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
+ restart: unless-stopped
+
+ caddy:
+ image: caddy:2-alpine
+ ports: ["127.0.0.1:8080:8080"] # frpc forwards 30011 → 8080
+ depends_on: [backend, frontend]
+ volumes:
+ - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
+ restart: unless-stopped
+```
+
+**Code sketch — [`deploy/caddy/Caddyfile`](../deploy/caddy/Caddyfile):**
+
+```
+:8080 {
+ encode gzip
+ handle /api/* {
+ reverse_proxy backend:8080
+ }
+ handle {
+ reverse_proxy frontend:3000
+ }
+}
+```
+
+**Code sketch — [`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml):**
+
+```yaml
+name: Deploy
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Login Gitea registry
+ run: echo "${{ secrets.GITEA_DEPLOY_TOKEN }}" | docker login docker.git.plate-software.de -u pplate --password-stdin
+ - name: Build backend
+ run: docker build -t docker.git.plate-software.de/pplate/sparkboard-backend:${{ github.sha }} ./backend
+ - name: Build frontend
+ run: docker build -t docker.git.plate-software.de/pplate/sparkboard-frontend:${{ github.sha }} ./frontend
+ - name: Push images
+ run: |
+ docker push docker.git.plate-software.de/pplate/sparkboard-backend:${{ github.sha }}
+ docker push docker.git.plate-software.de/pplate/sparkboard-frontend:${{ github.sha }}
+ - name: SSH deploy
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.TRUENAS_HOST }}
+ username: ${{ secrets.TRUENAS_USER }}
+ key: ${{ secrets.TRUENAS_SSH_KEY }}
+ script: |
+ cd /mnt/tank/sparkboard
+ export SHA=${{ github.sha }}
+ ./deploy.sh
+ - name: Smoke test
+ run: ./deploy/smoke-test.sh
+```
+
+**Code sketch — [`deploy/deploy.sh`](../deploy/deploy.sh):**
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+echo "Deploying SHA=${SHA}"
+
+docker compose -f docker-compose.prod.yml pull
+docker compose -f docker-compose.prod.yml up -d --remove-orphans
+docker image prune -af --filter "until=168h"
+
+echo "Deploy complete."
+```
+
+**Code sketch — [`deploy/smoke-test.sh`](../deploy/smoke-test.sh):**
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+URL="https://sparkboard.plate-software.de"
+
+check() {
+ local path="$1" expected="$2"
+ local got
+ got=$(curl -sS -o /dev/null -w "%{http_code}" "$URL$path")
+ [[ "$got" == "$expected" ]] || { echo "FAIL $path: got $got, expected $expected"; exit 1; }
+ echo "OK $path → $got"
+}
+
+check /api/health 200
+check /login 200
+check /manifest.json 200
+check /sw.js 200
+
+echo "All smoke checks passed."
+```
+
+`/api/health` is provided by `spring-boot-starter-actuator` — Sparkboard exposes `management.endpoints.web.exposure.include=health` in `application-prod.yml`. No code written.
+
+---
+
+_Continued in [Sprint-1-Plan-Part-4](Sprint-1-Plan-Part-4.md): implementation order, acceptance→workstream mapping, open questions._