Turn artist radio into a station sheet with reasons on each track

The radio button (r) opens a sheet with the artist, their tags and their closest
neighbours instead of switching the feed at once; tapping a neighbour walks the
artist graph. Starting jumps to the radio's first track, the top-bar chip shows the
station, each radio card says why it is there, and an end card offers the way back
when the radio runs dry.

GET /local/artist feeds the sheet and starts crawling a new artist's neighbourhood
(at most every 10 minutes per artist). Artist-radio tracks carry why: seed, related,
or via the neighbour that leads to them.
This commit is contained in:
nvrl committed 2026-11-01 14:32:51 +01:00
1 parent e8c0f40128
commit 76cfbb12ac
13 files changed
+654 -42

No files matched your search

+142 -3
View File
@@ -83,12 +83,25 @@ button {
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#mode-chip {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
display: inline-flex;
align-items: center;
gap: 0.45em;
padding-left: 0.3em;
white-space: nowrap;
background: color-mix(in oklab, var(--accent) 40%, transparent);
}
#mode-chip .chip-label { overflow: hidden; text-overflow: ellipsis; }
.chip-avatar {
flex: none;
width: 1.5em;
height: 1.5em;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.8em;
background: #0006 center / cover no-repeat;
}
.panel {
position: fixed;
@@ -305,6 +318,15 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
overflow-wrap: anywhere;
text-wrap: balance;
}
.why {
margin: 0;
min-height: 0;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in oklab, var(--card-accent) 70%, #fff);
}
.why:empty { display: none; }
.byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; }
@@ -473,6 +495,118 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
}
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
/* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */
#station {
position: fixed;
inset: 0;
z-index: 26;
display: grid;
place-items: center;
padding: 1rem;
background: #000b;
animation: fade 0.2s ease;
}
.station-box {
position: relative;
width: min(26rem, 100%);
max-height: calc(100dvh - 2rem);
overflow-y: auto;
background: var(--panel);
border: 1px solid #333;
border-radius: var(--radius-l);
padding: 1.4rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
animation: rise 0.35s var(--ease-out);
}
@keyframes rise { from { transform: translateY(2rem); opacity: 0; } }
#station-back { align-self: flex-start; }
#station-close { position: absolute; top: 0.8rem; right: 0.8rem; }
.icon-btn { width: 2.2rem; height: 2.2rem; padding: 0; display: grid; place-items: center; }
.icon-btn svg { width: 1rem; height: 1rem; }
.station-head { display: flex; gap: 1rem; align-items: center; }
.station-head h2 { margin: 0; font: 800 1.4rem/1.1 var(--font-display); overflow-wrap: anywhere; }
.station-kicker { margin: 0 0 0.2rem; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
#station-sub { margin: 0.3rem 0 0.4rem; color: var(--muted); font-size: 0.85rem; }
#station-tags { justify-content: flex-start; }
#station-tags span { background: rgba(255, 255, 255, 0.12); }
.station-avatar {
flex: none;
width: 5.5rem;
height: 5.5rem;
border-radius: 50%;
display: grid;
place-items: center;
background: #222 center / cover no-repeat;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 60%, transparent), 0 10px 30px #000a;
}
.station-avatar .initial { font-size: 2rem; }
.station-avatar.small { width: 3.4rem; height: 3.4rem; box-shadow: none; }
.station-avatar.small .initial { font-size: 1.2rem; }
.station-box h3 { margin: 0; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
#station-neighbours {
list-style: none;
margin: 0 -1.4rem;
padding: 0 1.4rem 0.3rem;
display: flex;
gap: 0.7rem;
overflow-x: auto;
scrollbar-width: none;
color: var(--muted);
font-size: 0.85rem;
}
#station-neighbours li { animation: fade 0.5s ease; }
#station-neighbours button {
width: 4.2rem;
padding: 0;
border: none;
background: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--fg);
}
#station-neighbours button > span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#station-neighbours button:hover .station-avatar { box-shadow: 0 0 0 2px var(--accent); }
#station-exploring { margin: 0; color: var(--muted); font-size: 0.85rem; animation: breathe 1.4s ease-in-out infinite; }
@keyframes breathe { 50% { opacity: 0.35; } }
button.primary {
background: var(--accent);
border: none;
color: #fff;
font-weight: 600;
padding: 0.7em 1.2em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4em;
}
button.primary svg { width: 1em; height: 1em; }
/* The end of an artist radio, over its last card. */
#radio-end {
position: fixed;
z-index: 6;
left: 50%;
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
transform: translateX(-50%);
width: min(24rem, calc(100vw - 2rem));
padding: 1rem;
border-radius: var(--radius-m);
background: var(--panel);
border: 1px solid #333;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
text-align: center;
animation: rise 0.35s var(--ease-out);
}
#radio-end p { flex-basis: 100%; margin: 0 0 0.3rem; color: var(--muted); font-size: 0.9rem; }
#help {
position: fixed;
inset: 0;
@@ -553,6 +687,11 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
#help-btn { display: none; }
#account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; }
#station { place-items: end stretch; padding: 0; }
.station-box { width: 100%; border-radius: var(--radius-l) var(--radius-l) 0 0; padding-bottom: max(1.4rem, env(safe-area-inset-bottom)); }
#mode-chip { max-width: 9rem; }
/* A station chip says more than the logo; there is no room for both. */
#bar:has(#mode-chip:not([hidden])) .logo { display: none; }
#likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
.actions {