docs: Pressing Plant frontend Phase 1 implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 13:57:35 +02:00
parent 4655d815ee
commit 5f0dcda2ab
@@ -0,0 +1,395 @@
# Pressing Plant Frontend — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give Lyra the "Pressing Plant" visual identity — design tokens + fonts, a shared component library, an app shell (masthead + contents-nav + theme toggle), and a fully rebuilt home / "The Floor" page — without breaking the existing web test suite.
**Architecture:** Hand-rolled CSS (no framework) via two global stylesheets — `globals.css` (tokens for both themes, reset) and `design.css` (component classes) — imported once in the root layout. Shared React components under `web/src/app/_ui/`. The home page reads existing `/api/requests`, `/api/wanted`, `/api/artists` data as-is; a pure `describeJob()` helper maps pipeline `state`/`currentStage` to a literal label + severity kind + stepped progress.
**Tech Stack:** Next.js 15.5 (App Router), React 19, TypeScript, vitest + Testing Library, self-hosted font via `next/font/local` (fallback `next/font/google`, ultimate fallback system serif stack), Playwright for visual verification.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-12-frontend-design-system.md`. Comp: https://claude.ai/code/artifact/af6f9654-619d-4245-b3dd-602a6427934a
- **Palette (verbatim), light / dark:** `--paper` `#E7DFCC`/`#14130F`, `--paper-2` `#DED5BE`/`#1D1B16`, `--ink` `#1B1A17`/`#E9E1CD`, `--graphite` `#726D5F`/`#938C7B`, `--rule` `rgba(ink,.16)`, `--rule-2` `rgba(ink,.30)`/`rgba(ink,.28)`, `--accent` `#1F5138`/`#5BB487`, `--accent-ink` `#F3EEDF`/`#10120F`, `--live` `#9A6B1E`/`#D2A24E`, `--alert` `#99392B`/`#D8695A`.
- **Type:** `--serif` = Fraunces stack, `--mono` = `ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace`. Uppercase mono labels get letter-spacing 0.120.22em. `tabular-nums` on aligned digits. `text-wrap: balance` on headings.
- **Theme mechanics:** tokens on `:root`; redefine under `@media (prefers-color-scheme: dark)` AND under `:root[data-theme="dark"]` / `:root[data-theme="light"]` (explicit toggle wins both ways). Style only through tokens.
- **Sharp corners** (radius 02px). Faint paper grain. `prefers-reduced-motion` honored. Visible `:focus-visible`.
- **Metaphor = chrome only.** Functional status labels literal (Downloading, Verifying/Tagging, Needs attention). Never obscure function.
- **Preserve test hooks:** keep existing `aria-label`s and button text (`aria-label="artist"`, `aria-label="album"`, button "Request", etc.). Any deliberate copy change updates its test in the same commit.
- Commands run from `web/`. Tests: `npm test` (vitest, DB `lyra_test`). Typecheck: `npx tsc --noEmit`.
---
### Task 1: Fonts + design tokens + base stylesheet
**Files:**
- Create: `web/src/app/globals.css`
- Create: `web/src/app/fonts.ts`
- Modify: `web/src/app/layout.tsx`
- (Vendor) `web/src/fonts/Fraunces.woff2` (if self-hosting succeeds)
**Interfaces:**
- Produces: global CSS custom properties (`--paper`, `--ink`, `--graphite`, `--rule`, `--rule-2`, `--accent`, `--accent-ink`, `--live`, `--alert`, `--serif`, `--mono`, `--shell`) available to all later tasks; `fraunces` font object exposing `.variable` (sets `--font-fraunces`).
- [ ] **Step 1: Obtain the display font (self-host, with fallbacks).**
Try, in order, and stop at the first that works:
1. Vendor Fraunces variable woff2:
```bash
mkdir -p web/src/fonts
curl -fsSL -o web/src/fonts/Fraunces.woff2 \
"https://github.com/undercasetype/Fraunces/raw/master/fonts/webfonts/Fraunces%5BSOFT,WONK,opsz,wght%5D.woff2"
```
Then `web/src/app/fonts.ts`:
```ts
import localFont from "next/font/local";
export const fraunces = localFont({
src: "../fonts/Fraunces.woff2",
variable: "--font-fraunces",
display: "swap",
fallback: ["Palatino Linotype", "Palatino", "Book Antiqua", "Georgia", "serif"],
});
```
2. If the download is blocked, use Google at build time instead — `web/src/app/fonts.ts`:
```ts
import { Fraunces } from "next/font/google";
export const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces", display: "swap" });
```
3. If neither works (offline build), skip `fonts.ts`, and in `globals.css` set `--serif` to the system stack `"Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`. The `--serif` indirection makes this graceful.
- [ ] **Step 2: Write `web/src/app/globals.css`.**
Copy the token + base block from the comp (`scratchpad/lyra-mockup.html` `<style>`), adapted: set `--serif: var(--font-fraunces), "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;` (drop the `var(--font-fraunces)` prefix if using fallback path 3). Include: `:root` tokens; `@media (prefers-color-scheme: dark)` token overrides; `:root[data-theme="light"]` and `:root[data-theme="dark"]` overrides (verbatim values from Global Constraints); `*{box-sizing:border-box}`; `body` (margin 0, `background:var(--paper)`, `color:var(--ink)`, `font-family:var(--serif)`, paper-grain `background-image` + `background-size:3px 3px`, antialiasing); `--shell:900px`; a `.wrap{max-width:var(--shell);margin:0 auto;padding:0 28px 72px}`; `a{color:var(--accent)}`; `:focus-visible{outline:2px solid var(--accent);outline-offset:3px}`; `@media (prefers-reduced-motion: reduce){*{animation:none!important}}`.
- [ ] **Step 3: Wire into `web/src/app/layout.tsx`.**
```tsx
import "./globals.css";
import { fraunces } from "./fonts"; // omit if fallback path 3
export const metadata = { title: "Lyra" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={fraunces.variable /* remove if path 3 */} suppressHydrationWarning>
<body>{children}</body>
</html>
);
}
```
(The shell — masthead/nav/toggle — is added in Task 3; keep `{children}` bare for now.)
- [ ] **Step 4: Verify it builds and applies.**
Run: `cd web && npx tsc --noEmit && npm run build 2>&1 | tail -5`
Expected: build succeeds. Then `npm test` → still green (styling-only change; 71 tests pass).
- [ ] **Step 5: Commit.**
```bash
git add web/src/app/globals.css web/src/app/fonts.ts web/src/app/layout.tsx web/src/fonts 2>/dev/null
git commit -m "feat(web): Pressing Plant design tokens + Fraunces font"
```
---
### Task 2: Shared component library + `describeJob` helper
**Files:**
- Create: `web/src/app/design.css`
- Create: `web/src/app/_ui/status.ts` (+ test `status.test.ts`)
- Create: `web/src/app/_ui/section-header.tsx`, `stat-tiles.tsx`, `status-chip.tsx`, `progress-bar.tsx`, `job-row.tsx`, `pressed-list.tsx`
- Modify: `web/src/app/layout.tsx` (add `import "./design.css";`)
**Interfaces:**
- Produces:
- `describeJob(state: string, stage: string): { label: string; kind: "idle"|"working"|"verify"|"done"|"attention"; step: number; totalSteps: number }`
- `<StatTiles items={{ k: string; v: number|string; unit?: string; accent?: boolean }[]} />`
- `<SectionHeader title={string} note?={string} />`
- `<StatusChip kind={Kind} label={string} />`
- `<ProgressBar kind={Kind} step={number} total={number} indeterminate?={boolean} />`
- `<JobRow artist album kind label step total note?={ReactNode} />`
- `<PressedList items={{ id; artist; album; matrix: string }[]} />`
- [ ] **Step 1: Write the failing test for `describeJob`.**
`web/src/app/_ui/status.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { describeJob } from "./status";
describe("describeJob", () => {
it("maps pipeline states to literal labels + severity kind", () => {
expect(describeJob("requested", "intake")).toMatchObject({ label: "Queued", kind: "idle" });
expect(describeJob("matching", "match")).toMatchObject({ label: "Searching", kind: "working" });
expect(describeJob("downloading", "download")).toMatchObject({ label: "Downloading", kind: "working" });
expect(describeJob("tagging", "tag")).toMatchObject({ label: "Tagging", kind: "verify" });
expect(describeJob("imported", "import")).toMatchObject({ label: "Pressed", kind: "done" });
expect(describeJob("needs_attention", "download")).toMatchObject({ label: "Needs attention", kind: "attention" });
});
it("derives a stepped progress from the pipeline stage", () => {
expect(describeJob("downloading", "download")).toMatchObject({ step: 4, totalSteps: 6 });
expect(describeJob("matching", "intake")).toMatchObject({ step: 1, totalSteps: 6 });
});
it("falls back gracefully for unknown states", () => {
expect(describeJob("weird", "intake").kind).toBe("idle");
expect(describeJob("weird", "intake").label).toBe("Weird");
});
});
```
- [ ] **Step 2: Run it — expect FAIL** (`describeJob` not defined).
Run: `cd web && npx vitest run src/app/_ui/status.test.ts`
- [ ] **Step 3: Implement `web/src/app/_ui/status.ts`.**
```ts
export type Kind = "idle" | "working" | "verify" | "done" | "attention";
const STAGES = ["intake", "match", "rank", "download", "tag", "import"];
const STATE_MAP: Record<string, { label: string; kind: Kind }> = {
requested: { label: "Queued", kind: "idle" },
matching: { label: "Searching", kind: "working" },
matched: { label: "Matched", kind: "working" },
downloading: { label: "Downloading", kind: "working" },
tagging: { label: "Tagging", kind: "verify" },
imported: { label: "Pressed", kind: "done" },
needs_attention: { label: "Needs attention", kind: "attention" },
};
export function describeJob(state: string, stage: string) {
const base = STATE_MAP[state] ?? {
label: state ? state[0].toUpperCase() + state.slice(1) : "Unknown",
kind: "idle" as Kind,
};
const idx = STAGES.indexOf(stage);
return { ...base, step: idx >= 0 ? idx + 1 : 0, totalSteps: STAGES.length };
}
```
- [ ] **Step 4: Run it — expect PASS.**
Run: `cd web && npx vitest run src/app/_ui/status.test.ts`
- [ ] **Step 5: Write `web/src/app/design.css`.**
Port every component class from the comp `<style>` verbatim (masthead, wordmark, tagline, press-status, live-dot + pulse, `nav.contents` + active underline, tiles/tile, dept/section header, floor/job/stripe/title/meta, prog/bar/indet/pct, chip + kind variants, pressed list + matrix, colophon, the `@media (max-width:620px)` block). Add button + input classes in the same voice:
```css
.btn { font-family: var(--mono); font-size: .7rem; letter-spacing: .14em; text-transform: uppercase;
padding: 9px 16px; border: 1.5px solid var(--ink); background: transparent; color: var(--ink);
cursor: pointer; }
.btn:hover { background: var(--ink); color: var(--paper); }
.btn.accent { border-color: var(--accent); color: var(--accent); }
.btn.accent:hover { background: var(--accent); color: var(--accent-ink); }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span { font-family: var(--mono); font-size: .64rem; letter-spacing: .14em; text-transform: uppercase; color: var(--graphite); }
.field input { font-family: var(--mono); font-size: .9rem; padding: 8px 2px; background: transparent;
border: 0; border-bottom: 1.5px solid var(--rule-2); color: var(--ink); }
.field input:focus { outline: none; border-bottom-color: var(--accent); }
```
- [ ] **Step 6: Implement the presentational components** under `_ui/` using those classes. Each is a thin, typed function component. Example `job-row.tsx`:
```tsx
import type { ReactNode } from "react";
import type { Kind } from "./status";
import { ProgressBar } from "./progress-bar";
import { StatusChip } from "./status-chip";
export function JobRow({ artist, album, kind, label, step, total, meta, note }: {
artist: string; album: string; kind: Kind; label: string;
step: number; total: number; meta?: string; note?: ReactNode;
}) {
const cls = kind === "attention" ? "attention" : kind === "verify" || kind === "done" ? "verify" : "working";
return (
<article className={`job ${cls}`}>
<div className="stripe" />
<div>
<h3 className="title">{album} <span className="artist">· {artist}</span></h3>
{meta ? <div className="meta">{meta}</div> : null}
{note ? <div className="note">{note}</div> : kind !== "attention"
? <ProgressBar kind={kind} step={step} total={total} /> : null}
</div>
<StatusChip kind={kind} label={label} />
</article>
);
}
```
Implement `StatusChip` (`<span className={"chip " + kindClass}>{label}</span>`), `ProgressBar` (bar with `width: step/total*100%`, or `indet` class), `SectionHeader`, `StatTiles`, `PressedList` analogously from the comp markup.
- [ ] **Step 7: Add `import "./design.css";` to `layout.tsx`; verify tsc + tests.**
Run: `cd web && npx tsc --noEmit && npm test` → green.
- [ ] **Step 8: Commit.**
```bash
git add web/src/app/design.css web/src/app/_ui web/src/app/layout.tsx
git commit -m "feat(web): Pressing Plant component library + describeJob"
```
---
### Task 3: App shell (masthead + contents-nav + theme toggle)
**Files:**
- Create: `web/src/app/_ui/masthead.tsx`, `contents-nav.tsx` (client), `theme-toggle.tsx` (client)
- Create test: `web/src/app/_ui/theme-toggle.test.tsx`
- Modify: `web/src/app/layout.tsx`
**Interfaces:**
- Consumes: design.css classes from Task 2.
- Produces: `<Masthead/>`, `<ContentsNav/>`, `<ThemeToggle/>` rendered by the layout around every page.
- [ ] **Step 1: Failing test for the theme toggle.**
`theme-toggle.test.tsx`:
```tsx
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ThemeToggle } from "./theme-toggle";
beforeEach(() => { localStorage.clear(); document.documentElement.removeAttribute("data-theme"); });
describe("ThemeToggle", () => {
it("toggles data-theme on the root and persists it", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("button", { name: /theme/i }));
const t = document.documentElement.getAttribute("data-theme");
expect(["light", "dark"]).toContain(t);
expect(localStorage.getItem("lyra-theme")).toBe(t);
});
});
```
- [ ] **Step 2: Run — expect FAIL.** Run: `cd web && npx vitest run src/app/_ui/theme-toggle.test.tsx`
- [ ] **Step 3: Implement `theme-toggle.tsx`.**
```tsx
"use client";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const saved = (localStorage.getItem("lyra-theme") as "light" | "dark" | null);
const initial = saved ?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
setTheme(initial);
document.documentElement.setAttribute("data-theme", initial);
}, []);
function toggle() {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("lyra-theme", next);
}
return (
<button type="button" className="btn" aria-label="Toggle theme" onClick={toggle}>
{theme === "dark" ? "Day" : "Night"}
</button>
);
}
```
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Implement `masthead.tsx` and `contents-nav.tsx`.**
`contents-nav.tsx` is a client component using `usePathname()` to mark the active link (`className={pathname === href ? "here" : ""}`); links: `/` "The Floor", `/artists` "Artists", `/discover` "Discover", `/wanted` "Wanted", `/settings` "Settings". `masthead.tsx` renders the wordmark + tagline (server component) and takes the `<ThemeToggle/>` in its right rail. Use the comp markup verbatim (masthead/wordmark/tagline/press-status). Keep the live-dot; the "Pressing №" mark can be static for now.
- [ ] **Step 6: Assemble the shell in `layout.tsx`.**
```tsx
<body>
<div className="wrap">
<Masthead />
<ContentsNav />
{children}
</div>
</body>
```
- [ ] **Step 7: Verify.** Run: `cd web && npx tsc --noEmit && npm test` → green (all 72+ tests).
- [ ] **Step 8: Commit.**
```bash
git add web/src/app/_ui web/src/app/layout.tsx
git commit -m "feat(web): app shell — masthead, contents nav, theme toggle"
```
---
### Task 4: Rebuild the home / "The Floor" page
**Files:**
- Modify: `web/src/app/page.tsx`, `web/src/app/queue.tsx`
- Create test: `web/src/app/queue.test.tsx`
**Interfaces:**
- Consumes: `describeJob`, `JobRow`, `StatTiles`, `SectionHeader`, `PressedList` (Tasks 23); data from `/api/requests`, `/api/wanted`, `/api/artists`.
- [ ] **Step 1: Failing test for the queue rendering.**
`queue.test.tsx` (mock fetch): render `<Queue/>`, resolve `/api/requests` with a `downloading` job and an `imported` job, assert the active one appears under a job row with label "Downloading" and the imported one under "Recently Pressed"; assert the request form still exposes `aria-label="artist"` and `aria-label="album"` and a "Request" button.
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { Queue } from "./queue";
function mockFetch(map: Record<string, unknown>) {
return vi.fn(async (url: string) => ({ ok: true, json: async () => map[url.split("?")[0]] ?? {} })) as any;
}
beforeEach(() => {
global.fetch = mockFetch({
"/api/requests": { requests: [
{ id: "1", artist: "Radiohead", album: "In Rainbows", status: "pending", job: { state: "downloading", currentStage: "download" } },
{ id: "2", artist: "Daft Punk", album: "Discovery", status: "done", job: { state: "imported", currentStage: "import" } },
] },
"/api/wanted": { wanted: [{}, {}] },
"/api/artists": { artists: [{}, {}, {}] },
});
});
describe("Queue / The Floor", () => {
it("splits active jobs from pressed and keeps the request form", async () => {
render(<Queue />);
await waitFor(() => expect(screen.getByText(/In Rainbows/)).toBeInTheDocument());
expect(screen.getByText("Downloading")).toBeInTheDocument();
expect(screen.getByText(/Recently Pressed/i)).toBeInTheDocument();
expect(screen.getByLabelText("artist")).toBeInTheDocument();
expect(screen.getByLabelText("album")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Request" })).toBeInTheDocument();
});
});
```
(If `@testing-library/jest-dom` matchers aren't set up, assert via `screen.getByText(...)` truthiness instead of `toBeInTheDocument()`. Check `web/src/test/` setup first.)
- [ ] **Step 2: Run — expect FAIL** (current Queue renders a flat `<ul>`).
- [ ] **Step 3: Rebuild `queue.tsx`.**
Fetch all three endpoints in `refresh()`. Partition requests: `active = state not in {imported}` (needs_attention included → attention row), `pressed = state === "imported"`. Render:
- `<StatTiles>` with `on the press` = active.length, `pressed today` (accent) = pressed.length, `wanted` = wanted count, `watching` = artists count (unit "artists").
- `<SectionHeader title="On the Press" note={`${active.length} running`} />` then either the request form + `active.map` → `<JobRow>` (via `describeJob(job.state, job.currentStage)`; for `needs_attention`, pass a `note` with a literal recovery hint), or an empty state ("The press is quiet. Queue something below, or from Wanted / Discover.").
- Keep the **request form** with `aria-label="artist"`, `aria-label="album"`, `<button className="btn accent">Request</button>` — styled `.field`s.
- `<SectionHeader title="Recently Pressed" />` + `<PressedList>` from `pressed` (matrix string e.g. `` `Lyra · ${album}` ``; real quality/format can be wired later — keep it honest: show artist/album only if format isn't in the payload).
Keep the 2s polling `setInterval`.
- [ ] **Step 4: Trim `page.tsx`** to just the section wrapper + `<Queue/>` (masthead/nav now come from the layout):
```tsx
import { Queue } from "./queue";
export default function Home() { return <Queue />; }
```
- [ ] **Step 5: Run tests — expect PASS.** Run: `cd web && npx tsc --noEmit && npm test` → all green.
- [ ] **Step 6: Visual verification (both themes + mobile).**
Deploy locally or run `npm run dev`, then drive with Playwright: load `http://localhost:8770/` (or dev port), screenshot; toggle Night; screenshot; set viewport 390px; screenshot. Eyeball: masthead/nav, tiles, job rows (stripe+chip+bar), pressed list, empty state, contrast in both themes, focus ring on tab. Fix any visual bug before commit.
- [ ] **Step 7: Commit.**
```bash
git add web/src/app/page.tsx web/src/app/queue.tsx web/src/app/queue.test.tsx
git commit -m "feat(web): rebuild home as 'The Floor' on the Pressing Plant system"
```
---
## Self-Review
**Spec coverage:** tokens/both themes (T1), fonts/Fraunces+fallbacks (T1), component library + state-as-form + honest labels + describeJob (T2), app shell + theme toggle (T3), home/The Floor rebuild on real data + empty state + preserved form hooks (T4), visual verification both themes + mobile (T4.6), test-hook preservation (constraints + T4). Phases 23 (other pages, Settings) explicitly out of scope. ✓
**Placeholder scan:** all steps carry real code/commands; the only intentional deferral is per-item format/quality in the matrix string (kept honest — omit until wired), noted in T4.3. ✓
**Type consistency:** `describeJob(state, stage) → {label, kind, step, totalSteps}` used consistently; `Kind` union shared from `status.ts`; `JobRow`/`ProgressBar`/`StatusChip` props match their definitions. ✓