Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
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,--rulergba(ink,.16),--rule-2rgba(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.12–0.22em.tabular-numson aligned digits.text-wrap: balanceon 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 0–2px). Faint paper grain.
prefers-reduced-motionhonored. Visible:focus-visible. - Metaphor = chrome only. Functional status labels literal (Downloading, Verifying/Tagging, Needs attention). Never obscure function.
- Preserve test hooks: keep existing
aria-labels 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, DBlyra_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;frauncesfont 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:
- Vendor Fraunces variable woff2:
Then
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"web/src/app/fonts.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"], }); - If the download is blocked, use Google at build time instead —
web/src/app/fonts.ts:import { Fraunces } from "next/font/google"; export const fraunces = Fraunces({ subsets: ["latin"], variable: "--font-fraunces", display: "swap" }); - If neither works (offline build), skip
fonts.ts, and inglobals.cssset--serifto the system stack"Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif. The--serifindirection 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.
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.
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(+ teststatus.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(addimport "./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:
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 (
describeJobnot defined). Run:cd web && npx vitest run src/app/_ui/status.test.ts -
Step 3: Implement
web/src/app/_ui/status.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:
.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. Examplejob-row.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";tolayout.tsx; verify tsc + tests. Run:cd web && npx tsc --noEmit && npm test→ green. -
Step 8: Commit.
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:
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.
"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.tsxandcontents-nav.tsx.contents-nav.tsxis a client component usingusePathname()to mark the active link (className={pathname === href ? "here" : ""}); links:/"The Floor",/artists"Artists",/discover"Discover",/wanted"Wanted",/settings"Settings".masthead.tsxrenders 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.
<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.
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 2–3); 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.
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 inrefresh(). Partition requests:active = state not in {imported}(needs_attention included → attention row),pressed = state === "imported". Render: -
<StatTiles>withon 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>(viadescribeJob(job.state, job.currentStage); forneeds_attention, pass anotewith 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.fields. -
<SectionHeader title="Recently Pressed" />+<PressedList>frompressed(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 pollingsetInterval. -
Step 4: Trim
page.tsxto just the section wrapper +<Queue/>(masthead/nav now come from the layout):
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: loadhttp://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.
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 2–3 (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. ✓