Index: src/assets/css/ward.css
===================================================================
--- src/assets/css/ward.css	(revision a5a62490bec7673afe4e4c5c788f48466eb96b8b)
+++ src/assets/css/ward.css	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
@@ -86,2 +86,16 @@
   overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
 }
+
+/* ── De hemel ─────────────────────────────────────────────────────────── */
+/* Ligt achter alles en draait altijd. In kaartenstand is hij decor; in
+   berichtenstand vallen de kaarten weg en is hij de interface. */
+#sky { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; }
+#app { position: relative; z-index: 1; }
+.topbar { display: flex; align-items: center; gap: 12px; }
+.topbar h1 { margin: 8px 0 20px; }
+.topbar #mode {
+  margin-left: auto; border: 0; border-radius: 999px; padding: 8px 16px;
+  background: #2a3342; color: var(--ink); font: inherit;
+}
+/* De kaarten vallen weg, de hemel blijft: dezelfde beweging als in de app. */
+body.messages-mode .help, body.messages-mode .cards { display: none; }
Index: src/assets/js/ward-sky.js
===================================================================
--- src/assets/js/ward-sky.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
+++ src/assets/js/ward-sky.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
@@ -0,0 +1,262 @@
+/**
+ * De avatarhemel: het model en de tekenlaag, met een naad ertussen.
+ *
+ * DE NAAD IS HET PUNT. `orbitPlacement` en `nearestFace` zijn pure wiskunde en
+ * bestaan al in Swift en Kotlin; dit is de derde schrijving en dus de derde
+ * kans op drift. Daarom staan ze hier apart, met dezelfde tests als daar, en
+ * raakt de renderer ze niet aan. Blijkt Canvas 2D ooit te krap bij duizenden
+ * gezichten, dan vervangt een WebGL-variant alleen `SkyRenderer` en merkt de
+ * rest er niets van.
+ *
+ * Waarom Canvas en (nog) geen engine: de rem is niet het aantal sprites maar
+ * het aantal texturen. Alle avatars gaan één keer in een atlas (één offscreen
+ * canvas), en daarna is elk gezicht een `drawImage` uit datzelfde plaatje.
+ */
+'use strict';
+
+const GOLDEN = Math.PI * (3 - Math.sqrt(5));
+
+/**
+ * Waar elk gezicht hangt, in eenheden van één avatarcel, met de oorsprong in
+ * het midden. Hoe verder van het midden, hoe langer geleden je iets van
+ * diegene hoorde: log-geschaald, want het verschil tussen een uur en een dag
+ * telt zwaarder dan tussen een jaar en een jaar-en-een-dag.
+ *
+ * Letterlijke port van de Kotlin- en Swift-versie, inclusief de push-apart.
+ */
+export function orbitPlacement(agesMs) {
+  const n = agesMs.length;
+  if (!n) return [];
+  const logs = agesMs.map((a) => Math.log(1 + Math.max(0, a) / 3600000));
+  const lo = Math.min(...logs);
+  const hi = Math.max(...logs);
+  const norm = logs.map((l) => (hi > lo ? (l - lo) / (hi - lo) : 0.5));
+  // De ruimte groeit met de drukte, zodat de push-apart convergeert in plaats
+  // van tegen zichzelf te duwen.
+  const spread = Math.max(3, Math.sqrt(n) * 1.1);
+  // De hoek volgt de RANG in tijd, zodat mensen die kort na elkaar schreven
+  // niet op een hoop komen.
+  const rank = new Map(
+    agesMs.map((a, i) => [i, a]).sort((p, q) => p[1] - q[1]).map(([i], r) => [i, r]),
+  );
+  const xs = new Float64Array(n);
+  const ys = new Float64Array(n);
+  for (let i = 0; i < n; i++) {
+    const r = 1.4 + norm[i] * spread;
+    const a = (rank.get(i) ?? i) * GOLDEN;
+    xs[i] = r * Math.cos(a);
+    ys[i] = r * Math.sin(a);
+  }
+  const minD = 1.15;
+  for (let pass = 0; pass < 120; pass++) {
+    let moved = false;
+    for (let i = 0; i < n; i++) {
+      for (let j = i + 1; j < n; j++) {
+        const dx = xs[j] - xs[i];
+        const dy = ys[j] - ys[i];
+        const d = Math.sqrt(dx * dx + dy * dy);
+        if (d < minD) {
+          moved = true;
+          const push = (minD - d) / 2 + 1e-3;
+          // Precies samenvallende plekken splitsen langs hun eigen indexhoek.
+          const ux = d > 1e-6 ? dx / d : Math.cos(i * GOLDEN);
+          const uy = d > 1e-6 ? dy / d : Math.sin(i * GOLDEN);
+          xs[i] -= ux * push; ys[i] -= uy * push;
+          xs[j] += ux * push; ys[j] += uy * push;
+        }
+      }
+    }
+    if (!moved) break;
+  }
+  return Array.from({ length: n }, (_, i) => ({ x: xs[i], y: ys[i] }));
+}
+
+/**
+ * Het gezicht dat het dichtst bij een punt ligt, of null als je ernaast mikte.
+ *
+ * Dezelfde rekensom als de plaatsing, ZONDER de ademhaling: die beweegt hooguit
+ * een paar pixels en zou de treffer alleen maar grillig maken.
+ */
+export function nearestFace(px, py, spots, { cx, cy, cell, scale, panX, panY }) {
+  const reach = (cell / 2) * Math.min(Math.max(scale, 0.5), 1.6);
+  let best = -1;
+  let bestD = Infinity;
+  for (let i = 0; i < spots.length; i++) {
+    const x = cx + panX + spots[i].x * cell * scale;
+    const y = cy + panY + spots[i].y * cell * scale;
+    const d = Math.hypot(px - x, py - y);
+    if (d <= reach && d < bestD) { best = i; bestD = d; }
+  }
+  return best < 0 ? null : best;
+}
+
+/* ── De tekenlaag ─────────────────────────────────────────────────────────
+   Alles hieronder raakt de DOM en is dus vervangbaar; alles hierboven niet. */
+
+const CELL = 92;          // één avatarcel, zelfde maat als in de apps
+const FACE = 44;          // de tekening zelf
+const ATLAS_PAD = 4;
+
+/** Eén canvas met alle avatars naast elkaar, zodat tekenen één drawImage is. */
+async function buildAtlas(faces) {
+  const size = FACE * 2;                       // retina-scherp
+  const cols = Math.max(1, Math.ceil(Math.sqrt(faces.length || 1)));
+  const rows = Math.max(1, Math.ceil((faces.length || 1) / cols));
+  const atlas = document.createElement('canvas');
+  atlas.width = cols * (size + ATLAS_PAD);
+  atlas.height = rows * (size + ATLAS_PAD);
+  const g = atlas.getContext('2d');
+
+  await Promise.all(faces.map((f, i) => new Promise((done) => {
+    const col = i % cols;
+    const row = Math.floor(i / cols);
+    const x = col * (size + ATLAS_PAD);
+    const y = row * (size + ATLAS_PAD);
+    f.atlas = { x, y, size };
+
+    const drawInitial = () => {
+      // Geen plaatje? Dan de eerste letter op een gekleurde schijf, zoals de
+      // apps ook doen: een gezicht zonder foto is nog steeds een persoon.
+      g.save();
+      g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip();
+      g.fillStyle = '#5a32e6'; g.fillRect(x, y, size, size);
+      g.fillStyle = '#fff';
+      g.font = `600 ${size * 0.45}px system-ui, sans-serif`;
+      g.textAlign = 'center'; g.textBaseline = 'middle';
+      g.fillText((f.name || '?').trim().charAt(0).toUpperCase(), x + size / 2, y + size / 2);
+      g.restore();
+      done();
+    };
+
+    if (!f.icon) return drawInitial();
+    const img = new Image();
+    img.crossOrigin = 'anonymous';
+    img.onload = () => {
+      g.save();
+      g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip();
+      g.drawImage(img, x, y, size, size);
+      g.restore();
+      done();
+    };
+    // Een avatar van een andere server kan weigeren; dan de letter, niet niets.
+    img.onerror = drawInitial;
+    img.src = f.icon;
+  })));
+
+  return atlas;
+}
+
+export class SkyRenderer {
+  /**
+   * @param {HTMLCanvasElement} canvas
+   * @param {(index:number)=>void} onPick  een gezicht aangetikt
+   */
+  constructor(canvas, onPick) {
+    this.canvas = canvas;
+    this.g = canvas.getContext('2d');
+    this.onPick = onPick;
+    this.faces = [];
+    this.spots = [];
+    this.scale = 1;
+    this.panX = 0;
+    this.panY = 0;
+    this.running = false;
+    this.interactive = false;
+    this._wire();
+  }
+
+  /** @param {{name:string, icon:string|null, ageMs:number}[]} faces */
+  async setFaces(faces) {
+    this.faces = faces;
+    this.spots = orbitPlacement(faces.map((f) => f.ageMs));
+    this.atlas = await buildAtlas(faces);
+    this._resize();
+  }
+
+  start() { if (!this.running) { this.running = true; this._loop(); } }
+  stop() { this.running = false; }
+
+  _resize() {
+    const dpr = Math.min(window.devicePixelRatio || 1, 2);
+    const r = this.canvas.getBoundingClientRect();
+    this.canvas.width = Math.max(1, r.width * dpr);
+    this.canvas.height = Math.max(1, r.height * dpr);
+    this.g.setTransform(dpr, 0, 0, dpr, 0, 0);
+    this.w = r.width; this.h = r.height;
+  }
+
+  _loop() {
+    if (!this.running) return;
+    this._draw(performance.now());
+    requestAnimationFrame(() => this._loop());
+  }
+
+  _draw(t) {
+    const { g } = this;
+    g.clearRect(0, 0, this.w, this.h);
+    if (!this.atlas) return;
+    const cx = this.w / 2;
+    const cy = this.h / 2;
+    // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in
+    // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec).
+    const phase = ((t / 9000) % 1) * Math.PI * 2;
+    const s = Math.min(Math.max(this.scale, 0.5), 1.6);
+    const draw = FACE * s;
+
+    for (let i = 0; i < this.spots.length; i++) {
+      const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 };
+      const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale;
+      const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale;
+      // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de
+      // enige die bij duizenden gezichten echt telt.
+      if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue;
+      const a = this.faces[i].atlas;
+      g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw);
+    }
+  }
+
+  _wire() {
+    const c = this.canvas;
+    let dragging = false, lastX = 0, lastY = 0, moved = 0;
+    let pinch = null;
+
+    c.addEventListener('pointerdown', (e) => {
+      if (!this.interactive) return;
+      dragging = true; moved = 0;
+      lastX = e.clientX; lastY = e.clientY;
+      c.setPointerCapture(e.pointerId);
+    });
+    c.addEventListener('pointermove', (e) => {
+      if (!this.interactive || !dragging) return;
+      const dx = e.clientX - lastX, dy = e.clientY - lastY;
+      moved += Math.abs(dx) + Math.abs(dy);
+      this.panX += dx; this.panY += dy;
+      lastX = e.clientX; lastY = e.clientY;
+    });
+    const end = (e) => {
+      if (!this.interactive || !dragging) return;
+      dragging = false;
+      // Nauwelijks bewogen? Dan was het een tik en geen sleep.
+      if (moved < 6) {
+        const r = c.getBoundingClientRect();
+        const hit = nearestFace(e.clientX - r.left, e.clientY - r.top, this.spots, {
+          cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
+          panX: this.panX, panY: this.panY,
+        });
+        if (hit != null) this.onPick?.(hit);
+      }
+    };
+    c.addEventListener('pointerup', end);
+    c.addEventListener('pointercancel', () => { dragging = false; });
+
+    // Knijpen op een telefoon, wiel op een desktop; allebei zoomen om het
+    // midden, zodat de hemel zich als een kaart gedraagt.
+    c.addEventListener('wheel', (e) => {
+      if (!this.interactive) return;
+      e.preventDefault();
+      this.scale = Math.min(Math.max(this.scale * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2.5);
+    }, { passive: false });
+
+    window.addEventListener('resize', () => this._resize());
+  }
+}
Index: src/assets/js/ward.js
===================================================================
--- src/assets/js/ward.js	(revision a5a62490bec7673afe4e4c5c788f48466eb96b8b)
+++ src/assets/js/ward.js	(revision 084b046d930ee79f779d73d05b08e90d00fd684f)
@@ -7,5 +7,5 @@
  * pagina en dit bestand tekent hem. Alles wat nieuw is komt van /ward/api/*.
  */
-'use strict';
+import { SkyRenderer } from '/ward/sky.js';
 
 const state = JSON.parse(document.getElementById('ward-state').textContent);
@@ -215,5 +215,53 @@
 }
 
-/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
+/**
+ * De hemel en de kaarten zijn één scherm met twee standen.
+ *
+ * De hemel draait ALTIJD en ligt achter de kaarten; in kaartenstand is hij
+ * decor en vangt hij geen tikken, in berichtenstand vallen de kaarten weg en
+ * wordt hij de interface. Dat is precies de beweging uit Shaer: de kaarten
+ * vallen weg en wat eronder lag was er al.
+ */
+function wireSky() {
+  const canvas = document.getElementById('sky');
+  const btn = document.getElementById('mode');
+  const main = document.getElementById('app');
+  let convs = [];
+
+  const sky = new SkyRenderer(canvas, (i) => {
+    const c = convs[i];
+    if (c) window.location.href = `/ward/thread?with=${encodeURIComponent(c.uri)}`;
+  });
+
+  let messagesMode = false;
+  const setMode = (on) => {
+    messagesMode = on;
+    sky.interactive = on;
+    canvas.setAttribute('aria-hidden', String(!on));
+    // touch-action pas uitzetten als de hemel echt bedienbaar is: anders kun
+    // je in kaartenstand niet meer scrollen.
+    canvas.style.touchAction = on ? 'none' : '';
+    canvas.style.pointerEvents = on ? 'auto' : 'none';
+    document.body.classList.toggle('messages-mode', on);
+    btn.textContent = on ? state.strings.toCards : state.strings.toSky;
+    btn.setAttribute('aria-pressed', String(on));
+    main.querySelectorAll('section').forEach((sec) => { sec.hidden = on; });
+  };
+
+  btn.addEventListener('click', () => setMode(!messagesMode));
+  setMode(false);
+
+  (async () => {
+    try {
+      const r = await fetch('/ward/api/conversations', { headers: { Accept: 'application/json' } });
+      if (!r.ok) return;
+      convs = (await r.json()).items || [];
+      await sky.setFaces(convs.map((c) => ({ name: c.name, icon: c.icon, ageMs: c.ageMs })));
+      sky.start();
+    } catch { /* zonder hemel blijft het scherm gewoon werken */ }
+  })();
+}
+
+/** 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. */
 async function loadChecks() {
   if (!state.guardians.length) return;
@@ -240,4 +288,5 @@
 renderGuardians(null);
 wireBuoy();
+wireSky();
 loadFeed();
 renderOffers();
