Index: src/assets/css/ward.css
===================================================================
--- src/assets/css/ward.css	(revision 5412cbbcf42cb02fb3fb6d67ef433290c95bb38c)
+++ src/assets/css/ward.css	(revision 2086ea255ae8ea5a474fc805eda10bd71146c947)
@@ -166,2 +166,28 @@
 .friend .wie { display: flex; flex-direction: column; }
 #friends-cancel { margin-top: 12px; border: 0; background: none; color: var(--dim); font: inherit; }
+
+/* ── De composer ──────────────────────────────────────────────────────── */
+.feed-head { display: flex; align-items: center; gap: 12px; }
+.feed-head h2 { margin: 24px 0 10px; }
+#compose-open { margin-left: auto; border: 0; border-radius: 999px; padding: 8px 16px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; }
+
+dialog#compose-dialog {
+  border: 0; border-radius: 16px; padding: 16px; max-width: 480px; width: calc(100% - 32px);
+  background: var(--card); color: var(--ink);
+}
+dialog#compose-dialog::backdrop { background: rgba(0,0,0,.5); }
+.compose-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
+.compose-head #compose-cancel { border: 0; background: #2a3342; color: var(--ink); border-radius: 10px; padding: 8px 12px; font-size: 1rem; }
+.compose-head #compose-vis { flex: 1; border-radius: 10px; border: 1px solid #2a3342; background: #131a25; color: var(--ink); padding: 8px; font: inherit; }
+.compose-head .primary { border: 0; border-radius: 10px; padding: 8px 16px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; }
+.compose-head .primary:disabled { background: #3a3f4d; color: var(--dim); }
+#compose-text { width: 100%; border-radius: 12px; border: 1px solid #2a3342; background: #131a25; color: var(--ink); padding: 10px; font: inherit; resize: vertical; }
+.compose-media { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+.media-chip { position: relative; display: flex; align-items: center; gap: 6px; background: #131a25; border-radius: 10px; padding: 6px 8px; }
+.media-chip img { width: 56px; height: 56px; object-fit: cover; border-radius: 8px; }
+.media-chip .kind { font-size: 1.2rem; }
+.media-chip .name { font-size: .8rem; color: var(--dim); max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.media-chip .drop { position: absolute; top: -6px; right: -6px; width: 22px; height: 22px; border: 0; border-radius: 50%; background: #000; color: #fff; font-size: .75rem; line-height: 1; }
+.compose-tools { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
+.compose-tools .tool { font-size: 1.4rem; cursor: pointer; padding: 4px 8px; border-radius: 10px; background: #131a25; }
+.compose-tools .hint { margin-left: auto; }
Index: src/assets/js/ward.js
===================================================================
--- src/assets/js/ward.js	(revision 5412cbbcf42cb02fb3fb6d67ef433290c95bb38c)
+++ src/assets/js/ward.js	(revision 2086ea255ae8ea5a474fc805eda10bd71146c947)
@@ -423,5 +423,97 @@
 }
 
-/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. *//** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
+/**
+ * De composer: schrijven met foto, geluid of video.
+ *
+ * De media gaan eerst apart naar /ward/api/upload (dezelfde bak als de app),
+ * en de post draagt daarna alleen de url mee. Zo blijft de post-payload klein
+ * en kan de server elke upload los valideren en verrijken (poster, waveform).
+ */
+function wireComposer() {
+  const dlg = document.getElementById('compose-dialog');
+  const openBtn = document.getElementById('compose-open');
+  const text = document.getElementById('compose-text');
+  const mediaBox = document.getElementById('compose-media');
+  const status = document.getElementById('compose-status');
+  const sendBtn = document.getElementById('compose-send');
+  let media = [];   // { url, mediaType, name }
+  let uploading = false;
+
+  function reset() {
+    text.value = '';
+    media = [];
+    mediaBox.replaceChildren();
+    status.textContent = '';
+    updateSend();
+  }
+  function updateSend() {
+    // Een lege post kan niet: tekst OF media, en nooit tijdens een upload.
+    sendBtn.disabled = (!text.value.trim() && !media.length) || uploading;
+  }
+  function renderMedia() {
+    mediaBox.replaceChildren();
+    media.forEach((m, i) => {
+      const chip = el('div', 'media-chip');
+      if (m.mediaType.startsWith('image/')) {
+        const img = el('img'); img.src = m.url; img.alt = m.name || ''; chip.append(img);
+      } else {
+        chip.append(el('span', 'kind', m.mediaType.startsWith('audio/') ? '🎤' : '🎬'));
+        chip.append(el('span', 'name', m.name || ''));
+      }
+      const x = el('button', 'drop', '✕'); x.type = 'button';
+      x.addEventListener('click', () => { media.splice(i, 1); renderMedia(); updateSend(); });
+      chip.append(x);
+      mediaBox.append(chip);
+    });
+  }
+
+  async function upload(file) {
+    if (media.length >= 4) return;
+    // 32 MB is de servergrens; hier alvast vangen scheelt een mislukte upload.
+    if (file.size > 32 * 1024 * 1024) { status.textContent = state.strings.tooBig; return; }
+    uploading = true; status.textContent = state.strings.uploading; updateSend();
+    try {
+      const fd = new FormData();
+      fd.append('file', file);
+      const r = await fetch('/ward/api/upload', { method: 'POST', body: fd });
+      if (r.ok) { media.push(await r.json()); renderMedia(); status.textContent = ''; }
+      else status.textContent = (await r.json().catch(() => ({}))).error === 'media_type'
+        ? 'Dat bestandstype kan niet.' : 'Uploaden lukte niet.';
+    } catch { status.textContent = 'Uploaden lukte niet.'; }
+    uploading = false; updateSend();
+  }
+
+  openBtn.addEventListener('click', () => { reset(); dlg.showModal(); });
+  document.getElementById('compose-cancel').addEventListener('click', () => dlg.close());
+  text.addEventListener('input', updateSend);
+
+  dlg.querySelectorAll('input[type=file]').forEach((inp) => {
+    inp.addEventListener('change', () => {
+      if (inp.files[0]) upload(inp.files[0]);
+      inp.value = '';   // zelfde bestand opnieuw kunnen kiezen
+    });
+  });
+
+  sendBtn.addEventListener('click', async () => {
+    if (sendBtn.disabled) return;
+    sendBtn.disabled = true;
+    try {
+      const r = await fetch('/ward/api/post', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          text: text.value,
+          media,
+          visibility: document.getElementById('compose-vis').value,
+        }),
+      });
+      if (r.ok) { dlg.close(); loadFeed(); }
+      else status.textContent = 'Plaatsen lukte niet.';
+    } catch { status.textContent = 'Plaatsen lukte niet.'; }
+    updateSend();
+  });
+}
+
+/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
 async function loadChecks() {
   if (!state.guardians.length) return;
@@ -449,4 +541,5 @@
 wireBuoy();
 wireSky();
+wireComposer();
 loadFeed();
 renderOffers();
