feat: themed spotify-style shell for the web ui

This commit is contained in:
2026-07-26 19:09:36 -07:00
parent ca6006023d
commit 8675d5594b
2 changed files with 493 additions and 43 deletions
+459 -43
View File
@@ -2,41 +2,412 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>karaoke-maker</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
form { display: grid; gap: 0.5rem; margin-bottom: 2rem; }
label { display: flex; flex-direction: column; font-size: 0.9rem; }
textarea { min-height: 6rem; font-family: inherit; }
#jobs table { width: 100%; border-collapse: collapse; }
#jobs th, #jobs td { text-align: left; padding: 0.3rem; border-bottom: 1px solid #ccc; }
#jobs tr { cursor: pointer; }
#detail { background: #f4f4f4; padding: 1rem; white-space: pre-wrap; max-height: 20rem; overflow-y: auto; }
@font-face {
font-family: "Chakra Petch";
src: url("/static/fonts/chakra-petch-600.woff2") format("woff2");
font-weight: 600; font-display: swap;
}
@font-face {
font-family: "Chakra Petch";
src: url("/static/fonts/chakra-petch-700.woff2") format("woff2");
font-weight: 700; font-display: swap;
}
:root {
--bg: #0A0A0F;
--panel: #14101F;
--panel-raised: #1E1730;
--border: #2E2440;
--text: #F0EDF7;
--text-muted: #9B92AD;
--purple: #A855F7;
--aqua: #22D3EE;
--warn: #FBBF24;
--fail: #FB5E7E;
--done: #C4A5F5;
--font-display: "Chakra Petch", system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
height: 100vh;
height: 100dvh;
overflow: hidden;
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas: "sidebar main" "now now";
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
}
:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }
.eyebrow {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-muted);
}
.empty-note { margin: 0; font-size: 0.82rem; color: var(--text-muted); }
/* --- sidebar ------------------------------------------------------------ */
#sidebar {
grid-area: sidebar;
overflow-y: auto;
padding: 24px 0 28px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 22px;
}
.brand { padding: 0 18px; }
.wordmark {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
line-height: 0.98;
letter-spacing: 0.2em;
}
.wordmark span { display: block; }
.wordmark .second { color: var(--purple); }
.kana {
margin-top: 9px;
font-size: 0.72rem;
letter-spacing: 0.4em;
color: var(--purple);
}
#sidebar .eyebrow, #jobs .empty-note { padding: 0 18px; }
#jobs { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; }
.job {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
gap: 1px 10px;
width: 100%;
padding: 10px 16px 11px 15px;
border: 0;
border-left: 3px solid var(--border);
border-radius: 0;
background: var(--panel);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 120ms ease;
}
/* inset so the sidebar's scroll container cannot clip the left edge */
.job:focus-visible { outline-offset: -2px; }
.job:hover { background: var(--panel-raised); }
.job[aria-current="true"] { background: var(--panel-raised); }
.job[aria-current="true"]::after {
content: "";
position: absolute;
inset: 0 0 0 auto;
width: 2px;
background: var(--purple);
}
.job-title {
grid-area: 1 / 1;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.01em;
}
.job-artist { grid-area: 2 / 1; font-size: 0.78rem; color: var(--text-muted); }
.job-title, .job-artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-state {
grid-area: 3 / 1;
margin-top: 4px;
display: flex;
align-items: baseline;
gap: 7px;
font-size: 0.72rem;
color: var(--text-muted);
}
.job-glyph { font-size: 0.8rem; }
.job-elapsed {
grid-area: 3 / 2;
align-self: end;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
}
.st-queued .job-glyph { color: var(--text-muted); }
.st-running { border-left-color: var(--aqua); box-shadow: inset 7px 0 14px -7px var(--aqua); }
.st-running .job-glyph, .st-running .job-state { color: var(--aqua); }
.st-done { border-left-color: var(--done); }
.st-done .job-glyph { color: var(--done); }
.st-failed { border-left-color: var(--fail); }
.st-failed .job-glyph, .st-failed .job-state { color: var(--fail); }
/* --- main --------------------------------------------------------------- */
#main { grid-area: main; overflow-y: auto; padding: 30px 34px 36px; }
#main > section { max-width: 760px; }
#main h2 {
margin: 0 0 18px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.8rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#song-form { display: grid; gap: 18px; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-muted);
}
input, textarea {
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--text);
background: transparent;
border: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: 7px 2px;
transition: border-color 120ms ease;
}
input:not([type="file"]):hover { border-bottom-color: var(--text-muted); }
input:not([type="file"]):focus { border-bottom-color: var(--aqua); }
textarea {
min-height: 8.5rem;
padding: 11px 13px;
line-height: 1.6;
background: var(--panel);
border: 1px solid var(--border);
resize: vertical;
}
textarea:hover { border-color: var(--text-muted); }
textarea:focus { border-color: var(--aqua); }
input[type="file"] {
font-size: 0.82rem;
color: var(--text-muted);
padding: 10px;
background: var(--panel);
border: 1px dashed var(--border);
}
input[type="file"]:hover { border-color: var(--text-muted); }
input[type="file"]::file-selector-button {
margin-right: 11px;
padding: 6px 11px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text);
background: var(--panel-raised);
border: 1px solid var(--border);
border-radius: 2px;
cursor: pointer;
}
details { border-top: 1px solid var(--border); padding-top: 15px; }
summary {
display: flex;
align-items: center;
gap: 9px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
cursor: pointer;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "+"; font-family: var(--font-mono); color: var(--aqua); }
details[open] > summary::before { content: "\2212"; }
summary:hover { color: var(--text); }
details > .stack { display: grid; gap: 18px; margin-top: 18px; }
button { cursor: pointer; }
.primary {
justify-self: start;
padding: 12px 24px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #12061F;
background: var(--purple);
border: 0;
border-radius: 2px;
transition: background 120ms ease;
}
.primary:hover { background: #BE7BF9; }
#form-error { margin: 12px 0 0; min-height: 1.2em; font-size: 0.85rem; color: var(--fail); }
#review { margin-top: 38px; }
#detail {
margin: 13px 0 0;
padding: 14px;
max-height: 20rem;
overflow-y: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.65;
color: var(--text-muted);
background: var(--panel);
border: 1px solid var(--border);
}
/* --- now bar ------------------------------------------------------------ */
#now-bar {
grid-area: now;
display: flex;
align-items: center;
gap: 18px;
padding: 13px 20px;
background: var(--panel);
border-top: 1px solid var(--border);
}
#now-label {
flex: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.62rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#now-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#now-bar.idle #now-title { color: var(--text-muted); font-weight: 400; font-family: var(--font-body); font-size: 0.82rem; }
#stage-track { display: flex; align-items: center; gap: 15px; margin-left: auto; }
/* --- responsive --------------------------------------------------------- */
@media (max-width: 900px) {
body {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas: "sidebar" "main" "now";
}
#sidebar {
max-height: 42vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
#main { padding: 24px 18px 28px; }
.pair { grid-template-columns: 1fr; }
#now-bar { flex-wrap: wrap; gap: 8px 16px; }
#stage-track { margin-left: 0; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<h1>karaoke-maker</h1>
<form id="song-form">
<label>Title <input name="title" required></label>
<label>Artist <input name="artist" required></label>
<label>Lyrics <textarea name="lyrics" required></textarea></label>
<label>YouTube URL <input name="youtube_url"></label>
<label>Video file <input type="file" name="video"></label>
<label>Genre <input name="genre"></label>
<label>Year <input name="year" type="number"></label>
<label>Language <input name="language"></label>
<label>BPM override <input name="bpm" type="number" step="any"></label>
<label>Cover image <input type="file" name="cover"></label>
<button type="submit">Submit</button>
</form>
<p id="form-error" style="color: red;"></p>
<aside id="sidebar">
<div class="brand">
<h1 class="wordmark"><span>KARAOKE</span> <span class="second">MAKER</span></h1>
<div class="kana" aria-hidden="true">カラオケ</div>
</div>
<div>
<h2 class="eyebrow">QUEUE</h2>
<div id="jobs"></div>
</div>
</aside>
<h2>Jobs</h2>
<div id="jobs"></div>
<main id="main">
<section id="new-song">
<h2>NEW SONG</h2>
<form id="song-form">
<div class="pair">
<label class="field"><span>Title</span><input name="title" required></label>
<label class="field"><span>Artist</span><input name="artist" required></label>
</div>
<label class="field"><span>Lyrics</span><textarea name="lyrics" required></textarea></label>
<details>
<summary>Advanced</summary>
<div class="stack">
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
<label class="field"><span>Video file</span><input type="file" name="video"></label>
<div class="pair">
<label class="field"><span>Genre</span><input name="genre"></label>
<label class="field"><span>Year</span><input name="year" type="number"></label>
</div>
<div class="pair">
<label class="field"><span>Language</span><input name="language"></label>
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
</div>
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
</div>
</details>
<button type="submit" class="primary">CREATE SONG</button>
</form>
<p id="form-error" role="alert"></p>
</section>
<h2>Detail</h2>
<pre id="detail">select a job</pre>
<section id="review">
<h2>REVIEW</h2>
<div id="warnings"></div>
<details>
<summary>Log</summary>
<pre id="detail">select a job</pre>
</details>
</section>
</main>
<footer id="now-bar" class="idle">
<span id="now-label">NOW RUNNING</span>
<span id="now-title">nothing in progress</span>
<div id="stage-track"></div>
</footer>
<script>
const form = document.getElementById("song-form");
@@ -45,6 +416,21 @@ const jobsDiv = document.getElementById("jobs");
const detail = document.getElementById("detail");
let selectedId = null;
const STATE = {
queued: { glyph: "○", cls: "st-queued" },
running: { glyph: "◈", cls: "st-running" },
done: { glyph: "●", cls: "st-done" },
failed: { glyph: "✕", cls: "st-failed" },
};
function elapsed(job) {
if (!job.started_at) return "";
const end = job.finished_at ?? Date.now() / 1000;
const secs = Math.max(0, Math.round(end - job.started_at));
const m = Math.floor(secs / 60), s = secs % 60;
return m ? `${m}m${String(s).padStart(2, "0")}s` : `${s}s`;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
formError.textContent = "";
@@ -64,24 +450,50 @@ form.addEventListener("submit", async (e) => {
refreshJobs();
});
function jobCard(job) {
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
const card = document.createElement("button");
card.type = "button";
card.className = `job ${state.cls}`;
card.dataset.id = job.id;
if (job.id === selectedId) card.setAttribute("aria-current", "true");
const title = document.createElement("span");
title.className = "job-title";
title.textContent = job.title;
const artist = document.createElement("span");
artist.className = "job-artist";
artist.textContent = job.artist;
const glyph = document.createElement("span");
glyph.className = "job-glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = state.glyph;
const status = document.createElement("span");
status.textContent = job.stage ? `${job.status} · ${job.stage}` : job.status;
const stateLine = document.createElement("span");
stateLine.className = "job-state";
stateLine.append(glyph, status);
const time = document.createElement("span");
time.className = "job-elapsed";
time.textContent = elapsed(job);
card.append(title, artist, stateLine, time);
card.addEventListener("click", () => selectJob(job.id));
return card;
}
function renderJobs(jobs) {
const table = document.createElement("table");
const thead = document.createElement("thead");
thead.innerHTML = "<tr><th>Title</th><th>Artist</th><th>Status</th><th>Stage</th></tr>";
const tbody = document.createElement("tbody");
for (const j of jobs) {
const row = document.createElement("tr");
row.dataset.id = j.id;
for (const value of [j.title, j.artist, j.status, j.stage ?? ""]) {
const cell = document.createElement("td");
cell.textContent = value;
row.appendChild(cell);
}
row.addEventListener("click", () => selectJob(j.id));
tbody.appendChild(row);
if (!jobs.length) {
const note = document.createElement("p");
note.className = "empty-note";
note.textContent = "No songs yet. Create one to get started.";
jobsDiv.replaceChildren(note);
return;
}
table.append(thead, tbody);
jobsDiv.replaceChildren(table);
jobsDiv.replaceChildren(...jobs.map(jobCard));
}
async function refreshJobs() {
@@ -92,6 +504,10 @@ async function refreshJobs() {
async function selectJob(id) {
selectedId = id;
for (const card of jobsDiv.querySelectorAll(".job")) {
if (card.dataset.id === id) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
}
refreshDetail();
}
+34
View File
@@ -286,3 +286,37 @@ def test_font_licence_is_bundled():
from karaoke_maker.web import STATIC
licence = (STATIC / "fonts" / "OFL.txt").read_text(encoding="utf-8", errors="replace")
assert "SIL OPEN FONT LICENSE" in licence.upper()
def _index_html():
from karaoke_maker.web import STATIC
return (STATIC / "index.html").read_text(encoding="utf-8")
def test_index_has_required_hooks():
html = _index_html()
for hook in ("song-form", "jobs", "detail", "review", "now-bar", "sidebar"):
assert f'id="{hook}"' in html, hook
for name in ("title", "artist", "lyrics", "youtube_url", "video",
"genre", "year", "language", "bpm", "cover"):
assert f'name="{name}"' in html, name
def test_index_makes_no_external_requests():
import re as _re
html = _index_html()
for url in _re.findall(r'(?:src|href)\s*=\s*["\']([^"\']+)', html):
assert not url.startswith(("http://", "https://", "//")), url
assert "@import" not in html
def test_index_renders_api_data_without_innerhtml():
"""API-derived values must go through textContent, never innerHTML."""
import re as _re
html = _index_html()
for m in _re.finditer(r"\.(innerHTML|outerHTML|insertAdjacentHTML)\s*(=|\()\s*(.*)", html):
assert "${" not in m.group(3), f"interpolation into {m.group(1)}: {m.group(3)[:80]}"
def test_index_respects_reduced_motion():
assert "prefers-reduced-motion" in _index_html()