← RETURN TO HALL

Under the house

The Cellar

Every house has one room where the beams are left showing. This is that room. Below the hall and the archive sit the five lamps that light everything above them, the six colours they burn in, and the rules that decide what never gets built. Nothing here is for sale. It is only how the house stands up.

Five lamps

I

Raking Light

Move the pointer across the plate

A conservator walks a lamp at a grazing angle across a surface, and the relief no one could see comes up out of it — tool marks, seams, a thumbprint left in the clay. Here the lamp is your pointer. The photograph is treated as a height map, the light sits low above it, and only genuine relief answers; a flat area stays exactly the photograph it was. Move the lamp away and the plate returns to itself, byte for byte.

The workings 638 lines
<script lang="ts">
  /**
   * Raking Light — "examining the specimen under a conservator's grazing lamp".
   *
   * A museum technique: the restorer walks a light source at a shallow,
   * grazing angle across a surface, and the relief of the object emerges in
   * light and shadow — tool marks, seams, fingerprints, the topography paint
   * normally hides. Here the lamp is the pointer: a low torch whose planar
   * position tracks the cursor, raking a luminous pool across the plate while
   * the rest of the photograph stays its plain, untouched self.
   *
   * How the relief is recovered (the demonstrable part):
   *  - We treat a grayscale field as a height map h(uv). Surface normals come
   *    from its gradient by central differences: N = normalize(-∂h, +∂h, 1/k).
   *  - The lamp is a point light a short height `uGrazing` above the surface at
   *    the cursor's planar position. Low height ⇒ near-horizontal incidence ⇒
   *    long shadows off every slope — the defining property of raking light.
   *  - Shading is Lambertian diffuse minus the *flat-surface* response, so a
   *    perfectly flat region keeps the photo unchanged and only genuine relief
   *    (deviation of N from straight-up) brightens or darkens. A tight grazing
   *    specular adds the characteristic glint that catches raised edges.
   *  - Directly under the lamp the incidence is steep (light ≈ overhead) so
   *    relief flattens there, exactly as a real handheld lamp behaves; the
   *    revealing happens in the raked ring around the hotspot.
   *
   * Height source, in order of preference:
   *  1. `heightSrc` — a precomputed depth/height map (the same grayscale media
   *     variant the Living Daguerreotype uses). The correct, albedo-free path.
   *  2. fallback — luminance of the colour image, lightly blurred in-shader.
   *     Lets the whole archive participate today; note it conflates dark paint
   *     with depressions, so the depth map is always preferred when present.
   *
   * Design contract (it must never read as a "product 3D viewer"):
   *  - at rest (pointer away) the canvas output is byte-for-byte the original
   *    photograph — the effect eases to zero, nothing lingers;
   *  - no chrome of its own; it fills the stage exactly like the <img> it
   *    replaces, so the mat / vignette / grain overlays stay as siblings;
   *  - graceful by construction: a plain <img> sits underneath for SSR, the
   *    card→detail view-transition, reduced-motion and WebGL-less browsers.
   *
   * Lifecycle mirrors LivingDaguerreotype: the GL context, program and geometry
   * are built ONCE in onMount and persist; switching the gallery image reloads
   * only the textures (via the $effect on src/heightSrc) — no recompile, no
   * context-loss churn while paging ←/→.
   */
  import { onMount } from 'svelte';

  let {
    src,
    heightSrc = null,
    alt = '',
    intensity = 0.6,
    class: className = '',
    onActivate,
  }: {
    src?: string | null;
    /** Precomputed grayscale depth/height map; falls back to colour luminance. */
    heightSrc?: string | null;
    alt?: string;
    /** 0..1 — how strongly relief is exaggerated (scales the normal slope). */
    intensity?: number;
    class?: string;
    onActivate?: () => void;
  } = $props();

  // Height of the torch above the surface plane. Small ⇒ grazing incidence.
  const GRAZING = 0.2;
  const EASE = 0.12;          // pointer → eased lamp position
  const ACT_EASE = 0.08;      // effect engage / release

  let host = $state<HTMLDivElement>();
  let canvas = $state<HTMLCanvasElement>();
  let baseImg = $state<HTMLImageElement>(); // visible <img>; reused as the GL texture source
  let imageFailed = $state(false);
  let glReady = $state(false); // canvas takes over from the base <img> only once it has drawn

  const reducedMotion =
    typeof window !== 'undefined' &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ── Persistent GL state (built once, reused across image switches) ──────────
  let gl: WebGLRenderingContext | null = null;
  let uColor: WebGLUniformLocation | null = null;
  let uHeight: WebGLUniformLocation | null = null;
  let uHasHeight: WebGLUniformLocation | null = null;
  let uImageAspect: WebGLUniformLocation | null = null;
  let uCanvasAspect: WebGLUniformLocation | null = null;
  let uMouse: WebGLUniformLocation | null = null;
  let uActivation: WebGLUniformLocation | null = null;
  let uTexel: WebGLUniformLocation | null = null;
  let uRelief: WebGLUniformLocation | null = null;
  let uGrazing: WebGLUniformLocation | null = null;

  let colorTex: WebGLTexture | null = null;
  let heightTex: WebGLTexture | null = null;
  let hasHeight = 0;
  let imageAspect = 1;
  let texelX = 1 / 1024, texelY = 1 / 1024; // updated from the loaded image

  // pointer → eased lamp position; activation eases the effect in/out
  let targetX = 0, targetY = 0, curX = 0, curY = 0;
  let targetAct = 0, curAct = 0;
  let pointerInside = false;
  let visible = true;
  let running = false;
  let raf = 0;
  let hostRect: DOMRect | null = null;
  let hostRectDirty = true;

  let destroyed = false;
  let initialized = false; // GL context + program ready
  let loadedKey = '';      // de-dupes the (src|heightSrc) currently loaded/loading
  let loadSeq = 0;         // supersedes in-flight loads when the image changes fast
  let isPointerFine = $state(true);

  function handleActivate() {
    if (isPointerFine) onActivate?.();
  }

  function updateHostRect() {
    hostRect = host?.getBoundingClientRect() ?? null;
    hostRectDirty = false;
  }
  function markHostRectDirty() {
    hostRectDirty = true;
  }
  function stopAnimation() {
    running = false;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
  }

  function reliefStrength() {
    // grad samples are in 0..1; this maps intensity → a slope multiplier that
    // reads as tactile relief without tipping into a plastic, embossed look.
    return 8 + Math.max(0, Math.min(1, intensity)) * 28;
  }

  function makeTexture(): WebGLTexture | null {
    if (!gl) return null;
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    return tex;
  }

  function uploadImage(tex: WebGLTexture | null, img: HTMLImageElement): boolean {
    if (!gl) return false;
    try {
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
      return true;
    } catch {
      return false; // cross-origin taint etc. → caller falls back
    }
  }

  function loadImage(url: string): Promise<HTMLImageElement | null> {
    return new Promise((resolve) => {
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = () => resolve(img);
      img.onerror = () => resolve(null);
      img.src = url;
    });
  }

  function isSameOrigin(url: string): boolean {
    try {
      return new URL(url, window.location.href).origin === window.location.origin;
    } catch {
      return false;
    }
  }

  // Await an existing <img> instead of refetching: only when same-origin, so
  // a prod-dump URL on ritunia.com can still paint the photograph (no CORS
  // on the visible <img>) while WebGL quietly sits out.
  function imageMatches(img: HTMLImageElement, url: string): boolean {
    try {
      return img.currentSrc === url || img.src === new URL(url, window.location.href).href;
    } catch {
      return img.currentSrc === url || img.src === url;
    }
  }
  function awaitImg(img: HTMLImageElement, expectedUrl: string): Promise<HTMLImageElement | null> {
    if (!imageMatches(img, expectedUrl)) return Promise.resolve(null);
    if (img.complete) return Promise.resolve(img.naturalWidth > 0 ? img : null);
    return new Promise((resolve) => {
      img.addEventListener('load', () => resolve(imageMatches(img, expectedUrl) && img.naturalWidth > 0 ? img : null), { once: true });
      img.addEventListener('error', () => resolve(null), { once: true });
    });
  }

  function resize() {
    if (!host || !canvas) return;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.round(host.clientWidth * dpr));
    const h = Math.max(1, Math.round(host.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function draw() {
    if (!gl || !canvas || !colorTex) return;
    resize();
    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, colorTex);
    gl.uniform1i(uColor, 0);
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, heightTex ?? colorTex);
    gl.uniform1i(uHeight, 1);

    gl.uniform1f(uHasHeight, hasHeight);
    gl.uniform1f(uImageAspect, imageAspect);
    gl.uniform1f(uCanvasAspect, canvas.width / canvas.height);
    gl.uniform2f(uMouse, curX, curY);
    gl.uniform1f(uActivation, curAct);
    gl.uniform2f(uTexel, texelX, texelY);
    gl.uniform1f(uRelief, reliefStrength());
    gl.uniform1f(uGrazing, GRAZING);

    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    if ((host?.clientWidth ?? 0) > 8 && (host?.clientHeight ?? 0) > 8) glReady = true;
  }

  function frame() {
    const dx = targetX - curX;
    const dy = targetY - curY;
    const da = targetAct - curAct;
    curX += dx * EASE;
    curY += dy * EASE;
    curAct += da * ACT_EASE;
    draw();
    // settle: once the lamp is at rest and the effect has fully released, stop
    // burning frames. The final frame at curAct≈0 equals the untouched photo.
    if (!pointerInside && Math.hypot(dx, dy) < 0.0006 && Math.abs(da) < 0.0015) {
      curAct = targetAct; // snap to exact 0 so the last drawn frame is the plain image
      draw();
      running = false;
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(frame);
  }

  function kick() {
    if (running || destroyed || !colorTex) return;
    if (!visible) {
      draw();
      return;
    }
    running = true;
    raf = requestAnimationFrame(frame);
  }

  function onMove(e: PointerEvent) {
    if (!host) return;
    if (!hostRect || hostRectDirty) updateHostRect();
    const r = hostRect;
    if (!r || !r.width || !r.height) return;
    targetX = ((e.clientX - r.left) / r.width) * 2 - 1;
    targetY = ((e.clientY - r.top) / r.height) * 2 - 1;
    pointerInside = true;
    targetAct = 1;
    kick();
  }
  function onEnter(e: PointerEvent) {
    updateHostRect();
    // seed the lamp at the entry point so it doesn't sweep in from the corner
    onMove(e);
  }
  function onLeave() {
    pointerInside = false;
    hostRect = null;
    hostRectDirty = true;
    targetAct = 0; // ease the relief away → back to the plain photograph
    kick();
  }

  // Load (or reload) the colour + optional height textures for a given pair of
  // sources. Idempotent per (src|heightSrc); a newer call supersedes an older
  // in-flight one via loadSeq. Reuses the persistent GL context — no teardown.
  async function loadTextures(colorSrc: string, heightSrc2: string | null) {
    if (!gl || destroyed) return;
    const key = `${colorSrc}|${heightSrc2 ?? ''}`;
    if (key === loadedKey) return;
    loadedKey = key;
    const seq = ++loadSeq;

    glReady = false;
    imageFailed = false;

    const colorImg = baseImg && isSameOrigin(colorSrc)
      ? (await awaitImg(baseImg, colorSrc)) ?? await loadImage(colorSrc)
      : await loadImage(colorSrc);
    if (destroyed || seq !== loadSeq) return;
    if (!colorImg) return;

    imageAspect = colorImg.naturalWidth / Math.max(1, colorImg.naturalHeight);
    texelX = 1 / Math.max(1, colorImg.naturalWidth);
    texelY = 1 / Math.max(1, colorImg.naturalHeight);
    if (!colorTex) colorTex = makeTexture();
    if (!uploadImage(colorTex, colorImg)) return; // tainted → base <img> stays

    hasHeight = 0;
    if (heightSrc2) {
      const heightImg = await loadImage(heightSrc2);
      if (destroyed || seq !== loadSeq) return;
      if (heightImg) {
        if (!heightTex) heightTex = makeTexture();
        if (uploadImage(heightTex, heightImg)) hasHeight = 1;
      }
    }

    draw();
    kick();
  }

  onMount(() => {
    isPointerFine = window.matchMedia('(pointer: fine)').matches;
    if (reducedMotion || !canvas || !host || !src) return;

    // preserveDrawingBuffer: like the daguerreotype, this canvas draws then
    // parks its rAF loop. Without buffer preservation the browser discards
    // those pixels on a composite not preceded by a redraw (view-transition
    // snapshot, the canvas's own opacity fade), leaving the stage blank until
    // the next pointermove. Cheap here; correctness over a micro-optimisation.
    gl =
      (canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true, preserveDrawingBuffer: true }) as WebGLRenderingContext | null) ||
      (canvas.getContext('experimental-webgl', { alpha: true, preserveDrawingBuffer: true }) as WebGLRenderingContext | null);
    if (!gl) return; // base <img> stays visible — silent, correct fallback

    const vsrc = `
      attribute vec2 aPos;
      attribute vec2 aUv;
      varying vec2 vUv;
      void main() { vUv = aUv; gl_Position = vec4(aPos, 0.0, 1.0); }
    `;
    const fsrc = `
      precision highp float;
      varying vec2 vUv;
      uniform sampler2D uColor;
      uniform sampler2D uHeight;
      uniform float uHasHeight;
      uniform float uImageAspect;
      uniform float uCanvasAspect;
      uniform vec2  uMouse;      // -1..1 over the stage; eased lamp position
      uniform float uActivation; // 0..1 effect engagement (eased)
      uniform vec2  uTexel;      // 1/imgW, 1/imgH
      uniform float uRelief;     // normal slope multiplier
      uniform float uGrazing;    // lamp height above the plane (small = grazing)

      float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }

      // Height field h(uv). Depth map when present; else a lightly blurred
      // luminance so the relief survives but pixel noise doesn't.
      float heightAt(vec2 uv) {
        if (uHasHeight > 0.5) return texture2D(uHeight, uv).r;
        vec2 r = uTexel * 1.25;
        float s  = luma(texture2D(uColor, uv).rgb)              * 0.40;
        s += luma(texture2D(uColor, uv + vec2(r.x, 0.0)).rgb)   * 0.15;
        s += luma(texture2D(uColor, uv - vec2(r.x, 0.0)).rgb)   * 0.15;
        s += luma(texture2D(uColor, uv + vec2(0.0, r.y)).rgb)   * 0.15;
        s += luma(texture2D(uColor, uv - vec2(0.0, r.y)).rgb)   * 0.15;
        return s;
      }

      void main() {
        // object-fit: contain — letterbox bands stay transparent so the
        // parchment mat shows through, exactly like the <img> did.
        vec2 scale = vec2(1.0);
        if (uImageAspect > uCanvasAspect) scale.y = uCanvasAspect / uImageAspect;
        else                              scale.x = uImageAspect / uCanvasAspect;

        vec2 imgUv = (vUv - 0.5) / scale + 0.5;
        if (imgUv.x < 0.0 || imgUv.x > 1.0 || imgUv.y < 0.0 || imgUv.y > 1.0) discard;

        vec3 base = texture2D(uColor, imgUv).rgb;

        // ── surface normal from the height field (central differences) ──
        // Steps are a fixed texel count on each axis, so the gradient is
        // isotropic in pixel space; the per-axis constant folds into uRelief.
        vec2 e = uTexel * 1.5;
        float hL = heightAt(imgUv - vec2(e.x, 0.0));
        float hR = heightAt(imgUv + vec2(e.x, 0.0));
        float hD = heightAt(imgUv - vec2(0.0, e.y));
        float hU = heightAt(imgUv + vec2(0.0, e.y));
        vec2 grad = vec2(hR - hL, hU - hD);
        vec3 N = normalize(vec3(-grad * uRelief, 1.0));

        // ── lamp: a low point light at the cursor's planar position ──
        // aspect-correct image space so the luminous pool stays circular.
        vec2 q   = vec2(imgUv.x * uImageAspect, imgUv.y);
        vec2 lUv = uMouse * 0.5 / scale + 0.5;          // cursor → image uv
        vec2 lq  = vec2(lUv.x * uImageAspect, lUv.y);
        vec2 toL = lq - q;
        float dist = length(toL);
        vec3 L = normalize(vec3(toL, uGrazing));

        // Subtract the flat-surface response so flat regions keep the photo
        // unchanged and only true relief (N tilted off vertical) modulates it.
        float flatResp = L.z;                 // = dot(vec3(0,0,1), L)
        float lambert  = max(dot(N, L), 0.0);

        // Tight grazing specular — the glint that catches raised edges.
        vec3 V = vec3(0.0, 0.0, 1.0);
        vec3 H = normalize(L + V);
        float spec = pow(max(dot(N, H), 0.0), 32.0);

        // Luminous pool that follows the lamp; soft, never crushing the rest.
        float pool = exp(-dist * dist * 4.0);

        float relief = 1.0
          + (lambert - flatResp) * (2.6 * (0.32 + pool))
          + spec * pool * 1.35;

        vec3 lit = base * clamp(relief, 0.0, 4.0);

        // Engage smoothly; at activation 0 the output equals the plain photo.
        float k = uActivation * (0.42 + 0.58 * pool);
        gl_FragColor = vec4(mix(base, lit, k), 1.0);
      }
    `;

    function compile(type: number, source: string): WebGLShader | null {
      const sh = gl!.createShader(type);
      if (!sh) return null;
      gl!.shaderSource(sh, source);
      gl!.compileShader(sh);
      if (!gl!.getShaderParameter(sh, gl!.COMPILE_STATUS)) {
        gl!.deleteShader(sh);
        return null;
      }
      return sh;
    }

    const vs = compile(gl.VERTEX_SHADER, vsrc);
    const fs = compile(gl.FRAGMENT_SHADER, fsrc);
    if (!vs || !fs) { gl = null; return; }
    const prog = gl.createProgram();
    if (!prog) { gl = null; return; }
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { gl = null; return; }
    gl.useProgram(prog);

    // ── geometry: full-frame quad. uv (0,0) = top-left, matching image rows. ──
    const quad = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, quad);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
        // aPos      aUv
        -1, -1, 0, 1,
         1, -1, 1, 1,
        -1,  1, 0, 0,
         1,  1, 1, 0,
      ]),
      gl.STATIC_DRAW,
    );
    const aPos = gl.getAttribLocation(prog, 'aPos');
    const aUv = gl.getAttribLocation(prog, 'aUv');
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
    gl.enableVertexAttribArray(aUv);
    gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 16, 8);

    uColor = gl.getUniformLocation(prog, 'uColor');
    uHeight = gl.getUniformLocation(prog, 'uHeight');
    uHasHeight = gl.getUniformLocation(prog, 'uHasHeight');
    uImageAspect = gl.getUniformLocation(prog, 'uImageAspect');
    uCanvasAspect = gl.getUniformLocation(prog, 'uCanvasAspect');
    uMouse = gl.getUniformLocation(prog, 'uMouse');
    uActivation = gl.getUniformLocation(prog, 'uActivation');
    uTexel = gl.getUniformLocation(prog, 'uTexel');
    uRelief = gl.getUniformLocation(prog, 'uRelief');
    uGrazing = gl.getUniformLocation(prog, 'uGrazing');

    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    function handleContextLost(e: Event) {
      e.preventDefault();
      stopAnimation();
      glReady = false;
      initialized = false;
      loadedKey = '';
      colorTex = null;
      heightTex = null;
      gl = null;
    }
    function handleContextRestored() {
      glReady = false; // a full remount recreates context + program
    }

    // After a view transition the live canvas can hold a stale/blank buffer
    // until something forces a fresh draw; the layout fires this once the
    // transition settles so the plate redraws without user input.
    function onExternalRedraw() {
      markHostRectDirty();
      draw();
      kick();
    }
    window.addEventListener('gotiga:redraw', onExternalRedraw);

    canvas.addEventListener('webglcontextlost', handleContextLost);
    canvas.addEventListener('webglcontextrestored', handleContextRestored);
    host.addEventListener('pointerenter', onEnter);
    host.addEventListener('pointermove', onMove);
    host.addEventListener('pointerleave', onLeave);
    const scrollOptions = { passive: true, capture: true } as const;
    window.addEventListener('scroll', markHostRectDirty, scrollOptions);
    window.addEventListener('resize', markHostRectDirty);

    const io = new IntersectionObserver(
      ([entry]) => {
        visible = entry.isIntersecting;
        if (visible) { markHostRectDirty(); kick(); }
      },
      { threshold: 0 },
    );
    io.observe(host);

    const ro = new ResizeObserver(() => {
      markHostRectDirty();
      kick();
    });
    ro.observe(host);

    initialized = true;
    loadTextures(src, heightSrc);

    return () => {
      destroyed = true;
      stopAnimation();
      window.removeEventListener('gotiga:redraw', onExternalRedraw);
      canvas?.removeEventListener('webglcontextlost', handleContextLost);
      canvas?.removeEventListener('webglcontextrestored', handleContextRestored);
      host?.removeEventListener('pointerenter', onEnter);
      host?.removeEventListener('pointermove', onMove);
      host?.removeEventListener('pointerleave', onLeave);
      window.removeEventListener('scroll', markHostRectDirty, scrollOptions);
      window.removeEventListener('resize', markHostRectDirty);
      io.disconnect();
      ro.disconnect();
      const ext = gl?.getExtension('WEBGL_lose_context');
      ext?.loseContext();
      gl = null;
    };
  });

  // Reload only the textures when the gallery image (or its height map) changes —
  // context, program and geometry are untouched. Guarded so the initial mount
  // (handled in onMount) isn't loaded twice.
  $effect(() => {
    const s = src;
    const h = heightSrc;
    if (initialized && s) loadTextures(s, h);
  });

  $effect(() => {
    intensity;
    if (initialized && colorTex) { draw(); kick(); }
  });
</script>

<div
  bind:this={host}
  class="raking {className}"
  class:raking--zoomable={isPointerFine && !!onActivate}
  role={isPointerFine && onActivate ? 'button' : 'img'}
  tabindex={isPointerFine && onActivate ? 0 : undefined}
  aria-label={alt}
  onclick={handleActivate}
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleActivate(); } }}
>
  <!-- Base photograph: always present. No crossorigin — a failed CORS check
       on the visible <img> is a broken-image icon, not a quiet GL fallback. -->
  {#if src && !imageFailed}
    <img bind:this={baseImg} class="raking-base" {src} {alt}
         draggable="false"
         onerror={() => (imageFailed = true)} />
  {:else}
    <div class="raking-fallback" aria-hidden="true"></div>
  {/if}
  <canvas bind:this={canvas} class="raking-canvas" class:is-ready={glReady} aria-hidden="true"></canvas>
</div>

<style>
  .raking {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
  }
  .raking--zoomable {
    cursor: zoom-in;
  }
  .raking-base,
  .raking-canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
  .raking-base {
    object-fit: contain;
    user-select: none;
    -webkit-user-drag: none;
  }
  .raking-canvas {
    opacity: 0;
    transition: opacity 0.4s ease;
    pointer-events: none;
  }
  .raking-canvas.is-ready {
    opacity: 1;
  }
  .raking-fallback {
    width: 100%;
    height: 100%;
    background:
      radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.5), transparent 48%),
      rgba(244, 236, 222, 0.75);
  }
</style>
II

Dust

The dust settles on the whole cellar — it was never made to live in a box

Eighty motes, drifting at fifteen frames a second, thinking about your cursor from a distance. It costs almost nothing and it does the one thing no still image can: it says the room has air in it, and that nobody has swept for a while.

The workings 182 lines
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';

  let { opacity = 0.6 }: { opacity?: number } = $props();

  let canvas: HTMLCanvasElement;
  let ctx: CanvasRenderingContext2D | null;
  let animationFrameId: number;
  let particles: Particle[] = [];
  let mouse = { x: -1000, y: -1000 };
  let lastFrameTime = 0;
  const TARGET_FPS = 15;
  const FRAME_INTERVAL = 1000 / TARGET_FPS;

  const PARTICLE_COUNT = 80;
  const CONNECTION_DISTANCE = 100;
  const MOUSE_RADIUS = 150;

  class Particle {
    x: number;
    y: number;
    vx: number;
    vy: number;
    size: number;
    baseX: number;
    baseY: number;
    density: number;
    alpha: number;

    constructor(w: number, h: number) {
      this.x = Math.random() * w;
      this.y = Math.random() * h;
      this.vx = (Math.random() - 0.5) * 0.5; // Slow drift velocity
      this.vy = (Math.random() - 0.5) * 0.5;
      this.size = Math.random() * 2 + 0.5;
      this.baseX = this.x;
      this.baseY = this.y;
      this.density = (Math.random() * 30) + 1;
      this.alpha = Math.random() * 0.5 + 0.1;
    }

    update(w: number, h: number) {
      // Mouse interaction
      let dx = mouse.x - this.x;
      let dy = mouse.y - this.y;
      let distance = Math.sqrt(dx*dx + dy*dy);
      
      // Repulsion force
      let forceDirectionX = dx / distance;
      let forceDirectionY = dy / distance;
      let maxDistance = MOUSE_RADIUS;
      let force = (maxDistance - distance) / maxDistance;
      let directionX = forceDirectionX * force * this.density;
      let directionY = forceDirectionY * force * this.density;

      if (distance < MOUSE_RADIUS) {
        this.x -= directionX;
        this.y -= directionY;
      } else {
          // Return to drift
          if (this.x !== this.baseX) {
              let dx = this.x - this.baseX;
              this.x -= dx/20; // slow return? No, let's just drift.
          }
          if (this.y !== this.baseY) {
               let dy = this.y - this.baseY;
               this.y -= dy/20;
          }
          
          // Constant drift
          this.x += this.vx;
          this.y += this.vy;
      }
      
      // Wrap around screen
      if (this.x < 0) this.x = w;
      else if (this.x > w) this.x = 0;
      
      if (this.y < 0) this.y = h;
      else if (this.y > h) this.y = 0;
    }

    draw(ctx: CanvasRenderingContext2D) {
      ctx.fillStyle = `rgba(198, 95, 60, ${this.alpha})`; // Cabinet bone color
      ctx.beginPath();
      ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
      ctx.closePath();
      ctx.fill();
    }
  }

  function init() {
    if (!canvas) return;
    particles = [];
    for (let i = 0; i < PARTICLE_COUNT; i++) {
      particles.push(new Particle(canvas.width, canvas.height));
    }
  }

  function animate(timestamp: number) {
    if (!ctx || !canvas) return;
    animationFrameId = requestAnimationFrame(animate);

    if (timestamp - lastFrameTime < FRAME_INTERVAL) return;
    lastFrameTime = timestamp;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < particles.length; i++) {
      particles[i].update(canvas.width, canvas.height);
      particles[i].draw(ctx);
    }
  }

  function handleResize() {
    if (!canvas) return;
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    init();
  }
  
  function handleMouseMove(e: MouseEvent) {
      mouse.x = e.x;
      mouse.y = e.y;
  }

  function handleTouchMove(e: TouchEvent) {
      if (e.touches.length > 0) {
          mouse.x = e.touches[0].clientX;
          mouse.y = e.touches[0].clientY;
      }
  }

  function handleTouchEnd() {
      mouse.x = -1000;
      mouse.y = -1000;
  }

  function handleVisibilityChange() {
    if (document.hidden) {
      cancelAnimationFrame(animationFrameId);
    } else {
      animationFrameId = requestAnimationFrame(animate);
    }
  }

  onMount(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    if (window.matchMedia('(pointer: coarse)').matches) return;
    if (window.innerWidth < 768) return;

    ctx = canvas.getContext('2d');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

    window.addEventListener('resize', handleResize);
    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('touchmove', handleTouchMove, { passive: true });
    window.addEventListener('touchend', handleTouchEnd);
    document.addEventListener('visibilitychange', handleVisibilityChange);

    init();
    animationFrameId = requestAnimationFrame(animate);
  });

  onDestroy(() => {
    if (typeof window !== 'undefined') {
      window.removeEventListener('resize', handleResize);
      window.removeEventListener('mousemove', handleMouseMove);
      window.removeEventListener('touchmove', handleTouchMove);
      window.removeEventListener('touchend', handleTouchEnd);
      document.removeEventListener('visibilitychange', handleVisibilityChange);
      cancelAnimationFrame(animationFrameId);
    }
  });
</script>

<canvas
  bind:this={canvas}
  class="fixed inset-0 pointer-events-none z-50 hidden md:block"
  style="opacity: {opacity}"
></canvas>
III

The Living Plate

Move the pointer — slowly

A depth map turns one still photograph into two planes, and the subject drifts against its ground by three per cent of the frame as you move. Three per cent is the whole argument. Any more and it becomes a product viewer that wants you to buy something; this much only makes the portrait seem to breathe when you look away from it.

The workings 615 lines
<script lang="ts">
  /**
   * Living Daguerreotype — monocular-depth 2.5D parallax for a single still.
   *
   * The work sits very slightly *behind* its plate: as the pointer drifts, the
   * subject shifts against its ground by a few pixels, depth-weighted, so the
   * photograph seems faintly alive — a portrait that breathes when you look away.
   *
   * Design contract (so it never reads as a "store viewer"):
   *  - extremely low displacement (a few % of the frame), eased, never snappy;
   *  - no chrome of its own — fills the stage exactly like the <img> it replaces,
   *    the mat / vignette / grain stay as sibling overlays;
   *  - graceful by construction: a plain <img> is always present underneath, so
   *    SSR, the card→detail view-transition, reduced-motion and WebGL-less
   *    browsers all show the real photograph with zero jank.
   *
   * Lifecycle: the WebGL context, shader program and geometry are built ONCE in
   * onMount and persist for the component's life. Switching the gallery image
   * only reloads the textures (via the $effect on src/depthSrc) — no context
   * teardown, no shader recompile, no context-loss churn while paging ←/→.
   *
   * Depth source, in order of preference:
   *  1. `depthSrc` — a precomputed monocular depth map (Depth-Anything-class),
   *     a grayscale image served as a media variant. The headline path.
   *  2. fallback — luminance of the colour image, blurred in-shader. Lets the
   *     whole archive participate today with no per-image ML pass.
   */
  import { onMount } from 'svelte';

  let {
    src,
    depthSrc = null,
    alt = '',
    intensity = 0.6,
    imageFit = 'contain',
    objectPosition = 'center center',
    class: className = '',
    onActivate,
  }: {
    src?: string | null;
    depthSrc?: string | null;
    alt?: string;
    /** 0..1 — multiplies the (already subtle) maximum displacement. */
    intensity?: number;
    imageFit?: 'cover' | 'contain';
    objectPosition?: string;
    class?: string;
    onActivate?: () => void;
  } = $props();

  // Max texture-space shift at intensity 1 and full depth. ~3.4% of the frame —
  // perceptible as presence, never as a gimmick.
  const MAX_SHIFT = 0.034;
  const EASE = 0.09;

  let host = $state<HTMLDivElement>();
  let canvas = $state<HTMLCanvasElement>();
  let baseImg = $state<HTMLImageElement>(); // the visible <img>; reused as the GL texture source
  let imageFailed = $state(false);
  let glReady = $state(false); // canvas takes over from the base <img> only once it has drawn

  const reducedMotion =
    typeof window !== 'undefined' &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ── Persistent GL state (built once, reused across image switches) ──────────
  let gl: WebGLRenderingContext | null = null;
  let uColor: WebGLUniformLocation | null = null;
  let uDepth: WebGLUniformLocation | null = null;
  let uHasDepth: WebGLUniformLocation | null = null;
  let uImageAspect: WebGLUniformLocation | null = null;
  let uCanvasAspect: WebGLUniformLocation | null = null;
  let uMouse: WebGLUniformLocation | null = null;
  let uIntensity: WebGLUniformLocation | null = null;
  let uCover: WebGLUniformLocation | null = null;
  let uOrigin: WebGLUniformLocation | null = null;

  let colorTex: WebGLTexture | null = null;
  let depthTex: WebGLTexture | null = null;
  let hasDepth = 0;
  let imageAspect = 1;

  // pointer → eased camera offset
  let targetX = 0, targetY = 0, curX = 0, curY = 0;
  let pointerInside = false;
  let visible = true;
  let running = false;
  let raf = 0;
  let hostRect: DOMRect | null = null;
  let hostRectDirty = true;

  let destroyed = false;
  let initialized = false; // GL context + program ready
  let loadedKey = '';      // de-dupes the (src|depthSrc) currently loaded/loading
  let loadSeq = 0;         // supersedes in-flight loads when the image changes fast
  let isPointerFine = $state(true);

  function handleActivate() {
    if (isPointerFine) onActivate?.();
  }

  function originFromPosition(pos: string): [number, number] {
    const parts = pos.trim().split(/\s+/);
    const parse = (token: string | undefined, fallback: number) => {
      if (!token) return fallback;
      if (token === 'center' || token === 'centre') return 0.5;
      if (token === 'left' || token === 'top') return 0;
      if (token === 'right' || token === 'bottom') return 1;
      if (token.endsWith('%')) {
        const n = parseFloat(token);
        return Number.isFinite(n) ? n / 100 : fallback;
      }
      return fallback;
    };
    return [parse(parts[0], 0.5), parse(parts[1], 0.5)];
  }

  function updateHostRect() {
    hostRect = host?.getBoundingClientRect() ?? null;
    hostRectDirty = false;
  }

  function markHostRectDirty() {
    hostRectDirty = true;
  }

  function stopAnimation() {
    running = false;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
  }

  function makeTexture(): WebGLTexture | null {
    if (!gl) return null;
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    return tex;
  }

  function uploadImage(tex: WebGLTexture | null, img: HTMLImageElement): boolean {
    if (!gl) return false;
    try {
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
      return true;
    } catch {
      return false; // cross-origin taint etc. → caller falls back
    }
  }

  function loadImage(url: string): Promise<HTMLImageElement | null> {
    return new Promise((resolve) => {
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = () => resolve(img);
      img.onerror = () => resolve(null);
      img.src = url;
    });
  }

  function isSameOrigin(url: string): boolean {
    try {
      return new URL(url, window.location.href).origin === window.location.origin;
    } catch {
      return false;
    }
  }

  // Await an existing <img> instead of fetching the URL again. Only when that
  // element is same-origin: a cross-origin display photo without CORS is fine
  // to show, but uploading it to WebGL taints the context. Prod-dump URLs that
  // still point at ritunia.com take the loadImage path (and skip GL if CORS
  // refuses), while the visible <img> stays unadorned and paints.
  function imageMatches(img: HTMLImageElement, url: string): boolean {
    try {
      return img.currentSrc === url || img.src === new URL(url, window.location.href).href;
    } catch {
      return img.currentSrc === url || img.src === url;
    }
  }

  function awaitImg(img: HTMLImageElement, expectedUrl: string): Promise<HTMLImageElement | null> {
    if (!imageMatches(img, expectedUrl)) return Promise.resolve(null);
    if (img.complete) return Promise.resolve(img.naturalWidth > 0 ? img : null);
    return new Promise((resolve) => {
      img.addEventListener('load', () => resolve(imageMatches(img, expectedUrl) && img.naturalWidth > 0 ? img : null), { once: true });
      img.addEventListener('error', () => resolve(null), { once: true });
    });
  }

  function resize() {
    if (!host || !canvas) return;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.round(host.clientWidth * dpr));
    const h = Math.max(1, Math.round(host.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function draw() {
    if (!gl || !canvas || !colorTex) return;
    resize();
    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, colorTex);
    gl.uniform1i(uColor, 0);
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, depthTex ?? colorTex);
    gl.uniform1i(uDepth, 1);

    gl.uniform1f(uHasDepth, hasDepth);
    gl.uniform1f(uImageAspect, imageAspect);
    gl.uniform1f(uCanvasAspect, canvas.width / canvas.height);
    gl.uniform2f(uMouse, curX, curY);
    gl.uniform1f(uIntensity, MAX_SHIFT * Math.max(0, Math.min(1, intensity)));
    gl.uniform1f(uCover, imageFit === 'cover' ? 1 : 0);
    const origin = originFromPosition(objectPosition);
    gl.uniform2f(uOrigin, origin[0], origin[1]);

    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    if ((host?.clientWidth ?? 0) > 8 && (host?.clientHeight ?? 0) > 8) glReady = true;
  }

  function frame() {
    const dx = targetX - curX;
    const dy = targetY - curY;
    curX += dx * EASE;
    curY += dy * EASE;
    draw();
    // settle: once at rest and the pointer has left, stop burning frames.
    if (!pointerInside && Math.hypot(dx, dy) < 0.0006) {
      running = false;
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(frame);
  }

  function kick() {
    if (running || destroyed || !colorTex) return;
    if (!visible) {
      draw();
      return;
    }
    running = true;
    raf = requestAnimationFrame(frame);
  }

  function onMove(e: PointerEvent) {
    if (!host) return;
    if (!hostRect || hostRectDirty) updateHostRect();
    const r = hostRect;
    if (!r) return;
    if (!r.width || !r.height) return;
    targetX = ((e.clientX - r.left) / r.width) * 2 - 1;
    targetY = ((e.clientY - r.top) / r.height) * 2 - 1;
    pointerInside = true;
    kick();
  }
  function onLeave() {
    pointerInside = false;
    hostRect = null;
    hostRectDirty = true;
    targetX = 0;
    targetY = 0;
    kick(); // ease back to the resting frame
  }

  // Load (or reload) the colour + optional depth textures for a given pair of
  // sources. Idempotent per (src|depthSrc); a newer call supersedes an older
  // in-flight one via loadSeq. Reuses the persistent GL context — no teardown.
  async function loadTextures(colorSrc: string, depthSrc2: string | null) {
    if (!gl || destroyed) return;
    const key = `${colorSrc}|${depthSrc2 ?? ''}`;
    if (key === loadedKey) return;
    loadedKey = key;
    const seq = ++loadSeq;

    // Fade the canvas out while the new plate loads; the base <img> (its src is
    // bound reactively) shows through and fades the new photograph in.
    glReady = false;
    imageFailed = false;

    // Colour: reuse the visible base <img> when it's the same element/source,
    // else a fresh load (covers the no-DOM-yet edge).
    const colorImg = baseImg && isSameOrigin(colorSrc)
      ? (await awaitImg(baseImg, colorSrc)) ?? await loadImage(colorSrc)
      : await loadImage(colorSrc);
    if (destroyed || seq !== loadSeq) return;
    // Keep the visible <img> — a failed texture upload must never blank the plate.
    if (!colorImg) return;

    imageAspect = colorImg.naturalWidth / Math.max(1, colorImg.naturalHeight);
    if (!colorTex) colorTex = makeTexture();
    if (!uploadImage(colorTex, colorImg)) return; // tainted → base <img> stays

    // Depth: optional, fetched separately (it's small and only present sometimes).
    hasDepth = 0;
    if (depthSrc2) {
      const depthImg = await loadImage(depthSrc2);
      if (destroyed || seq !== loadSeq) return;
      if (depthImg) {
        if (!depthTex) depthTex = makeTexture();
        if (uploadImage(depthTex, depthImg)) hasDepth = 1;
      }
    }

    draw();
    kick();
  }

  onMount(() => {
    isPointerFine = window.matchMedia('(pointer: fine)').matches;
    if (reducedMotion || !canvas || !host || !src) return;

    // preserveDrawingBuffer: this canvas draws a single frame then parks its rAF
    // loop to save power. Without buffer preservation the browser discards those
    // pixels on any composite that isn't preceded by a redraw — notably a view
    // transition snapshot (the book page-turn) or the canvas's own opacity
    // fade-in — leaving the stage blank until the next pointermove forces draw().
    // Cheap for a small, mostly-static canvas; correctness over a micro-optimisation.
    gl =
      (canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true, preserveDrawingBuffer: true }) as WebGLRenderingContext | null) ||
      (canvas.getContext('experimental-webgl', { alpha: true, preserveDrawingBuffer: true }) as WebGLRenderingContext | null);
    if (!gl) return; // base <img> stays visible — silent, correct fallback

    // ── program ────────────────────────────────────────────────────────────
    const vsrc = `
      attribute vec2 aPos;
      attribute vec2 aUv;
      varying vec2 vUv;
      void main() { vUv = aUv; gl_Position = vec4(aPos, 0.0, 1.0); }
    `;
    const fsrc = `
      precision mediump float;
      varying vec2 vUv;
      uniform sampler2D uColor;
      uniform sampler2D uDepth;
      uniform float uHasDepth;
      uniform float uImageAspect;
      uniform float uCanvasAspect;
      uniform vec2  uMouse;     // -1..1
      uniform float uIntensity; // max texture-space shift
      uniform float uCover;     // 0 contain, 1 cover
      uniform vec2  uOrigin;    // object-position 0..1

      float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }

      float depthAt(vec2 uv) {
        if (uHasDepth > 0.5) return texture2D(uDepth, uv).r;
        // cheap separable-ish blur of luminance — depth is low-frequency, so a
        // 5-tap cross at this radius is enough to kill texture shimmer.
        float r = 0.012;
        float s = luma(texture2D(uColor, uv).rgb) * 0.36;
        s += luma(texture2D(uColor, uv + vec2(r, 0.0)).rgb) * 0.16;
        s += luma(texture2D(uColor, uv - vec2(r, 0.0)).rgb) * 0.16;
        s += luma(texture2D(uColor, uv + vec2(0.0, r)).rgb) * 0.16;
        s += luma(texture2D(uColor, uv - vec2(0.0, r)).rgb) * 0.16;
        return s;
      }

      void main() {
        vec2 scale = vec2(1.0);
        if (uCover > 0.5) {
          if (uImageAspect > uCanvasAspect) scale.x = uImageAspect / uCanvasAspect;
          else                              scale.y = uCanvasAspect / uImageAspect;
        } else {
          // contain — letterbox bands stay transparent so the mat shows through
          if (uImageAspect > uCanvasAspect) scale.y = uCanvasAspect / uImageAspect;
          else                              scale.x = uImageAspect / uCanvasAspect;
        }

        vec2 origin = vec2(0.5);
        if (uCover > 0.5) origin = clamp(uOrigin, 0.5 / scale, 1.0 - 0.5 / scale);
        vec2 imgUv = (vUv - 0.5) / scale + origin;
        if (uCover < 0.5 && (imgUv.x < 0.0 || imgUv.x > 1.0 || imgUv.y < 0.0 || imgUv.y > 1.0)) discard;

        float d = depthAt(imgUv);
        vec2 disp = uMouse * d * uIntensity;
        vec2 s = clamp(imgUv - disp, 0.0, 1.0); // clamp, not wrap → no ground bleed at edges
        gl_FragColor = texture2D(uColor, s);
      }
    `;

    function compile(type: number, source: string): WebGLShader | null {
      const sh = gl!.createShader(type);
      if (!sh) return null;
      gl!.shaderSource(sh, source);
      gl!.compileShader(sh);
      if (!gl!.getShaderParameter(sh, gl!.COMPILE_STATUS)) {
        gl!.deleteShader(sh);
        return null;
      }
      return sh;
    }

    const vs = compile(gl.VERTEX_SHADER, vsrc);
    const fs = compile(gl.FRAGMENT_SHADER, fsrc);
    if (!vs || !fs) { gl = null; return; }
    const prog = gl.createProgram();
    if (!prog) { gl = null; return; }
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { gl = null; return; }
    gl.useProgram(prog);

    // ── geometry: full-frame quad. uv (0,0) = top-left, matching image rows. ──
    const quad = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, quad);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
        // aPos      aUv
        -1, -1, 0, 1,
         1, -1, 1, 1,
        -1,  1, 0, 0,
         1,  1, 1, 0,
      ]),
      gl.STATIC_DRAW,
    );
    const aPos = gl.getAttribLocation(prog, 'aPos');
    const aUv = gl.getAttribLocation(prog, 'aUv');
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
    gl.enableVertexAttribArray(aUv);
    gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 16, 8);

    uColor = gl.getUniformLocation(prog, 'uColor');
    uDepth = gl.getUniformLocation(prog, 'uDepth');
    uHasDepth = gl.getUniformLocation(prog, 'uHasDepth');
    uImageAspect = gl.getUniformLocation(prog, 'uImageAspect');
    uCanvasAspect = gl.getUniformLocation(prog, 'uCanvasAspect');
    uMouse = gl.getUniformLocation(prog, 'uMouse');
    uIntensity = gl.getUniformLocation(prog, 'uIntensity');
    uCover = gl.getUniformLocation(prog, 'uCover');
    uOrigin = gl.getUniformLocation(prog, 'uOrigin');

    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    function handleContextLost(e: Event) {
      e.preventDefault();
      stopAnimation();
      glReady = false;
      initialized = false;
      loadedKey = '';
      colorTex = null;
      depthTex = null;
      gl = null;
    }

    function handleContextRestored() {
      // The base <img> remains visible. A full remount recreates the context and
      // shader program; avoid presenting a stale canvas after browser recovery.
      glReady = false;
    }

    // After a view transition (e.g. the book page-turn) the live canvas can be
    // left holding a stale/blank buffer until something forces a fresh draw —
    // a pointermove normally does it, which is why the image "returned on hover".
    // The layout fires this once the transition settles so the plate redraws
    // without any user input.
    function onExternalRedraw() {
      markHostRectDirty();
      draw();
      kick();
    }
    window.addEventListener('gotiga:redraw', onExternalRedraw);

    canvas.addEventListener('webglcontextlost', handleContextLost);
    canvas.addEventListener('webglcontextrestored', handleContextRestored);
    host.addEventListener('pointerenter', updateHostRect);
    host.addEventListener('pointermove', onMove);
    host.addEventListener('pointerleave', onLeave);
    const scrollOptions = { passive: true, capture: true };
    window.addEventListener('scroll', markHostRectDirty, scrollOptions);
    window.addEventListener('resize', markHostRectDirty);

    const io = new IntersectionObserver(
      ([entry]) => {
        visible = entry.isIntersecting;
        if (visible) markHostRectDirty();
        if (visible) kick();
      },
      { threshold: 0 },
    );
    io.observe(host);

    const ro = new ResizeObserver(() => {
      markHostRectDirty();
      kick();
    });
    ro.observe(host);

    initialized = true;
    // Initial textures. Later src/depthSrc changes are driven by the $effect
    // below, which reuses this very context (no remount, no recompile).
    loadTextures(src, depthSrc);

    return () => {
      destroyed = true;
      stopAnimation();
      window.removeEventListener('gotiga:redraw', onExternalRedraw);
      canvas?.removeEventListener('webglcontextlost', handleContextLost);
      canvas?.removeEventListener('webglcontextrestored', handleContextRestored);
      host?.removeEventListener('pointerenter', updateHostRect);
      host?.removeEventListener('pointermove', onMove);
      host?.removeEventListener('pointerleave', onLeave);
      window.removeEventListener('scroll', markHostRectDirty, scrollOptions);
      window.removeEventListener('resize', markHostRectDirty);
      io.disconnect();
      ro.disconnect();
      const ext = gl?.getExtension('WEBGL_lose_context');
      ext?.loseContext();
      gl = null;
    };
  });

  // Reload only the textures when the gallery image (or its depth map) changes —
  // the GL context, program and geometry above are untouched. Guarded so the
  // initial mount (handled in onMount) isn't loaded twice.
  $effect(() => {
    const s = src;
    const d = depthSrc;
    if (initialized && s) loadTextures(s, d);
  });

  $effect(() => {
    intensity;
    imageFit;
    objectPosition;
    if (initialized && colorTex) {
      draw();
      kick();
    }
  });
</script>

<div
  bind:this={host}
  class="daguerreotype {className}"
  class:daguerreotype--zoomable={isPointerFine && !!onActivate}
  role={isPointerFine && onActivate ? 'button' : 'img'}
  tabindex={isPointerFine && onActivate ? 0 : undefined}
  aria-label={alt}
  onclick={handleActivate}
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleActivate(); } }}
>
  <!-- Base photograph: always present (SSR, view-transition, reduced-motion,
       no-WebGL fallback). No crossorigin here — a failed CORS check replaces
       the photograph with the browser's broken-image icon. The canvas texture
       is loaded separately when the URL is CORS-clean. -->
  {#if src && !imageFailed}
    <img bind:this={baseImg} class="daguerreotype-base" class:daguerreotype-base--cover={imageFit === 'cover'} {src} {alt}
         draggable="false" fetchpriority="high"
         style="object-position: {objectPosition};"
         onerror={() => (imageFailed = true)} />
  {:else}
    <div class="daguerreotype-fallback" aria-hidden="true"></div>
  {/if}
  <canvas bind:this={canvas} class="daguerreotype-canvas" class:is-ready={glReady} aria-hidden="true"></canvas>
</div>

<style>
  .daguerreotype {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
  }
  .daguerreotype--zoomable {
    cursor: zoom-in;
  }
  .daguerreotype-base,
  .daguerreotype-canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
  .daguerreotype-base {
    object-fit: contain;
    user-select: none;
    -webkit-user-drag: none;
  }
  .daguerreotype-base--cover {
    object-fit: cover;
  }
  .daguerreotype-canvas {
    opacity: 0;
    transition: opacity 0.4s ease;
    pointer-events: none;
  }
  .daguerreotype-canvas.is-ready {
    opacity: 1;
  }
  .daguerreotype-fallback {
    width: 100%;
    height: 100%;
    background:
      radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.5), transparent 48%),
      rgba(244, 236, 222, 0.75);
  }
</style>
IV

The Keyhole

The Keyhole

What is hidden is not lost, only unvisited

A soft darkness over the work, with one lit fragment left showing. It lifts once you have actually stepped inside the piece — the house remembers what you have opened. Enough is visible to draw you across the room; not enough to spare you the walk.

The workings 201 lines
<script lang="ts">
  import { fade } from 'svelte/transition';
  /**
   * KeyholeVeil — the "sealed specimen" overlay.
   *
   * Lays a soft radial darkness over a framed image, leaving only a candle-lit
   * fragment visible around a focal point. The rest of the work stays in shadow
   * until the visitor chooses to step into the card (see HomeFigurineTile, which
   * lifts the veil once a piece has been opened — `gotiga_viewed`).
   *
   * Frame-relative by construction: focal point and radius are normalised 0..1
   * against the *rendered frame*, so the same numbers produce the same fragment
   * on any card size — as long as the frame keeps the same aspect/fit. The admin
   * picker reuses this very component (editable=true) over an identical 4/3
   * `contain` frame, so what the editor places is exactly what visitors see.
   *
   * Pure overlay: it never touches the <img> beneath it, so image loading, the
   * card→detail view-transition and reduced-motion all keep working untouched.
   * pointer-events stay off unless editing, so the card link underneath is live.
   */
  let {
    focalX = null,
    focalY = null,
    revealRadius = null,
    darkness = null,
    show = true,
    dwelling = false,
    partial = false,
    dwellMs = 0,
    editable = false,
    onpick = undefined,
  }: {
    focalX?: number | null;
    focalY?: number | null;
    revealRadius?: number | null;
    /**
     * Whether the shadow is present. When it flips to false the veil dissipates
     * with a soft fade (the work being revealed); flips back on without one.
     */
    show?: boolean;
    /**
     * Per-image darkness override (0..1). When null the veil inherits the global
     * `--kh-darkness` (theme setting), which itself falls back to a built-in
     * default — so depth can be tuned globally and overridden per work.
     */
    darkness?: number | null;
    /** A sustained look is in progress — the shadow eases toward "half-lit" over `dwellMs`. */
    dwelling?: boolean;
    /** A glance was completed (looked but not opened) — hold the shadow half-lit, not gone. */
    partial?: boolean;
    /** Dwell duration in ms; sets how slowly the shadow thins while dwelling. */
    dwellMs?: number;
    editable?: boolean;
    /** Called with normalised (x, y) when editing the focal point. */
    onpick?: (x: number, y: number) => void;
  } = $props();

  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
  const clamp01 = (v: number) => clamp(v, 0, 1);

  let fx = $derived(clamp01(focalX ?? 0.5));
  let fy = $derived(clamp01(focalY ?? 0.5));
  // 0.30 of the frame is a fragment — enough to hook, not enough to give the work away.
  let r = $derived(clamp(revealRadius ?? 0.3, 0.08, 1));
  // Per-image darkness override, if any (else inherit the global --kh-darkness).
  let dark = $derived(darkness == null ? null : clamp01(darkness));

  let dragging = $state(false);

  function locate(e: PointerEvent) {
    const el = e.currentTarget as HTMLElement;
    const rect = el.getBoundingClientRect();
    const x = clamp01((e.clientX - rect.left) / rect.width);
    const y = clamp01((e.clientY - rect.top) / rect.height);
    onpick?.(x, y);
  }

  function onDown(e: PointerEvent) {
    if (!editable) return;
    dragging = true;
    (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
    locate(e);
  }
  function onMove(e: PointerEvent) {
    if (!editable || !dragging) return;
    locate(e);
  }
  function onUp(e: PointerEvent) {
    dragging = false;
    (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
  }
</script>

{#if show}
  <div
    class="keyhole-veil"
    class:editable
    class:dwelling
    class:partial
    style="--kh-fx:{fx}; --kh-fy:{fy}; --kh-base:{r};{dark != null ? ` --kh-darkness:${dark};` : ''}{dwelling && dwellMs > 0 ? ` transition-duration:${dwellMs}ms;` : ''}"
    out:fade={{ duration: 750 }}
    onpointerdown={onDown}
    onpointermove={onMove}
    onpointerup={onUp}
    onpointercancel={onUp}
    role="presentation"
    aria-hidden="true"
  >
    {#if editable}
      <span class="kh-marker" style="left:{fx * 100}%; top:{fy * 100}%;"></span>
    {/if}
  </div>
{/if}

<style>
  .keyhole-veil {
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    border-radius: inherit;
    /* Effective radius: the editor's base, widened as a look is rewarded, plus a
       faint breathing pulse. */
    --kh-r: calc(max(var(--kh-base), var(--kh-spread)) + var(--kh-breathe));
    /* Shadow depth: per-image override (inline) → global theme → built-in 0.88. */
    --kh-dark: var(--kh-darkness, 0.88);
    background: radial-gradient(
      circle at calc(var(--kh-fx) * 100%) calc(var(--kh-fy) * 100%),
      transparent 0,
      transparent calc(var(--kh-r) * 72%),
      rgba(18, 11, 7, calc(var(--kh-dark) * 0.6)) calc(var(--kh-r) * 100% + 8%),
      rgba(14, 8, 5, var(--kh-dark)) calc(var(--kh-r) * 100% + 30%)
    );
    /* Default (settle) easing; while dwelling the duration is overridden inline
       to the configured dwell time so the shadow thins over exactly that long. */
    transition-property: --kh-spread, --kh-dark;
    transition-timing-function: ease;
    transition-duration: 0.8s;
  }

  /* A sustained look (in progress) or a completed glance: the shadow thins to a
     half-lit state and the keyhole widens — revealing more, but never all. Only
     opening the work clears it fully. */
  .keyhole-veil.dwelling,
  .keyhole-veil.partial {
    --kh-spread: 0.42;
    --kh-dark: calc(var(--kh-darkness, 0.88) * 0.42);
  }

  /* The breathing pulse animates gradient stops, which forces a paint every
     frame — so it runs ONLY on the card under an active look (at most one at a
     time), never across the whole gallery at rest. */
  .keyhole-veil.dwelling {
    animation: kh-breathe 7s ease-in-out infinite;
  }

  @keyframes kh-breathe {
    0%,
    100% {
      --kh-breathe: 0;
    }
    50% {
      --kh-breathe: 0.025;
    }
  }

  .keyhole-veil.editable {
    pointer-events: auto;
    cursor: crosshair;
    animation: none;
  }

  /* Focal marker — a quiet brass ring, shown only while editing. */
  .kh-marker {
    position: absolute;
    width: 22px;
    height: 22px;
    transform: translate(-50%, -50%);
    border: 1.5px solid rgba(255, 226, 170, 0.92);
    border-radius: 50%;
    box-shadow:
      0 0 0 1px rgba(20, 12, 7, 0.55),
      0 0 10px rgba(255, 200, 120, 0.5);
    pointer-events: none;
  }
  .kh-marker::after {
    content: '';
    position: absolute;
    inset: 9px;
    background: rgba(255, 226, 170, 0.92);
    border-radius: 50%;
  }

  @media (prefers-reduced-motion: reduce) {
    .keyhole-veil {
      animation: none;
      transition: none;
    }
  }
</style>
V

Cipher and Candle

∑≤− ≡≈≠§℻ ≡ℵ†℺§ ℵℷ† †∏§ ≈ℶ≥∆ℷ the house keeps its own hours
?

Light the candle, then bring it close to the line

Some lines are set in a cipher and stay that way until a candle is lit and carried near them. It is a small ceremony, entirely optional, and most visitors never light it. That is the point: a house is allowed to keep something back for whoever thinks to look.

The workings 202 lines
// ── SecretText.svelte ───────────────────────────────────────
<script lang="ts">
  import { onMount } from 'svelte';
  import { fade } from 'svelte/transition';

  let { text = '', isCandleLit = false } = $props();

  let mouseX = $state(-1000);
  let mouseY = $state(-1000);
  let element: HTMLElement;
  let isRevealed = $state(false);
  
  // Generate cipher string once
  const symbols = "†‡§ℵℶℷℸ℺℻∂∆∏∑−∫≈≠≡≤≥";
  let cipherText = $derived(
      text.split('').map(char => {
          if (char === ' ') return ' ';
          return symbols[Math.floor(Math.random() * symbols.length)];
      }).join('')
  );

  function handleMouseMove(e: MouseEvent) {
      if (!isCandleLit) return;
      mouseX = e.clientX;
      mouseY = e.clientY;
      
      if (element) {
          const rect = element.getBoundingClientRect();
          const elX = rect.left + rect.width / 2;
          const elY = rect.top + rect.height / 2;
          const dist = Math.sqrt(Math.pow(mouseX - elX, 2) + Math.pow(mouseY - elY, 2));
          
          // Reveal radius: 150px
          isRevealed = dist < 150;
      }
  }

  $effect(() => {
      if (isCandleLit) {
          window.addEventListener('mousemove', handleMouseMove);
      } else {
          window.removeEventListener('mousemove', handleMouseMove);
          isRevealed = false; // Reset when candle off
      }
      return () => {
          if (typeof window !== 'undefined') window.removeEventListener('mousemove', handleMouseMove);
      };
  });
</script>

<div bind:this={element} class="relative inline-block select-none cursor-help transition-all duration-1000">
    
    <!-- THE CIPHER (Always visible when not revealed) -->
    <span 
        class="font-['Instrument Sans'] text-xl tracking-wide transition-all duration-700
        {isRevealed ? 'opacity-0 blur-sm scale-95' : 'opacity-65 blur-[0.6px]'}
        {isCandleLit ? 'text-[#6b3a26]' : 'text-[#d8c6b1]'}"
    >
        {cipherText}
    </span>

    <!-- THE REVEALED TEXT (Visible only when revealed) -->
    <span 
        class="absolute inset-0 font-['Georgia'] text-2xl italic tracking-wide text-[#5a1f12] drop-shadow-[0_1px_0_rgba(255,246,229,0.7)]
        transition-all duration-500 transform
        {isRevealed ? 'opacity-100 scale-100' : 'opacity-0 scale-105'}"
    >
        {text}
    </span>
    
    <!-- Hint particle if candle is NOT lit? -->
    {#if !isCandleLit}
        <div class="absolute -right-4 -top-2 text-[10px] text-[#d8c6b1] opacity-75 animate-pulse">?</div>
    {/if}

</div>


// ── CandleReveal.svelte ─────────────────────────────────────
<script lang="ts">
  import { fade } from 'svelte/transition';

  let { isActive = false } = $props();

  let x = $state(-100);
  let y = $state(-100);
  let reduced = $state(false);

  let rawX = -100;
  let rawY = -100;
  let rafId: number | null = null;

  function scheduleUpdate() {
    if (rafId !== null) return;
    rafId = requestAnimationFrame(() => {
      x = rawX;
      y = rawY;
      rafId = null;
    });
  }

  $effect(() => {
    if (typeof window === 'undefined') return;
    reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  });

  // Listen only while the candle is actually lit. Attaching on mount meant every
  // figurine page paid a window mousemove/touchmove handler for a tool almost
  // nobody turns on.
  $effect(() => {
    if (!isActive || reduced || typeof window === 'undefined') return;

    function handleMouseMove(e: MouseEvent) {
      rawX = e.clientX;
      rawY = e.clientY;
      scheduleUpdate();
    }
    function handleTouchMove(e: TouchEvent) {
      if (e.touches.length === 0) return;
      rawX = e.touches[0].clientX;
      rawY = e.touches[0].clientY;
      scheduleUpdate();
    }

    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('touchmove', handleTouchMove, { passive: true });
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      window.removeEventListener('touchmove', handleTouchMove);
      if (rafId !== null) {
        cancelAnimationFrame(rafId);
        rafId = null;
      }
    };
  });
</script>

{#if isActive && !reduced}
  <div class="fixed inset-0 pointer-events-none z-[100] overflow-hidden" transition:fade={{ duration: 1000 }}>
    <!-- Darkening layer to make the room feel dimmer -->
    <div class="absolute inset-0 bg-[#2f170e]/[0.06]"></div>

    <!-- The Candle Light -->
    <div
      class="absolute rounded-full pointer-events-none mix-blend-soft-light transition-opacity duration-100"
      style="
        left: {x}px;
        top: {y}px;
        width: 360px;
        height: 360px;
        transform: translate(-50%, -50%);
        background: radial-gradient(circle, rgba(255, 214, 126, 0.82) 0%, rgba(211, 96, 41, 0.28) 36%, transparent 72%);
        opacity: 0.62;
      "
    ></div>
    
    <!-- Secondary Glow (Warmth) -->
    <div
      class="absolute rounded-full pointer-events-none mix-blend-multiply"
      style="
        left: {x}px;
        top: {y}px;
        width: 560px;
        height: 560px;
        transform: translate(-50%, -50%);
        background: radial-gradient(circle, transparent 0%, transparent 42%, rgba(78, 33, 18, 0.08) 72%, transparent 100%);
      "
    ></div>
    
    <!-- Cursor Flame Icon -->
    <div 
        class="absolute pointer-events-none text-2xl filter drop-shadow-[0_0_10px_rgba(255,160,0,0.8)] animate-pulse"
        style="
            left: {x}px;
            top: {y}px;
            transform: translate(-50%, -120%);
        "
    >
        🔥
    </div>

  </div>
{/if}

<style>
  /* Global style for secret text to react to this light */
  /* This needs to be globally available or applied to specific elements */
  :global(.secret-ink) {
    color: #d8c6b1;
    transition: color 1s ease;
    user-select: none;
  }
  
  /* When candle is active, we rely on mix-blend-mode to reveal it. 
     Alternatively, we could use a different technique if blend modes are tricky with text colors.
     
     Actually, let's use a simpler CSS variable approach for the text itself?
     No, the mix-blend-mode `color-dodge` over warm parchment text on `#f8f1e7` background 
     should make the text pop out as golden/bright when the orange light hits it.
  */
</style>

Six colours

Nothing is picked from a generator. Parchment for ground, iron for text, ember for the one place a visitor may act — and that ember appears perhaps twice on a page, which is exactly why it still works.

  • Parchment #f8f1e7
  • Iron gall #34251c
  • Bark #5f4636
  • Deep wood #6f3b24
  • Ember #c65f3c
  • Dust #d8c6b1

Three hands

  • The collection rests, and the dust never settles

    Fraunces — for the names of rooms and works

  • The collection rests, and the dust never settles

    Georgia — for anything the house says in its own voice

  • The collection rests, and the dust never settles

    Instrument Sans — for labels, dates and the small print of the ledger

Rules of the house

  1. 01

    If a feature speeds up perception, it does not belong here. Slowness is the exhibit.

  2. 02

    The gallery answers the arrow keys. Nothing on the page says so. A hint would cost more than the discovery is worth.

  3. 03

    A booking needs no account. You are given a token and you keep it, the way you would keep a cloakroom ticket.

  4. 04

    One motion per crossing, not a new animation per link. The archive is a drawer; the workshop is a curtain; every other door simply fades.

  5. 05

    This is not a shop. It never learned to behave like one, and it will not be taught.

That is the whole cellar. The lamps are five files, the colours are six lines of CSS, and the rules are the only part that was difficult. Mind the step on the way up.

← RETURN TO HALL