Add review, discover, email; rebuild the design system

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>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 02:10:33 -05:00
1 parent cc3490483e
commit 205e0a44b8
23 files changed
+1484 -102

No files matched your search

+4 -4
View File
@@ -35,12 +35,12 @@ export function ActivityChart({ data }: { data: Activity }) {
<div className="glass rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
Requests · last {data.days} days
</h3>
<div className="mt-1 text-[22px] font-bold tracking-[-0.6px]">
{shown ? shown.n : totals.n}
<span className="ml-2 text-[13px] font-normal text-dim">
<span className="ml-2 text-callout font-normal text-dim">
{shown
? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
@@ -51,7 +51,7 @@ export function ActivityChart({ data }: { data: Activity }) {
</span>
</div>
</div>
<div className="flex gap-3 text-[11.5px] text-dim">
<div className="flex gap-3 text-caption text-dim">
<Legend className="bg-accent" label={`${shown ? shown.imported : totals.imported} imported`} />
<Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} />
</div>
@@ -99,7 +99,7 @@ export function ActivityChart({ data }: { data: Activity }) {
})}
</svg>
<div className="mt-1.5 flex justify-between text-[11px] text-dim">
<div className="mt-1.5 flex justify-between text-caption text-dim">
<span>{fmt(data.series[0]?.date)}</span>
<span>{fmt(data.series[data.series.length - 1]?.date)}</span>
</div>
+5 -5
View File
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-[13px]",
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout",
healthy ? "border-ok/30" : "border-err/40",
)}
>
@@ -142,7 +142,7 @@ function Breakdown({
const max = Math.max(1, ...rows.map((r) => r.n));
return (
<div className="glass rounded-panel p-5">
<h3 className="mb-3 text-[13px] font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
<div className="flex flex-col gap-2">
{rows.slice(0, 8).map((r, i) => (
<motion.div
@@ -152,7 +152,7 @@ function Breakdown({
transition={{ delay: i * 0.04 }}
className="flex items-center gap-3"
>
<span className="w-32 shrink-0 text-[13px] clamp1">{r.label}</span>
<span className="w-32 shrink-0 text-callout clamp1">{r.label}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-faint">
<motion.i
className="block h-full rounded-full bg-accent"
@@ -161,11 +161,11 @@ function Breakdown({
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
/>
</div>
<span className="w-10 shrink-0 text-right text-[12.5px] tabular-nums text-dim">
<span className="w-10 shrink-0 text-right text-callout tabular-nums text-dim">
{r.n}
</span>
{r.sub && (
<span className="hidden w-16 shrink-0 text-right text-[11.5px] text-dim sm:block">
<span className="hidden w-16 shrink-0 text-right text-caption text-dim sm:block">
{r.sub}
</span>
)}
+2 -2
View File
@@ -28,11 +28,11 @@ export function StatCard({
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
className="glass rounded-glass p-3.5"
>
<div className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</div>
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
{value}
</div>
{sub && <div className="mt-0.5 text-[11.5px] text-dim clamp1">{sub}</div>}
{sub && <div className="mt-0.5 text-caption text-dim clamp1">{sub}</div>}
{pct != null && (
<div className="mt-2 h-1 overflow-hidden rounded-full bg-faint">
<motion.i
+15 -15
View File
@@ -25,10 +25,10 @@ export function UsersTable({
<div className="glass rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
Users
</h3>
<p className="mt-1 text-[12.5px] text-dim">
<p className="mt-1 text-callout text-dim">
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
with that provider and they&rsquo;re in.
</p>
@@ -117,12 +117,12 @@ function UserRow({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[14px] font-semibold clamp1">
<div className="flex items-center gap-1.5 text-body font-semibold clamp1">
{u.display || u.username}
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
{isMe && <span className="text-[11px] font-normal text-dim">(you)</span>}
{isMe && <span className="text-caption font-normal text-dim">(you)</span>}
</div>
<div className="text-[12px] text-dim clamp1">
<div className="text-caption text-dim clamp1">
{u.username} · {u.provider}
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
{u.disabled && " · disabled"}
@@ -135,7 +135,7 @@ function UserRow({
onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)}
aria-label={`Role for ${u.username}`}
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
px-2.5 py-1 text-[12px] text-ink outline-none
px-2.5 py-1 text-caption text-ink outline-none
focus:border-accent-line"
>
{roles.map((r) => (
@@ -188,7 +188,7 @@ function UserRow({
)}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-[12px]">
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-caption">
<QuotaField
label="Requests / month"
value={u.quota}
@@ -206,7 +206,7 @@ function UserRow({
}
/>
{u.bootstrap && (
<span className="text-[11.5px] text-warn" title="Listed in MUSE_ALLOWED_USERS">
<span className="text-caption text-warn" title="Listed in MUSE_ALLOWED_USERS">
bootstrap account — disabling won&rsquo;t lock them out
</span>
)}
@@ -295,7 +295,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
};
const input =
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-[13px] " +
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
"text-ink outline-none focus:border-accent-line";
return (
@@ -307,7 +307,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
>
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
<span className="text-[11.5px] text-dim">
<span className="text-caption text-dim">
{provider === "github" ? "GitHub username" : "Google email"}
</span>
<input
@@ -320,14 +320,14 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] text-dim">Provider</span>
<span className="text-caption text-dim">Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
<option value="github" className="bg-[#12141c]">GitHub</option>
<option value="google" className="bg-[#12141c]">Google</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] text-dim">Role</span>
<span className="text-caption text-dim">Role</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
{roles.map((r) => (
<option key={r} value={r} className="bg-[#12141c]">
@@ -337,17 +337,17 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
</select>
</label>
<label className="flex w-24 flex-col gap-1">
<span className="text-[11.5px] text-dim">Req/month</span>
<span className="text-caption text-dim">Req/month</span>
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
</label>
<label className="flex w-24 flex-col gap-1">
<span className="text-[11.5px] text-dim">Storage GB</span>
<span className="text-caption text-dim">Storage GB</span>
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
</label>
<Button onClick={submit} disabled={busy}>
{busy ? "Inviting…" : "Invite"}
</Button>
<span className="w-full text-[11.5px] text-dim">0 means unlimited.</span>
<span className="w-full text-caption text-dim">0 means unlimited.</span>
</div>
</motion.div>
);