← TORNA ALLA PAGINA INIZIALE

Sotto il sito

La cantina

Ogni casa ha una stanza dove le travi restano a vista. Questa è quella stanza. Sotto la pagina iniziale e l’archivio ci sono le cinque lampade che illuminano tutto ciò che sta sopra, i sei colori in cui ardono e le regole che decidono cosa non si costruirà mai. Qui non c’è niente in vendita. È solo il modo in cui il sito sta in piedi.

Cinque lampade

I

Luce radente

Muovi il puntatore sulla tavola

Un restauratore fa scorrere una lampada a luce radente su una superficie, e affiora il rilievo che nessuno vedeva — segni degli attrezzi, giunture, un’impronta lasciata nell’argilla. Qui la lampada è il tuo puntatore. La fotografia viene trattata come una mappa delle altezze, la luce sta bassa sopra di essa e risponde solo il rilievo vero; una zona piatta resta esattamente la fotografia che era. Allontana la lampada e la tavola torna com’era, byte per byte.

Il codice 638 righe
<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

Polvere

La polvere si posa su tutta la cantina — non è fatta per stare in una scatola

Ottanta granelli che fluttuano a quindici fotogrammi al secondo e tengono d’occhio il cursore da lontano. Non costano quasi nulla e fanno l’unica cosa che un’immagine ferma non sa fare: dicono che nella stanza c’è aria, e che da un po’ nessuno spazza.

Il codice 182 righe
<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

La tavola viva

Muovi il puntatore — piano

Una mappa di profondità divide una fotografia ferma in due piani, e il soggetto si sposta rispetto allo sfondo del tre per cento dell’inquadratura mentre ti muovi. Il tre per cento è tutto il discorso. Di più, e diventa un visualizzatore di prodotti che vuole venderti qualcosa; così poco fa solo sembrare che il ritratto respiri quando distogli lo sguardo.

Il codice 615 righe
<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

Il buco della serratura

Il buco della serratura

Ciò che è nascosto non è perduto, solo non ancora visto

Un’ombra morbida sull’opera, con un solo frammento illuminato. Si solleva quando sei davvero entrato nell’opera — il sito ricorda cosa hai aperto. Se ne vede abbastanza da attirarti dall’altra parte della stanza; non abbastanza da risparmiarti il passo.

Il codice 201 righe
<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

Cifra e candela

≈∂ †∂≤† ≥≡ ℸ ≡ℷ≥∑ ℸ†∏≠≈ il sito ha i suoi orari
?

Accendi la candela, poi avvicinala alla riga

Alcune righe sono scritte in cifra e restano così finché non si accende una candela e la si avvicina. È una piccola cerimonia, del tutto facoltativa, e la maggior parte dei visitatori non la accende mai. È proprio questo il punto: un sito può tenere qualcosa da parte per chi pensa di guardare.

Il codice 202 righe
// ── 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>

Sei colori

Niente viene da un generatore. Pergamena per il fondo, ferro per il testo, brace per l’unico punto in cui il visitatore può agire — e quella brace compare forse due volte per pagina, ed è proprio per questo che funziona ancora.

  • Pergamena #f8f1e7
  • Inchiostro ferrogallico #34251c
  • Corteccia #5f4636
  • Legno scuro #6f3b24
  • Brace #c65f3c
  • Polvere #d8c6b1

Tre caratteri

  • La raccolta riposa, e la polvere non si posa mai

    Fraunces — per i nomi delle sezioni e delle opere

  • La raccolta riposa, e la polvere non si posa mai

    Georgia — per tutto ciò che il sito dice con la sua voce

  • La raccolta riposa, e la polvere non si posa mai

    Instrument Sans — per etichette, date e caratteri piccoli del registro

Le regole del sito

  1. 01

    Se una funzione accelera la percezione, qui non ha posto. La lentezza fa parte dell’esposizione.

  2. 02

    La galleria risponde ai tasti freccia. Nulla sulla pagina lo dice. Un suggerimento costerebbe più di quanto valga la scoperta.

  3. 03

    Una prenotazione non richiede un account. Ricevi un codice e lo conservi, come lo scontrino del guardaroba.

  4. 04

    Un movimento per passaggio, non un’animazione nuova per ogni link. L’archivio è un cassetto; il laboratorio è una tenda; ogni altra porta semplicemente sfuma.

  5. 05

    Questo non è un negozio. Non ha mai imparato a comportarsi come tale, e non lo imparerà.

Questa è tutta la cantina. Le lampade sono cinque file, i colori sei righe di CSS, e le regole sono l’unica parte che è stata difficile. Occhio al gradino salendo.

← TORNA ALLA PAGINA INIZIALE