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:
@@ -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.12–0.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 0–2px). 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 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.
|
||||||
|
```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 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. ✓
|
||||||
Reference in New Issue
Block a user