Release 2026.08.01-1

Lyrics, versioning, and the song page's fact line.

- Lyrics are suggested at submission and never imposed: the worker makes one
  LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist
  are typed. Neither overwrites what the submitter wrote. They live on the
  submission, travel to the song at publish, and stay editable after the song
  locks — the lock freezes what a song claims to be, and nobody reviewed the
  lyrics
- The review strip reads and writes side by side: lyrics left, review right.
  Synced LRC highlights the playing line and seeks on click; plain text scrolls
  continuously with a nudge knob. Following can be turned off
- CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the
  footer, the startup log and /healthz
- The song page's metadata became four labelled cells instead of one flat run
  of five different kinds of fact

Fixes: publishing wiped lyrics the worker had just fetched (a request that
omitted a field cleared it), lyric auto-scroll landed in the wrong place, and
the fader shifted the deck sideways at score 100.
This commit is contained in:
Esa Kataja
2026-08-01 00:45:55 +03:00
parent 1b3bbbbd7b
commit 400b5d3833
16 changed files with 869 additions and 41 deletions
+107 -3
View File
@@ -330,6 +330,35 @@ footer.sitefooter {
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
.lyrics {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-5);
}
.lyrics > summary {
cursor: pointer;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--primary);
}
/* Lyrics are typed with intent: line breaks and indentation are the content. */
.lyricstext {
font-family: inherit;
font-size: 0.95rem;
line-height: 1.7;
white-space: pre-wrap;
margin: var(--space-4) 0 0;
max-height: 26rem;
overflow-y: auto;
}
.lyrics form { margin-top: var(--space-4); }
.review {
padding: var(--space-4);
border-left: 3px solid var(--input-border);
@@ -424,10 +453,77 @@ button.link:hover { background: none; color: var(--primary-hover); }
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.deck .grow { flex: 1; }
/* Read on the left, write on the right. One column when the song has no lyrics — the pane is
absent rather than empty. */
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); flex: 1; min-height: 0; }
.panes.solo { grid-template-columns: 1fr; }
.lyricspane, .writepane { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.writepane .grow { display: flex; flex-direction: column; gap: var(--space-1); }
.writepane textarea { flex: 1; min-height: 12rem; }
.cap {
font-family: var(--font-display);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.lyricsbox {
/* Grows with the pane but stops before it can push the page: long lyrics scroll inside the box
rather than stretching the strip past the screen. */
flex: 1 1 auto;
min-height: 10rem;
max-height: 24rem;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
background: var(--bar);
border: 1px solid var(--hairline);
border-radius: var(--radius);
white-space: pre-wrap;
line-height: 1.7;
font-size: 0.95rem;
/* Scrolling is smoothed in JS, which also knows when to skip it. Doing it here as well makes two
mechanisms fight over the same element. */
}
/* Synced lyrics: the line that is playing is the only bright one, and clicking any line seeks. */
.lyricsbox.synced { white-space: normal; }
.lline {
margin: 0;
padding: 0.1rem 0;
color: var(--muted);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.lline:hover { color: var(--text); }
.lline.on {
color: var(--gold-1);
font-weight: 600;
}
/* Uniform distribution models a song no real song obeys, so the reader gets a knob. */
.follow { display: flex; align-items: center; gap: var(--space-2); font-size: 0.8rem;
color: var(--muted); cursor: pointer; }
.follow input { width: auto; accent-color: var(--primary); }
.nudge { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem;
color: var(--muted); font-family: var(--font-display); text-transform: uppercase;
letter-spacing: 0.06em; }
.nudge input { flex: 1; accent-color: var(--primary); }
.nudge output { min-width: 4ch; text-align: right; color: var(--text); }
.deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
/* Fixed columns, not auto: the readout spans both, so an auto track would widen the whole fader
when the score reaches three digits and shove the deck sideways mid-drag. */
.fader { display: grid; grid-template-columns: 2rem 2rem; grid-template-rows: 1fr auto;
gap: var(--space-2); align-items: stretch; }
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
@@ -486,14 +582,16 @@ button.link:hover { background: none; color: var(--primary-hover); }
.readout {
grid-column: 1 / -1;
font-family: var(--font-display);
font-size: 2rem;
font-size: 1.8rem;
font-weight: 700;
/* Digits of equal width, so 99 → 100 does not shift anything inside the box either. */
font-variant-numeric: tabular-nums;
color: var(--gold-1);
text-align: center;
background: var(--bar);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: 0 var(--space-2);
padding: 0 var(--space-1);
}
/* --- the reveal: one channel per reviewer --- */
@@ -740,6 +838,9 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
/* A 200px fader on a phone is worse than a horizontal one. */
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
/* Side by side needs width it does not have here, so reading stacks above writing. */
.panes { grid-template-columns: 1fr; }
.lyricsbox { max-height: 14rem; }
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
width: 100%; height: auto; min-height: 0; }
@@ -980,3 +1081,6 @@ img.avatar { object-fit: cover; }
.version { color: var(--muted); font-family: var(--font-display); }
.version::before { content: "·"; margin: 0 var(--space-2); }
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
margin-top: var(--space-2); }