diff --git a/web/src/app/_ui/job-row.tsx b/web/src/app/_ui/job-row.tsx new file mode 100644 index 0000000..e667c27 --- /dev/null +++ b/web/src/app/_ui/job-row.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import type { Kind } from "./status"; +import { ProgressBar } from "./progress-bar"; +import { StatusChip } from "./status-chip"; + +// Map the severity kind to the row's visual class (stripe/bar color). +function rowClass(kind: Kind): string { + if (kind === "attention") return "attention"; + if (kind === "verify" || kind === "done") return "verify"; + return "working"; +} + +export function JobRow({ + artist, + album, + kind, + label, + step, + total, + meta, + note, + indeterminate, +}: { + artist: string; + album: string; + kind: Kind; + label: string; + step: number; + total: number; + meta?: ReactNode; + note?: ReactNode; + indeterminate?: boolean; +}) { + return ( +
+
+
+

+ {album} · {artist} +

+ {meta ?
{meta}
: null} + {note ? ( +
{note}
+ ) : kind !== "attention" ? ( + + ) : null} +
+ +
+ ); +} diff --git a/web/src/app/_ui/pressed-list.tsx b/web/src/app/_ui/pressed-list.tsx new file mode 100644 index 0000000..bea3e5e --- /dev/null +++ b/web/src/app/_ui/pressed-list.tsx @@ -0,0 +1,17 @@ +export type PressedItem = { id: string; artist: string; album: string; matrix: string }; + +export function PressedList({ items }: { items: PressedItem[] }) { + return ( + + ); +} diff --git a/web/src/app/_ui/progress-bar.tsx b/web/src/app/_ui/progress-bar.tsx new file mode 100644 index 0000000..069819d --- /dev/null +++ b/web/src/app/_ui/progress-bar.tsx @@ -0,0 +1,37 @@ +import type { Kind } from "./status"; + +/** A thin ruled progress bar. `indeterminate` for open-ended stages (searching); + * otherwise a discrete step/total fill with a step readout. */ +export function ProgressBar({ + kind, + step, + total, + indeterminate, +}: { + kind: Kind; + step: number; + total: number; + indeterminate?: boolean; +}) { + if (indeterminate) { + return ( +
+
+ +
+
searching
+
+ ); + } + const pct = total > 0 ? Math.round((step / total) * 100) : 0; + return ( +
+
+ +
+
+ {step}/{total} +
+
+ ); +} diff --git a/web/src/app/_ui/section-header.tsx b/web/src/app/_ui/section-header.tsx new file mode 100644 index 0000000..ef14af5 --- /dev/null +++ b/web/src/app/_ui/section-header.tsx @@ -0,0 +1,9 @@ +export function SectionHeader({ title, note }: { title: string; note?: string }) { + return ( +
+

{title}

+ + {note ? {note} : null} +
+ ); +} diff --git a/web/src/app/_ui/stat-tiles.tsx b/web/src/app/_ui/stat-tiles.tsx new file mode 100644 index 0000000..0d2f025 --- /dev/null +++ b/web/src/app/_ui/stat-tiles.tsx @@ -0,0 +1,17 @@ +export type Tile = { k: string; v: number | string; unit?: string; accent?: boolean }; + +export function StatTiles({ items }: { items: Tile[] }) { + return ( +
+ {items.map((t) => ( +
+
{t.k}
+
+ {t.v} + {t.unit ? {t.unit} : null} +
+
+ ))} +
+ ); +} diff --git a/web/src/app/_ui/status-chip.tsx b/web/src/app/_ui/status-chip.tsx new file mode 100644 index 0000000..3176b83 --- /dev/null +++ b/web/src/app/_ui/status-chip.tsx @@ -0,0 +1,5 @@ +import type { Kind } from "./status"; + +export function StatusChip({ kind, label }: { kind: Kind; label: string }) { + return {label}; +} diff --git a/web/src/app/_ui/status.test.ts b/web/src/app/_ui/status.test.ts new file mode 100644 index 0000000..9a85ba5 --- /dev/null +++ b/web/src/app/_ui/status.test.ts @@ -0,0 +1,24 @@ +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/stages", () => { + expect(describeJob("weird", "intake").kind).toBe("idle"); + expect(describeJob("weird", "intake").label).toBe("Weird"); + expect(describeJob("downloading", "???").step).toBe(0); + }); +}); diff --git a/web/src/app/_ui/status.ts b/web/src/app/_ui/status.ts new file mode 100644 index 0000000..0252ebc --- /dev/null +++ b/web/src/app/_ui/status.ts @@ -0,0 +1,30 @@ +export type Kind = "idle" | "working" | "verify" | "done" | "attention"; + +// Pipeline stage order (worker Job.currentStage), used to derive a stepped progress. +const STAGES = ["intake", "match", "rank", "download", "tag", "import"]; + +// Worker Job.state -> a literal, legible label + severity kind. The record-label +// metaphor stays in the chrome; these labels never obscure what's happening. +const STATE_MAP: Record = { + 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, +): { label: string; kind: Kind; step: number; totalSteps: number } { + const base = + STATE_MAP[state] ?? + ({ label: state ? state[0].toUpperCase() + state.slice(1) : "Unknown", kind: "idle" } as { + label: string; + kind: Kind; + }); + const idx = STAGES.indexOf(stage); + return { ...base, step: idx >= 0 ? idx + 1 : 0, totalSteps: STAGES.length }; +} diff --git a/web/src/app/design.css b/web/src/app/design.css new file mode 100644 index 0000000..a688c51 --- /dev/null +++ b/web/src/app/design.css @@ -0,0 +1,171 @@ +/* Lyra — "Pressing Plant" component classes. Styled through tokens (globals.css). */ + +/* ── Masthead ─────────────────────────────────────────── */ +.masthead { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 20px; flex-wrap: wrap; padding: 40px 0 18px; +} +.wordmark { display: flex; align-items: baseline; gap: 16px; } +.wordmark h1 { + margin: 0; font-weight: 600; letter-spacing: -0.02em; + font-size: clamp(2.6rem, 6vw, 3.7rem); line-height: 0.9; +} +.wordmark a { color: inherit; text-decoration: none; } +.tagline { + font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.14em; + text-transform: uppercase; color: var(--graphite); max-width: 15ch; line-height: 1.5; +} +.press-status { + display: flex; flex-direction: column; align-items: flex-end; gap: 8px; + font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--graphite); text-align: right; line-height: 1.7; +} +.press-status .catalog { color: var(--ink); letter-spacing: 0.18em; } +.live-dot { + display: inline-block; width: 7px; height: 7px; border-radius: 50%; + background: var(--accent); margin-right: 6px; vertical-align: middle; + animation: lyra-pulse 2.4s ease-out infinite; +} +@keyframes lyra-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); } + 70% { box-shadow: 0 0 0 7px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +/* ── Contents-bar nav ─────────────────────────────────── */ +nav.contents { + display: flex; gap: 26px; align-items: center; flex-wrap: wrap; + border-top: 1.5px solid var(--rule-2); border-bottom: 1px solid var(--rule); + padding: 11px 0; margin-bottom: 34px; + font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.16em; text-transform: uppercase; +} +nav.contents a { color: var(--graphite); text-decoration: none; position: relative; padding: 2px 0; } +nav.contents a:hover { color: var(--ink); } +nav.contents a.here { color: var(--ink); } +nav.contents a.here::after { + content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2px; background: var(--accent); +} +nav.contents .sep { flex: 1; } + +/* ── Summary tiles ────────────────────────────────────── */ +.tiles { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; + background: var(--rule); border: 1px solid var(--rule); margin-bottom: 44px; +} +.tile { background: var(--paper); padding: 16px 18px 18px; } +.tile .k { + font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--graphite); +} +.tile .v { + font-size: 2.5rem; line-height: 1; margin-top: 12px; + font-variant-numeric: tabular-nums; letter-spacing: -0.02em; +} +.tile .v small { font-size: 0.8rem; font-family: var(--mono); color: var(--graphite); letter-spacing: 0.06em; margin-left: 4px; } +.tile.accent .v { color: var(--accent); } + +/* ── Section (dept) header ────────────────────────────── */ +.dept { display: flex; align-items: baseline; gap: 14px; margin: 0 0 4px; } +.dept h2 { + margin: 0; font-family: var(--mono); font-weight: 500; font-size: 0.78rem; + letter-spacing: 0.22em; text-transform: uppercase; +} +.dept .count { font-family: var(--mono); font-size: 0.72rem; color: var(--graphite); letter-spacing: 0.1em; } +.dept .fill { flex: 1; border-bottom: 1px solid var(--rule); transform: translateY(-3px); } + +/* ── Job rows (the pressing floor) ────────────────────── */ +.floor { margin: 0 0 52px; } +.job { + display: grid; grid-template-columns: 4px 1fr auto; gap: 0 20px; + align-items: start; padding: 20px 0 18px; border-bottom: 1px solid var(--rule); +} +.job .stripe { width: 4px; align-self: stretch; background: var(--graphite); } +.job.working .stripe { background: var(--live); } +.job.verify .stripe { background: var(--accent); } +.job.attention .stripe { background: var(--alert); } +.job .title { font-size: 1.42rem; line-height: 1.15; letter-spacing: -0.01em; margin: -3px 0 0; text-wrap: balance; } +.job .title .artist { color: var(--graphite); } +.job .meta { + font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.06em; color: var(--graphite); + text-transform: uppercase; margin-top: 9px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; +} +.job .meta .dot { opacity: 0.5; } +.job .note { grid-column: 2 / 3; margin-top: 12px; font-size: 0.98rem; color: var(--ink); } +.job .note a { color: var(--accent); } + +/* ── Progress bar ─────────────────────────────────────── */ +.prog { grid-column: 2 / 3; margin-top: 14px; display: flex; align-items: center; gap: 12px; } +.bar { flex: 1; height: 3px; background: var(--rule); position: relative; overflow: hidden; } +.bar > span { position: absolute; inset: 0 auto 0 0; background: var(--live); } +.job.verify .bar > span, .job.done .bar > span { background: var(--accent); } +.bar.indet > span { width: 34%; animation: lyra-slide 1.6s ease-in-out infinite; } +@keyframes lyra-slide { 0% { left: -34%; } 100% { left: 100%; } } +.pct { + font-family: var(--mono); font-size: 0.72rem; color: var(--ink); + font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; +} +.pct.muted { color: var(--graphite); } + +/* ── Status chip ──────────────────────────────────────── */ +.chip { + font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; + padding: 5px 9px; white-space: nowrap; border: 1px solid var(--rule-2); color: var(--graphite); align-self: start; +} +.chip.working { color: var(--live); border-color: color-mix(in srgb, var(--live) 45%, var(--rule)); } +.chip.verify, .chip.done { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--rule)); } +.chip.attention { + color: var(--alert); border-color: color-mix(in srgb, var(--alert) 50%, var(--rule)); + background: color-mix(in srgb, var(--alert) 8%, transparent); +} + +/* ── Recently pressed (deadwax etch list) ─────────────── */ +.pressed { list-style: none; margin: 0; padding: 0; } +.pressed li { display: flex; align-items: baseline; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--rule); } +.pressed .mark { color: var(--accent); font-family: var(--mono); font-size: 0.8rem; } +.pressed .name { font-size: 1.08rem; } +.pressed .name .artist { color: var(--graphite); } +.pressed .matrix { + margin-left: auto; font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.18em; + color: var(--graphite); text-transform: uppercase; +} + +/* ── Empty state ──────────────────────────────────────── */ +.empty { + padding: 28px 0 32px; color: var(--graphite); font-size: 1.05rem; line-height: 1.6; max-width: 46ch; +} + +/* ── Buttons + fields ─────────────────────────────────── */ +.btn { + font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.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); } +.btn:disabled { opacity: 0.45; cursor: default; } +.btn:disabled:hover { background: transparent; color: var(--ink); } + +.request-form { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; margin: 22px 0 30px; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field > span { + font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--graphite); +} +.field input { + font-family: var(--mono); font-size: 0.9rem; padding: 8px 2px; background: transparent; + border: 0; border-bottom: 1.5px solid var(--rule-2); color: var(--ink); min-width: 200px; +} +.field input::placeholder { color: var(--graphite); opacity: 0.6; } +.field input:focus { outline: none; border-bottom-color: var(--accent); } + +/* ── Colophon ─────────────────────────────────────────── */ +.colophon { + margin-top: 40px; font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--graphite); display: flex; justify-content: space-between; + border-top: 1.5px solid var(--rule-2); padding-top: 14px; flex-wrap: wrap; gap: 10px; +} + +@media (max-width: 620px) { + .tiles { grid-template-columns: repeat(2, 1fr); } + .job { grid-template-columns: 4px 1fr; } + .job .chip { grid-column: 2 / 3; justify-self: start; margin-top: 12px; } +}