{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"focus-peaking","title":"Focus Peaking","description":"Lights an image's in-focus edges on hover, like a camera monitor: the Sobel gradient of luma, thresholded with a soft knee and tinted over a dimmed picture. WebGL2.","dependencies":[],"files":[{"path":"src/components/FocusPeaking/index.js","content":"export { default } from './FocusPeaking.jsx'\n","type":"registry:component","target":"components/focus-peaking/index.js"},{"path":"src/components/FocusPeaking/FocusPeaking.jsx","content":"'use client'\n\nimport { useEffect, useLayoutEffect, useRef } from 'react'\nimport { createGL, createLoop, linearRGB, loadImage } from '../gl-core/gl.js'\nimport './FocusPeaking.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   FocusPeaking\n   Hover an image and its sharp edges light up in a signal\n   colour — what a camera's focus peaking shows an operator\n   pulling focus by eye.\n\n   A lens renders the plane of focus with steep transitions\n   and everything else with soft ones; defocus is a low-pass\n   filter. So \"in focus\" can be measured locally as the size\n   of the brightness gradient, and peaking is just that\n   gradient, thresholded and painted over the picture. It is\n   honest about what it shows: a sharp edge lights whether or\n   not it is the subject, exactly as it does on a monitor.\n\n   The gradient is the Sobel operator on luma,\n\n     Gx = [−1 0 1; −2 0 2; −1 0 1] ∗ Y,   Gy = Gxᵀ ∗ Y,\n     |∇Y| = √(Gx² + Gy²)\n\n   taken on gamma-encoded Rec. 709 luma, which is what a\n   camera's peaking circuit sees — its thresholds are set in\n   perceptual steps, and on linear light the same edge in a\n   shadow would barely register. The step between taps is one\n   displayed pixel, so peaking lines keep one weight whatever\n   the image is scaled to. A soft knee above the threshold\n   keeps them from crawling as the image moves.\n\n   Peaking is a mode the monitor is put into, so it comes on\n   as one thing: the picture dims and the edges light on a\n   single amount. A fade that closes a fixed share of the gap\n   each frame runs twice as fast on a 120 Hz display, and\n   starts at its fastest, so it lands as a flash. This one is\n   a critically damped spring solved over the real time since\n   the last frame. It comes on in `fade`, softly at first, and\n   goes off in seven tenths of that: the picture is what the\n   viewer came for, and it is given back sooner than it was\n   taken. A hand that leaves mid-fade turns it round at the\n   speed it had, so a pointer brushing across leaves a\n   glimmer that comes and goes, never a blink. Nothing is\n   painted once it is still.\n\n   With `reach` the peaking is a disc around the pointer,\n   feathered over its outer three tenths so it has no rim.\n   The disc is carried, not pinned: it trails the hand by\n   `trail`, enough to read as a thing following and not enough\n   to read as lag, and nothing overshoots. Only a disc that can\n   be seen has to travel. Faded out it is wherever it is next\n   wanted, so it comes up under the hand and never streaks in\n   from where it was last left; one still showing when a hand\n   arrives somewhere else is brought over at the pace of the\n   fade, and the hand's grip tightens as it comes. A new reach\n   is grown or shrunk into, and reach 0 is a disc wide enough\n   to cover the picture from anywhere, so going between the\n   two is a disc opening out, not a cut. Held by `active`\n   with nobody pointing, it sits mid-picture until a hand has\n   put it somewhere, and stays where it was left after that;\n   the keyboard, which has no place of its own, takes one\n   still showing where it is and brings a faded one up\n   mid-picture.\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 for the first\n   fifth of a second nothing shows. A finger still resting\n   then peaks the picture under it, while the page can still\n   be scrolled out from under it. With no reach that is all a\n   finger does: there is nothing for it to carry, so every\n   drag stays the page's, sideways ones too, and a picture in\n   a carousel does not stop the carousel being swiped. With a\n   reach there is a disc to carry. Rested as long as a long\n   press, or slid sideways, the finger has the picture: it\n   peaks for as long as it is held and carries the disc in\n   every direction. Dragged up or down first, it scrolls the\n   page (touch-action: pan-y), and two fingers still zoom. A\n   tap peaks for a glance, counted from when it landed, which\n   is how anyone finds out it is there. A long press on a\n   picture inside a link is still the link's, and raises the\n   menu it always did.\n\n   Keyboard focus counts as hover: the picture peaks while the\n   link or button it sits in has focus from the keyboard, or\n   while it does itself, given a tabIndex. Focus a click left\n   behind is the pointer's business, until a key is pressed and\n   the browser starts to ring it. The picture takes no tab\n   stop of its own — it is not a control, and one in a card\n   link would otherwise be two stops for one thing. Given one,\n   and a reach, the arrow keys carry the disc, further with\n   Shift; against an edge of the picture they are the page's\n   to scroll with again. `active` holds it on or off instead\n   of following any of this.\n\n   Under reduced motion it comes on and goes off at once, and\n   the disc sits at the pointer.\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 uPixel;        // one device pixel, in CSS px\nuniform float uThreshold;\nuniform float uAmount;       // 0..1, eased in and out\nuniform float uDim;\nuniform vec3 uColor;         // linear\nuniform vec3 uReach;         // disc x, y and radius in px; radius 0 is the whole image\n\nfloat luma(vec2 p) {\n  vec2 uv = clamp((p - uRect.xy) / uRect.zw, 0.0, 1.0);\n  vec3 c = texture(uImage, vec2(uv.x, 1.0 - uv.y)).rgb;\n  return dot(c, vec3(0.2126, 0.7152, 0.0722));\n}\n\nvoid main() {\n  vec2 p = vec2(vUv.x, 1.0 - vUv.y) * uSize;\n  vec2 uv = (p - uRect.xy) / uRect.zw;\n  if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) {\n    outColor = vec4(0.0);\n    return;\n  }\n\n  float s = uPixel;\n  float tl = luma(p + vec2(-s, -s)), t = luma(p + vec2(0.0, -s)), tr = luma(p + vec2(s, -s));\n  float l = luma(p + vec2(-s, 0.0)),                               r = luma(p + vec2(s, 0.0));\n  float bl = luma(p + vec2(-s, s)),  b = luma(p + vec2(0.0, s)),  br = luma(p + vec2(s, s));\n  float gx = (tr + 2.0 * r + br) - (tl + 2.0 * l + bl);\n  float gy = (bl + 2.0 * b + br) - (tl + 2.0 * t + tr);\n  float g = sqrt(gx * gx + gy * gy);\n\n  float edge = smoothstep(uThreshold, uThreshold * 1.6 + 0.02, g);\n  float reach = uReach.z > 0.0 ? 1.0 - smoothstep(uReach.z * 0.7, uReach.z, distance(p, uReach.xy)) : 1.0;\n  float amount = uAmount * reach;\n\n  vec3 image = texture(uImage, vec2(uv.x, 1.0 - uv.y)).rgb;\n  vec3 lin = pow(image, vec3(2.2)) * (1.0 - uDim * amount);\n  vec3 c = mix(lin, uColor, edge * amount);\n  outColor = vec4(pow(c, vec3(1.0 / 2.2)), 1.0);\n}`\n\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 OFF = 0.7       // going off takes this share of the time coming on does\nconst SEEN = 0.004    // peaking amount below which the disc cannot be seen\nconst FEATHER = 0.7   // share of the radius the disc is at full strength out to, as in the shader\nconst RESIZE = 0.06   // s — how quickly the disc takes up a new reach\nconst DWELL = 200     // ms a finger rests before the picture peaks under it\nconst HOLD = 400      // ms a finger rests before it has the picture: a long press\nconst SLOP = 8        // px a finger may wander and still be resting\nconst GLANCE = 1200   // ms a tap peaks for, counted from when it landed\nconst NUDGE = 16      // px an arrow key carries the disc; four times that with Shift\nconst ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }\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. */\nfunction glide(s, to, w, dt) {\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 e = Math.exp(-w * dt)\n  const x = (a + b * dt) * e\n  s.x = to + x\n  s.v = b * 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\n/* What keyboard focus has to be on for the picture to count as hovered: the\n   picture itself, once it has been given a tabIndex, or else the link or\n   button it sits in. Never just any focused ancestor — a region focused to\n   move the reading position holds every picture on the page. */\nconst CONTROL = 'a[href], button, summary, [role=\"link\"], [role=\"button\"]'\nconst owner = (root) => (root.hasAttribute('tabindex') ? root : root.parentElement?.closest(CONTROL) ?? null)\n\nexport default function FocusPeaking({\n  src,\n  alt = '',\n  active,                  // undefined follows hover, a resting finger and keyboard focus; true or false holds it\n  color = '#ff3b30',       // peaking colour\n  threshold = 0.22,        // gradient magnitude an edge must clear, 0..~1.4\n  dim = 0.35,              // how far the picture dims while peaking, 0..1\n  reach = 0,               // px around the pointer that peaks; 0 is the whole image\n  fade = 240,              // ms for peaking to come on; it goes off in 70% of that; 0 is at once\n  trail = 30,              // ms the reach disc trails a moving hand by; 0 pins it to the pointer\n  fit = 'cover',           // 'cover' | 'contain'\n  focusX = 0.5,\n  focusY = 0.5,\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 = { active, color, threshold, dim, reach, fade, trail, fit, focusX, focusY }\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      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, dpr = 1\n\n    const A = { x: 0, v: 0 }         // how far peaking is on, 0..1\n    const X = { x: 0, v: 0 }         // where the reach disc is\n    const Y = { x: 0, v: 0 }\n    let rad = 0                      // and its radius, px\n    // Where the disc is wanted, and the client point of the hand that put it there.\n    let tx = 0, ty = 0, cx = 0, cy = 0\n    let placed = false               // a hand has put it somewhere; until then it is mid-picture\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 touch = null                 // the finger on it: { id, x, y, t, seen, owned }\n    let glance = 0                   // timer holding peaking up after a tap\n    let focused = false              // the keyboard is on the picture, or on the control it sits in\n    let guarded = false              // touchmove is being listened for\n\n    let last = 0                     // time of the last frame; 0 while the loop is stopped\n    let period = 1 / 60              // s between this display's frames, as last measured\n    let stale = true                 // something has changed since the picture was last painted\n    let lit = false                  // peaking is showing, or on its way: a moving disc is worth a frame\n    let seen = null                  // the props the colour below was worked out for\n    let tint = [1, 0.05, 0.03]\n\n    const reachOf = () => Math.max(0, num(live.current?.reach, 0))\n    // A radius whose full-strength core reaches every corner from anywhere on the picture.\n    const cover = () => Math.hypot(W, H) / FEATHER + 1\n    // A finger counts as a hand once it has rested long enough to show, or has the picture.\n    const fingered = () => touch !== null && (touch.seen || touch.owned)\n    // Only a disc gives a finger something to carry, and so a reason to take the picture from the page.\n    const carries = () => reachOf() > 0\n    // While a finger is resting the loop runs to time it, painting nothing.\n    const timing = () => touch !== null && (!touch.seen || (!touch.owned && carries()))\n\n    const draw = (now) => {\n      /* Timed 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. */\n      if (timing()) {\n        const held = now - touch.t\n        if (held >= HOLD && carries()) own()\n        else if (!touch.seen && held >= DWELL) { touch.seen = true; arrive() }\n      }\n      if (!stale) return timing()\n\n      const p = live.current\n      /* A stopped loop has no last frame, so its first step is one frame of\n         this display — the same on 60 Hz and 120 Hz. The display's period is\n         the shortest gap it shows, so a shorter one is believed at once and a\n         longer one, which is as likely a dropped frame, only a little. A tab\n         left in the background resumes by a capped step, not a jump. */\n      const gap = last ? Math.max(now - last, 0) / 1000 : period\n      if (last && gap > 1 / 250 && gap < 1 / 20) period = gap < period ? gap : period + (gap - period) * 0.1\n      const dt = Math.min(gap, 1 / 30)\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        tint = linearRGB(p.color, [1, 0.05, 0.03])\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      // How far peaking is on. Up in `fade`, down in OFF of it, from the speed it had.\n      const shown = hover || focused || glance !== 0 || fingered()\n      const want = (p.active ?? shown) ? 1 : 0\n      const on = Math.max(0, num(p.fade, 240)) / 1000\n      const time = want > A.x ? on : on * OFF\n      const flat = still || A.x < SEEN        // asked before it comes on this frame\n      if (still || !(time > 0)) {\n        A.x = want; A.v = 0\n      } else {\n        glide(A, want, SETTLE / time, dt)\n        if (Math.abs(want - A.x) < 2e-3 && Math.abs(A.v) < 0.1) { A.x = want; A.v = 0 }\n      }\n      const amount = clamp01(A.x)\n      lit = want > 0 || amount > 0\n\n      /* The disc. Unseen until now, or covering the whole picture, its place\n         cannot be told, so it is simply where it is wanted; otherwise it has\n         to travel there. */\n      const whole = cover()\n      const R = Math.min(reachOf() || whole, whole)\n      if (flat) rad = R\n      else if (rad !== R) {\n        rad += (R - rad) * (1 - Math.exp(-dt / RESIZE))\n        if (Math.abs(R - rad) < 0.25) rad = R\n      }\n      if (flat || rad >= whole) {\n        X.x = tx; X.v = 0\n        Y.x = ty; Y.v = 0\n        hop = 0\n      } else {\n        /* Led by a hand it keeps close: at a steady speed this spring sits\n           2/ω behind, which is `trail`. Wanted somewhere else while it shows,\n           it is brought there at the pace of the fade, and a hand that takes\n           it over tightens its grip as it comes, so there is no moment at\n           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        const grip = Math.min(close, 1e4)   // pinned, as far as one frame can tell, and finite to blend toward\n        const heft = on > 0 ? SETTLE / on : Infinity\n        if (hop > 0 && (hover || fingered())) {\n          hop = on > 0 ? hop * Math.exp(-3 * dt / on) : 0\n          if (hop < 0.01) hop = 0\n        }\n        const w = hop > 0 && heft < grip ? heft * (grip / 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      }\n\n      program.set({\n        uImage: image,\n        uSize: [W, H],\n        uRect: [(W - dw) * clamp01(num(p.focusX, 0.5)), (H - dh) * clamp01(num(p.focusY, 0.5)), dw, dh],\n        uPixel: 1 / dpr,\n        uThreshold: Math.max(0, num(p.threshold, 0.22)),\n        uAmount: amount,\n        uDim: clamp01(num(p.dim, 0.35)),\n        uColor: tint,\n        uReach: [X.x, Y.x, rad >= whole ? 0 : rad],\n      })\n      glx.draw()\n\n      stale = A.x !== want || A.v !== 0 || rad !== R || X.x !== tx || Y.x !== ty\n      if (!stale) last = 0\n      return stale || timing()\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 disc is wanted under the hand at (cx, cy). It stays on\n       the picture, so one dragged past an edge waits there. */\n    const aim = () => {\n      const b = root.getBoundingClientRect()\n      tx = clamp(cx - b.left, 0, W)\n      ty = clamp(cy - b.top, 0, H)\n      placed = true\n    }\n    /* A hand moving. With no reach its place shows nothing, and neither does a\n       disc held dark by `active`, so nothing is painted for either. */\n    const follow = () => {\n      const ox = tx\n      const oy = ty\n      aim()\n      if (lit && reachOf() > 0 && (tx !== ox || ty !== oy)) wake()\n    }\n\n    // The page scrolling under a still hand moves the picture, not the hand.\n    const onScroll = () => follow()\n    let watching = false\n    const watch = () => {\n      const on = hover || fingered()\n      if (on === watching) return\n      watching = on\n      window[on ? 'addEventListener' : 'removeEventListener']('scroll', onScroll, { capture: true, passive: true })\n    }\n\n    /* A hand has arrived: a disc still showing elsewhere is brought to it, not\n       snatched. These two are called from the frame loop as well as from\n       events, so they only mark the picture stale; waking the loop is left to\n       the caller, since a loop kicked from inside its own frame runs twice. */\n    const arrive = () => {\n      hop = 1\n      aim(); watch()\n      stale = true\n    }\n\n    // The finger has said it is here for the picture.\n    const own = () => {\n      const first = !touch.seen\n      touch.owned = touch.seen = true\n      try { root.setPointerCapture(touch.id) } catch { /* capture is a nicety */ }\n      if (first) arrive()\n      stale = true\n    }\n\n    const stopGlance = () => {\n      clearTimeout(glance)\n      glance = 0\n    }\n    /* Every finger is off, and none of them gets a parting look. A finger that\n       never showed anything leaves nothing to repaint: this is every scroll\n       that happens to start on the picture. */\n    const drop = () => {\n      const showing = fingered() || glance !== 0\n      touch = null\n      stopGlance()\n      watch()\n      if (showing) wake()\n    }\n\n    const steers = () => {\n      const p = live.current\n      return p != null && (p.active == null || (p.active === true && reachOf() > 0))\n    }\n\n    const onDown = (e) => {\n      if (e.pointerType !== 'touch') return\n      // A second finger makes it a pinch, and that is the page's.\n      if (!e.isPrimary) { drop(); return }\n      if (!steers()) return\n      cx = e.clientX\n      cy = e.clientY\n      touch = { id: e.pointerId, x: cx, y: cy, t: performance.now(), seen: false, owned: false }\n      loop.kick()                    // the frame loop is what times the rest; there is nothing yet to paint\n    }\n\n    const onMove = (e) => {\n      if (e.pointerType === 'touch') {\n        if (!touch || e.pointerId !== touch.id) return\n        cx = e.clientX\n        cy = e.clientY\n        if (!touch.owned) {\n          const dx = Math.abs(cx - touch.x)\n          const dy = Math.abs(cy - touch.y)\n          if (carries()) {\n            // Held long enough already, if no frame has come round to say so; or slid sideways.\n            if (performance.now() - touch.t >= HOLD || (dx > SLOP && dx > dy)) { own(); wake(); return }\n            if (dy > SLOP) { drop(); return }        // a scroll: the browser is about to say so\n          } else if (!touch.seen && (dx > SLOP || dy > SLOP)) {\n            /* Nothing to carry, so a drag either way is the page's, and one\n               that starts before anything shows should show nothing. A finger\n               already resting may shift; whether that has become a scroll is\n               the browser's to say, with a pointercancel. */\n            drop()\n            return\n          }\n        }\n        if (touch.seen) follow()\n        return\n      }\n      cx = e.clientX\n      cy = e.clientY\n      if (!hover) {\n        hover = true\n        stopGlance()\n        arrive(); wake()\n      } else {\n        follow()\n      }\n    }\n\n    /* A finger lets go. One that lifts before it had the picture was a tap,\n       and it gets its glance where it tapped; one that had it has had its\n       look. A finger the page took, to scroll or to pinch with, gets nothing. */\n    const onUp = (e) => {\n      /* A finger is captured by whatever it landed on, which is the canvas.\n         Taking the capture makes the canvas lose it, and that bubbles: it is\n         the capture moving, not the finger leaving. */\n      if (e.type === 'lostpointercapture' && e.target !== root) return\n      if (e.pointerType !== 'touch' || !touch || e.pointerId !== touch.id) return\n      const { t, seen: was, owned } = touch\n      touch = null\n      if (e.type === 'pointerup' && !owned) {\n        stopGlance()\n        const left = GLANCE - (performance.now() - t)\n        if (left > 0) {\n          glance = setTimeout(() => { glance = 0; wake() }, left)\n          if (!was) arrive()\n        }\n      }\n      watch()\n      if (was || glance !== 0) wake()\n    }\n\n    // It fades where the pointer left by, not wherever it last reported from.\n    const onLeave = (e) => {\n      if (e.pointerType === 'touch' || !hover) return   // fires after every lift; not a leave\n      cx = e.clientX\n      cy = e.clientY\n      aim()\n      hover = false\n      watch(); wake()\n    }\n\n    // Once a finger has the picture every direction is its; two are still a pinch.\n    const onTouchMove = (e) => {\n      if (touch?.owned && 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 a finger can have the picture: never, with no\n       reach. */\n    const guard = () => {\n      const on = steers() && carries()\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 picture is held, not a request for a menu —\n       unless the picture is a link, whose menu (open in a new tab, copy the\n       address) is worth more to whoever asked for it than a longer look. */\n    const onMenu = (e) => { if (touch && !root.closest('a[href]')) e.preventDefault() }\n\n    /* The keyboard carries the disc when it is on the picture itself, which\n       it only can be once the picture has a tabIndex. An arrow against the\n       edge it points at has nowhere to take the disc, and is left to scroll\n       the page. Each press is a step, and the disc glides to it on `trail`. */\n    const onKey = (e) => {\n      if (e.target !== root) return\n      const dir = ARROWS[e.key]\n      if (!dir || !focused || e.altKey || e.ctrlKey || e.metaKey) return\n      if (!steers() || !carries()) return\n      const step = NUDGE * (e.shiftKey ? 4 : 1)\n      const nx = clamp(tx + dir[0] * step, 0, W)\n      const ny = clamp(ty + dir[1] * step, 0, H)\n      if (nx === tx && ny === ty) return\n      e.preventDefault()\n      tx = nx\n      ty = ny\n      placed = true\n      hop = 0\n      wake()\n    }\n\n    /* Focus moving anywhere on the page is checked against the one control\n       this picture belongs to. Something losing focus means nothing has it\n       until a focusin says otherwise, so the element on its way out is not\n       asked whether it still matches. A key going down asks too: a click's\n       focus becomes the keyboard's at the first key pressed, and the browser\n       starts ringing it without any focus event to say so. With nobody\n       pointing, a disc that has faded out comes up mid-picture for the\n       keyboard. */\n    const onFocus = (e) => {\n      const o = owner(root)\n      const now = !!o && e?.type !== 'focusout' && keyboard(o)\n      if (now === focused) return\n      focused = now\n      if (focused && !hover && !fingered() && A.x < SEEN) {\n        tx = W / 2\n        ty = H / 2\n      }\n      wake()\n    }\n\n    const onMotion = () => { still = motion.matches; wake() }\n\n    const ro = new ResizeObserver(() => {\n      const b = root.getBoundingClientRect()\n      const covering = rad >= cover() - 0.5\n      W = Math.max(1, b.width)\n      H = Math.max(1, b.height)\n      dpr = glx.resize(W, H, resolution).dpr\n      if (covering) rad = cover()\n      if (hover || fingered()) aim()\n      else if (!placed) { tx = W / 2; ty = H / 2 }\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', onKey)\n    document.addEventListener('focusin', onFocus)\n    document.addEventListener('focusout', onFocus)\n    // On the way down, so the picture's own key handler finds it already settled.\n    document.addEventListener('keydown', onFocus, true)\n    motion.addEventListener?.('change', onMotion)\n    onFocus()\n\n    return () => {\n      api.current = null\n      ro.disconnect()\n      loop.stop()\n      stopGlance()\n      touch = null\n      hover = false\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', onKey)\n      document.removeEventListener('focusin', onFocus)\n      document.removeEventListener('focusout', onFocus)\n      document.removeEventListener('keydown', onFocus, true)\n      motion.removeEventListener?.('change', onMotion)\n      glx.dispose()\n    }\n  }, [resolution])\n\n  /* Without WebGL2 the plain image stands in. Marked after every render and\n     not once: React writes the class attribute afresh whenever className\n     changes, and would take the mark off with it, leaving an empty box. And\n     before the browser paints, or a frame of that empty box shows between. */\n  useIsomorphicLayoutEffect(() => {\n    if (plain.current) rootRef.current?.classList.add('is-fallback')\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  useEffect(() => {\n    api.current?.kick()\n  }, [active, color, threshold, dim, reach, fade, trail, fit, focusX, focusY])\n\n  const aims = num(reach, 0) > 0\n  // The pointer does something here: it peaks the picture, or carries a disc that shows.\n  const steers = active == null || (active === true && aims)\n  return (\n    <div\n      ref={rootRef}\n      className={[\n        'focus-peaking',\n        steers && 'focus-peaking--live',\n        steers && aims && 'focus-peaking--aim',\n        className,\n      ].filter(Boolean).join(' ')}\n      /* Same contract as <img>: a description names the picture; an empty alt\n         marks it decorative — unless it has been given a tabIndex, since a\n         thing focus can land on must not be hidden from whoever landed there. */\n      role={alt ? 'img' : undefined}\n      aria-label={alt || undefined}\n      aria-hidden={alt || rest.tabIndex != null ? undefined : 'true'}\n      style={{\n        '--peaking-src': src ? `url(\"${String(src).replace(/\"/g, '%22')}\")` : 'none',\n        '--peaking-fit': fit === 'contain' ? 'contain' : 'cover',\n        '--peaking-position': `${clamp01(num(focusX, 0.5)) * 100}% ${clamp01(num(focusY, 0.5)) * 100}%`,\n        '--peaking-color': typeof color === 'string' ? color : undefined,\n        ...style,\n      }}\n      {...rest}\n    >\n      <canvas ref={canvasRef} className=\"focus-peaking__canvas\" aria-hidden=\"true\" />\n    </div>\n  )\n}\n","type":"registry:component","target":"components/focus-peaking/FocusPeaking.jsx"},{"path":"src/components/FocusPeaking/FocusPeaking.css","content":"/* FocusPeaking — the canvas draws the picture itself, peaking included. Where\n   WebGL2 is unavailable the plain image stands in, without the peaking. */\n\n.focus-peaking {\n  position: relative;\n  overflow: hidden;\n}\n\n/* While peaking follows the user, a finger rested on the picture peaks it. A\n   long press is how it is held there, so it must not also select the page\n   around it or raise a callout. Every drag is still the page's. */\n.focus-peaking--live {\n  -webkit-user-select: none;\n  user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n/* Except on a picture that is a link: its long press is the link's, and the\n   callout (open in a new tab, copy the address) stays. */\na[href] .focus-peaking--live {\n  -webkit-touch-callout: default;\n}\n\n/* A crosshair promises that where the pointer is matters, and it only does\n   with a reach. Without one the cursor is left to whatever the picture sits\n   in, so a picture inside a link still shows the link's hand. With one there\n   is a disc for a finger to carry, and only then is a sideways drag taken\n   from the page; dragged up or down first it still scrolls, and two fingers\n   still zoom. */\n.focus-peaking--aim {\n  cursor: crosshair;\n  touch-action: pan-y pinch-zoom;\n}\n\n.focus-peaking__canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n/* Keyboard focus on the picture itself — it takes some only when given a\n   tabIndex — is drawn inside it, where a clipping ancestor cannot cut it off:\n   a rule in the peaking colour on a dark keyline, so a light picture does not\n   swallow a light colour. The colour goes through `color`, so one the browser\n   cannot read leaves the rule in the text colour and not gone altogether. The\n   transparent outline is what forced colours draw instead, since they drop\n   box-shadow. */\n.focus-peaking:focus-visible {\n  outline: 2px solid transparent;\n  outline-offset: -2px;\n}\n\n.focus-peaking:focus-visible::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  color: var(--peaking-color, #ff3b30);\n  box-shadow:\n    inset 0 0 0 2px currentColor,\n    inset 0 0 0 3px rgb(0 0 0 / 0.55);\n}\n\n/* No peaking to hold, so every gesture is the page's again. The image keeps\n   the fit and alignment the canvas would have given it. */\n.focus-peaking.is-fallback {\n  background: var(--peaking-src) var(--peaking-position, 50% 50%) / var(--peaking-fit, cover) no-repeat;\n  cursor: inherit;\n  touch-action: auto;\n  -webkit-user-select: auto;\n  user-select: auto;\n  -webkit-touch-callout: default;\n}\n\n.focus-peaking.is-fallback .focus-peaking__canvas {\n  display: none;\n}\n","type":"registry:file","target":"components/focus-peaking/FocusPeaking.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/FocusPeaking/FocusPeaking.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport interface FocusPeakingProps 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, and hides it\n   *  from assistive technology unless it has been given a tabIndex. */\n  alt?: string\n  /** Leave undefined to peak while a pointer is over the picture, while a finger\n   *  rests on it (a tap peaks for a moment), and while keyboard focus is on the\n   *  link or button the picture sits in — or on the picture itself, given a\n   *  tabIndex. true or false holds it on or off instead. */\n  active?: boolean\n  /** Peaking colour. Any CSS colour. */\n  color?: string\n  /** Sobel magnitude an edge must clear to light up, on 0..1 luma. Lower lights softer edges. */\n  threshold?: number\n  /** How far the picture dims while peaking, 0..1, so the edges carry. */\n  dim?: number\n  /** Distance around the pointer that peaks, px. 0 peaks the whole image. With a\n   *  reach a finger can carry the disc (a sideways drag is then the picture's,\n   *  not the page's), and so can the arrow keys while the picture itself has\n   *  keyboard focus, which it can once given a tabIndex. */\n  reach?: number\n  /** Time for peaking to come on, ms; it goes off in 70% of that. 0 switches at once. */\n  fade?: number\n  /** How far the reach disc trails a moving hand, ms. 0 pins it to the pointer. */\n  trail?: number\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  /** Device-pixel-ratio cap for the canvas. */\n  resolution?: number\n}\n\ndeclare const FocusPeaking: (props: FocusPeakingProps) => ReactElement\nexport default FocusPeaking\n","type":"registry:lib","target":"components/focus-peaking/FocusPeaking.d.ts"}],"type":"registry:component"}