Index: src/assets/css/ward.css
===================================================================
--- src/assets/css/ward.css	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ src/assets/css/ward.css	(revision 52188632909b3b1b9120748844eed2779f7fc030)
@@ -100,2 +100,47 @@
 /* De kaarten vallen weg, de hemel blijft: dezelfde beweging als in de app. */
 body.messages-mode .help, body.messages-mode .cards { display: none; }
+
+/* ── De zwaai-hand ────────────────────────────────────────────────────── */
+/* Zelfde hoek als de boei; ze wisselen elkaar af met de stand. */
+#hand {
+  position: fixed; right: 18px; bottom: calc(18px + env(safe-area-inset-bottom));
+  width: 76px; height: 76px; border: 0; border-radius: 50%;
+  background: transparent; font-size: 52px; line-height: 1;
+  touch-action: none; cursor: grab; z-index: 50;
+}
+#hand.dragging { cursor: grabbing; transform: scale(1.1); }
+
+/* ── Het gesprek over de hemel ────────────────────────────────────────── */
+#thread {
+  position: fixed; inset: 0; z-index: 40;
+  display: flex; flex-direction: column;
+  /* Doorschijnend: de hemel eronder blijft zichtbaar draaien, net als de
+     glazen bubbels in de app. */
+  background: rgba(20, 26, 36, .72); backdrop-filter: blur(14px);
+}
+#thread header { display: flex; align-items: center; gap: 10px; padding: 12px 16px; }
+#thread header h2 { margin: 0; font-size: 1.05rem; }
+#thread-close { border: 0; background: #2a3342; color: var(--ink); border-radius: 10px; padding: 8px 12px; font-size: 1rem; }
+#thread-list { flex: 1; overflow-y: auto; padding: 8px 16px; display: flex; flex-direction: column; gap: 8px; }
+.bubble { max-width: 78%; padding: 10px 12px; border-radius: 14px; background: var(--card); }
+.bubble.out { align-self: flex-end; background: var(--accent); }
+.bubble.wave { font-size: 1.4rem; }
+.bubble .when { display: block; margin-top: 4px; font-size: .7rem; color: rgba(255,255,255,.55); }
+.bubble .body img { max-width: 100%; border-radius: 8px; }
+#thread-form { display: flex; gap: 8px; padding: 10px 16px calc(14px + env(safe-area-inset-bottom)); }
+#thread-form input {
+  flex: 1; border-radius: 999px; border: 1px solid #2a3342;
+  background: #131a25; color: var(--ink); padding: 10px 16px; font: inherit;
+}
+#thread-form button { border: 0; border-radius: 999px; padding: 10px 18px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; }
+
+/* ── Het zwaai-paneel ─────────────────────────────────────────────────── */
+dialog#wave-dialog {
+  border: 0; border-radius: 16px; padding: 18px; max-width: 320px; width: calc(100% - 48px);
+  background: var(--card); color: var(--ink); text-align: center;
+}
+dialog#wave-dialog::backdrop { background: rgba(0,0,0,.4); }
+dialog#wave-dialog h2 { margin: 0 0 12px; font-size: 1.05rem; }
+.wave-answers { display: grid; gap: 8px; }
+.wave-answers button { border: 0; border-radius: 10px; padding: 12px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; }
+#wave-cancel { margin-top: 10px; border: 0; background: none; color: var(--dim); font: inherit; }
Index: src/assets/js/ward-sky.js
===================================================================
--- src/assets/js/ward-sky.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ src/assets/js/ward-sky.js	(revision 52188632909b3b1b9120748844eed2779f7fc030)
@@ -172,4 +172,12 @@
     this.atlas = await buildAtlas(faces);
     this._resize();
+  }
+
+  /** Publiek mikken, voor de zwaai-hand: zelfde rekensom als een tik. */
+  pick(px, py) {
+    return nearestFace(px, py, this.spots, {
+      cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
+      panX: this.panX, panY: this.panY,
+    });
   }
 
Index: src/assets/js/ward.js
===================================================================
--- src/assets/js/ward.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ src/assets/js/ward.js	(revision 52188632909b3b1b9120748844eed2779f7fc030)
@@ -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));
