playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+464 -19
View File
@@ -29,6 +29,11 @@
let step = $state("source");
let root = $state(null); // the folder being imported
// Where `root` sits relative to the LIBRARY folder. An absolute path is
// only true on the machine that wrote it; this is what survives the drive
// being carried somewhere else and coming back at another letter.
let rootRel = $state(null);
let rootMissing = $state(false); // saved source folder could not be found
let dest = $state(null); // { dir, freeBytes, layout } from the backend
let scan = $state(null); // ScanResult
let scanProg = $state(null); // latest per-file tick; running totals ride along
@@ -93,7 +98,7 @@
return [rel, rest];
}));
await api.importSessionSet(JSON.stringify({
step, root, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
step, root, rootRel, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
bucket, bucketRel, auditions: auditionsToSave, keepSeparate,
}));
} catch (e) {
@@ -108,8 +113,29 @@
if (!raw) return;
const v = JSON.parse(raw);
if (v?.root) {
root = v.root;
// The saved path first — nothing has moved in the common case.
// Then the same folder relative to where the library is NOW,
// which is how a review saved at one machine finds its source
// again at another. Both `root` and the scan's own copy are
// repointed: apply passes `scan.root`, so leaving that stale
// would resume onto a folder that is not there.
// NOT `found` — that name is the identify-results state, and
// shadowing it here throws on the assignment further down.
const liveRoot = await api
.importResolveRoot(v.root, v.rootRel ?? null)
.catch(() => null);
root = liveRoot ?? v.root;
// A session saved before the portable form existed learns it
// now, while its source is still findable — waiting until the
// next save would strand any review already in progress.
rootRel = v.rootRel
?? (liveRoot ? await api.importPortableRoot(liveRoot).catch(() => null) : null);
rootMissing = !liveRoot;
scan = v.scan ?? null;
if (scan && liveRoot) scan.root = liveRoot;
// Learned or repointed: write it back now rather than waiting
// for a transition that may never come.
const learned = rootRel !== (v.rootRel ?? null) || root !== v.root;
layoutChoice = v.layout ?? null;
choices = v.choices ?? {};
found = v.found ?? {};
@@ -123,6 +149,7 @@
auditions = v.auditions ?? {};
keepSeparate = v.keepSeparate ?? [];
step = v.step === "scanning" ? "source" : (v.step ?? "source");
if (learned) schedulePersist();
}
} catch (e) {
console.warn("import: session restore failed", e);
@@ -161,8 +188,7 @@
// The dev seed ({ root } form) still overrides the source on open.
$effect(() => {
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
root = ui.importOpen.root;
scan = null;
setRoot(ui.importOpen.root);
step = "source";
}
});
@@ -206,9 +232,18 @@
}
// ---- source ----------------------------------------------------------
/** Every route to a source folder goes through here, so the portable form
* is recorded at the same moment as the absolute one and cannot drift. */
async function setRoot(p) {
root = p;
scan = null;
rootMissing = false;
rootRel = p ? await api.importPortableRoot(p).catch(() => null) : null;
}
async function pickSource() {
const p = await api.pickFolder().catch(() => null);
if (p) { root = p; scan = null; }
if (p) await setRoot(p);
}
async function pickDest() {
@@ -473,7 +508,9 @@
else
k = matchEvidence(
f.result.candidate,
artistOf.get(x.rel) ?? x.artistFolder,
// The artist the answer was found under — the folder,
// unless the tags found what the folder could not.
f.result.askedArtist ?? artistOf.get(x.rel) ?? x.artistFolder,
albumOf.get(x.rel) ?? x.title,
x.tracks.length,
).verdict;
@@ -708,6 +745,125 @@
}
}
/** Ask again for the tracks a found copy still owes us.
*
* Transfers do not survive the app; the files they wrote do. So a session
* restored after a crash, a power cut or an ordinary restart brings back
* an audition that LOOKS live and is not: `restStarted` is already true,
* so nothing ever asks for the tracks that never landed, and every future
* apply refuses the record for the same reason — "the found copy is still
* arriving" — with nothing arriving. Two records sat at 12 of 13 and 6 of
* 11 that way.
*
* The pen decides how far it got, not the counter: `arrived` is a running
* total kept by events, and those events stopped with the process that
* was listening for them. A count that was already right costs one stat
* per file to confirm; a count that was wrong is corrected before it can
* refuse anything again.
*
* Asked at most once per record per run. A peer that has gone away should
* cost one round of requests, not a fresh one every time the record is
* looked at — "Try another peer" is the answer to that, and it is a
* deliberate act. */
const resumeAsked = new Set();
async function resumeAudition(rel) {
const au = auditions[rel];
if (!au || resumeAsked.has(rel)) return;
const files = au.candidate?.files ?? [];
if (!files.length || ["searching", "none", "failed"].includes(au.status)) return;
resumeAsked.add(rel);
const peer = au.candidate.username;
let missing;
try {
missing = await api.importAuditionMissing(
rel,
files.map((f) => ({ path: f.path, size: f.size ?? 0 })),
);
} catch (e) {
console.warn("import: could not read the pen", e);
resumeAsked.delete(rel);
return;
}
// Anything could have happened across the await — a switch to another
// peer, a fresh search. Credit this answer only to the copy it is about.
const cur = auditions[rel];
if (!cur || cur.candidate?.username !== peer) return;
cur.expected = files.length;
cur.arrived = files.length - missing.length;
// WHICH files are owed, not just how many — the strip states a size
// only when it knows exactly which files make it up.
cur.missingIdx = missing;
if (!missing.length) {
// Everything is already here. The record was refused by a stale
// number, and nothing needs downloading to unstick it.
cur.status = "complete";
schedulePersist();
return;
}
for (const i of missing) {
try {
const { ids } = await startAudition(rel, cur.candidate, { from: i, count: 1 });
cur.ids = [...(cur.ids ?? []), ...ids];
} catch (e) {
console.warn("import: could not re-request a missing track", e);
break;
}
}
cur.status = cur.track1Path ? "ready" : "downloading";
cur.xfer = { ...(cur.xfer ?? {}), phase: "requested", at: Date.now() };
schedulePersist();
}
/** Ask again for every refusal that is a found copy still owed tracks.
*
* Called from BOTH applies. They refuse the same way and have to recover
* the same way: wiring only the full one left the picker's partial apply
* — the one that runs when you import from the review itself — repeating
* "it imports as soon as the rest land" over records nothing was asking
* for. No-ops on a refusal that is not about an audition. */
function resumeRefused(skipped) {
for (const s of skipped ?? []) resumeAudition(s.rel);
}
// Opening a record is when we find out whether its found copy is still
// owed anything — and the only moment a resumed transfer has somewhere to
// be seen. Deliberately not a sweep at window-open: that would put a
// dozen peers' worth of requests on the wire for records nobody is
// looking at. The refused set after an apply is handled there.
$effect(() => {
if (step !== "upgrades" || !open) return;
const rel = curPick?.rel;
const au = rel ? auditions[rel] : null;
const want = au?.candidate?.files?.length ?? 0;
if (!want || (au.arrived ?? 0) >= (au.expected ?? want)) return;
resumeAudition(rel);
});
/** Throw away what the abandoned peer left in the pen.
*
* The cancel stops its transfers; this removes what they already wrote.
* The pen is keyed by RECORD, not by peer, so without it the dropped
* copy's files sit beside the replacement's until the session is cleared
* — 189 MB of a stranger's Centipede Hz did exactly that, and apply filed
* all 17 tracks. Apply takes the chosen copy's files by name now, so this
* is about disk rather than correctness; the ORDER still matters, because
* a file written after the clear would be a stray nothing asked for.
*
* Deliberately unconditional: "another peer" means this copy is not the
* one, and a partial copy of a rip you have walked away from is not worth
* the gigabytes. The automatic switch only fires before track 1 lands, so
* it can never discard more than one unfinished file. */
async function clearPen(rel, why) {
try {
const freed = await api.importAuditionClear(rel);
if (freed) {
console.info(`import: pen cleared for ${rel} — ${Math.round(freed / 1e6)} MB (${why})`);
}
} catch (e) {
console.warn("import: could not clear the pen", e);
}
}
/** Stop waiting on this peer and drop to the next ranked copy.
*
* Cancels the transfers first — without that, a switch left the old peer
@@ -721,6 +877,7 @@
for (const id of au.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
await clearPen(rel, reason ?? "peer switched");
const [next, ...rest] = au.alts ?? [];
if (!next) {
auditions[rel] = { status: "none", dead, exhausted: true, reason };
@@ -751,10 +908,13 @@
for (const id of prev?.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
// A fresh search abandons the current copy just as squarely as a
// switch does; whatever answers will be a different peer's rip.
if (prev?.candidate) await clearPen(rel, "searched again");
auditions[rel] = { status: "searching", dead };
try {
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
const list = await findAlbumCopies({
const { copies, silent } = await findAlbumCopies({
artist: pickerArtist(x),
// The record's own name leads; its folder is the narrower
// fallback for when the tags are wrong or absent.
@@ -763,15 +923,18 @@
candidate: trusted ? found[rel]?.result?.candidate : null,
wantTracks: x.tracks.length,
});
if (!list.length) {
auditions[rel] = { status: "none", dead };
if (!copies.length) {
// `silent` separates "the network never answered" from "peers
// answered and none had it" — they read identically on the
// card otherwise, and the first one is not about this record.
auditions[rel] = { status: "none", dead, silent };
schedulePersist();
return;
}
await beginAudition(
rel,
slimCandidate(list[0]),
list.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
slimCandidate(copies[0]),
copies.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
{ dead },
);
} catch (e) {
@@ -812,6 +975,7 @@
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
if (p.done) {
au.arrived = (au.arrived ?? 0) + 1;
if (au.missingIdx) au.missingIdx = au.missingIdx.filter((i) => i !== idx);
if (idx === 0) {
au.track1Path = p.localPath;
au.status = "ready";
@@ -900,6 +1064,105 @@
};
}
/* ---- what the upgrade step is still waiting on ---------------------- */
/** Records whose found copy is not fully in the pen yet, plus ones that
* ARE and have not been imported — a finished copy belongs here too,
* because it is the thing standing between you and pressing Import.
*
* Deliberately not "every audition": a record you listened to and then
* kept your own copy of is not something you are waiting for. */
const arrivals = $derived(
Object.entries(auditions)
.flatMap(([rel, au]) => {
const files = au?.candidate?.files ?? [];
const want = au?.expected ?? files.length;
if (!files.length || !want) return [];
const have = Math.max(0, Math.min(au.arrived ?? 0, want));
const owed = want - have;
const theirs = choices[rel]?.audio === "theirs";
if (choices[rel]?.applied || (!owed && !theirs)) return [];
// Bytes only when the pen check told us WHICH files are owed.
// Owed-count times an average would be a number that looks
// measured and is not.
const idx = au.missingIdx;
return [{
rel, au, want, have, owed, theirs,
name: rel.split(/[\\/]/).pop() || rel,
peer: au.candidate.username,
live: liveOf(au),
bytes: idx ? idx.reduce((n, i) => n + (files[i]?.size ?? 0), 0) : null,
}];
})
.sort((a, b) => b.owed - a.owed || a.name.localeCompare(b.name)));
const arrivalsOwed = $derived(arrivals.filter((a) => a.owed > 0));
const arrivalFiles = $derived(arrivalsOwed.reduce((n, a) => n + a.owed, 0));
const arrivalStalled = $derived(arrivalsOwed.some((a) => a.live?.stalled));
const arrivalBytes = $derived(
arrivalsOwed.length && arrivalsOwed.every((a) => a.bytes != null)
? arrivalsOwed.reduce((n, a) => n + a.bytes, 0) : null);
let trayOpen = $state(false);
/** What one row says, in the language the picker already uses. Nothing
* here invents a state: every branch is something the transfer events
* actually said, or the plain absence of them — after a restart there is
* no `xfer` at all, and "asked, no answer yet" is the truth then. */
function arrivalWords(a) {
if (!a.owed) return { cls: "ok", label: `all ${a.want} cached`, detail: "ready to import" };
const x = a.live;
if (x?.stalled) {
const mins = Math.max(1, Math.round((x.quietMs ?? 0) / 60000));
return { cls: "stall", label: `nothing for ${mins} min`, detail: `${a.peer} stopped sending` };
}
if (x?.phase === "transferring") {
const mbs = (x.speedBps ?? 0) / 1e6;
return { cls: "move", label: "arriving", detail: mbs >= 0.05 ? `${mbs.toFixed(1)} MB/s` : "slowly" };
}
if (x?.place != null || x?.phase === "queued") {
return {
cls: "wait", label: `queued at ${a.peer}`,
detail: x.place != null ? `${nfmt(x.place)} ahead of you` : "nothing moving yet",
};
}
return { cls: "wait", label: `asked ${a.peer}`, detail: "no answer yet" };
}
/** The container is what the peer sent, the depth and bitrate are what the
* probe MEASURED of the file that landed. */
function arrivalFormat(a) {
const first = a.au.candidate.files?.[0];
const ext = String(first?.name ?? first?.path ?? "").split(".").pop().toUpperCase();
const p = a.au.probe;
if (a.au.candidate.lossless === a.au.candidate.trackCount) {
return p?.bitDepth ? `${ext} ${p.bitDepth}-bit` : ext;
}
const k = p?.bitrate ?? a.au.candidate.avgBitrate;
return k ? `${ext} ${nfmt(k)}k` : ext;
}
const arrivalInitials = (name) =>
name.replace(/^.*?[-–—]\s*/, "").split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase();
/** Jump the picker to a record in the tray. The lens can be narrower than
* the tray — a record filtered out of the current bucket is still one you
* are waiting for — so widen rather than fail silently. */
function goToArrival(rel) {
if (!seekTo(rel)) {
bucket = "all";
seekTo(rel);
}
trayOpen = false;
schedulePersist();
}
/** Refusals the tray is not already speaking for. A record whose found
* copy is still arriving is IN the tray — live, with the peer and the
* count — and saying it again in the past tense above is the same fact
* twice, the staler of the two winning the eye. */
const leftBehind = $derived(
(report?.skipped ?? []).filter((s) => !arrivals.some((a) => a.rel === s.rel)));
/** The picker's resolved snapshot — what apply writes. Stored beside the
* choices but NOT a "seen": looking is not deciding. */
function snapshot(rel, snap) {
@@ -1182,6 +1445,12 @@
if (c && !refused.has(rel)) c.applied = true;
if (refused.size) {
await persist();
// A refusal is usually "the found copy is short", and the copy
// is short because a transfer died. Ask for the rest NOW, so
// "it imports as soon as the rest land" is something that can
// actually happen — otherwise the next run refuses the same
// records for the same reason, forever.
resumeRefused(report.skipped);
} else {
await api.importSessionClear().catch(() => {});
importResume.active = false;
@@ -1221,6 +1490,17 @@
// copy out of the source folder.
audio: r.audio === "theirs" ? "theirs" : null,
audioTracks: r.audio === "theirs" ? (r.audioTracks ?? null) : null,
// WHICH files the chosen copy is made of. The pen is
// keyed by RECORD, not by peer, so a record that
// changed peers has both copies sitting in it — the
// abandoned peer's transfers were cancelled, but the
// files they had already written stayed. Apply took
// everything in the folder and filed Centipede Hz as
// 17 tracks: deletia's 11 plus the 6 smash2 had
// managed before it was dropped.
audioFiles: r.audio === "theirs"
? (auditions[p.rel]?.candidate?.files ?? []).map((f) => f.path)
: null,
},
};
});
@@ -1231,7 +1511,17 @@
* doesn't redo them. The eventual full apply still covers everything —
* idempotent copies make the banked files no-ops. */
async function applyPartial() {
if (!dest?.dir || applying || !scan) return;
// Banking on the way out must never fail in silence. Closing the
// picker after deciding 25 records and finding nothing in the library —
// with no toast, no error and nothing in the log — is what a bare
// `return` here looks like from the outside.
if (applying || !scan) return;
if (!dest?.dir) {
importStatus.phase = "error";
importStatus.error = "No library folder is set, so nothing could be imported.";
console.warn("import: banking skipped — no destination");
return;
}
const seen = new Set(Object.entries(choices)
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
if (!seen.size) return;
@@ -1255,6 +1545,7 @@
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
for (const r of seen)
if (choices[r] && !refused.has(r)) choices[r].applied = true;
resumeRefused(rep.skipped);
importStatus.done = rep.albums;
importStatus.label = fmtGB(rep.bytes);
importStatus.skipped = refused.size;
@@ -1297,6 +1588,8 @@
}
step = "source";
root = null;
rootRel = null;
rootMissing = false;
scan = null;
scanProg = null;
scanEta = null;
@@ -1364,14 +1657,14 @@
show it on, so without this the only trace of a refusal was
a toast reading "Imported 0 records". Not dismissible while
it is still true — it clears itself when they import. -->
{#if step !== "done" && report?.skipped?.length}
{#if step !== "done" && leftBehind.length}
<div class="im-left">
<b>{nfmt(report.skipped.length)} record{report.skipped.length === 1 ? "" : "s"}
{report.skipped.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {report.skipped.length === 1 ? "it" : "them"} and
{report.skipped.length === 1 ? "it is" : "they are"} still on the list —
<b>{nfmt(leftBehind.length)} record{leftBehind.length === 1 ? "" : "s"}
{leftBehind.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {leftBehind.length === 1 ? "it" : "them"} and
{leftBehind.length === 1 ? "it is" : "they are"} still on the list —
importing again once the reason below is gone will pick
{report.skipped.length === 1 ? "it" : "them"} up.
{leftBehind.length === 1 ? "it" : "them"} up.
<div class="im-left-rows">
<!-- The FOLDER, not the tag album name: this record's
tags call it "user48736353001", which names
@@ -1397,7 +1690,13 @@
<div class="im-drop-t">
<div class="im-drop-h">The music — read from</div>
<div class="im-path">{root ?? "Choose a folder…"}</div>
{#if scan}
{#if rootMissing}
<!-- A resumed review whose source could not be
found: say so HERE, where the folder is
named, rather than letting apply fail on
a path the review still displays. -->
<small class="missing">this folder isn't here — choose where it lives now</small>
{:else if scan}
<small>{nfmt(totals.albums)} folders · {nfmt(totals.tracks)} files ·
{fmtGB(totals.bytes)} · scanned</small>
{:else if root}
@@ -1638,7 +1937,83 @@
{label}<i>{nfmt(n)}</i>
</button>
{/each}
<!-- Not a lens: the outcome chips choose what the picker
WALKS, this one discloses what the found copies still
owe. It rides here because it belongs to the same
glance, and it costs no space at all when nothing is
owed — which is the normal case. -->
{#if arrivals.length}
<button class="im-bucket ar-chip" class:stalled={arrivalStalled}
aria-expanded={trayOpen}
onclick={() => (trayOpen = !trayOpen)}
title={arrivalFiles
? "What the found copies still owe"
: "Copies cached and waiting to be imported"}>
{#if arrivalFiles}<span class="ar-dot"></span>{/if}
{arrivalStalled ? "Stalled" : arrivalFiles ? "Arriving" : "Arrived"}
<i>{nfmt(arrivalFiles || arrivals.length)}</i>
</button>
{/if}
</div>
{#if trayOpen && arrivals.length}
<div class="ar-tray" class:stalled={arrivalStalled} class:done={!arrivalFiles}>
<div class="ar-h">
{#if !arrivalFiles}
<b>Everything arrived.</b>
{arrivals.length === 1 ? "This copy is" : `All ${nfmt(arrivals.length)} copies are`}
cached — importing now takes {arrivals.length === 1 ? "it" : "them"}.
{:else if arrivalStalled}
<b>Stalled.</b> {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}
still owed, and nothing is moving.
{:else}
<b>Still arriving.</b> {nfmt(arrivalsOwed.length)}
record{arrivalsOwed.length === 1 ? "" : "s"} ·
{nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}{#if arrivalBytes} ·
{nfmt(Math.round(arrivalBytes / 1e6))} MB to come{/if}.
{/if}
</div>
<div class="ar-rows">
{#each arrivals as a (a.rel)}
{@const w = arrivalWords(a)}
<div class="ar-row">
<div class="ar-art" class:ok={!a.owed}>{arrivalInitials(a.name)}</div>
<div class="ar-nm">
<b title={a.rel}>{a.name}</b>
<div class="ar-meta">
<div class="im-bar" class:live={a.owed > 0 && a.live?.phase === "transferring"}>
<i style:width="{Math.round((a.have / a.want) * 100)}%"></i>
</div>
<span>{nfmt(a.have)} of {nfmt(a.want)} cached</span>
<span>·</span><span>{arrivalFormat(a)}</span>
<b class="ar-state {w.cls}">{w.label}</b>
<span>{w.detail}</span>
</div>
</div>
<div class="ar-acts">
<button class="pill ghost" onclick={() => goToArrival(a.rel)}>Go to record</button>
{#if a.owed}
<button class="pill ghost"
onclick={() => switchPeer(a.rel, "you asked for another peer")}
>Try another peer</button>
{/if}
</div>
</div>
{/each}
</div>
<div class="ar-foot">
{#if arrivalFiles}
Nothing here needs you — they import themselves on the next run once the
files land. "Keep mine" on the record takes your own copy instead.
{:else}
<!-- The picker's own bank, not the full run:
it applies what has been decided and not
yet written, which is exactly these. -->
<button class="pill" onclick={applyPartial} disabled={applying}>
Import {nfmt(arrivals.length)} record{arrivals.length === 1 ? "" : "s"}</button>
{/if}
</div>
</div>
{/if}
{#if curPick}
{#key curPick.rel}
<ImportPicker
@@ -2117,6 +2492,72 @@
.im-bucket[data-out="bad"]:not([disabled]):not([aria-selected="true"]) { color: #f19292; }
.im-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- what the found copies still owe ------------------------------
The chip rides in the outcome row but is NOT one of them: the others
choose what the picker walks, this one discloses a state. Accent fill
and a pulse say so, and it is absent entirely when nothing is owed. */
.im-bucket.ar-chip {
background: color-mix(in srgb, var(--accent) 26%, rgba(10, 10, 12, 0.5));
color: #fff;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.im-bucket.ar-chip:hover {
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
}
.im-bucket.ar-chip.stalled {
background: rgba(233, 185, 73, 0.2); color: #f0cd77;
box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.4);
}
.ar-dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor;
animation: ar-pulse 1.4s ease-in-out infinite;
}
@keyframes ar-pulse { 50% { opacity: 0.25; } }
.ar-tray {
position: relative; z-index: 1;
margin: -6px 0 16px; padding: 11px 13px; border-radius: var(--radius);
max-width: 96ch;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
}
.ar-h {
display: flex; align-items: baseline; gap: 9px;
font-size: 12.5px; line-height: 1.5; color: var(--text-secondary);
}
.ar-h b { color: color-mix(in srgb, var(--accent) 55%, #fff); font-weight: 800; }
.ar-tray.stalled .ar-h b { color: #f0cd77; }
.ar-tray.done .ar-h b { color: #7fe0a4; }
.ar-rows { margin-top: 9px; display: flex; flex-direction: column; gap: 7px; }
.ar-row {
display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center;
padding: 8px 9px; border-radius: 6px; background: rgba(255, 255, 255, 0.03);
}
.ar-art {
width: 34px; height: 34px; border-radius: 4px; display: grid; place-items: center;
font-size: 10px; font-weight: 800; color: rgba(255, 255, 255, 0.78);
background: color-mix(in srgb, var(--accent) 30%, rgba(255, 255, 255, 0.06));
}
.ar-art.ok { background: rgba(127, 224, 164, 0.22); }
.ar-nm { min-width: 0; }
.ar-nm > b {
display: block; font-size: 13px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ar-meta {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
margin-top: 3px; font-size: 11.5px; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.ar-meta .im-bar { width: 120px; flex: none; }
.ar-state { font-weight: 700; }
.ar-state.wait { color: var(--text-secondary); }
.ar-state.move { color: color-mix(in srgb, var(--accent) 60%, #fff); }
.ar-state.stall { color: #f0cd77; }
.ar-state.ok { color: #7fe0a4; }
.ar-acts { display: flex; gap: 6px; align-items: center; }
.ar-foot { margin-top: 9px; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
/* Left behind, not broken — amber, the picker's .im-tl-warn language. */
.im-left {
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
@@ -2137,6 +2578,9 @@
}
.im-reset b { color: #f0a0a0; }
.im-reset-row { margin-top: 10px; display: flex; gap: 8px; }
/* The source folder a resumed review could not find. Amber, not red: it
reports something that happened rather than asking to destroy anything. */
.im-drop-t small.missing { color: rgb(232, 178, 96); }
.pill.danger { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; }
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
@@ -2177,5 +2621,6 @@
@media (prefers-reduced-motion: reduce) {
.im-bar.live i { animation: none; }
.led-pulse { animation: none; }
.ar-dot { animation: none; }
}
</style>