Files
muse/web/app/globals.css
T
Aryan SinghandClaude Opus 5 171e117f7c Recover blocked searches, fall through to the next peer, rebuild the UI
Measured against a live instance: 47 failed jobs against 33 imported. Three
independent causes, all of which ended the request.

The Soulseek server filters some artist names. "Michael Jackson Billie Jean"
and bare "Michael Jackson" both return zero responses; "Billie Jean" returns
1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since
pick_and_enqueue sent exactly one query of `artist title`, every request for a
filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences.
search_queries() now walks a ladder, loosest last, ending at the bare title,
and escalates only when a search underdelivers so the common case still costs
one search. Dropping the artist from the query does not mean ignoring it:
match_ratio() looks for it in the folder path, where an organised share puts it.

A rejected transfer ended the request. pick_and_enqueue kept a running maximum
and discarded every runner-up, so "Transfer rejected: File not shared." -- a
per-file condition where candidate two almost always works -- was terminal, as
were timeouts and slskd's 500 on an unreachable peer. The ranked list is now
persisted on the job row, ordered to put a different peer next, and
try_next_candidate() advances through it. A job fails only once the list is
exhausted, and then reports every peer it tried and what each one said.

norm() destroyed every non-ASCII character, so "Beyonce" never matched
Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops
apostrophes while leaving & as a separator.

Also: availability outranks format in score(), because a FLAC behind 5000
queued users used to beat an immediately available MP3; transfers that stall or
that slskd forgets are abandoned instead of sitting in `downloading` forever;
searches are deleted only once finished, since deleting one mid-flight makes
slskd throw an optimistic-concurrency error and lose the record; a failed
search POST is retried rather than failing the job on one blip.

The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with
neutral chrome and the sampled artwork colour confined to hero areas rather
than repainting the whole interface. Adds URL routing, a primitive set under
components/ui, and a request sheet showing the attempt ladder. Fixes
hover:bg-glass-hi, which was never a valid class, so track rows and the search
field had no hover or focus state at all; a reduced-motion path that could
strand elements at opacity 0; the login page's hardcoded palette; and the
missing 404 and error boundaries.

tests/test_acquisition.py covers this path against a faked slskd. It had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:29:59 -05:00

378 lines
11 KiB
CSS

@import "tailwindcss";
/* ───────────────────────────────────────────────────────────────
muse design system
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
The previous system sampled the album artwork and repainted the whole
interface from it. That is the inverse of how a native application behaves
-- Music, Mail and Finder keep their chrome grey and let the artwork be the
only saturated thing on screen -- and it is most of why this read as a
themed web page rather than an app.
So there are two colours now:
--accent fixed. Controls, selection, focus rings. Never sampled.
--art-r/g/b sampled from the cover. Hero washes and now-playing only.
That split also closes a real bug: the focus ring used to be drawn in the
sampled colour, so its contrast against the page depended on which album
you happened to be looking at.
─────────────────────────────────────────────────────────────── */
@theme inline {
--color-accent: var(--accent);
--color-accent-soft: var(--accent-soft);
--color-accent-line: var(--accent-line);
--color-accent-ink: var(--accent-ink);
--color-art: rgb(var(--a-r) var(--a-g) var(--a-b));
--color-art-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
--color-ink: var(--fg);
--color-dim: var(--dim);
--color-mute: var(--mute);
--color-edge: var(--edge);
--color-edge-hi: var(--edge-hi);
--color-faint: var(--faint);
/* Opaque surfaces. Content sits on a real surface and vibrancy is reserved
for the sidebar and toolbar; previously `glass` was on everything, which
is why nothing read as layered. */
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-ok: var(--ok);
--color-err: var(--err);
--color-warn: var(--warn);
--radius-control: 8px;
--radius-card: 12px;
--radius-panel: 18px;
--radius-sheet: 22px;
--radius-pill: 999px;
/* One shared curve, so unrelated components feel like one physical system. */
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
--ease-spring: linear(
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
);
/* System faces on purpose. next/font self-hosts a webfont, which would make
the Docker build reach out to Google, and `font-src 'self'` in the CSP
blocks a CDN outright. -apple-system resolves to SF Pro on the devices
this is used from, which is the target anyway. */
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
"Segoe UI", Roboto, sans-serif;
--font-display:
-apple-system, BlinkMacSystemFont, "SF Pro Display", ui-sans-serif,
system-ui, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
/* Type scale. Tracking tightens as size grows -- display text at default
tracking looks loose, small text at display tracking looks cramped. */
--text-hero: 2.75rem;
--text-hero--line-height: 1.05;
--text-hero--letter-spacing: -0.038em;
--text-hero--font-weight: 700;
--text-display: 2.125rem;
--text-display--line-height: 1.08;
--text-display--letter-spacing: -0.033em;
--text-display--font-weight: 700;
--text-title: 1.4375rem;
--text-title--line-height: 1.18;
--text-title--letter-spacing: -0.024em;
--text-title--font-weight: 700;
--text-headline: 1.0625rem;
--text-headline--line-height: 1.35;
--text-headline--letter-spacing: -0.014em;
--text-headline--font-weight: 600;
--text-body: 0.9375rem;
--text-body--line-height: 1.5;
--text-body--letter-spacing: -0.008em;
--text-callout: 0.8125rem;
--text-callout--line-height: 1.45;
--text-callout--letter-spacing: -0.003em;
--text-caption: 0.6875rem;
--text-caption--line-height: 1.35;
--text-caption--letter-spacing: 0.005em;
/* Named layout constants. These were arbitrary literals repeated across
files: max-w-[940px] twice, gap-[7px] five times. */
--spacing-row: 7px;
--container-3xl: 1280px;
--sidebar-w: 240px;
--rail-w: 68px;
--toolbar-h: 52px;
}
:root {
color-scheme: light dark;
/* Default artwork tint, used until a cover is sampled. */
--a-r: 120;
--a-g: 140;
--a-b: 255;
--bg: #0a0b0f;
--surface: #121319;
--surface-2: #181a22;
--surface-3: #20232c;
--fg: #f4f6fb;
--dim: #a0a8bb;
--mute: #6d7488;
--faint: rgb(255 255 255 / 0.06);
--edge: rgb(255 255 255 / 0.09);
--edge-hi: rgb(255 255 255 / 0.16);
/* Vibrancy, for sidebar and toolbar only. */
--material: rgb(18 19 25 / 0.72);
--material-hi: rgb(24 26 34 / 0.86);
--blur: saturate(180%) blur(20px);
--scrim: rgb(10 11 15 / 0.76);
--scrim-2: rgb(10 11 15 / 0.35);
/* Fixed accent. Apple's system blue, which stays legible on both grounds
and is independent of whatever is playing. */
--accent: #0a84ff;
--accent-soft: rgb(10 132 255 / 0.16);
--accent-line: rgb(10 132 255 / 0.45);
--accent-ink: #ffffff;
--ok: #30d158;
--err: #ff453a;
--warn: #ff9f0a;
/* The specular edge. A real frosted panel catches light along its top rim;
without this, blur alone reads as a flat grey rectangle. */
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
/* Elevation. Two shadows each: a tight contact shadow that anchors the
element and a wide soft one for the light. One alone always looks fake. */
--e1: 0 1px 2px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.16);
--e2: 0 2px 6px rgb(0 0 0 / 0.34), 0 12px 32px rgb(0 0 0 / 0.26);
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f2f3f6;
--surface-3: #e9ebf0;
--fg: #0d1117;
--dim: #596273;
--mute: #858da0;
--faint: rgb(10 14 25 / 0.05);
--edge: rgb(10 14 25 / 0.1);
--edge-hi: rgb(10 14 25 / 0.18);
--material: rgb(246 247 249 / 0.72);
--material-hi: rgb(255 255 255 / 0.88);
--scrim: rgb(255 255 255 / 0.8);
--scrim-2: rgb(255 255 255 / 0.45);
--accent: #0071e3;
--accent-soft: rgb(0 113 227 / 0.12);
--accent-line: rgb(0 113 227 / 0.4);
--accent-ink: #ffffff;
/* Darkened to clear 4.5:1 on the light ground. Same hues, so a status
still reads as the same status when the theme flips. */
--ok: #0a7d43;
--err: #c62828;
--warn: #8a5a00;
/* On light material the specular rim is near-white and the shadows have to
lose most of their weight, or every card looks like it is hovering. */
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
--e1: 0 1px 2px rgb(20 30 60 / 0.06), 0 4px 12px rgb(20 30 60 / 0.05);
--e2: 0 2px 6px rgb(20 30 60 / 0.07), 0 12px 32px rgb(20 30 60 / 0.08);
--e3: 0 6px 16px rgb(20 30 60 / 0.09), 0 28px 68px rgb(20 30 60 / 0.12);
}
}
@layer base {
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.55;
letter-spacing: -0.008em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* Headings take the display face and its tighter tracking automatically, so
a component never has to remember to ask for it. */
h1,
h2,
h3 {
font-family: var(--font-display);
text-wrap: balance;
}
/* Ragged-right paragraphs with one orphan word are the giveaway of an
interface nobody typeset. */
p {
text-wrap: pretty;
}
button,
input,
select,
textarea {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
::selection {
background: var(--accent-soft);
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
}
/* Vibrancy, for chrome that sits over scrolling content. */
@utility material {
background: var(--material);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
@utility material-hi {
background: var(--material-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
/* An opaque content surface. The default for cards, rows and panels. */
@utility card {
background: var(--surface);
border: 1px solid var(--edge);
box-shadow: var(--spec), var(--e1);
}
@utility sheet {
background: var(--surface);
border: 1px solid var(--edge-hi);
box-shadow: var(--spec-hi), var(--e3);
}
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
feedback is barely perceptible frame to frame and obvious in aggregate.
Anything larger reads as a web page, not an interface. */
@utility lift {
transition:
transform 0.28s var(--ease-out),
box-shadow 0.28s var(--ease-out),
border-color 0.28s var(--ease-out),
background 0.28s var(--ease-out);
&:hover {
transform: translateY(-2px);
box-shadow: var(--spec-hi), var(--e2);
border-color: var(--edge-hi);
}
&:active {
transform: translateY(0) scale(0.994);
transition-duration: 0.06s;
}
}
/* ONE focus treatment for the whole app. There used to be two that looked
nothing alike -- an outline utility on ~12 elements and a box-shadow ring on
buttons -- so keyboard focus changed shape depending on what you tabbed to. */
@utility focus-ring {
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: inherit;
}
}
/* Numbers that change in place (counts, durations, percentages) must not
reflow the text around them as digits swap. */
@utility nums {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
@utility clamp1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@utility clamp2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
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(--surface-2) 30%,
var(--surface-3) 50%,
var(--surface-2) 70%
);
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Framer's animations are JS-driven and ignore this block entirely, which is
why every component also consults useReducedMotion(). This covers the CSS
half: shimmer, spinners, and every utility transition above. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}