Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
+++ src/assets/css/style.css	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -5076,2 +5076,176 @@
   background: var(--accent, #e8b04b); color: #1a1205;
 }
+
+/* ── Het bandje (mixtape) ──────────────────────────────────────────────
+   Zelfde familie als .post-album hierboven: dezelfde rand, dezelfde radius,
+   dezelfde papierkleur. Het verschil zit in wat je ziet draaien, niet in een
+   ander jasje -- een mixtape is een soort uitgave binnen Klonkt, geen ander
+   product.
+
+   Alles hieronder gebruikt de bestaande variabelen (--rule, --paper-2, --ink,
+   --accent), dus het volgt licht en donker en het accent van de site vanzelf. */
+.post-tape {
+    position: relative;
+    border: 1px solid var(--rule, rgba(0,0,0,.1));
+    border-radius: 14px;
+    overflow: hidden;
+    margin: 1.25rem 0;
+    background: var(--paper-2, color-mix(in srgb, var(--ink) 3%, transparent));
+}
+
+/* De cassette zelf. De verhouding is die van een echt bandje (ongeveer 10:6),
+   zodat hij ook op een telefoon als cassette leest en niet als balk. */
+.tape-shell {
+    display: grid;
+    grid-template-rows: auto auto;
+    gap: .5rem;
+    padding: .8rem .8rem .4rem;
+}
+.tape-window {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 12%;
+    padding: .9rem 1.2rem;
+    border-radius: 10px;
+    background: color-mix(in srgb, var(--ink) 8%, transparent);
+    border: 1px solid var(--rule, rgba(0,0,0,.12));
+}
+/* De band tussen de spoelen. Een dun lijntje is genoeg: het gaat om de twee
+   draaiende schijven, dat is wat een cassette een cassette maakt. */
+.tape-band {
+    position: absolute;
+    left: 22%; right: 22%;
+    height: 3px;
+    background: color-mix(in srgb, var(--ink) 35%, transparent);
+    border-radius: 2px;
+}
+.tape-reel {
+    position: relative;
+    width: clamp(48px, 16vw, 76px);
+    aspect-ratio: 1;
+    border-radius: 50%;
+    background:
+      repeating-conic-gradient(from 0deg,
+        color-mix(in srgb, var(--ink) 22%, transparent) 0deg 12deg,
+        transparent 12deg 24deg);
+    border: 2px solid color-mix(in srgb, var(--ink) 30%, transparent);
+    display: grid;
+    place-items: center;
+}
+.tape-reel-hub {
+    width: 34%;
+    aspect-ratio: 1;
+    border-radius: 50%;
+    background: var(--paper, #fff);
+    border: 2px solid color-mix(in srgb, var(--ink) 40%, transparent);
+}
+/* Draaien doet hij alleen als hij speelt. De animatie staat op de spoel en niet
+   op de hele cassette: het label hoort stil te blijven, anders wordt de titel
+   onleesbaar. */
+.post-tape.is-playing .tape-reel { animation: tape-spin 2.4s linear infinite; }
+.post-tape.is-playing .tape-reel--right { animation-duration: 3.2s; }
+@keyframes tape-spin { to { transform: rotate(360deg); } }
+/* Wie liever geen beweging ziet, krijgt geen beweging. De cassette blijft dan
+   gewoon staan; de statusregel eronder vertelt nog steeds wat er speelt. */
+@media (prefers-reduced-motion: reduce) {
+    .post-tape.is-playing .tape-reel { animation: none; }
+}
+
+.tape-label {
+    padding: .5rem .6rem;
+    border-radius: 8px;
+    background-size: cover;
+    background-position: center;
+    position: relative;
+}
+/* Staat er een hoes achter het label, dan moet de tekst leesbaar blijven. */
+.tape-label[style] { color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.7); }
+.tape-kind {
+    margin: 0;
+    font-size: .7rem;
+    letter-spacing: .06em;
+    text-transform: uppercase;
+    opacity: .75;
+}
+.tape-title { margin: .1rem 0 0; font-size: 1.05rem; line-height: 1.25; }
+.tape-artist { margin: .05rem 0 0; font-size: .85rem; opacity: .8; }
+.tape-meta { margin: .15rem 0 0; font-size: .75rem; opacity: .65; }
+
+/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een bandje niet,
+   en dat is de bedoeling -- er is geen zoekbalk omdat je op een cassette niet
+   kunt zoeken. */
+.tape-controls {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: .6rem;
+    padding: .3rem 0 .7rem;
+}
+.tape-btn {
+    display: grid;
+    place-items: center;
+    width: 40px; height: 40px;
+    border-radius: 50%;
+    border: 1px solid var(--rule, rgba(0,0,0,.15));
+    background: var(--paper, #fff);
+    color: var(--ink);
+    cursor: pointer;
+    padding: 0;
+}
+.tape-btn svg { width: 18px; height: 18px; }
+.tape-btn:hover { border-color: var(--accent); color: var(--accent); }
+.tape-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.tape-btn--play {
+    width: 52px; height: 52px;
+    background: var(--accent);
+    border-color: var(--accent);
+    color: var(--paper, #fff);
+}
+.tape-btn--play svg { width: 22px; height: 22px; }
+/* Speelt hij, dan is dezelfde knop de pauzeknop. Twee knoppen zou een bandje
+   niet hebben. */
+.post-tape.is-playing .tape-btn--play svg path { d: path("M7 5h4v14H7zM13 5h4v14h-4z"); }
+
+.tape-now {
+    margin: 0;
+    padding: 0 .8rem .4rem;
+    text-align: center;
+    font-size: .8rem;
+    opacity: .8;
+    min-height: 1.2em;
+}
+
+/* De nummers staan er als INHOUD, niet als knoppen. Geen cursor, geen hover,
+   geen aanwijzing dat je erop kunt drukken: op een cassette kun je dat niet. */
+.tape-tracks {
+    margin: 0;
+    padding: 0 .8rem .8rem;
+    list-style: none;
+    counter-reset: tape;
+    display: grid;
+    gap: .1rem;
+}
+.tape-track {
+    counter-increment: tape;
+    display: flex;
+    gap: .5rem;
+    align-items: baseline;
+    font-size: .85rem;
+    padding: .18rem .3rem;
+    border-radius: 6px;
+    opacity: .75;
+}
+.tape-track::before {
+    content: counter(tape) ".";
+    opacity: .55;
+    min-width: 1.4em;
+    font-variant-numeric: tabular-nums;
+}
+.tape-track-title { flex: 1; }
+.tape-track-dur { opacity: .6; font-variant-numeric: tabular-nums; }
+.tape-track.is-current {
+    opacity: 1;
+    background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
Index: src/assets/js/mod/tape.js
===================================================================
--- src/assets/js/mod/tape.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
+++ src/assets/js/mod/tape.js	(revision 16fd4fe6b34ef7645745880fb460d77773d50c47)
@@ -0,0 +1,130 @@
+/**
+ * Het bandje: een cassette die draait, en alleen vooruit en achteruit speelt.
+ *
+ * WAT DEZE MODULE WEL EN NIET DOET. Hij speelt niets af. De site heeft al een
+ * speler (audio-player.js) met een wachtrij, een mini-speler onderin en een
+ * sessie die een paginawissel overleeft. Een tweede speler ernaast bouwen om
+ * een bandgevoel na te doen zou twee dingen tegelijk laten klinken en die hele
+ * sessie weggooien. Dus: de afspeelknop draagt dezelfde data-attributen als een
+ * albumhoes en wordt door die speler opgepakt, en spoelen is next()/prev().
+ *
+ * DAAROM SPOELT HET PER NUMMER en niet per seconde. Die speler denkt in een
+ * wachtrij. Dat is een eerlijke benadering van een cassette: je komt vooruit en
+ * achteruit, en je springt niet naar nummer zeven.
+ *
+ * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
+ * is een gesloten module met een klein oppervlak (setQueue, play, pause, next,
+ * prev, isPlaying, currentTrack). Liever hier vijf keer per seconde kijken dan
+ * daar een gebeurtenissenlaag inbouwen die de rest van de site niet vraagt. Het
+ * polsen stopt zodra er geen bandje op de pagina staat.
+ */
+
+const TIK_MS = 200;
+let tikker = null;
+let gebonden = null;   // de container waarop de klikafhandelaar hangt
+
+/** De speler, of niets als hij (nog) niet geladen is. */
+const speler = () => window.pcmsAudioPlayer || null;
+
+/** De url's die op dit bandje staan, in volgorde. */
+function bandUrls(tape) {
+  try {
+    return JSON.parse(tape.dataset.pcmsAlbum || '[]').map((t) => t.url);
+  } catch { return []; }
+}
+
+/**
+ * Speelt DIT bandje op dit moment? Vergelijken op url en niet op index: de
+ * wachtrij kan ondertussen van een ander blok komen, en dan hoort deze cassette
+ * gewoon stil te staan.
+ */
+function huidigeIndex(tape) {
+  const p = speler();
+  if (!p) return -1;
+  const nu = p.currentTrack();
+  if (!nu || !nu.url) return -1;
+  return bandUrls(tape).indexOf(nu.url);
+}
+
+function tekenBand(tape) {
+  const i = huidigeIndex(tape);
+  const p = speler();
+  const draait = i >= 0 && !!p && p.isPlaying();
+
+  tape.classList.toggle('is-playing', draait);
+  tape.classList.toggle('is-loaded', i >= 0);
+
+  const knop = tape.querySelector('[data-tape-play]');
+  if (knop) {
+    knop.classList.toggle('is-pauze', draait);
+    knop.setAttribute('aria-label', draait ? 'Pauzeren' : 'Afspelen');
+  }
+
+  // Welk nummer loopt er. Bij een bandje is dat de enige plek waar je het leest,
+  // want de lijst is met opzet geen knoppenrij.
+  const items = [...tape.querySelectorAll('.tape-track')];
+  items.forEach((li, n) => li.classList.toggle('is-current', n === i));
+  const nu = tape.querySelector('[data-tape-now]');
+  if (nu) {
+    const titel = i >= 0 && items[i] ? items[i].querySelector('.tape-track-title').textContent : '';
+    nu.textContent = i >= 0 ? `${i + 1}. ${titel}` : '';
+  }
+}
+
+function tekenAlles() {
+  for (const tape of document.querySelectorAll('.post-tape')) tekenBand(tape);
+}
+
+function opKlik(e) {
+  const tape = e.target.closest && e.target.closest('.post-tape');
+  if (!tape) return;
+  const p = speler();
+
+  const spoel = e.target.closest('[data-tape-go]');
+  if (spoel) {
+    if (!p) return;
+    // Spoelen kan alleen als DIT bandje loopt. Anders zou de knop de wachtrij
+    // van een ander blok verzetten, en dat is niet wat een spoelknop op deze
+    // cassette hoort te doen.
+    if (huidigeIndex(tape) < 0) return;
+    if (spoel.dataset.tapeGo === 'fwd') p.next(); else p.prev();
+    setTimeout(tekenAlles, 60);
+    return;
+  }
+
+  const play = e.target.closest('[data-tape-play]');
+  if (play && p) {
+    // Loopt dit bandje al, dan is deze knop een pauzeknop. De globale
+    // afhandelaar van audio-player.js zou hem anders opnieuw vanaf nummer een
+    // starten, want die kent alleen "speel deze wachtrij".
+    const i = huidigeIndex(tape);
+    if (i >= 0) {
+      e.preventDefault();
+      e.stopPropagation();
+      if (p.isPlaying()) p.pause(); else p.play();
+      setTimeout(tekenAlles, 60);
+    }
+    // Staat het bandje stil, dan laten we de klik doorlopen: de speler pakt de
+    // data-attributen op en zet de wachtrij op.
+  }
+}
+
+export function init() {
+  const tapes = document.querySelectorAll('.post-tape');
+
+  // Altijd eerst opruimen, ook als er niets staat. Zonder dat blijft de tikker
+  // lopen na een paginawissel naar een pagina zonder bandje -- dezelfde fout
+  // die in de leesweergave een keer de hele site heeft vastgezet.
+  if (tikker) { clearInterval(tikker); tikker = null; }
+  if (gebonden) { gebonden.removeEventListener('click', opKlik, true); gebonden = null; }
+  if (!tapes.length) return;
+
+  // Vangen in de CAPTURE-fase, want de speler luistert zelf op document in de
+  // bubbelfase. Alleen zo kan de pauzeknop zijn klik tegenhouden voordat de
+  // wachtrij opnieuw wordt gezet.
+  gebonden = document;
+  gebonden.addEventListener('click', opKlik, true);
+
+  tikker = setInterval(tekenAlles, TIK_MS);
+  tekenAlles();
+}
