Index: src/assets/js/ward.js
===================================================================
--- src/assets/js/ward.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ src/assets/js/ward.js	(revision 4bb93aa4a060a71f9d58edb59da3c88711d16844)
@@ -231,5 +231,126 @@
   const sky = new SkyRenderer(canvas, (i) => {
     const c = convs[i];
-    if (c) window.location.href = `/ward/thread?with=${encodeURIComponent(c.uri)}`;
+    if (c) openThread(c);
+  });
+
+  /* ── Het gesprek: een overlay OVER de levende hemel ────────────────────
+     Geen aparte pagina: de hemel eronder blijft dezelfde draaiende
+     instantie, precies zoals de thread in Shaer over de hemel schuift. */
+  const thread = document.getElementById('thread');
+  const list = document.getElementById('thread-list');
+  let openWith = null;
+  let pollTimer = null;
+
+  async function loadThread() {
+    if (!openWith) return;
+    try {
+      const r = await fetch(`/ward/api/thread?with=${encodeURIComponent(openWith.uri)}`, { headers: { Accept: 'application/json' } });
+      if (!r.ok) return;
+      const { items } = await r.json();
+      list.replaceChildren();
+      // De server geeft nieuwste eerst; een gesprek lees je oudste eerst.
+      [...items].reverse().forEach((m) => {
+        const b = el('div', `bubble ${m.dir}${m.wave ? ' wave' : ''}`);
+        const body = el('div', 'body');
+        body.innerHTML = m.bodyHtml || '';
+        b.append(body, el('span', 'when', m.whenText || ''));
+        list.append(b);
+      });
+      list.scrollTop = list.scrollHeight;
+    } catch { /* volgende poll probeert opnieuw */ }
+  }
+
+  function openThread(c) {
+    openWith = c;
+    document.getElementById('thread-name').textContent = c.name;
+    thread.hidden = false;
+    loadThread();
+    // Pollen zolang het gesprek open staat: eenvoudig en zuinig genoeg.
+    // Een socket kan later; het ritme van een gesprek is geen milliseconde.
+    clearInterval(pollTimer);
+    pollTimer = setInterval(loadThread, 8000);
+  }
+
+  function closeThread() {
+    openWith = null;
+    thread.hidden = true;
+    clearInterval(pollTimer);
+  }
+  document.getElementById('thread-close').addEventListener('click', closeThread);
+  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openWith) closeThread(); });
+
+  document.getElementById('thread-form').addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const input = document.getElementById('thread-text');
+    const text = input.value.trim();
+    if (!text || !openWith) return;
+    input.value = '';
+    await sendMessage(openWith.uri, text, false);
+    loadThread();
+  });
+
+  async function sendMessage(to, text, wave) {
+    try {
+      const r = await fetch('/ward/api/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ to, text, wave }),
+      });
+      return r.ok;
+    } catch { return false; }
+  }
+
+  /* ── De zwaai-hand (§5) ────────────────────────────────────────────────
+     Dezelfde hoek als de boei, en ze wisselen elkaar af met de stand: met de
+     kaarten verdwijnt de boei, op de hemel hangt de hand. Slepen op een
+     gezicht opent het paneel; de tekst die je kiest IS het bericht. */
+  const hand = document.getElementById('hand');
+  const waveDlg = document.getElementById('wave-dialog');
+  let waveTarget = null;
+
+  (() => {
+    let dragging = false, dx = 0, dy = 0;
+    hand.addEventListener('pointerdown', (e) => {
+      dragging = true;
+      hand.setPointerCapture(e.pointerId);
+      const r = hand.getBoundingClientRect();
+      dx = e.clientX - r.left; dy = e.clientY - r.top;
+      hand.classList.add('dragging');
+    });
+    hand.addEventListener('pointermove', (e) => {
+      if (!dragging) return;
+      hand.style.left = `${e.clientX - dx}px`;
+      hand.style.top = `${e.clientY - dy}px`;
+      hand.style.right = 'auto'; hand.style.bottom = 'auto';
+    });
+    const drop = (e) => {
+      if (!dragging) return;
+      dragging = false;
+      hand.classList.remove('dragging');
+      hand.style.left = hand.style.top = 'auto';
+      hand.style.right = ''; hand.style.bottom = '';
+      // Zelfde rekensom als een tik op de hemel: het dichtstbijzijnde gezicht
+      // binnen bereik wint, en ernaast is bewust niets.
+      const r = canvas.getBoundingClientRect();
+      const hit = sky.pick(e.clientX - r.left, e.clientY - r.top);
+      if (hit != null && convs[hit]) {
+        waveTarget = convs[hit];
+        document.getElementById('wave-title').textContent =
+          state.strings.waveTo.replace('{who}', waveTarget.name);
+        waveDlg.showModal();
+      }
+    };
+    hand.addEventListener('pointerup', drop);
+    hand.addEventListener('pointercancel', drop);
+  })();
+
+  waveDlg.addEventListener('click', async (e) => {
+    const btn = e.target.closest('button');
+    if (!btn) return;
+    waveDlg.close();
+    if (btn.id === 'wave-cancel' || !waveTarget) return;
+    const isWave = btn.dataset.wave === '1';
+    const text = isWave ? '👋' : btn.dataset.text;
+    if (await sendMessage(waveTarget.uri, text, isWave)) openThread(waveTarget);
   });
 
@@ -244,4 +365,7 @@
     canvas.style.pointerEvents = on ? 'auto' : 'none';
     document.body.classList.toggle('messages-mode', on);
+    document.getElementById('buoy').hidden = on;
+    hand.hidden = !on;
+    if (!on) closeThread();
     btn.textContent = on ? state.strings.toCards : state.strings.toSky;
     btn.setAttribute('aria-pressed', String(on));
