Index: src/assets/js/mod/post-edit.js
===================================================================
--- src/assets/js/mod/post-edit.js	(revision 25dd08f3833da4deb81ef7a027a7e4382dbc5181)
+++ src/assets/js/mod/post-edit.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -8,5 +8,5 @@
 // Dezelfde regel die de server gebruikt -- zie shared/post-music-type.js voor
 // waarom hij daar staat en niet twee keer.
-import { afleidenUitInsluitingen } from '../shared/post-music-type.js';
+import { afleidenUitInsluitingen, SOORTEN } from '../shared/post-music-type.js';
 
 // De oude inline scripts draaiden bij ELKE render; een module draait zijn
@@ -1020,5 +1020,5 @@
     // alleen als ijkpunt onthouden. Anders zou een oude post van type veranderen
     // door hem te bekijken.
-    const VOLGBAAR = new Set(['post', 'album', 'playlist', 'audio']);
+    const VOLGBAAR = new Set(['post', 'album', 'playlist', 'mixtape', 'audio']);
     const kindVan = new Map();
     let vorigeAfleiding = null;
@@ -1028,5 +1028,9 @@
       .then((j) => {
         if (j && j.ok && Array.isArray(j.playlists)) {
-          for (const p of j.playlists) kindVan.set(p.id, p.kind === 'playlist' ? 'playlist' : 'album');
+          // De soort ZOALS HIJ IS. Dit was `=== 'playlist' ? 'playlist' : 'album'`,
+          // en dan ziet de editor een mixtape als album terwijl de server hem
+          // als mixtape opslaat: het type verspringt onder je handen bij het
+          // bewaren. SOORTEN is dezelfde lijst die de server gebruikt.
+          for (const p of j.playlists) kindVan.set(p.id, SOORTEN.includes(p.kind) ? p.kind : 'album');
         }
       })
Index: src/config/post-types.js
===================================================================
--- src/config/post-types.js	(revision 25dd08f3833da4deb81ef7a027a7e4382dbc5181)
+++ src/config/post-types.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -13,8 +13,13 @@
  * zo'n post mag zijn type niet stilzwijgend weggooien -- tot de backfill hem
  * heeft omgezet. Daarna kan audio hier weg.
+ *
+ * MIXTAPE erbij (Robins idee, 21-8): een bandje, samengesteld uit de nummers
+ * van een playlist, dat alleen vooruit en achteruit speelt. Het draagt muziek,
+ * dus het deelt het muziekpaneel en de afleiding met album en playlist -- het
+ * verschil zit in de playlist die je insluit, niet in wat je hier uploadt.
  */
 
 /** Types die een gebruiker in de editor kan kiezen. */
-export const KEUZE_TYPES = ['post', 'foto', 'video', 'album', 'playlist'];
+export const KEUZE_TYPES = ['post', 'foto', 'video', 'album', 'playlist', 'mixtape'];
 
 /** Alles wat in de kolom posts.type mag staan, inclusief wat er historisch is. */
@@ -22,3 +27,3 @@
 
 /** De types die muziek dragen -- ze delen hetzelfde paneel en dezelfde afleiding. */
-export const MUZIEK_TYPES = new Set(['album', 'playlist', 'audio']);
+export const MUZIEK_TYPES = new Set(['album', 'playlist', 'audio', 'mixtape']);
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 25dd08f3833da4deb81ef7a027a7e4382dbc5181)
+++ src/services/AudioEmbedService.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -10,4 +10,8 @@
  * </figure>
  */
+
+// Dezelfde lijst soorten als de server en de editor gebruiken. Zie
+// assets/js/shared/post-music-type.js: die module is puur, dus hij mag hier.
+import { SOORTEN } from '../assets/js/shared/post-music-type.js';
 
 // "Open in" icons (brand-colored via CSS .pat-link--).
@@ -538,6 +542,12 @@
 
       const albumDomId = 'album-' + id;
-      const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
-      const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
+      // De soort zoals hij is opgeslagen. Stond hier als ternair met twee
+      // uitkomsten, en dan draagt een mixtape het jasje en het woord van een
+      // album -- dezelfde vorm die op vier andere plekken al misging.
+      // DE SPELER IS ER NOG NIET. Een mixtape rendert voorlopig als de gewone
+      // lijst; wat hier af is, is dat hij zichzelf goed noemt.
+      const kind = SOORTEN.includes(pl.kind) ? pl.kind : 'album';
+      const KIND_LABEL = { album: '💿 Album', playlist: '📃 Playlist', mixtape: '📼 Mixtape' };
+      const kindLabel = KIND_LABEL[kind] || KIND_LABEL.album;
       const titleH  = this.escape(pl.title || 'Naamloos');
       const artistH = this.escape(pl.artist || '');
@@ -638,5 +648,5 @@
             data-pcms-track-url="${firstUrl}"
             data-pcms-album-id="${albumDomId}"
-            aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
+            aria-label="Speel ${kind}">
       ${coverH
         ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 25dd08f3833da4deb81ef7a027a7e4382dbc5181)
+++ src/services/i18n.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -770,5 +770,5 @@
     'pedit.type_audio': 'Audio',
     'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist',
+    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
     'pedit.s_type': 'Wat voor post?',
     'pedit.audio_up_drop': 'Sleep audio hierheen of klik om te kiezen',
@@ -1779,5 +1779,5 @@
     'pedit.type_audio': 'Audio',
     'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist',
+    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
     'pedit.s_type': 'What kind of post?',
     'pedit.audio_up_drop': 'Drop audio here or click to choose',
@@ -2788,5 +2788,5 @@
     'pedit.type_audio': 'Audio',
     'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist',
+    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
     'pedit.s_type': 'Was für ein Beitrag?',
     'pedit.audio_up_drop': 'Audio hierher ziehen oder klicken',
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision 25dd08f3833da4deb81ef7a027a7e4382dbc5181)
+++ src/views/pages/post-edit.ejs	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -24,10 +24,10 @@
         Foto → cover/insert hint. %>
     <% var ptype = post.type || 'post'; %>
-    <% var TYPE_ICONS = { post: '📝', foto: '📷', video: '🎬', album: '💿', playlist: '📃', audio: '🎵' }; %>
+    <% var TYPE_ICONS = { post: '📝', foto: '📷', video: '🎬', album: '💿', playlist: '📃', mixtape: '📼', audio: '🎵' }; %>
     <%# Audio is geen keuze meer (shaer-cyg): alles wat muziek is landt op Album
         of Playlist. De knop blijft alleen staan zolang deze post nog audio IS,
         zodat een oude post niet stilzwijgend van type verandert bij het openen. %>
     <% var TYPES = (typeof keuzeTypes !== 'undefined' && keuzeTypes.length)
-                     ? keuzeTypes.slice() : ['post','foto','video','album','playlist']; %>
+                     ? keuzeTypes.slice() : ['post','foto','video','album','playlist','mixtape']; %>
     <% if (ptype === 'audio' && TYPES.indexOf('audio') < 0) TYPES.push('audio'); %>
     <section class="pe-card pe-type-card">
@@ -45,7 +45,8 @@
 
       <%# Muziek: inline upload (transcodes + drops [[track]] into the post).
-          Album en Playlist delen dit paneel -- het verschil zit in de playlist,
-          niet in wat je hier uploadt. Audio staat er alleen nog voor oude posts. %>
-      <div class="pe-type-panel" data-panel="album playlist audio" hidden>
+          Album, Playlist en Mixtape delen dit paneel -- het verschil zit in de
+          playlist die je insluit, niet in wat je hier uploadt. Audio staat er
+          alleen nog voor oude posts. %>
+      <div class="pe-type-panel" data-panel="album playlist mixtape audio" hidden>
         <div class="pe-audio-up" id="pe-audio-drop" tabindex="0" role="button" aria-label="<%= t('pedit.audio_up_drop') %>">
           <input type="file" id="pe-audio-file" accept="audio/*,.mp3,.m4a,.ogg,.opus,.flac,.wav" multiple hidden>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 25dd08f3833da4deb81ef7a027a7e4382dbc5181)
+++ src/views/shell.ejs	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -505,5 +505,5 @@
   // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
   // bumpen kost een bugfix die nooit aankomt.
-  var MOD_V = 50;
+  var MOD_V = 51;
 
   // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Index: test/mixtape-ui.test.js
===================================================================
--- test/mixtape-ui.test.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
+++ test/mixtape-ui.test.js	(revision 8e1af9c0d284f99d5dbe69c16f2f5242239f9e4e)
@@ -0,0 +1,97 @@
+// De mixtape als POSTTYPE: kiesbaar, opslaanbaar, en zichzelf noemend.
+//
+// De waarschuwing staat al in config/post-types.js en is hier het hele punt:
+// kent de opslag een type niet, dan wordt de post ZONDER MELDING een gewone
+// post. De keuze verdwijnt dan in plaats van geweigerd te worden, en niemand
+// ziet het tot een post van soort blijkt te zijn veranderd.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+
+const { KEUZE_TYPES, POST_TYPES, MUZIEK_TYPES } = await import('../src/config/post-types.js');
+
+test('mixtape is een kiesbaar type, en draagt muziek', () => {
+  assert.ok(KEUZE_TYPES.includes('mixtape'), 'anders staat de knop er niet');
+  assert.ok(POST_TYPES.has('mixtape'), 'anders gooit het opslaan hem stil weg');
+  assert.ok(MUZIEK_TYPES.has('mixtape'), 'anders deelt hij het muziekpaneel niet');
+});
+
+test('de composer toont de knop en deelt het muziekpaneel', () => {
+  const ejsBron = fs.readFileSync('src/views/pages/post-edit.ejs', 'utf8');
+  assert.match(ejsBron, /mixtape: '📼'/, 'een eigen teken, niet dat van een album');
+  assert.match(ejsBron, /data-panel="album playlist mixtape audio"/,
+    'zonder mixtape in deze lijst verdwijnt het uploadpaneel zodra je het type kiest');
+});
+
+test('het type mag meeveranderen met de muziek', () => {
+  const mod = fs.readFileSync('src/assets/js/mod/post-edit.js', 'utf8');
+  assert.match(mod, /VOLGBAAR = new Set\(\[[^\]]*'mixtape'/,
+    'anders blijft het type op post staan als je een mixtape invoegt');
+  // De opzoeker van de editor moet dezelfde soorten kennen als de server, anders
+  // ziet het scherm album terwijl de server mixtape opslaat.
+  assert.doesNotMatch(mod, /kindVan\.set\(p\.id, p\.kind === 'playlist'/,
+    'de binaire vorm hoort weg te zijn');
+});
+
+test('een post van type mixtape houdt zijn type bij het opslaan', async () => {
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'baas', 'b@t.nl', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s1', 'robo', 'Soundfabrics', 'u1');
+
+  const express = (await import('express')).default;
+  const router = (await import('../src/routes/posts.js')).default;
+  const app = express();
+  app.use(express.urlencoded({ extended: true }));
+  app.use((req, res, next) => {
+    req.session = { user: { id: 'u1', role: 'god', username: 'baas' } };
+    res.locals.site = db.prepare('SELECT * FROM sites WHERE slug = ?').get('robo');
+    res.locals.siteUrlBase = '';
+    next();
+  });
+  app.use('/', router);
+  const server = app.listen(0);
+  server.unref();
+
+  const r = await fetch(`http://127.0.0.1:${server.address().port}/posts/create`, {
+    method: 'POST', redirect: 'manual',
+    headers: { 'content-type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      title: 'De Mixtape', content: '<p>kant A</p>', type: 'mixtape', status: 'published',
+    }).toString(),
+    signal: AbortSignal.timeout(5000),
+  }).catch((e) => assert.fail(`de route antwoordde niet (${e.name})`));
+  assert.ok(r.status === 302 || r.status === 200, 'opslaan mag niet stranden, kreeg ' + r.status);
+
+  const post = db.prepare("SELECT type FROM posts WHERE title = ?").get('De Mixtape');
+  assert.ok(post, 'de post hoort te bestaan');
+  assert.equal(post.type, 'mixtape', 'het type mag niet stil terugvallen op post');
+  server.close();
+});
+
+test('een ingesloten mixtape noemt zich mixtape en geen album', async () => {
+  const AudioEmbedService = (await import('../src/services/AudioEmbedService.js')).default;
+  const pl = {
+    id: 'tape', title: 'De Mixtape', artist: 'robo', kind: 'mixtape',
+    tracks: [{ id: 't1', title: 'Kant A', artist: 'robo', url: '/a.mp3', duration: 100, cover: '' }],
+  };
+  // Door de ECHTE weg: de shortcode in de tekst, uitgeklapt zoals bij het tonen
+  // van een post. De vorige versie van deze test riep een methode aan die niet
+  // bestaat en sloeg zichzelf dan over -- groen zonder iets te meten.
+  const html = AudioEmbedService.embedPlaylistShortcodes('<p>[[playlist:tape]]</p>', (id) => (id === 'tape' ? pl : null));
+  assert.match(html, /📼 Mixtape/, 'een mixtape hoort zichzelf zo te noemen');
+  assert.doesNotMatch(html, /💿 Album/, 'en niet het jasje van een album te dragen');
+});
+
+test('de renderer kiest zijn label niet meer met een tweewegkeuze', () => {
+  const bron = fs.readFileSync('src/services/AudioEmbedService.js', 'utf8');
+  assert.doesNotMatch(bron, /kind === 'playlist' \? '📃 Playlist' : '💿 Album'/,
+    'die vorm gaf een mixtape het jasje van een album');
+  assert.match(bron, /mixtape: '📼 Mixtape'/);
+});
