Files
Lyra/docs/superpowers/plans/2026-07-12-frontend-pressing-plant-phase1.md
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

21 KiB
Raw Permalink Blame History

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-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, 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:
    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:
    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:
    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.
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 (+ 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:

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.

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. Example job-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"; to layout.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.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.

<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 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.

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 .fields.

  • <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):

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.

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. ✓