Synced LRC highlights the playing line, keeps it centred and seeks on click.
Plain text scrolls continuously with a nudge knob instead — a highlight on
guessed timings turns guaranteed drift into what looks like a bug. Seuraa
kappaletta turns following off without losing the highlight, and scrolling by
hand turns it off too.
Fixes the scroll landing in the wrong place (offsetTop measured from a
different coordinate space than the box it was applied to) and the fader
shifting the deck sideways at score 100 (auto-sized grid columns plus a
readout spanning both).
Versions are YYYY.MM.DD-N, injected with -ldflags from a git tag, so no file
in the repo carries the number and a local build honestly says dev. The
version shows in the footer, the startup log and /healthz.
The song page's metadata was five different kinds of fact — artist, genre,
duration, provenance and a badge about the viewer — in one flat run with two
competing pills. Now the artist has its own line in the display face, and the
facts sit in four labelled cells like the spine of a cassette insert. The
submitter links to their profile, "oma kappale" became "lähetti: sinä", and
the clock time is gone: nobody needs the minute a song was published.
The app is about operating something — playing a track and setting a level on
it — but every screen looked like a form. One metaphor now does three jobs.
- Reviewing: a vertical fader beside the text, so the two things you do at
once stop being a screen apart. Native range input, so keyboard, focus and
form submission are unchanged; on mobile it lies down and the ticks reverse
- The reveal: everyone's scores as a row of channels. The silhouette of that
row is the spread, which the stats page can only tell you as a number
- Profiles: given versus received as two faders, the one comparison that says
something about a person
The player is now a transport: play/pause, a range input for seeking so arrow
keys come free, and a stereo level meter driven by a real AnalyserNode. It is
progressive enhancement — the page ships native audio controls and the script
takes over, so no JS means the browser's own player. The meter is dark until
audio actually plays and stops when it does; reduced motion skips it entirely.
Also: hidden scores are hatched rather than blank, the nav carries the queue
count, "Seuraava jonossa" keeps the loop going after a review, leaderboards
gained level bars and a range bar where divisive is the point, durations read
3:54, both lists can get back to the start, and the admin invite table lists
unused codes instead of silently truncating at 50.
Slogan restored from the original app, three decades on.
Step 6. The surfaces around the review loop.
- Nine leaderboards, ordered and limited in SQL, each with a deterministic
tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews
to qualify, for reviewer boards too
- Profiles show counts and history-wide averages and the member's songs,
never a list of their reviews — per-song opinion stays gated
- Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation.
No upload still means initials, and avatars are public
- Changing your own password requires the current one and drops your other
sessions
- Palaute: free text plus the page you were on, carried in a footer link, and
the user agent from the header. Reporters see their own; the admin resolves
them with a timestamp rather than a status enum
- Admin gained the song list with delete, the reports page, and an open-report
count on the dashboard
Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter
because display:grid suppresses list markers, and count-based boards were
printing 3.0 where they mean 3.
The theme handoff encoded as CSS custom properties rather than a Tailwind
config, since there is no Tailwind here. Palette, spacing, radii, shadows and
motion follow it as written; docs/theme.md lists what differs and why.
- Oswald vendored as a 21KB variable woff2, latin subset, no CDN. Its phantom
weight 900 resolved to 700 — loading a weight you don't have is what made
the brand render differently per platform
- color-scheme: dark makes the native audio element fit the palette, which
was the handoff's complaint about it
- Songs are text cards rather than artwork tiles, because there is no
artwork. The unreviewed state keeps its red-brown border and gains a badge,
so it is never carried by colour alone
- Nav is the three-column grid; the mobile menu is <details>, no JS
- Flash messages became bottom-right toasts
Favicon carried over from the Nuxt project.
Steps 3 and 4 of the build order. A member can now upload a song, watch it
convert, publish it, and review what everyone else has published.
Pipeline:
- ffprobe reads tags synchronously at submit so prefill never races typing;
ffmpeg converts to Opus in the background, two at a time
- ffmpeg succeeding is the validation — no container sniffing
- publish moves the file inside the transaction, so a song row and its .ogg
appear together or neither does
- five submissions per rolling 24h, failures excluded
Reviews and the reveal rule:
- the queue is unreviewed songs only, oldest first, never your own
- other people's reviews and the average are withheld in the query, not the
template — a hidden average is never sent
- 30 minutes to edit or delete your own review, enforced in the WHERE clause
- deleting the last review unlocks the song for its submitter again
The waiting page has one button: the metadata form autosaves after a pause in
typing, and Julkaise submits it and publishes in the same request, so nothing
is lost without JS.
Genres store an English code and render a Finnish label.
Step 2 of the build order. The admin mints an invite link, the recipient
registers with it, and from then on has a session.
- The invite is spent in the same transaction that creates the account, so a
failed signup leaves the code usable
- Sessions are idle timeouts, 24h or 30 days with remember me, read from a
cookie or a bearer header, extended at most once a minute
- Ban is a reversible toggle that drops the member's live sessions
- No password minimum; login is rate limited instead, 10 failures per email
in 15 minutes, cleared by a correct password
- Invite codes render as links carrying ?code=, which the register form
prefills; PUBLIC_URL makes them pasteable from the loopback admin panel
Tests cover invite spending, the idle timeout, ban, and the rate limiter.