Three features and a typographic pass over everything. REVIEW. Picking a file and trusting it are different questions, so they now use different numbers. The existing rank decides which candidate to fetch and is right to weigh peer upload slots and queue length -- but none of that says anything about whether the file is the correct recording, and a fast peer must not be able to raise the confidence of a wrong match. So confidence() scores the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag you did not ask for multiplies by 0.8 AND caps below the auto threshold. Confidence gates the IMPORT, not the download. The transfer completes either way and the file waits in downloads, so approving is instant and rejecting is a delete. Holding the transfer instead would mean the peer is usually gone by the time anyone looks. The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75 -- title exactly right, artist simply absent, the commonest honest case there is -- and must be reviewed rather than binned. And "Creep (Live at Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what stops a live recording auto-filing over the studio version. Ask for "Creep (Acoustic)" and it auto-imports at 0.97, because then the variant is the request. DISCOVER. Related artists seeded from what you have actually imported, plus a recap of top artists, genres and decades. Built on muse's own request history, never on listening data: Navidrome owns play counts and history, and a second source of truth for those would disagree with the one you listen through. EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on review-needed. smtplib is blocking so it runs off the event loop, and every failure is logged and swallowed -- a notification is never worth failing an import over. Google accounts need no configuration, since the identity is an address. Each user owns their own setting under Settings, deliberately not an admin route: an address you cannot change yourself is someone else's chore. DESIGN. A real type scale (display/title/headline/body/callout/caption) whose tracking tightens as size grows, which is the single biggest tell of typography nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic colours are now per-theme: the amber tuned for the dark background failed contrast outright on white, which was exactly the text a reviewer most needs to read. The tab underline became a sliding pill so the eye tracks one object, and Review only appears when something is actually waiting. Add tests/test_matching.py: 13 assertions on the confidence bands rather than the exact scores. 90 tests pass across the three suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
9.5 KiB
CSS
314 lines
9.5 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: var(--ok);
|
|
--color-err: var(--err);
|
|
--color-warn: var(--warn);
|
|
|
|
--radius-glass: 14px;
|
|
--radius-panel: 28px;
|
|
--radius-control: 11px;
|
|
--radius-pill: 999px;
|
|
|
|
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
|
/* Apple's motion reads as weight, not duration: things overshoot slightly
|
|
and settle. A single shared curve keeps unrelated components feeling like
|
|
one physical system. */
|
|
--ease-spring: linear(
|
|
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
|
);
|
|
|
|
--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. The defining trait of Apple's typography is that tracking
|
|
tightens as size grows — display text at default tracking looks loose and
|
|
amateurish, and small text at display tracking looks cramped. */
|
|
--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;
|
|
}
|
|
|
|
: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(180%) blur(24px);
|
|
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
|
|
|
|
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
|
|
dark background; the same amber on white fails contrast outright, which
|
|
is exactly the text a reviewer most needs to read. */
|
|
--ok: #3ddc84;
|
|
--err: #ff6b6b;
|
|
--warn: #ffc95c;
|
|
|
|
/* The specular edge. A real frosted panel catches light along its top rim;
|
|
without this, blur alone reads as a flat grey rectangle. This one line is
|
|
most of the difference between "translucent" and "material". */
|
|
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
|
|
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
|
|
|
|
/* 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.28), 0 4px 12px rgb(0 0 0 / 0.18);
|
|
--e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28);
|
|
--e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42);
|
|
}
|
|
|
|
@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);
|
|
/* 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.07), 0 4px 12px rgb(20 30 60 / 0.06);
|
|
--e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09);
|
|
--e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14);
|
|
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
|
|
still reads as the same status when the theme flips. */
|
|
--ok: #0a7d43;
|
|
--err: #c62828;
|
|
--warn: #8a5a00;
|
|
}
|
|
}
|
|
|
|
@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;
|
|
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(--color-accent-soft);
|
|
}
|
|
}
|
|
|
|
@utility glass {
|
|
background: var(--glass);
|
|
border: 1px solid var(--edge);
|
|
backdrop-filter: var(--blur);
|
|
-webkit-backdrop-filter: var(--blur);
|
|
box-shadow: var(--spec);
|
|
}
|
|
|
|
@utility glass-hi {
|
|
background: var(--glass-hi);
|
|
border: 1px solid var(--edge-hi);
|
|
backdrop-filter: var(--blur);
|
|
-webkit-backdrop-filter: var(--blur);
|
|
box-shadow: var(--spec-hi);
|
|
}
|
|
|
|
@utility sheet {
|
|
background: var(--sheet);
|
|
border: 1px solid var(--edge-hi);
|
|
backdrop-filter: var(--blur);
|
|
-webkit-backdrop-filter: var(--blur);
|
|
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-glass),
|
|
box-shadow 0.28s var(--ease-glass),
|
|
border-color 0.28s var(--ease-glass),
|
|
background 0.28s var(--ease-glass);
|
|
&: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;
|
|
}
|
|
}
|
|
|
|
/* Keyboard focus only — a mouse click should never draw a ring. */
|
|
@utility ring {
|
|
&:focus-visible {
|
|
outline: 2px solid var(--color-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
}
|