Index: src/assets/js/mod/post-edit.js
===================================================================
--- src/assets/js/mod/post-edit.js	(revision d7e72b84b3578aaadd6519dbe240f683eec33591)
+++ src/assets/js/mod/post-edit.js	(revision b406d25e8f0b0b49e6c45df1a3281e46425ca139)
@@ -6,4 +6,7 @@
 
 import { pageData, esc, makeSweeper } from './lib.js';
+// 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';
 
 // De oude inline scripts draaiden bij ELKE render; een module draait zijn
@@ -791,5 +794,11 @@
     }
     updateCharCount();
-  }
+    volgMuziek();
+  }
+
+  // Wordt hieronder gevuld door het typeblok. Zolang dat er niet is (of de
+  // gebruiker het type zelf koos) gebeurt er niets -- insertChip mag daar niet
+  // op stuklopen.
+  let volgMuziek = () => {};
 
   // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
@@ -972,4 +981,5 @@
     const seg = card.querySelector('.pe-typeseg');
     const panels = card.querySelectorAll('.pe-type-panel');
+    let handmatig = false;   // heeft de gebruiker het type zelf aangeklikt?
 
     function applyType(tt) {
@@ -989,7 +999,66 @@
     seg.addEventListener('click', (e) => {
       const btn = e.target.closest('.pe-typeseg-btn');
-      if (btn) applyType(btn.dataset.type);
+      if (!btn) return;
+      handmatig = true;          // jouw klik wint vanaf nu van de afleiding
+      applyType(btn.dataset.type);
     });
     applyType(typeInput.value || 'post');
+
+    // ── Het type volgt de muziek (shaer-cyg) ──────────────────────
+    //
+    // Robins regel: de post neemt de soort van zijn muziek over als hij precies
+    // EEN muzikale eenheid bevat. Dus zodra je een playlist invoegt verspringt
+    // de balk mee, en zie je wat je aan het maken bent in plaats van het zelf te
+    // moeten bijhouden.
+    //
+    // TWEE DINGEN DIE HIJ MET RUST LAAT. Klik je zelf een type aan, dan wint die
+    // keuze -- daarna wordt er niets meer voor je omgezet. En Foto en Video zijn
+    // een bewuste andere keuze over dezelfde post, dus die overschrijft hij
+    // nooit, ook niet als er muziek in staat.
+    //
+    // Bij het OPENEN van een post gebeurt er niets: de eerste afleiding wordt
+    // 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 kindVan = new Map();
+    let vorigeAfleiding = null;
+
+    const soortenGeladen = fetch('/admin/playlists/api/list', { credentials: 'same-origin' })
+      .then((r) => r.json())
+      .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');
+        }
+      })
+      // Zonder de soorten leidt de regel niets af (elke playlist is dan
+      // 'onbekend'), en dat is beter dan een gok die als keuze oogt.
+      .catch(() => {})
+      .then(() => { vorigeAfleiding = afleidenNu(); });
+
+    function afleidenNu() {
+      // Precies de tekst die straks wordt opgeslagen: chips terug naar
+      // shortcodes. Zo leidt de editor af uit wat de server ook zal zien.
+      const clone = editor.cloneNode(true);
+      serializeChips(clone);
+      const r = afleidenUitInsluitingen(clone.innerHTML, (id) => kindVan.get(id) || null);
+      return r ? r.type : null;
+    }
+
+    volgMuziek = async () => {
+      await soortenGeladen;
+      const nu = afleidenNu();
+      if (nu === vorigeAfleiding) return;    // de muziek is niet van soort veranderd
+      vorigeAfleiding = nu;
+      if (handmatig || !nu) return;
+      if (!VOLGBAAR.has(typeInput.value)) return;
+      applyType(nu);
+    };
+
+    // Ook getypte of geplakte shortcodes tellen, niet alleen de knoppen.
+    let tik = null;
+    editor.addEventListener('input', () => {
+      clearTimeout(tik);
+      tik = setTimeout(volgMuziek, 300);
+    });
 
     // Video URL → [[embed:url]] chip
Index: src/assets/js/shared/post-music-type.js
===================================================================
--- src/assets/js/shared/post-music-type.js	(revision b406d25e8f0b0b49e6c45df1a3281e46425ca139)
+++ src/assets/js/shared/post-music-type.js	(revision b406d25e8f0b0b49e6c45df1a3281e46425ca139)
@@ -0,0 +1,101 @@
+/**
+ * De regel: welk soort uitgave is een post? (shaer-cyg)
+ *
+ * WAAROM DIT ONDER assets/ STAAT. De regel wordt op twee plaatsen gebruikt: de
+ * server leidt er het type van bestaande posts mee af, en de editor laat het
+ * type er live door volgen als je muziek invoegt. Dat MOET dezelfde regel zijn.
+ * Twee kopieën zouden niet luidruchtig kapotgaan maar stil uit elkaar lopen --
+ * de editor zegt album, de server zegt playlist, en niemand die het merkt. De
+ * browser kan alleen bij /assets, de server kan overal bij; dus staat hij hier,
+ * en importeert de server hem vanuit services/music.
+ *
+ * Deze module is bewust PUUR: geen database, geen DOM, geen fetch. Wie hem
+ * gebruikt levert zelf een opzoeker voor de soort van een playlist.
+ *
+ * DE REGEL GAAT OVER IDENTITEIT, NIET OVER TELLEN (Robins herformulering, 9-8):
+ * een post neemt het type van zijn muziek over als hij precies EEN muzikale
+ * eenheid bevat. Zijn het er meer, dan is de post een post die naar muziek
+ * verwijst, en houden de collecties hun eigen identiteit.
+ *
+ *   losse track(s)                -> playlist   metadata van de post geleend
+ *   een collectie                 -> die soort  metadata van de post geleend
+ *   een collectie + losse tracks  -> album      de losse zijn BONUS-TRACKS
+ *   twee of meer collecties       -> post       NIETS geleend
+ *
+ * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
+ * dun is -- geen eigen hoes, geen eigen titel. Bij twee is de post niet meer de
+ * drager van EEN identiteit, en vervalt de reden vanzelf.
+ *
+ * WIE KIEST ALBUM OF PLAYLIST: dat gebeurt wanneer de PLAYLIST wordt gemaakt.
+ * Die keuze staat in playlists.kind en wordt hier OPGEZOCHT, niet afgeleid --
+ * een post om een album is een album, ook al heet de shortcode [[playlist:...]].
+ *
+ * EN ALS WE HET NIET WETEN: een gewone post met insluitingen die de post als
+ * context hebben. Geen noodgreep maar de rustende toestand; tracks wijzen met
+ * `context` toch al terug naar hun post, dus er gaat niets verloren.
+ */
+
+// Dezelfde patronen als de renderer in AudioEmbedService: wat daar niet
+// insluit, telt hier niet mee. Anders zou een shortcode die niets oplevert wel
+// het type van de post kunnen bepalen.
+const RE_PLAYLIST = /\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi;
+const RE_ALBUM    = /\[\[album:([^\]]+)\]\]/g;
+const RE_TRACK    = /\[\[track:([A-Za-z0-9_-]+)\]\]/g;
+
+/**
+ * @param {string} content    de HTML/tekst van de post
+ * @param {(id: string) => ('album'|'playlist'|null)} kindVan
+ *        de gekozen soort van een playlist, of null als hij onbekend is
+ * @returns {null|{type, collectie?, collecties?, tracks, bonus, onbekend?, leentMetadata}}
+ */
+export function afleidenUitInsluitingen(content, kindVan) {
+  const c = String(content || '');
+  const zoek = typeof kindVan === 'function' ? kindVan : () => null;
+  const uniek = (re) => [...new Set([...c.matchAll(re)].map((m) => m[1].trim()))];
+
+  const playlists = uniek(RE_PLAYLIST);
+  const albums    = uniek(RE_ALBUM);
+  const tracks    = uniek(RE_TRACK);
+
+  if (!playlists.length && !albums.length && !tracks.length) return null;
+
+  // De soort van een playlist is een gegeven, geen afleiding.
+  const onbekend = [];
+  const uitPlaylists = playlists.map((id) => {
+    const kind = zoek(id);
+    if (kind !== 'album' && kind !== 'playlist') { onbekend.push(id); return null; }
+    return { soort: kind, id };
+  }).filter(Boolean);
+
+  const collecties = [
+    ...uitPlaylists,
+    ...albums.map((naam) => ({ soort: 'album', naam })),
+  ];
+
+  // Onbekende situatie -> gewone post. De insluitingen blijven staan en houden
+  // de post als context; alleen het label wordt niet verzonnen.
+  if (onbekend.length) {
+    return { type: 'post', collecties, tracks, bonus: [], onbekend, leentMetadata: false };
+  }
+
+  if (!collecties.length) {
+    // Ook EEN losse track wordt een playlist: naar buiten toe is er dan altijd
+    // een collectie om naar te wijzen. Hoe Klonkt dat toont is een aparte vraag.
+    return { type: 'playlist', collectie: null, tracks, bonus: [], leentMetadata: true };
+  }
+
+  if (collecties.length === 1) {
+    const c0 = collecties[0];
+    // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
+    // en dat maakt het geheel een album.
+    if (tracks.length) return { type: 'album', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
+    return { type: c0.soort, collectie: c0, tracks: [], bonus: [], leentMetadata: true };
+  }
+
+  return { type: 'post', collecties, tracks, bonus: [], leentMetadata: false };
+}
+
+/** De playlist-ids die in een tekst worden ingesloten. */
+export function ingeslotenPlaylists(content) {
+  return [...new Set([...String(content || '').matchAll(RE_PLAYLIST)].map((m) => m[1].trim()))];
+}
