Files
muse/web/app/globals.css
T
Aryan SinghandClaude Opus 5 3b4b68daaf Rebuild the UI on Next.js, and give search real metadata
The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.

Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.

Search now carries what a music UI needs:

- year and genre, grafted on from /album/{id} concurrently, deduplicated by
  album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
  search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
  so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
  music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
  with no image proxy.

/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.

Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.

CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.

Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:26:31 -05:00

175 lines
4.7 KiB
CSS

@import "tailwindcss";
/* ───────────────────────────────────────────────────────────────
muse design system
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
from whatever album is on screen; every tinted surface derives from them,
so the page takes on the palette of the music instead of a fixed brand
colour. JS writes those three numbers and nothing else.
─────────────────────────────────────────────────────────────── */
@theme inline {
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
--color-ink: var(--fg);
--color-dim: var(--dim);
--color-edge: var(--edge);
--color-edge-hi: var(--edge-hi);
--color-faint: var(--faint);
--color-ok: #3ddc84;
--color-err: #ff6b6b;
--color-warn: #ffc95c;
--radius-glass: 14px;
--radius-panel: 28px;
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
"Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
:root {
--a-r: 120;
--a-g: 140;
--a-b: 255;
--bg: #08090d;
--fg: #f2f4f9;
--dim: #9aa3b6;
--faint: rgb(255 255 255 / 0.07);
--glass: rgb(255 255 255 / 0.055);
--glass-hi: rgb(255 255 255 / 0.085);
--edge: rgb(255 255 255 / 0.1);
--edge-hi: rgb(255 255 255 / 0.17);
--sheet: rgb(16 18 26 / 0.86);
--header: rgb(8 9 13 / 0.62);
--scrim: rgb(8 9 13 / 0.74);
--scrim-2: rgb(8 9 13 / 0.4);
--blur: saturate(170%) blur(22px);
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8;
--fg: #0e1116;
--dim: #5a6274;
--faint: rgb(10 14 25 / 0.07);
--glass: rgb(255 255 255 / 0.7);
--glass-hi: rgb(255 255 255 / 0.88);
--edge: rgb(10 14 25 / 0.09);
--edge-hi: rgb(10 14 25 / 0.16);
--sheet: rgb(255 255 255 / 0.95);
--header: rgb(244 245 248 / 0.72);
--scrim: rgb(255 255 255 / 0.78);
--scrim-2: rgb(255 255 255 / 0.5);
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
}
}
@layer base {
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
::selection {
background: var(--color-accent-soft);
}
}
@utility glass {
background: var(--glass);
border: 1px solid var(--edge);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
@utility glass-hi {
background: var(--glass-hi);
border: 1px solid var(--edge-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
@utility sheet {
background: var(--sheet);
border: 1px solid var(--edge-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--shadow);
}
@utility clamp1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Very light noise over the gradients — large flat blurs band visibly on
8-bit panels without it. */
@utility grain {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
/* Keyframes live here rather than in a <style> tag inside a component: the
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
elements are blocked outright. Setting element.style from JS is fine —
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
@keyframes shimmer {
to {
background-position: -220% 0;
}
}
.shimmer {
background-image: linear-gradient(
100deg,
var(--glass) 30%,
var(--glass-hi) 50%,
var(--glass) 70%
);
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}