Files
Lyra/docs/superpowers/plans/2026-07-12-frontend-pressing-plant-phase1.md
T
Jonathan 5f0dcda2ab docs: Pressing Plant frontend Phase 1 implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:57:35 +02:00

396 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. ✓