Ending My Vinyl Buying Anxiety (pt VI)

September 27, 2026 | Seattle 🌧️

The record site could do a lot by now. Track my collection, log every spin off an NFC tap, chart my listening history six ways from Sunday. It just wasn’t pretty. It looked like what it was: a very useful spreadsheet with album covers.

This chapter is the redesign. It’s where I gave every record its own color, deleted a feature people generally consider mandatory, and then lost an entire weekend to sub-pixel nonsense. Let’s go.

Every record gets its own color world

The big idea: the “Now Playing” hero at the top of the timeline shouldn’t be one static design. It should become the record. Play a bright red sleeve, the whole hero goes red. Play a moody blue jazz record, it goes blue. The art sets the mood.

To do that I needed one number per cover: its dominant color. And I did NOT want to compute that in the browser on every load — that’s slow and wasteful. So it gets precomputed once, during the sync, and stored on the record:

ALTER TABLE records ADD COLUMN IF NOT EXISTS dominant_color text;

The extractor (running in the sync tool with sharp) downscales the cover to 32×32, then instead of naively averaging every pixel — which gives you sad brown mud every time — it weights pixels by saturation and buckets similar colors together, then picks the heaviest bucket:

const sat = max === 0 ? 0 : (max - min) / max;
const weight = 0.05 + sat;                                 // vivid pixels count more
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); // 4-bit bucket

So a mostly-black cover with one splash of orange picks the orange, not the black. It falls back to a plain average for genuinely grayscale covers. I also ran a one-time backfill to fill the color in for every record already in the catalog.

One color, a whole palette

Here’s the part that made it click. From that single stored hex color, the hero derives an entire legible palette in the browser — text color, subtext, the scrim, the metadata pills, even the equalizer bars.

The key move is picking readable ink based on how bright the cover is (perceived luminance, the BT.601 weighting):

const isLight = (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62;
const text    = isLight ? "#141414" : "#f5f5f5";  // dark ink on light covers

The equalizer bars along the bottom get an analogous color — same family, hue nudged, contrast tuned so they read on either a light or dark cover:

const bar = hslToHex((h + 340) % 360, barSat, barLight);

And the drop shadow under the cover isn’t flat black — it’s a darkened tint of the cover itself, so the whole thing feels grounded in its own color:

boxShadow: `rgba(${r * 0.38}, ${g * 0.38}, ${b * 0.38}, 0.32)`

The really satisfying trick: the hero publishes all of these as CSS custom properties on the document root:

document.documentElement.style.setProperty("--hero-text", palette.text);
document.documentElement.style.setProperty("--hero-color", palette.color);

Which means the fixed nav bar floating over the hero — a totally separate component — can tint itself to match, just by reading --hero-*. No prop drilling, no context. The color leaks exactly as far as I want it to.

I deleted dark mode

Yeah. I had a theme system — light, dark, even a couple novelty themes. I ripped it all out. Light mode only now.

Why? Because the hero already owns a rich, per-record color world, and a global dark theme fought with it constantly — I was tuning every color twice and it never looked as good in dark. The site’s whole personality now comes from the album art, not from a theme toggle. Deleting the toggle, the theme context, and all the duplicate dark styles made the codebase smaller and the design more confident. Fewer knobs, better result.

(If you’re the kind of person who needs dark mode on everything: I hear you, I’m sorry, this one’s for me.)

A ranked “Top Listens” page

With weeks of listen data piling up, the obvious question is what do I actually play the most. So there’s a Top Listens route now — albums (or artists) ranked by all-time spins.

Ranking sounds trivial until ties show up. Early on the list barely differs — half my records sit at 2 or 3 plays. Showing distinct ranks (2, 3, 4…) on tied items is a lie; it implies an order that isn’t real. I went a few rounds on this and landed somewhere clean: the marker on each row is just the raw play count itself.

[...items]
  .sort((a, b) =>
    b.plays - a.plays ||                 // most plays first
    b.last.localeCompare(a.last) ||       // ties: most recent listen
    a.sortKey.localeCompare(b.sortKey))   // then name, for stability
  .map(it => ({ ...it, marker: String(it.plays) }));

Two records at 6 plays both show a 6. No fake ranking, no dashes, no drama. As the data grows the top naturally separates and it sorts itself out. Ties break by most-recent listen, so the record I played last edges ahead — which feels right.

One nav to rule them all

I also stopped making navigation a puzzle. There’s now a single persistent cluster of three circular buttons in the corner — Collection grid, Timeline, Top Listens — identical on every page, so you can jump between any of them from anywhere. Plus a compact search that mirrors those buttons’ size and skin, hidden on the hero views where it’d just be noise.

The polish rabbit hole

This is the part I can’t fully justify but absolutely loved. Once the bones were good I could not stop tightening things:

  • Metadata as pills — year, 12 tracks | 40m, listen count, a “1st Spin” badge — all styled to adapt to the cover’s brightness.
  • A 3D tilt on the cover — hover it and it leans away from your cursor, like the corner you’re over is being gently pushed back. It’s a couple lines writing CSS variables straight to the element, no React re-render:
const nx = ((e.clientX - r.left) / r.width - 0.5) * 2;
const ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
el.style.setProperty("--ry", `${nx * 4}deg`);
el.style.setProperty("--rx", `${-ny * 4}deg`);
  • A muted copy of the album art bleeding in from the right edge of the hero, faded almost to nothing and dissolving into the background color:
.edgeArt {
  width: min(48%, 620px);
  opacity: 0.16;
  filter: saturate(0.8);
  mask-image: linear-gradient(to left, #000 0%, transparent 82%);
}
  • text-wrap: balance on the title so long album names break into even lines instead of a long line and one sad orphan word.

None of this was necessary. All of it makes the thing feel alive.

Where this leaves me (for now)

What started as “don’t buy the same record twice” is now a living, self-updating, self-logging, color-reactive little shrine to my listening habits. It syncs itself every morning, logs a spin when I tap a puck, charts how I listen, ranks what I love, and paints itself the color of whatever’s on the platter.

Is it over-engineered for an audience of one? Completely. That was always the point — coding dumb stuff is fun, and this dumb thing turned into the most satisfying project I own.

Go poke at it: records.manhart.io. And no, I still won’t apologize for the Barbra Streisand record.

What was spinning while writing:

Run the Jewels by Run the Jewels