{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"halftone-screen","title":"Halftone Screen","description":"Four-colour process halftone as a press prints it: CMYK screened at 15/75/0/45°, grey-component replacement, and dot area — not radius — matched to ink coverage, including where dots merge. A loupe under the pointer. WebGL2.","dependencies":[],"files":[{"path":"src/components/HalftoneScreen/index.js","content":"export { default } from './HalftoneScreen.jsx'\n","type":"registry:component","target":"components/halftone-screen/index.js"},{"path":"src/components/HalftoneScreen/HalftoneScreen.jsx","content":"'use client'\n\nimport { useEffect, useLayoutEffect, useRef } from 'react'\nimport { createGL, createLoop, linearRGB, loadImage } from '../gl-core/gl.js'\nimport './HalftoneScreen.css'\n\n// Before paint in the browser; on the server, where a layout effect only warns, a plain one.\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect\n\n/* =========================================================\n   HalftoneScreen\n   An image resolved into four-colour process halftone, the\n   way a press prints it: cyan, magenta, yellow and black\n   each screened into dots on its own rotated grid. Hover to\n   look through a loupe.\n\n   The common \"halftone\" filter draws one grid of dots sized\n   by brightness. Print never looked like that, for two\n   reasons this component keeps.\n\n   Each ink is screened at its own angle — C 15°, M 75°,\n   Y 0°, K 45°. Grids at close angles beat against each\n   other into a coarse moiré; 30° apart, the interference is\n   pushed down to a small, even rosette, which is the texture\n   everyone recognises from a magazine under a loupe.\n\n   And ink coverage is dot AREA, not radius. A dot radius r in\n   a unit cell covers πr² until it touches its neighbours at\n   r = ½; past that the circle is clipped by the cell, so\n\n     A(r) = πr² − 4·(r²·acos(1/2r) − ½·√(r² − ¼))\n\n   until the cell is solid at r = √½. Sizing radius linearly\n   with tone crushes the shadows; the shader inverts A(r) by\n   bisection instead, so 30% ink is 30% of the paper covered.\n\n   Colour separation uses grey-component replacement: the\n   grey that cyan, magenta and yellow share is printed as\n   black instead, which is what presses do to keep shadows\n   neutral and wet ink off the page. Inks are filters, so\n   they compose by multiplication over the paper.\n\n   The loupe is a glass, not a hole that opens. A magnifier\n   of focal length f held a distance d above the page\n   enlarges by\n\n     m = f / (f − d)\n\n   so lying on the paper it magnifies nothing, and raised\n   toward f it magnifies without limit. A loupe that opens\n   like an iris — its radius growing from nothing, at full\n   power — is a hole spreading in the page, and one that\n   simply appears is a sticker. This one keeps its size and\n   is animated in d, the one thing a hand changes: it is on\n   the picture, flat, where it cannot be seen, and it rises\n   to the height that gives `magnification`; leaving, it is\n   lowered again. Pressing lifts it a little further, and\n   because m runs away near f a little is enough for a\n   closer look.\n\n   The same steepness makes the two ways different. Most of\n   the enlargement is in the last of the lift, so it is the\n   last thing to arrive on the way up and the first to go on\n   the way down. Up, that is a kindness: a spring pulls its\n   hardest at the start, and at the start the glass magnifies\n   least, so nobody sees the jolt. Down, the same start lands\n   where it magnifies most, and nearly half the enlargement\n   is gone in a thirtieth of a second. That is a pop. So a\n   glass being lowered is not let go of: the hand eases into\n   the descent and the glass follows the hand, three lags in\n   a row where the rise has two, and it is given exactly as\n   long to come down as it took to come up. It never looks\n   that long. With the enlargement the first thing to go, it\n   reads as put down in about two thirds of the time, which\n   is the head start a thing leaving ought to have.\n\n   Height and position are critically damped springs, solved\n   exactly over the real time since the last frame, so a\n   120 Hz display does not run them twice as fast, a glass\n   turned round mid-rise carries on from the height and\n   speed it had, and nothing overshoots: glass is heavy. It\n   trails the hand by `trail`, enough to read as a thing\n   being carried and not enough to read as lag, and the loop\n   stops when it is still. Only a glass that can be seen has\n   to travel. Flat on the page it is wherever it is next\n   wanted, so it comes up under the hand and never streaks\n   in from where it was last put down. One still up when it\n   is wanted somewhere else is brought there at the weight\n   it was raised with, and a hand that takes it over\n   tightens its grip as it comes.\n\n   Touch has no hover, and a finger that lands has not yet\n   said whether it is the start of a scroll, so nothing moves\n   until it has. Rested as long as a long press takes\n   anywhere else, or dragged sideways, it has picked the\n   glass up, which then follows it in every direction, riding\n   above the finger where it can be seen. Dragged up or down\n   first, it scrolls the page as it would anywhere\n   (touch-action: pan-y), and the rest is a long press and\n   no shorter so that a thumb which hesitates before it\n   scrolls is still scrolling. A tap raises the glass\n   where it landed, for a moment: that is how anyone finds\n   out it is there, and how it is placed by someone who\n   cannot drag. However brief the touch, the look it gets is\n   that long, and a tap somewhere else while it is still up\n   carries it over.\n\n   A picture with an alt takes keyboard focus, since there is\n   something here to operate: the glass rises where the\n   keyboard last left it, mid-picture the first time; the\n   arrow keys carry it (further with Shift) — a tap of one\n   nudges it, and held it gathers to a walking pace — Space\n   or Enter leans in and Escape puts it down. The keys are\n   only borrowed from the page. An arrow that points past the\n   edge the glass is against scrolls again, and so do all of\n   them once the glass is down, until Space or Enter picks\n   it back up. Focus that a click left behind has not asked\n   for the glass at all, and takes no keys. One with an\n   empty alt is decoration and takes no focus. Under reduced\n   motion the glass is simply there, at the pointer, and\n   gone.\n   ========================================================= */\n\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 vUv;\nout vec4 outColor;\n\nuniform sampler2D uImage;\nuniform vec2 uSize;          // CSS px\nuniform vec4 uRect;          // drawn image: x, y, w, h in CSS px\nuniform float uCell;         // screen period, CSS px\nuniform vec4 uAngles;        // C, M, Y, K, radians\nuniform float uGcr;\nuniform vec3 uPaper;         // linear\nuniform vec3 uInk[4];        // linear transmittance of each ink\nuniform vec3 uLoupe;         // x, y, radius (px); radius 0 is off\nuniform float uMagnify;      // f / (f − d) at the height the glass is at now\n\nfloat coverage(float r) {\n  if (r <= 0.5) return 3.14159265 * r * r;\n  float seg = r * r * acos(0.5 / r) - 0.5 * sqrt(r * r - 0.25);\n  return 3.14159265 * r * r - 4.0 * seg;\n}\n\n// Radius whose clipped area is a, by bisection on [0, √½]. A(r) is monotonic.\nfloat radiusFor(float a) {\n  if (a <= 0.0) return 0.0;\n  if (a >= 1.0) return 0.7072;\n  float lo = 0.0, hi = 0.7072;\n  for (int i = 0; i < 12; i++) {\n    float mid = 0.5 * (lo + hi);\n    if (coverage(mid) < a) lo = mid; else hi = mid;\n  }\n  return 0.5 * (lo + hi);\n}\n\nvec3 sampleImage(vec2 p) {\n  vec2 uv = (p - uRect.xy) / uRect.zw;\n  if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return vec3(1.0);\n  return texture(uImage, vec2(uv.x, 1.0 - uv.y)).rgb;\n}\n\nvec4 separate(vec3 rgb) {\n  vec3 cmy = 1.0 - rgb;\n  float k = uGcr * min(cmy.r, min(cmy.g, cmy.b));\n  return vec4((cmy - k) / max(1.0 - k, 1e-4), k);\n}\n\nvoid main() {\n  vec2 p = vec2(vUv.x, 1.0 - vUv.y) * uSize;\n\n  // A loupe magnifies paper and ink together, blending to 1× across its rim.\n  if (uLoupe.z > 0.0) {\n    float d = distance(p, uLoupe.xy);\n    float m = mix(uMagnify, 1.0, smoothstep(uLoupe.z * 0.85, uLoupe.z, d));\n    p = uLoupe.xy + (p - uLoupe.xy) / m;\n  }\n\n  vec3 color = uPaper;\n  for (int i = 0; i < 4; i++) {\n    float a = uAngles[i];\n    mat2 rot = mat2(cos(a), sin(a), -sin(a), cos(a));\n    vec2 s = rot * p / uCell;\n    vec2 cell = floor(s) + 0.5;\n\n    // Tone is read where the dot sits, averaged over the cell, as a screen does.\n    vec2 c = transpose(rot) * cell * uCell;\n    vec2 o = transpose(rot) * vec2(0.25 * uCell);\n    vec2 q = transpose(rot) * vec2(0.25 * uCell, -0.25 * uCell);\n    vec3 rgb = (sampleImage(c + o) + sampleImage(c - o) + sampleImage(c + q) + sampleImage(c - q)) * 0.25;\n    float ink = separate(rgb)[i];\n\n    float d = length(s - cell);\n    float r = radiusFor(clamp(ink, 0.0, 1.0));\n    float aa = max(fwidth(d), 1e-4);\n    // A dot finer than a pixel fades out with its size, so no ink prints nothing:\n    // left alone, the edge below puts a half-tone speck at every cell of bare paper.\n    float dot_ = (1.0 - smoothstep(r - aa, r + aa, d)) * smoothstep(0.0, aa, r);\n    color *= mix(vec3(1.0), uInk[i], dot_);\n  }\n  outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);\n}`\n\nconst DEG = Math.PI / 180\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\nconst clamp01 = (n) => (n > 0 ? (n < 1 ? n : 1) : 0)\n// A finite number, or the fallback: one NaN in a uniform blanks the whole frame.\nconst num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d)\n\nconst SETTLE = 6.64   // ωt at which a critically damped spring is within 1% of home\nconst EASED = 8.41    // the same for one following an easing hand: three lags, not two\nconst LEAN = 0.16     // s for a press to arrive, and to let go\nconst SEEN = 0.002    // d/f below which a glass magnifies by less than anyone can see\nconst RESIZE = 0.06   // s — how quickly the glass takes a new `loupe` radius\nconst HOLD = 400      // ms a finger rests before it has picked the glass up: a long press\nconst SLOP = 8        // px a finger may wander and still be resting\nconst GLANCE = 1200   // ms a glass raised by touch stays up, at the least\nconst FINGER = 24     // px between a finger and the rim of the glass above it\nconst STRIDE = 320    // px/s a held arrow key carries the glass; three times that with Shift\nconst GATHER = 0.2    // s for a held arrow key to gather most of that pace\nconst NUDGE = 12      // px for the tap of an arrow key, however brief\nconst ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }\n\n/* d/f, the height of a glass as a share of its focal length, for the\n   magnification wanted: m = f/(f − d) turned round. */\nconst heightFor = (m) => 1 - 1 / clamp(m, 1, 1000)\n\n/* One exact step of a critically damped spring, ẍ = −ω²(x − to) − 2ω·ẋ, whose\n   solution is (a + b·t)·e^(−ωt). Exact for any dt, so the frame rate is not in\n   it, and it starts from the speed it had.\n\n   `lead` is how far short of `to` the hand holding the other end still is,\n   when that hand is itself closing the distance as lead·e^(−ωt): the spring\n   then pulls toward the hand and not the place, which adds ½ω²·lead·t² to the\n   bracket. The pull starts at nothing and builds, where a bare spring's starts\n   at its hardest. The caller moves the hand on by the same e^(−ω·dt). */\nfunction glide(s, to, w, dt, lead = 0) {\n  if (!(w < 1e5)) { s.x = to; s.v = 0; return }\n  const a = s.x - to\n  const b = s.v + w * a\n  const c = w * w * lead\n  const e = Math.exp(-w * dt)\n  const x = (a + b * dt + 0.5 * c * dt * dt) * e\n  s.x = to + x\n  s.v = (b + c * dt) * e - w * x\n}\n\n// Focus the browser would ring, from the keyboard; a click's focus is the pointer's business.\nconst keyboard = (el) => {\n  try { return el.matches(':focus-visible') } catch { return false }\n}\n\nexport default function HalftoneScreen({\n  src,\n  alt = '',\n  cell = 6,                         // screen period, px — a coarse magazine is ~6\n  angles = [15, 75, 0, 45],         // C, M, Y, K screen angles, degrees\n  gcr = 0.8,                        // grey-component replacement, 0..1\n  paper = '#f3eee3',\n  inks = ['#12a4d9', '#dd2a82', '#f7d51d', '#1b1a1f'], // C, M, Y, K\n  fit = 'cover',                    // 'cover' | 'contain'\n  focusX = 0.5,\n  focusY = 0.5,\n  loupe = 90,                       // loupe radius under the pointer, px; 0 turns it off\n  magnification = 3,\n  press = 1.5,                      // how much closer a held press looks, × magnification; 1 turns it off\n  rise = 280,                       // ms for the glass to come up to its height; 0 is at once\n  trail = 30,                       // ms the glass trails a moving hand by; 0 pins it to the pointer\n  resolution = 2,                   // device-pixel-ratio cap\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const canvasRef = useRef(null)\n  const api = useRef(null)\n  const plain = useRef(false)  // no WebGL2: the plain image stands in\n\n  const live = useRef(null)\n  // Synced after every render and before the effects below, so a redraw reads\n  // current props without the GL context being rebuilt.\n  useEffect(() => {\n    live.current = { cell, angles, gcr, paper, inks, fit, focusX, focusY, loupe, magnification, press, rise, trail }\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const glx = root && canvas && createGL(canvas, { onRestore: () => wake() })\n    if (!glx) {                      // the plain image stands in; the effect after this one keeps it there\n      plain.current = true\n      root?.classList.add('is-fallback')\n      root?.removeAttribute('tabindex')\n      return\n    }\n\n    const motion = window.matchMedia('(prefers-reduced-motion: reduce)')\n    let still = motion.matches\n\n    const program = glx.program(FRAGMENT)\n    const image = glx.texture()\n    let W = 1, H = 1\n\n    // The glass: where it is, how high it is held (d/f), and its radius.\n    const X = { x: 0, v: 0 }\n    const Y = { x: 0, v: 0 }\n    const D = { x: 0, v: 0 }\n    let hand = 0                     // the height the hand holding it has got to, which leads it down\n    let rad = 0\n    // Where it is wanted, and the client point of whatever hand put it there.\n    let tx = 0, ty = 0, cx = 0, cy = 0\n    let above = false                // carried by a finger, so it rides clear of it\n    let hop = 0                      // 1 while it is being brought from somewhere else, 0 once in hand\n\n    let hover = false                // a mouse or pen is over the picture\n    let down = false                 // its button, or the pen's tip, is held\n    let wait = null                  // a finger that has landed and not yet said what it is\n    let finger = null                // pointerId of the finger carrying the glass\n    let landed = 0                   // when the last finger landed\n    let glance = 0                   // timer holding up a glass a touch has left\n    let guarded = false              // touchmove is being listened for\n    let keyed = false                // the keyboard has it\n    let kept = null                  // where the keyboard last had it; null until it has\n    let leaning = false              // Space or Enter is held\n    let ax = 0, ay = 0, stride = 1   // where the held arrow keys point, and how far they reach\n    let pace = 0                     // 0..1, how much of its stride a held arrow has gathered\n    const arrows = new Set()\n    const owned = new Set()          // arrow keys still down that went down as the glass's\n\n    let last = 0                     // time of the last frame; 0 while the loop is stopped\n    let stale = true                 // something has changed since the picture was last painted\n    let seen = null                  // the props the colours below were worked out for\n    let ang = [0, 0, 0, 0]\n    let ink = new Array(12).fill(1)\n    let stock = [0.9, 0.87, 0.8]\n\n    const size = () => Math.max(0, num(live.current.loupe, 0))\n\n    const draw = (now) => {\n      /* A finger that has rested this long has picked the glass up. Timed\n         here and not by setTimeout: a browser may hold timers back for as\n         long as a touch it could still scroll with is down. While that is\n         all there is to do, nothing is painted: every finger that lands on\n         the picture on its way to scrolling the page is timed like this, and\n         a screen this size is not cheap to paint under a scroll. */\n      if (wait && now - wait.t >= HOLD) pick()\n      if (!stale) return wait !== null\n\n      const p = live.current\n      // A stopped loop has no last frame: its first step is a short nominal one.\n      const dt = last ? Math.min(Math.max(now - last, 0) / 1000, 1 / 30) : 1 / 120\n      last = now\n\n      // Colours go through a 2D context to be parsed: once per render, not per frame.\n      if (p !== seen) {\n        seen = p\n        ang = Array.from({ length: 4 }, (_, i) => (Number(p.angles?.[i]) || 0) * DEG)\n        ink = Array.from({ length: 4 }, (_, i) => linearRGB(p.inks?.[i], [1, 1, 1])).flat()\n        stock = linearRGB(p.paper, [0.9, 0.87, 0.8])\n        guard()\n      }\n\n      const iw = image.ready ? image.width : 1\n      const ih = image.ready ? image.height : 1\n      const scale = p.fit === 'contain' ? Math.min(W / iw, H / ih) : Math.max(W / iw, H / ih)\n      const dw = iw * scale\n      const dh = ih * scale\n\n      // Held arrow keys carry the place the glass is wanted, gathering to a\n      // walking pace, so the tap of one stays a nudge.\n      let carried = false\n      if (keyed && (ax || ay)) {\n        pace += (1 - pace) * (1 - Math.exp(-dt / GATHER))\n        const reach = STRIDE * stride * pace * dt\n        const nx = clamp(tx + ax * reach, 0, W)\n        const ny = clamp(ty + ay * reach, 0, H)\n        carried = nx !== tx || ny !== ty\n        tx = nx\n        ty = ny\n      }\n\n      const R = size()\n      const M = Math.max(1, num(p.magnification, 1))\n      const up = R > 0 && (hover || finger !== null || glance !== 0 || keyed)\n      const base = heightFor(M)\n      const want = !up ? 0 : down || leaning ? heightFor(M * Math.max(1, num(p.press, 1))) : base\n      const flat = D.x < SEEN        // asked before it is lifted this frame\n\n      const lift = Math.max(0, num(p.rise, 280)) / 1000\n      const heft = lift > 0 ? SETTLE / lift : Infinity   // ω of the spring that raises it\n      // A press, and letting go of one, are quicker than the rise; nothing is slower.\n      const time = want > 0 && (want > base || D.x > base) ? Math.min(lift, LEAN) : lift\n      if (still || !(time > 0)) {\n        D.x = hand = want; D.v = 0\n      } else {\n        /* Up, the hand is at the new height at once and the glass springs\n           after it. Down, the hand eases off too, and is still `lead` above\n           where it is going: counted from no higher than the glass, since\n           one turned round on its way up was never carried the rest of the\n           way, and is not carried there now. Either way is over in `time`. */\n        const lead = Math.max(0, Math.min(hand, D.x) - want)\n        const w = (lead > 0 ? EASED : SETTLE) / time\n        glide(D, want, w, dt, lead)\n        hand = want + lead * Math.exp(-w * dt)\n        // Headed for the page it only has to be too low to see; anywhere else, there.\n        const near = want === 0 ? SEEN : 1e-4\n        if (Math.abs(want - D.x) < near && Math.abs(D.v) < near * 30 && hand - want < near) {\n          D.x = hand = want; D.v = 0\n        }\n      }\n\n      // Flat on the page a glass shows nothing, so it is simply wherever it is\n      // wanted. Once it can be seen it has to travel to get there.\n      if (still || flat) {\n        X.x = tx; X.v = 0\n        Y.x = ty; Y.v = 0\n        if (R > 0) rad = R\n        hop = 0\n      } else {\n        /* Led by a hand it keeps close: at a steady speed this spring sits\n           2/ω behind. Wanted somewhere else while it is up, it is brought\n           there at the weight it was raised with; at the pace of the first\n           it would cross in six frames, a glass-width at a time. A tap sends\n           it the whole way like that. A hand that takes it up from where it\n           lay tightens its grip as it comes, over the time of a rise, so\n           there is no moment at which one spring becomes the other. */\n        const lag = Math.max(0, num(p.trail, 30)) / 1000\n        const close = lag > 0 ? 2 / lag : Infinity\n        if (hop > 0 && (hover || finger !== null)) {\n          hop *= Math.exp(-3 * dt / lift)\n          if (hop < 0.01) hop = 0\n        }\n        const w = hop > 0 ? heft * (close / heft) ** (1 - hop) : close\n        glide(X, tx, w, dt)\n        glide(Y, ty, w, dt)\n        if (Math.hypot(tx - X.x, ty - Y.x) < 0.05 && Math.hypot(X.v, Y.v) < 2) {\n          X.x = tx; X.v = 0\n          Y.x = ty; Y.v = 0\n          hop = 0\n        }\n        // A new radius is taken up rather than snapped to; turned off, the\n        // glass is lowered at the size it was.\n        if (R > 0) {\n          rad += (R - rad) * (1 - Math.exp(-dt / RESIZE))\n          if (Math.abs(R - rad) < 0.25) rad = R\n        }\n      }\n\n      program.set({\n        uImage: image,\n        uSize: [W, H],\n        // Until there is a picture the rect is off the page, and everywhere reads\n        // as bare paper: the stand-in texel is transparent black, which the\n        // separation would print as all four inks, solid.\n        uRect: image.ready\n          ? [(W - dw) * clamp01(p.focusX), (H - dh) * clamp01(p.focusY), dw, dh]\n          : [-2, -2, 1, 1],\n        uCell: Math.max(1.5, num(p.cell, 6)),\n        uAngles: ang,\n        uGcr: clamp01(num(p.gcr, 0.8)),\n        uPaper: stock,\n        uInk: ink,\n        uLoupe: [X.x, Y.x, D.x > 0 ? rad : 0],\n        uMagnify: 1 / (1 - D.x),\n      })\n      glx.draw()\n\n      stale = D.x !== want || D.v !== 0 || hand !== want || X.x !== tx || Y.x !== ty || (R > 0 && D.x > 0 && rad !== R)\n        || carried\n      if (!stale) last = 0\n      return stale || wait !== null\n    }\n\n    const loop = createLoop(root, draw)\n    const wake = () => {\n      stale = true\n      loop.kick()\n    }\n    api.current = {\n      kick: wake,\n      setImage(img) {\n        image.update(img)\n        wake()\n      },\n    }\n\n    /* Put the place the glass is wanted under the hand at (cx, cy) — or, for\n       a finger, clear above it, since a glass under a fingertip shows the\n       finger. It stays on the picture, so one dragged past an edge waits\n       there. */\n    const aim = () => {\n      const b = root.getBoundingClientRect()\n      tx = clamp(cx - b.left, 0, W)\n      ty = clamp(cy - b.top - (above ? Math.min(size() + FINGER, H / 2) : 0), 0, H)\n    }\n\n    // The page scrolling under a still hand moves the paper, not the hand.\n    const onScroll = () => { aim(); wake() }\n    let watching = false\n    const watch = () => {\n      const on = hover || finger !== null\n      if (on === watching) return\n      watching = on\n      window[on ? 'addEventListener' : 'removeEventListener']('scroll', onScroll, { capture: true, passive: true })\n    }\n\n    const stopGlance = () => {\n      clearTimeout(glance)\n      glance = 0\n    }\n    /* A glass that touch raised is up for GLANCE from when the finger landed,\n       so a tap gets a look, and so does a touch that was only just long\n       enough to count as a hold. A long one has had its look already. */\n    const look = () => {\n      stopGlance()\n      const left = GLANCE - (performance.now() - landed)\n      if (left > 0) glance = setTimeout(() => { glance = 0; wake() }, left)\n    }\n\n    /* The finger has said it is here for the glass. Called from the frame\n       loop as well as from a move, so waking the loop is left to the caller. */\n    const pick = () => {\n      finger = wait.id\n      wait = null\n      stale = true\n      hop = 1                       // if it is up somewhere, it is brought over, not snatched\n      stopGlance()\n      above = true\n      try { root.setPointerCapture(finger) } catch { /* capture is a nicety */ }\n      aim(); watch()\n    }\n    // Every finger is off the glass, and it gets no parting look.\n    const drop = () => {\n      wait = null\n      finger = null\n      stopGlance()\n      watch(); wake()\n    }\n\n    const onDown = (e) => {\n      if (e.pointerType === 'touch') {\n        // A second finger makes it a pinch, and that is the page's.\n        if (!e.isPrimary) { drop(); return }\n        if (!(size() > 0)) return\n        cx = e.clientX\n        cy = e.clientY\n        landed = performance.now()\n        wait = { id: e.pointerId, x: cx, y: cy, t: landed }\n        if (glance) look()          // a glass still up from the last touch waits to hear what this one is\n        loop.kick()                 // the frame loop is what times the hold; there is nothing yet to paint\n        return\n      }\n      if (e.button !== 0) return\n      down = true\n      try { root.setPointerCapture(e.pointerId) } catch { /* capture is a nicety */ }\n      onMove(e)\n    }\n\n    const onMove = (e) => {\n      if (e.pointerType === 'touch') {\n        if (wait && e.pointerId === wait.id) {\n          cx = e.clientX\n          cy = e.clientY\n          const dx = Math.abs(cx - wait.x)\n          const dy = Math.abs(cy - wait.y)\n          // Held long enough already, if no frame has come round to say so.\n          if (performance.now() - wait.t >= HOLD || (dx > SLOP && dx > dy)) { pick(); wake() }\n          else if (dy > SLOP) wait = null            // a scroll: the browser is about to say so\n        } else if (e.pointerId === finger) {\n          cx = e.clientX\n          cy = e.clientY\n          aim(); wake()\n        }\n        return\n      }\n      cx = e.clientX\n      cy = e.clientY\n      above = false\n      if (!hover) hop = 1           // a new hand: a glass still up elsewhere is brought to it\n      hover = true\n      stopGlance()\n      aim(); watch(); wake()\n    }\n\n    /* A hand lets go. A finger lifting leaves the glass where it was, to be\n       lowered there; one that lifts before it has said anything was a tap,\n       and the glass comes up at the place it tapped, since nothing is in\n       the way of it there now. A mouse button coming up only ends the lean. */\n    const onUp = (e) => {\n      /* A finger is captured by whatever it landed on, which is the canvas.\n         Taking the capture here makes the canvas lose it, and that bubbles:\n         it is the capture moving, not the finger leaving. */\n      if (e.type === 'lostpointercapture' && e.target !== root) return\n      if (e.pointerType === 'touch') {\n        const lifted = e.type === 'pointerup'   // or the page took it, to scroll or to pinch with\n        if (wait && e.pointerId === wait.id) {\n          wait = null\n          if (!lifted) return\n          above = false\n          aim()\n          hop = 1\n          look(); wake()\n        } else if (e.pointerId === finger) {\n          finger = null\n          if (lifted) look()\n          watch(); wake()\n        }\n        return\n      }\n      if (!down) return\n      down = false\n      // A pen that the page took to scroll with is no longer over anything.\n      if (e.type === 'pointercancel') { hover = false; watch() }\n      wake()\n    }\n\n    /* It is set down at the edge the pointer left by, not wherever it last\n       reported from. One the keyboard still has is not being set down at\n       all, and stays on the picture where the pointer had it. */\n    const onLeave = (e) => {\n      if (e.pointerType === 'touch' || !hover) return   // fires after every lift; not a leave\n      if (!keyed) {\n        cx = e.clientX\n        cy = e.clientY\n        aim()\n      }\n      hover = false\n      down = false\n      watch(); wake()\n    }\n\n    // Once a finger holds the glass every direction is the glass's; two are still a pinch.\n    const onTouchMove = (e) => {\n      if (finger !== null && e.cancelable && e.touches.length < 2) e.preventDefault()\n    }\n    /* To be able to say that, the listener cannot be passive, and then the\n       browser has to ask it before any scroll that starts on the picture. So\n       it is only there while there is a glass to carry. */\n    const guard = () => {\n      const on = size() > 0\n      if (on === guarded) return\n      guarded = on\n      root[on ? 'addEventListener' : 'removeEventListener']('touchmove', onTouchMove, { passive: false })\n    }\n    // A long press is how the glass is picked up, not a request for a menu.\n    const onMenu = (e) => { if (wait || finger !== null) e.preventDefault() }\n\n    const steer = () => {\n      ax = (arrows.has('ArrowRight') ? 1 : 0) - (arrows.has('ArrowLeft') ? 1 : 0)\n      ay = (arrows.has('ArrowDown') ? 1 : 0) - (arrows.has('ArrowUp') ? 1 : 0)\n      if (!ax && !ay) pace = 0\n    }\n\n    /* The keyboard takes the glass. One a hand is on, or one still up, is\n       taken where it is. One lying flat comes up where the keyboard last had\n       it, mid-picture the first time, and not at whichever edge a pointer\n       happened to leave by, half off the picture. */\n    const take = () => {\n      if (keyed) return\n      keyed = true\n      if (hover || finger !== null || glance !== 0 || D.x >= SEEN) return\n      const [x, y] = kept ?? [W / 2, H / 2]\n      tx = clamp(x, 0, W)\n      ty = clamp(y, 0, H)\n    }\n    // And lets it go, remembering where.\n    const letGo = () => {\n      if (keyed) kept = [tx, ty]\n      keyed = leaning = false\n      arrows.clear()\n      steer(); wake()\n    }\n    const onKeyDown = (e) => {\n      if (e.target !== root || e.altKey || e.ctrlKey || e.metaKey || !(size() > 0)) return\n      stride = e.shiftKey ? 3 : 1\n      const dir = ARROWS[e.key]\n      if (dir) {\n        /* An arrow is the glass's only while the keyboard has the glass and\n           there is somewhere left to carry it. Put down, or already against\n           that edge of the picture, the key is the page's to scroll with. A\n           held key stays whose it was when it went down: the frame loop is\n           carrying the glass, and neither reaching the edge nor Escape starts\n           the page moving under the same press. */\n        if (e.repeat) {\n          if (owned.has(e.key)) e.preventDefault()\n          return\n        }\n        if (!keyed) return\n        const nx = clamp(tx + dir[0] * NUDGE * stride, 0, W)\n        const ny = clamp(ty + dir[1] * NUDGE * stride, 0, H)\n        if (nx === tx && ny === ty) return\n        e.preventDefault()\n        hop = 0\n        tx = nx\n        ty = ny\n        arrows.add(e.key)\n        owned.add(e.key)\n        steer(); wake()\n      } else if (e.key === ' ' || e.key === 'Enter') {\n        // These pick the glass up as well as lean on it, but only for a\n        // keyboard that is on the picture: a click leaves focus here too.\n        if (!keyed && !keyboard(root)) return\n        e.preventDefault()\n        if (e.repeat) return\n        take()\n        leaning = true\n        wake()\n      } else if (e.key === 'Escape' && keyed) {\n        letGo()\n      }\n    }\n    const onKeyUp = (e) => {\n      stride = e.shiftKey ? 3 : 1\n      owned.delete(e.key)\n      if (e.key === ' ' || e.key === 'Enter') leaning = false\n      else if (!arrows.delete(e.key)) return\n      steer(); wake()\n    }\n    const onFocus = () => {\n      if (!keyboard(root)) return\n      take(); wake()\n    }\n    // A key still down when focus goes is let up somewhere else.\n    const onBlur = () => { owned.clear(); letGo() }\n\n    const onMotion = () => { still = motion.matches; wake() }\n\n    const ro = new ResizeObserver(() => {\n      const b = root.getBoundingClientRect()\n      W = Math.max(1, b.width)\n      H = Math.max(1, b.height)\n      glx.resize(W, H, resolution)\n      if (hover || finger !== null) aim()\n      else { tx = clamp(tx, 0, W); ty = clamp(ty, 0, H) }\n      wake()\n    })\n    ro.observe(root)\n    root.addEventListener('pointerenter', onMove, { passive: true })\n    root.addEventListener('pointermove', onMove, { passive: true })\n    root.addEventListener('pointerdown', onDown, { passive: true })\n    root.addEventListener('pointerup', onUp, { passive: true })\n    root.addEventListener('pointercancel', onUp, { passive: true })\n    root.addEventListener('lostpointercapture', onUp, { passive: true })\n    root.addEventListener('pointerleave', onLeave, { passive: true })\n    root.addEventListener('contextmenu', onMenu)\n    root.addEventListener('keydown', onKeyDown)\n    root.addEventListener('keyup', onKeyUp)\n    root.addEventListener('focus', onFocus)\n    root.addEventListener('blur', onBlur)\n    motion.addEventListener?.('change', onMotion)\n\n    return () => {\n      api.current = null\n      ro.disconnect()\n      loop.stop()\n      stopGlance()\n      wait = null\n      hover = false\n      finger = null\n      watch()\n      root.removeEventListener('pointerenter', onMove)\n      root.removeEventListener('pointermove', onMove)\n      root.removeEventListener('pointerdown', onDown)\n      root.removeEventListener('pointerup', onUp)\n      root.removeEventListener('pointercancel', onUp)\n      root.removeEventListener('lostpointercapture', onUp)\n      root.removeEventListener('pointerleave', onLeave)\n      root.removeEventListener('touchmove', onTouchMove)\n      root.removeEventListener('contextmenu', onMenu)\n      root.removeEventListener('keydown', onKeyDown)\n      root.removeEventListener('keyup', onKeyUp)\n      root.removeEventListener('focus', onFocus)\n      root.removeEventListener('blur', onBlur)\n      motion.removeEventListener?.('change', onMotion)\n      glx.dispose()\n    }\n  }, [resolution])\n\n  /* Without WebGL2 the plain image stands in, and there is no glass to carry,\n     so nothing to focus. Marked after every render and not once: React writes\n     class and tabindex afresh whenever a prop that feeds them changes, and\n     would take the mark off with it. And before the browser paints, or a\n     frame of bare paper shows between. */\n  useIsomorphicLayoutEffect(() => {\n    const root = rootRef.current\n    if (!plain.current || !root) return\n    root.classList.add('is-fallback')\n    root.removeAttribute('tabindex')\n  })\n\n  // The image loads on its own, and a stale load must never land on a newer src.\n  useEffect(() => loadImage(src, (img) => api.current?.setImage(img)), [src, resolution])\n\n  const angleKey = Array.isArray(angles) ? angles.join(',') : ''\n  const inkKey = Array.isArray(inks) ? inks.join(',') : ''\n  useEffect(() => {\n    api.current?.kick()\n  }, [cell, angleKey, gcr, paper, inkKey, fit, focusX, focusY, loupe, magnification, press, rise, trail])\n\n  const glass = loupe > 0\n  return (\n    <div\n      ref={rootRef}\n      className={[\n        'halftone-screen',\n        glass && 'halftone-screen--loupe',\n        glass && press > 1 && 'halftone-screen--lean',\n        className,\n      ].filter(Boolean).join(' ')}\n      /* Same contract as <img>: a description names the picture; an empty alt\n         marks it decorative. A named picture with a loupe is also a tab stop,\n         because the glass can be carried from the keyboard; tabIndex={-1}\n         takes it back out of the order. */\n      role={alt ? 'img' : undefined}\n      aria-label={alt || undefined}\n      aria-hidden={alt ? undefined : 'true'}\n      tabIndex={alt && glass ? 0 : undefined}\n      style={{\n        '--halftone-src': src ? `url(\"${String(src).replace(/\"/g, '%22')}\")` : 'none',\n        '--halftone-paper': paper,\n        '--halftone-key': Array.isArray(inks) ? inks[3] : undefined,\n        ...style,\n      }}\n      {...rest}\n    >\n      <canvas ref={canvasRef} className=\"halftone-screen__canvas\" aria-hidden=\"true\" />\n    </div>\n  )\n}\n","type":"registry:component","target":"components/halftone-screen/HalftoneScreen.jsx"},{"path":"src/components/HalftoneScreen/HalftoneScreen.css","content":"/* HalftoneScreen — the canvas fills the box. Where WebGL2 is unavailable the\n   plain image stands in, so the picture is never simply missing. */\n\n.halftone-screen {\n  position: relative;\n  overflow: hidden;\n  background: var(--halftone-paper, #f3eee3);\n}\n\n/* With a loupe the picture is something a hand works. A finger dragged up or\n   down still scrolls the page and two still zoom it; one dragged sideways, or\n   rested, carries the glass. A long press is how the glass is picked up, so it\n   must not also select the page around it or raise a callout. */\n.halftone-screen--loupe {\n  touch-action: pan-y pinch-zoom;\n  -webkit-user-select: none;\n  user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n/* zoom-in promises that a press looks closer; it is only shown where one does. */\n.halftone-screen--lean {\n  cursor: zoom-in;\n}\n\n.halftone-screen__canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n/* Keyboard focus is drawn inside the picture, where a clipping ancestor cannot\n   cut it off, as a key-ink rule between two paper keylines. Two bands are not\n   enough: on a dark page a light picture swallows both, the ink into the page\n   and the paper into the picture. Kept between paper on either side, the rule\n   has its own ground wherever it is put. The transparent outline is what\n   forced colours draw instead, since they drop box-shadow. */\n.halftone-screen:focus-visible {\n  outline: 2px solid transparent;\n  outline-offset: -2px;\n}\n\n.halftone-screen:focus-visible::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  box-shadow:\n    inset 0 0 0 1px var(--halftone-paper, #f3eee3),\n    inset 0 0 0 3px var(--halftone-key, #1b1a1f),\n    inset 0 0 0 4px var(--halftone-paper, #f3eee3);\n}\n\n/* No glass to carry, so every gesture is the page's again. */\n.halftone-screen.is-fallback {\n  background: var(--halftone-src) center / cover no-repeat;\n  cursor: default;\n  touch-action: auto;\n}\n\n.halftone-screen.is-fallback .halftone-screen__canvas {\n  display: none;\n}\n","type":"registry:file","target":"components/halftone-screen/HalftoneScreen.css"},{"path":"src/components/gl-core/gl.js","content":"/* =========================================================\n   gl-core\n   The WebGL2 layer under Caustic Surface, Halftone Screen,\n   Focus Peaking and Brushed Metal. Each of those is one\n   fragment shader over a rectangle; everything that is not\n   the shader lives here, once.\n\n   It is deliberately not a renderer. It owns the four places\n   raw WebGL goes quietly wrong, and nothing else.\n\n   1  Geometry. The picture is one triangle, (−1,−1) (3,−1)\n      (−1,3), clipped to the viewport — not a two-triangle\n      quad, whose diagonal seam is shaded twice. vUv runs\n      0..1 over the visible part, (0,0) bottom-left.\n\n   2  Uniforms. Which gl.uniform* call a value needs is decided\n      by the shader, not the value: [1,0] is a vec2 or an ivec2\n      depending on the declaration. So types come from the\n      linked program's own active-uniform table, and set(name,\n      value) cannot pick the wrong call. Samplers get fixed\n      texture units at link time; left alone they all default\n      to unit 0 and read whichever texture was bound last.\n\n   3  Size. A drawing buffer is device pixels, a layout box is\n      CSS pixels, and on a very large canvas the browser may\n      allocate less than was asked for without saying so.\n      resize() caps the density and takes the viewport from\n      what was actually allocated.\n\n   4  Context loss. The GPU can be taken away at any moment —\n      a driver reset, memory pressure on a phone, too many\n      contexts on one page. The browser only offers the context\n      back if the loss event was preventDefault()ed. Here it is,\n      and every handle keeps what it needs to rebuild itself,\n      so a component's references survive a loss and all it\n      does is draw again.\n\n   dispose() frees what was created but does not force the\n   context lost: StrictMode remounts an effect on the same\n   canvas, and getContext() hands back the same context — lose\n   it on the first unmount and the second mount inherits a\n   dead one.\n   ========================================================= */\n\n/* Clip-space triangle in, vUv out. */\nexport const FULLSCREEN_VERTEX = `#version 300 es\nlayout(location = 0) in vec2 position;\nout vec2 vUv;\nvoid main() {\n  vUv = position * 0.5 + 0.5;\n  gl_Position = vec4(position, 0.0, 1.0);\n}`\n\nconst EMPTY = new Uint8Array(4)\n\n/** Returns null when WebGL2 is unavailable, so the caller can render a fallback. */\nexport function createGL(canvas, { onLost, onRestore, ...attributes } = {}) {\n  let gl = null\n  try {\n    gl = canvas.getContext('webgl2', {\n      alpha: true, antialias: false, depth: false, stencil: false,\n      premultipliedAlpha: true, ...attributes,\n    })\n  } catch {\n    gl = null\n  }\n  if (!gl) return null\n\n  const resources = new Set()     // { build, free } behind every handle handed out\n  let lost = gl.isContextLost()\n  let dead = false\n  let vao = null\n  let vbo = null\n\n  const live = () => !lost && !dead\n\n  const SETTERS = new Map([\n    [gl.FLOAT, '1f'], [gl.FLOAT_VEC2, '2f'], [gl.FLOAT_VEC3, '3f'], [gl.FLOAT_VEC4, '4f'],\n    [gl.INT, '1i'], [gl.BOOL, '1i'], [gl.INT_VEC2, '2i'], [gl.INT_VEC3, '3i'],\n    [gl.FLOAT_MAT3, 'Matrix3f'], [gl.FLOAT_MAT4, 'Matrix4f'],\n  ])\n\n  const buildShared = () => {\n    vao = gl.createVertexArray()\n    vbo = gl.createBuffer()\n    gl.bindVertexArray(vao)\n    gl.bindBuffer(gl.ARRAY_BUFFER, vbo)\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)\n    gl.enableVertexAttribArray(0)\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)\n    gl.bindVertexArray(null)\n  }\n\n  const onContextLost = (e) => {\n    e.preventDefault()            // the only thing that makes 'restored' ever fire\n    lost = true\n    onLost?.()\n  }\n  const onContextRestored = () => {\n    if (dead) return\n    lost = false\n    buildShared()\n    for (const r of resources) r.build()\n    onRestore?.()\n  }\n  canvas.addEventListener('webglcontextlost', onContextLost)\n  canvas.addEventListener('webglcontextrestored', onContextRestored)\n  if (!lost) buildShared()\n\n  // Registers a handle's GL side: built now, or on restore if the context is down.\n  const own = (entry) => {\n    resources.add(entry)\n    if (live()) entry.build()\n    return () => {\n      if (resources.delete(entry) && !dead) entry.free()\n    }\n  }\n\n  /* ---------------- programs ---------------- */\n\n  const compileError = (shader, label) =>\n    gl.getShaderParameter(shader, gl.COMPILE_STATUS)\n      ? ''\n      : `${label} shader: ${(gl.getShaderInfoLog(shader) || '').trim()}`\n\n  function program(fragment, vertex = FULLSCREEN_VERTEX) {\n    // '#version' must be the very first characters, and template literals rarely start there.\n    const fs = fragment.trimStart()\n    const vs = vertex.trimStart()\n    const uniforms = new Map()\n\n    const h = {\n      program: null,\n      /** Sets one uniform, or several from an object. Unknown names are ignored:\n          a uniform the linker optimised away is not an error. */\n      set(name, value) {\n        if (!live() || !h.program) return h\n        gl.useProgram(h.program)\n        if (typeof name === 'object') {\n          for (const k in name) uniforms.get(k)?.(name[k])\n        } else {\n          uniforms.get(name)?.(value)\n        }\n        return h\n      },\n      dispose: () => release(),\n    }\n\n    const build = () => {\n      const p = gl.createProgram()\n      const v = gl.createShader(gl.VERTEX_SHADER)\n      const f = gl.createShader(gl.FRAGMENT_SHADER)\n      gl.shaderSource(v, vs)\n      gl.shaderSource(f, fs)\n      gl.compileShader(v)\n      gl.compileShader(f)\n      gl.attachShader(p, v)\n      gl.attachShader(p, f)\n      gl.linkProgram(p)\n      // Status is only queried after a failed link; asking up front forces a\n      // synchronous driver round-trip per shader even when nothing is wrong.\n      const why = gl.getProgramParameter(p, gl.LINK_STATUS) || gl.isContextLost()\n        ? ''\n        : compileError(v, 'vertex') || compileError(f, 'fragment') || gl.getProgramInfoLog(p)\n      gl.deleteShader(v)            // the program keeps them alive while attached\n      gl.deleteShader(f)\n      if (why) {\n        gl.deleteProgram(p)\n        throw new Error(`gl-core: ${why}`)\n      }\n\n      h.program = p\n      uniforms.clear()\n      gl.useProgram(p)\n      let unit = 0\n      const count = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS) || 0\n      for (let i = 0; i < count; i++) {\n        const info = gl.getActiveUniform(p, i)\n        const loc = info && gl.getUniformLocation(p, info.name)\n        if (!loc) continue\n\n        const name = info.name.replace(/\\[0\\]$/, '')\n        if (info.type === gl.SAMPLER_2D) {\n          const u = unit++\n          gl.uniform1i(loc, u)\n          uniforms.set(name, (tex) => {\n            gl.activeTexture(gl.TEXTURE0 + u)\n            gl.bindTexture(gl.TEXTURE_2D, tex?.texture ?? null)\n          })\n          continue\n        }\n        const suffix = SETTERS.get(info.type)\n        if (!suffix) continue\n        const vec = gl[`uniform${suffix}v`].bind(gl)\n        if (suffix.startsWith('Matrix')) uniforms.set(name, (m) => vec(loc, false, m))\n        else if (suffix[0] === '1' && info.size === 1) uniforms.set(name, (n) => gl[`uniform${suffix}`](loc, +n))\n        else uniforms.set(name, (list) => vec(loc, list))\n      }\n    }\n\n    const release = own({\n      build,\n      free: () => { gl.deleteProgram(h.program); h.program = null; uniforms.clear() },\n    })\n    return h\n  }\n\n  /* ---------------- textures ---------------- */\n\n  /* An image, canvas, bitmap or video frame. Until the source has pixels — or\n     if it is cross-origin without CORS, and so not ours to read — a transparent\n     1×1 stands in, so a sampler never reads an incomplete texture, which GL\n     defines as black and some drivers as garbage. */\n  function texture(source = null, { flipY = true, repeat = false } = {}) {\n    let src = source\n    const h = {\n      texture: null,\n      width: 0,\n      height: 0,\n      ready: false,\n      update(next) {\n        if (next !== undefined) src = next\n        if (live() && h.texture) upload()\n        return h.ready\n      },\n      dispose: () => release(),\n    }\n\n    const upload = () => {\n      // On a unit no sampler uses: binding to upload replaces what the active\n      // unit held, which would silently swap out a texture a sampler reads.\n      gl.activeTexture(gl.TEXTURE0 + gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) - 1)\n      gl.bindTexture(gl.TEXTURE_2D, h.texture)\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)\n      const w = src ? src.naturalWidth || src.videoWidth || src.width || 0 : 0\n      const ht = src ? src.naturalHeight || src.videoHeight || src.height || 0 : 0\n      let ok = false\n      if (w > 0 && ht > 0 && src.complete !== false) {\n        try {\n          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, src)\n          ok = true\n        } catch {\n          ok = false\n        }\n      }\n      if (!ok) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, EMPTY)\n\n      const wrap = repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap)\n      h.width = ok ? w : 1\n      h.height = ok ? ht : 1\n      h.ready = ok\n    }\n\n    const release = own({\n      build: () => { h.texture = gl.createTexture(); upload() },\n      free: () => { gl.deleteTexture(h.texture); h.texture = null; h.ready = false },\n    })\n    return h\n  }\n\n  return {\n    gl,\n    get lost() { return lost || dead },\n    program,\n    texture,\n\n    /** Draws the triangle with the program whose set() ran last. */\n    draw() {\n      if (!live() || !vao) return\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)\n      gl.bindVertexArray(vao)\n      gl.drawArrays(gl.TRIANGLES, 0, 3)\n      gl.bindVertexArray(null)\n    },\n\n    /* CSS size in, device-pixel size out. Assigning canvas.width clears the\n       buffer even to the same value, so it is only written on a change. */\n    resize(width, height, dprCap = 2) {\n      const dpr = Math.min(window.devicePixelRatio || 1, dprCap > 0 ? dprCap : 2)\n      const px = (n) => Math.max(1, Math.round((Number.isFinite(n) && n > 0 ? n : 0) * dpr))\n      const w = px(width)\n      const ht = px(height)\n      if (canvas.width !== w) canvas.width = w\n      if (canvas.height !== ht) canvas.height = ht\n      // Lost, the buffer reports 0×0; the requested size is the best answer then.\n      return { width: gl.drawingBufferWidth || w, height: gl.drawingBufferHeight || ht, dpr }\n    },\n\n    dispose() {\n      if (dead) return\n      for (const r of resources) r.free()\n      resources.clear()\n      gl.deleteVertexArray(vao)\n      gl.deleteBuffer(vbo)\n      vao = vbo = null\n      canvas.removeEventListener('webglcontextlost', onContextLost)\n      canvas.removeEventListener('webglcontextrestored', onContextRestored)\n      dead = true\n    },\n  }\n}\n\n/* Runs `frame(now)` on animation frames while the element is on screen and the\n   caller says there is more to draw. `frame` returns true to keep going; kick()\n   restarts a settled loop. Shared because all four GL components need exactly\n   this, and each hand-rolled copy is another place to forget the observer. */\nexport function createLoop(element, frame) {\n  let raf = 0\n  let onScreen = true\n  const tick = (now) => {\n    raf = 0\n    if (onScreen && frame(now)) raf = requestAnimationFrame(tick)\n  }\n  const io = new IntersectionObserver(([entry]) => {\n    onScreen = entry.isIntersecting\n    if (onScreen) kick()\n    else if (raf) { cancelAnimationFrame(raf); raf = 0 }\n  })\n  io.observe(element)\n  function kick() {\n    if (!raf && onScreen) raf = requestAnimationFrame(tick)\n  }\n  return {\n    kick,\n    stop() {\n      io.disconnect()\n      if (raf) cancelAnimationFrame(raf)\n      raf = 0\n    },\n  }\n}\n\n/* Any CSS colour to linear RGB, 0..1. Shaders light in linear space; a hex\n   string is sRGB, and lighting it without decoding darkens every midtone. The\n   2D context normalises whatever CSS accepts — names, rgb(), hsl() — to hex, or\n   to rgba() when it is not opaque.\n\n   A string the context cannot parse is ignored, and fillStyle keeps what it\n   held, so reading it back after one assignment cannot tell a typo from a real\n   black. Assigning from two different starting points can: only an unparseable\n   string gives two answers, and it takes the fallback. A translucent colour\n   gives its rgb, as a shader has nothing to blend with; a fully transparent\n   one has no colour to give and takes the fallback too. */\nlet probe = null\nexport function linearRGB(color, fallback = [0, 0, 0]) {\n  probe ??= document.createElement('canvas').getContext('2d')\n  if (!probe || typeof color !== 'string') return fallback\n  probe.fillStyle = '#000'\n  probe.fillStyle = color\n  const fromBlack = probe.fillStyle\n  probe.fillStyle = '#fff'\n  probe.fillStyle = color\n  if (probe.fillStyle !== fromBlack) return fallback\n  let rgb\n  const hex = /^#([0-9a-f]{6})$/i.exec(fromBlack)\n  if (hex) {\n    const n = parseInt(hex[1], 16)\n    rgb = [n >> 16, (n >> 8) & 255, n & 255]\n  } else {\n    const rgba = /^rgba\\(\\s*(\\d+),\\s*(\\d+),\\s*(\\d+),\\s*([\\d.]+)\\s*\\)$/i.exec(fromBlack)\n    if (!rgba || Number(rgba[4]) === 0) return fallback\n    rgb = [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])]\n  }\n  return rgb.map((c) => {\n    const v = c / 255\n    return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4\n  })\n}\n\n/* Loads an image whose pixels will be uploaded. A cross-origin URL is asked\n   for with CORS, because without it the pixels are not the page's to read and\n   the upload throws. Returns a cancel function, so a load that finishes after\n   the src changed — or after unmount — never lands. */\nexport function loadImage(src, done) {\n  let cancelled = false\n  if (!src) {\n    done(null)\n    return () => {}\n  }\n  const img = new Image()\n  try {\n    if (new URL(src, window.location.href).origin !== window.location.origin) img.crossOrigin = 'anonymous'\n  } catch {\n    // Not a URL the page can resolve; let the browser report it through onerror.\n  }\n  img.decoding = 'async'\n  img.onload = () => { if (!cancelled) done(img) }\n  img.onerror = () => { if (!cancelled) done(null) }\n  img.src = src\n  return () => { cancelled = true }\n}\n","type":"registry:component","target":"components/gl-core/gl.js"},{"path":"src/components/HalftoneScreen/HalftoneScreen.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport interface HalftoneScreenProps extends ComponentPropsWithoutRef<'div'> {\n  /** Image URL. Cross-origin images need CORS for their pixels to be read. */\n  src?: string\n  /** Names the picture, as on an <img>. Empty marks it decorative. A named picture with a loupe is a tab stop: arrows carry the glass, Space or Enter leans in, Escape puts it down. */\n  alt?: string\n  /** Screen period, px. Smaller is a finer screen. */\n  cell?: number\n  /** Screen angle of each ink, degrees: [cyan, magenta, yellow, black]. */\n  angles?: [number, number, number, number]\n  /** Grey-component replacement, 0..1: how much shared grey prints as black. */\n  gcr?: number\n  /** Paper colour. Any CSS colour. */\n  paper?: string\n  /** Ink colours: [cyan, magenta, yellow, black]. */\n  inks?: [string, string, string, string]\n  /** How the image fills the box. */\n  fit?: 'cover' | 'contain'\n  /** Horizontal alignment of the image within the box, 0..1. */\n  focusX?: number\n  /** Vertical alignment of the image within the box, 0..1. */\n  focusY?: number\n  /** Radius of the loupe under the pointer, finger or arrow keys, px. 0 turns it off. */\n  loupe?: number\n  /** Loupe magnification. */\n  magnification?: number\n  /** How much closer a held press (or Space / Enter) looks, × magnification. 1 turns it off. */\n  press?: number\n  /** Time for the glass to come up to its height, ms. 0 is at once. */\n  rise?: number\n  /** How far the glass trails a moving hand, ms. 0 pins it to the pointer. */\n  trail?: number\n  /** Device-pixel-ratio cap for the canvas. */\n  resolution?: number\n}\n\ndeclare const HalftoneScreen: (props: HalftoneScreenProps) => ReactElement\nexport default HalftoneScreen\n","type":"registry:lib","target":"components/halftone-screen/HalftoneScreen.d.ts"}],"type":"registry:component"}