← ПОВЕРНУТИСЯ ДО ХОЛУ

Під домом

Підвал

У кожному домі є кімната, де балки залишено на видноті. Це вона. Під холом і архівом стоять п’ять ламп, які освітлюють усе, що над ними, шість фарб, якими вони горять, і правила, що вирішують, чого тут не буде ніколи. Нічого з цього не продається. Це просто те, на чому дім стоїть.

П’ять ламп

I

Ковзне світло

Проведіть курсором по пластині

Реставратор веде лампу вздовж поверхні під найменшим кутом — і назовні виходить рельєф, якого ніхто не бачив: сліди інструмента, шви, відбиток пальця, що залишився в глині. Тут лампа — це ваш курсор. Фотографія читається як карта висот, світло стоїть низько над нею, і відгукується лише справжній рельєф; рівне місце залишається рівно тією самою фотографією. Відведіть лампу — і знімок повернеться до себе, байт у байт.

Облаштування 638 рядків
<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

Пил

Пил осідає на весь підвал — його ніколи не замикали в коробку

Вісімдесят порошинок, що дрейфують на п’ятнадцяти кадрах за секунду й думають про ваш курсор здалеку. Коштує майже нічого й робить єдине, чого не вміє нерухома картинка: каже, що в кімнаті є повітря і що тут давно не підмітали.

Облаштування 182 рядків
<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

Жива пластина

Рухайте курсором — повільно

Карта глибини перетворює один знімок на два плани, і предмет зміщується відносно тла на три відсотки кадру, поки ви рухаєтеся. Три відсотки — це і є вся розмова. Більше — і виходить вітрина, що хоче вам щось продати; рівно стільки — і портрет ніби дихає, коли ви відводите погляд.

Облаштування 615 рядків
<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

Замкова щілина

Замкова щілина

Приховане не втрачене — воно просто ще не відвідане

М’яка темрява поверх роботи, і одну освітлену латку залишено на видноті. Вона сходить, коли ви справді зайшли всередину роботи — дім пам’ятає, що ви відкривали. Видно достатньо, щоб покликати через кімнату, і недостатньо, щоб позбавити вас цього шляху.

Облаштування 201 рядків
<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

Шифр і свічка

§≈≥ ≤℻≤ℸ ∏‡ ≤ℶ≠≥∆ ≡∫ℸ≥℺∏≠≠≠∫ дім живе за своїм годинником
?

Запаліть свічку й піднесіть її до рядка

Деякі рядки набрані шифром і залишаються ним, доки не запалять свічку й не піднесуть її близько. Маленька церемонія, цілком необов’язкова, і більшість гостей її так і не запалює. У цьому й сенс: дім має право приберегти дещо для того, хто здогадається подивитися.

Облаштування 202 рядків
// ── 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>

Шість фарб

Нічого не взято з генератора. Пергамент — під основу, залізисте чорнило — під текст, вугілля — єдине місце, де гість може діяти. І це вугілля з’являється на сторінці разів зо два, тому воно досі працює.

  • Пергамент #f8f1e7
  • Залізисте чорнило #34251c
  • Кора #5f4636
  • Темне дерево #6f3b24
  • Вугілля #c65f3c
  • Пил #d8c6b1

Три почерки

  • Колекція відпочиває, а пил ніколи не осідає

    Fraunces — для назв кімнат і робіт

  • Колекція відпочиває, а пил ніколи не осідає

    Georgia — для всього, що дім говорить своїм голосом

  • Колекція відпочиває, а пил ніколи не осідає

    Instrument Sans — для підписів, дат і дрібного рядка в описі

Правила дому

  1. 01

    Якщо функція пришвидшує сприйняття — їй тут не місце. Повільність і є експонат.

  2. 02

    Галерея відповідає на стрілки. На сторінці про це не сказано ані слова. Підказка коштувала б дорожче за саме відкриття.

  3. 03

    Для броні не потрібен акаунт. Вам видають токен, і ви зберігаєте його, як зберігали б номерок із гардероба.

  4. 04

    Один рух на перехід, а не своя анімація на кожне посилання. Архів — це шухляда комода, майстерня — портьєра, решта дверей просто гасне.

  5. 05

    Це не крамниця. Вона так і не навчилася поводитися як крамниця і вчитися не стане.

Ось і весь підвал. Лампи — це п’ять файлів, фарби — шість рядків CSS, і складними виявилися лише правила. Обережно, східець на виході.

← ПОВЕРНУТИСЯ ДО ХОЛУ