{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"brushed-metal","title":"Brushed Metal","description":"A brushed metal panel under a light at the pointer, shaded with anisotropic GGX so the highlight streaks across the grain. Colour is the metal's F0; anisotropy 0 is polished. WebGL2.","dependencies":[],"files":[{"path":"src/components/BrushedMetal/index.js","content":"export { default } from './BrushedMetal.jsx'\n","type":"registry:component","target":"components/brushed-metal/index.js"},{"path":"src/components/BrushedMetal/BrushedMetal.jsx","content":"'use client'\n\nimport { useEffect, useLayoutEffect, useRef } from 'react'\nimport { createGL, createLoop, linearRGB } from '../gl-core/gl.js'\nimport './BrushedMetal.css'\n\n// Layout effects warn during server rendering; there is nothing to mark there.\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect\n\n/* =========================================================\n   BrushedMetal\n   A panel of brushed aluminium lit by a light that follows\n   the pointer. The highlight is a streak, and the streak runs\n   across the grain, not along it.\n\n   That is the tell of real brushed metal and the thing a\n   gradient overlay gets backwards. Brushing cuts grooves in\n   one direction. Along a groove the surface is smooth; across\n   it the facet normals rock from side to side. Reflection\n   spreads in the direction the normals spread, so the light\n   smears perpendicular to the brush lines.\n\n   Microfacet theory says exactly how much. The anisotropic\n   GGX distribution of facet normals h, with roughness αt\n   along the grain tangent t and αb across it, is\n\n     D(h) = 1 / (π·αt·αb·((h·t/αt)² + (h·b/αb)² + (h·n)²)²)\n\n   and the specular response is D·G·F / (4·(n·l)(n·v)), with\n   Smith's height-correlated masking G and Schlick's Fresnel\n   F = F0 + (1 − F0)(1 − v·h)⁵, F0 being the metal's colour —\n   metals have no diffuse term, all their colour is in F0.\n   With αb several times αt the lobe is an ellipse stretched\n   across the grain; with them equal it is a round spot, and\n   the surface reads as polished rather than brushed.\n\n   The grooves themselves are noise stretched along the grain,\n   perturbing the normal toward b, so the streak breaks into\n   the fine parallel scratches it has on a real laptop lid.\n\n   The light is a lamp on an arm, and it moves like one. It\n   follows the hand on a critically damped spring, solved\n   exactly over the real time since the last frame, so it\n   glides the same at 60 Hz and 120 Hz and never overshoots —\n   a lamp does not wobble on its arm. It trails a hand moving\n   steadily by 70 ms, enough to read as something with weight\n   being carried and not enough to read as lag. A hand that\n   has only just arrived is not snatched at: the light is\n   brought over at a heavier weight and the grip closes as it\n   comes, so it never streaks across from where it rested.\n   Let go, it drifts home, up and to the left where a room's\n   light usually is, following a hand that eases off rather\n   than one that lets go all at once, so the drift starts as\n   gently as it ends and carries on from whatever speed the\n   light already had. Nothing teleports: a hand arriving\n   mid-drift turns the light round from where it is.\n\n   A press lowers the lamp toward the metal by `press` of its\n   height, and the inverse-square law does the rest. The\n   brightness is set at the lamp's resting height, so brought\n   closer it lights the metal under it (h/h′)² as brightly,\n   and the streak, whose reach goes with the light's height,\n   draws in under the press. It goes down in about 150 ms and\n   comes back up in about a quarter of a second. A press on\n   bare metal keeps hold of the light through a drag past the\n   panel's edge, where it rakes across from outside; one on\n   anything placed on the metal leaves that thing its click.\n\n   Touch has no hover, so a finger brings the light: it comes\n   to where the finger lands, follows a finger dragged\n   sideways, and stays where it was left for a second before\n   it drifts home. A finger dragged up or down scrolls the\n   page as it would anywhere (touch-action: pan-y), and two\n   still zoom it.\n\n   The panel takes no focus itself — there is nothing on bare\n   metal to operate — but keyboard focus on anything placed on\n   it brings the light over, as a hand hovering there would,\n   and a key that presses it — Enter, or Space on anything but\n   a link — lowers the lamp as a click does, and lets it up\n   when the key comes up, even if what it pressed has gone\n   from the page by then. Focus a click left behind was\n   answered by the click, and is not answered twice, until a\n   key shows its ring; a text field shows its ring for a click\n   too, and the light goes to it only once the hand has left.\n\n   A changed prop — grain, finish, colour, the light's\n   height — eases to its new value in about a quarter of a\n   second instead of cutting, the grain turning the short way\n   round. The loop runs only while something is moving and\n   the panel is on screen. Under reduced motion the light is\n   simply where it is wanted — at the pointer, the focus, or\n   home — and a press lowers it at once.\n   ========================================================= */\n\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 vUv;\nout vec4 outColor;\n\nuniform vec2 uSize;          // CSS px\nuniform float uAngle;        // grain direction, radians\nuniform float uAlphaT;       // roughness along the grain\nuniform float uAlphaB;       // roughness across it\nuniform float uGrain;\nuniform vec3 uF0;            // linear\nuniform vec3 uLight;         // x, y (px), height (px)\nuniform float uRest;         // the height its brightness is set at, px\nuniform float uIntensity;\n\nconst float PI = 3.14159265;\n\nfloat hash(vec2 p) {\n  p = fract(p * vec2(123.34, 456.21));\n  p += dot(p, p + 45.32);\n  return fract(p.x * p.y);\n}\n\n// Value noise, stretched along the grain by the caller.\nfloat noise(vec2 p) {\n  vec2 i = floor(p), f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),\n             mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);\n}\n\nfloat lambda(vec3 w, float at, float ab) {\n  float n2 = max(w.z * w.z, 1e-6);\n  return 0.5 * (-1.0 + sqrt(1.0 + (at * at * w.x * w.x + ab * ab * w.y * w.y) / n2));\n}\n\nvoid main() {\n  vec2 p = vec2(vUv.x, 1.0 - vUv.y) * uSize;\n  vec2 t2 = vec2(cos(uAngle), sin(uAngle));\n  vec2 b2 = vec2(-t2.y, t2.x);\n\n  // Grooves: fine across the grain, long along it.\n  float along = dot(p, t2), across = dot(p, b2);\n  float scratch = noise(vec2(along * 0.012, across * 1.1)) * 0.65\n                + noise(vec2(along * 0.004, across * 0.23)) * 0.35;\n  float tilt = (scratch - 0.5) * 0.55 * uGrain;\n\n  vec3 n = normalize(vec3(b2 * tilt, 1.0));\n  vec3 t = normalize(vec3(t2, 0.0) - n * dot(vec3(t2, 0.0), n));\n  vec3 b = cross(n, t);\n\n  // An eye far above the panel's centre, and the pointer's light above p.\n  vec3 V = normalize(vec3(uSize * 0.5 - p, max(uSize.x, uSize.y) * 1.6));\n  vec3 Lv = vec3(uLight.xy - p, uLight.z);\n  float dist2 = dot(Lv, Lv);\n  vec3 L = Lv / sqrt(dist2);\n  vec3 H = normalize(L + V);\n\n  // To the tangent frame.\n  vec3 l = vec3(dot(L, t), dot(L, b), dot(L, n));\n  vec3 v = vec3(dot(V, t), dot(V, b), dot(V, n));\n  vec3 h = vec3(dot(H, t), dot(H, b), dot(H, n));\n  float nl = max(l.z, 0.0), nv = max(v.z, 1e-4);\n\n  float at = uAlphaT, ab = uAlphaB;\n  float e = (h.x * h.x) / (at * at) + (h.y * h.y) / (ab * ab) + h.z * h.z;\n  float D = 1.0 / (PI * at * ab * e * e);\n  float G = 1.0 / (1.0 + lambda(l, at, ab) + lambda(v, at, ab));\n  vec3 F = uF0 + (1.0 - uF0) * pow(1.0 - max(dot(V, H), 0.0), 5.0);\n  vec3 spec = D * G * F / (4.0 * max(nl, 1e-4) * nv);\n\n  // A light at the pointer, falling off with the square of its distance, over a\n  // dim sky the metal reflects everywhere so nothing is ever black. Its\n  // brightness is set at its resting height: lowered by a press, it is closer.\n  float falloff = uRest * uRest / dist2;\n  vec3 sky = uF0 * (0.10 + 0.08 * (1.0 - vUv.y)) * (0.8 + 0.4 * scratch);\n  vec3 c = sky + spec * nl * falloff * uIntensity * 0.9;\n\n  c = c / (1.0 + c);\n  outColor = vec4(pow(c, 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)\n// A finite number, or the fallback: one NaN in a uniform blanks the whole frame.\nconst num = (v, d) => (v != null && v !== '' && 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 FOLLOW = 0.07   // s the light trails a hand moving steadily: weight, not lag\nconst BRING = 0.45    // s to bring the light over to a hand that has just arrived\nconst GRIP = 0.2      // s, time constant of that hand's grip closing to FOLLOW's\nconst HOME = 0.8      // s for a light let go to drift home\nconst DIP = 0.15      // s for a press to lower the lamp\nconst RISE = 0.26     // s for it to come back up\nconst GLIDE = 0.08    // s, time constant a changed prop eases in over\nconst LINGER = 1000   // ms a light stays where a finger left it\nconst REST_X = 0.28   // home, as a share of the panel: up and to the left\nconst REST_Y = 0.22\n\n// ω of the springs above.\nconst W_FOLLOW = 2 / FOLLOW          // a critically damped spring at a steady speed sits 2/ω behind\nconst W_BRING = EASED / BRING        // both of these follow a hand that eases in, so three lags\nconst W_HOME = EASED / HOME\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\n// Somewhere a key types rather than presses.\nconst typing = (el) =>\n  el.isContentEditable || /^(TEXTAREA|SELECT)$/.test(el.tagName)\n  || (el.tagName === 'INPUT' && !/^(button|submit|reset|checkbox|radio|image|color|file)$/.test(el.type))\n\nconst isPress = (e) => e.key === 'Enter' || e.key === ' '\n// Space presses a button; on a link it scrolls the page, and the lamp stays up.\nconst presses = (el, e) => isPress(e) && !typing(el) && !(e.key === ' ' && el.tagName === 'A')\n\n// Props that ease, and how close counts as arrived.\nconst NEAR = { angle: 1e-4, at: 1e-4, ab: 1e-4, grain: 1e-3, intensity: 1e-3, height: 0.02, press: 1e-4 }\n\nexport default function BrushedMetal({\n  angle = 42,              // grain direction, degrees clockwise — 0 brushes left to right\n  roughness = 0.69,        // across the grain, 0..1\n  anisotropy = 0.92,       // how much smoother it is along the grain, 0..1\n  grain = 2.03,            // depth of the scratches\n  color = '#c9cdd3',       // the metal's reflectance, F0 — aluminium by default\n  intensity = 1,           // brightness of the light at its resting height\n  height = 417,            // the light's height above the panel, px\n  press = 0.9,             // how far a press lowers the light, as a share of its height; 0 turns it off\n  resolution = 1.5,        // device-pixel-ratio cap\n  children,\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const canvasRef = useRef(null)\n  const api = useRef(null)\n  const flat = useRef(false)         // no WebGL2 here: the gradient 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 = { angle, roughness, anisotropy, grain, color, intensity, height, press, resolution }\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const glx = root && canvas && createGL(canvas, { onRestore: () => loop.kick() })\n    if (!glx) {                      // a plain gradient stands in; the effect after this one keeps it there\n      flat.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    let W = 0, H = 0                 // 0 until the panel has been measured\n    let cap = live.current.resolution // the pixel density the canvas was last sized at\n\n    // The light over the panel, px, and how far a press has it lowered, 0..1.\n    const X = { x: 0, v: 0 }\n    const Y = { x: 0, v: 0 }\n    const Z = { x: 0, v: 0 }\n    let holder = null                // 'pointer' | 'finger' | 'focus' while something has the light\n    let tx = 0, ty = 0               // where that hand is\n    let held = 0                     // s it has had the light, which its grip closes over\n    let wx = 0, wy = 0               // where the light was wanted as of the last frame\n    let hx = 0, hy = 0               // how far short of there the hand carrying it still is\n\n    let cx = 0, cy = 0               // client point of the last pointer or finger\n    let hover = false                // a mouse or pen is over the panel\n    let finger = null                // pointerId of the finger on it\n    let linger = 0                   // timer keeping the light where a finger left it\n    let focused = null               // the thing on it the keyboard has focused\n    let pressing = false             // the primary button or a pen's tip is down\n    let touching = false             // the finger is down\n    let keying = false               // Enter or Space is down on the focused thing\n\n    let last = 0                     // time of the last frame; 0 while the loop is stopped\n    let seen = null                  // the props `goal` was worked out for\n    let fresh = true                 // the first frame takes the props as they are\n    const goal = { angle: 42 * DEG, at: 0.0552, ab: 0.69, grain: 2.03, intensity: 1, height: 417, press: 0.9, f0: [0.6, 0.62, 0.65] }\n    const cur = { ...goal, f0: [...goal.f0] }\n\n    // Eases every prop toward its goal by k; true while any is still on its way.\n    const ease = (k) => {\n      let moving = false\n      for (const key in NEAR) {\n        const c = cur[key] + (goal[key] - cur[key]) * k\n        if (Math.abs(goal[key] - c) <= NEAR[key]) cur[key] = goal[key]\n        else { cur[key] = c; moving = true }\n      }\n      for (let i = 0; i < 3; i++) {\n        const c = cur.f0[i] + (goal.f0[i] - cur.f0[i]) * k\n        if (Math.abs(goal.f0[i] - c) <= 1e-4) cur.f0[i] = goal.f0[i]\n        else { cur.f0[i] = c; moving = true }\n      }\n      return moving\n    }\n\n    const draw = (now) => {\n      if (!W) return false\n      const p = live.current\n      /* A stopped loop has no last frame: its first step is one nominal frame,\n         about how long the event that woke it has been waiting, so a press is\n         already visibly under way on the first frame after it. A tab that was\n         away comes back one short step on, not a leap. */\n      const dt = last ? Math.min(Math.max(now - last, 0) / 1000, 1 / 30) : 1 / 60\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        // A new density cap resizes the canvas where it stands; the light is not disturbed.\n        if (p.resolution !== cap) {\n          cap = p.resolution\n          glx.resize(W, H, cap)\n        }\n        const ab = clamp(num(p.roughness, 0.69), 0.02, 1)\n        // The grain turns the short way round: 350° is ten degrees back from 0°, not a whole turn on.\n        const turn = num(p.angle, 42) * DEG\n        goal.angle = fresh ? turn : turn + Math.round((cur.angle - turn) / (2 * Math.PI)) * 2 * Math.PI\n        goal.ab = ab\n        goal.at = Math.max(0.01, ab * (1 - clamp(num(p.anisotropy, 0.92), 0, 0.98)))\n        goal.grain = Math.max(0, num(p.grain, 2.03))\n        goal.intensity = Math.max(0, num(p.intensity, 1))\n        goal.height = Math.max(10, num(p.height, 417))\n        goal.press = clamp(num(p.press, 0.9), 0, 0.9)\n        goal.f0 = linearRGB(p.color, [0.6, 0.62, 0.65])\n      }\n      const easing = ease(fresh || still ? 1 : 1 - Math.exp(-dt / GLIDE))\n      fresh = false\n\n      // The press: down quickly, up a little slower, never past either end.\n      const down = pressing || touching || keying ? 1 : 0\n      if (still) {\n        Z.x = down; Z.v = 0\n      } else {\n        glide(Z, down, SETTLE / (down ? DIP : RISE), dt)\n        if (Math.abs(down - Z.x) < 1e-3 && Math.abs(Z.v) < 1e-2) { Z.x = down; Z.v = 0 }\n      }\n\n      /* The light: carried by whatever holds it, or eased home. Either way it\n         hangs from a hand that is still closing on where it is wanted, (hx, hy)\n         short of it, so a new destination never yanks it: the hand sets off\n         from where it already was and the pull builds as it goes. A light\n         already moving is turned round, or carried on, not jerked. A hand\n         that holds it tightens its grip from the weight it was brought over\n         at to the one it is carried at. */\n      const gx = holder ? tx : W * REST_X\n      const gy = holder ? ty : H * REST_Y\n      wx = gx\n      wy = gy\n      if (still) {\n        X.x = gx; Y.x = gy\n        X.v = Y.v = hx = hy = 0\n      } else {\n        // The grip at the middle of the step, on the clock the springs run on, so\n        // the weight it is brought over at does not depend on how often it is drawn.\n        const grip = holder ? 1 - Math.exp(-(held + dt / 2) / GRIP) : 0\n        held += dt\n        const w = holder ? W_BRING * (W_FOLLOW / W_BRING) ** grip : W_HOME\n        glide(X, gx, w, dt, hx)\n        glide(Y, gy, w, dt, hy)\n        const e = Math.exp(-w * dt)\n        hx *= e\n        hy *= e\n      }\n      // Within a fifth of a pixel of a highlight this broad is there.\n      if (Math.hypot(gx - X.x, gy - Y.x) < 0.2 && Math.hypot(X.v, Y.v) < 4 && Math.hypot(hx, hy) < 0.2) {\n        X.x = gx; Y.x = gy\n        X.v = Y.v = hx = hy = 0\n      }\n\n      program.set({\n        uSize: [W, H],\n        uAngle: cur.angle % (2 * Math.PI),\n        uAlphaT: cur.at,\n        uAlphaB: cur.ab,\n        uGrain: cur.grain,\n        uF0: cur.f0,\n        uLight: [X.x, Y.x, cur.height * (1 - cur.press * clamp(Z.x, 0, 1))],\n        uRest: cur.height,\n        uIntensity: cur.intensity,\n      })\n      glx.draw()\n\n      const moving = easing || X.x !== gx || Y.x !== gy || X.v !== 0 || Y.v !== 0 || hx !== 0 || hy !== 0\n        || Z.x !== down || Z.v !== 0\n      if (!moving) {\n        last = 0\n        held = Infinity              // a light brought to rest under a hand is held as firmly as it will be\n      }\n      return moving\n    }\n\n    const loop = createLoop(root, draw)\n    const wake = () => loop.kick()\n    api.current = { kick: wake }\n\n    // The hand at client (cx, cy), over the panel.\n    const aim = () => {\n      const b = root.getBoundingClientRect()\n      tx = cx - b.left\n      ty = cy - b.top\n    }\n    // Or the middle of the thing the keyboard is on, kept on the panel.\n    const aimAt = (el) => {\n      const b = root.getBoundingClientRect()\n      const r = el.getBoundingClientRect()\n      tx = clamp(r.left + r.width / 2 - b.left, 0, W)\n      ty = clamp(r.top + r.height / 2 - b.top, 0, H)\n    }\n\n    /* Something takes the light, (tx, ty) already set to where it is wanted.\n       A hand that has only just arrived, or that has jumped somewhere else,\n       sets off from where the last one had got to and brings it over; one\n       that already has it keeps the grip it has. */\n    const take = (who, jump = false) => {\n      if (jump || holder !== who) {\n        held = 0\n        hx += wx - tx\n        hy += wy - ty\n        wx = tx\n        wy = ty\n      }\n      holder = who\n    }\n    // Nothing has it now: it is eased home from there, at the speed it has.\n    const letGo = () => {\n      if (!holder) return\n      holder = null\n      hx += wx - W * REST_X\n      hy += wy - H * REST_Y\n      wx = W * REST_X\n      wy = H * REST_Y\n    }\n    // Whatever had it has let go. Something else that wants it takes it, or it goes home.\n    const reconsider = () => {\n      if (finger !== null || linger) return\n      // A focused thing taken out of the page loses focus without a word.\n      if (focused && document.activeElement !== focused) focused = null\n      if (hover) { aim(); take('pointer') }\n      else if (focused) { aimAt(focused); take('focus') }\n      else letGo()\n      wake()\n    }\n\n    // The page scrolling under a still hand moves the metal, not the hand.\n    const onScroll = () => {\n      if (holder !== 'pointer' && holder !== 'finger') return\n      aim(); wake()\n    }\n    /* And a finger's lift is heard wherever it happens. Its events go to what\n       it landed on, but if that takes itself off the page mid-touch they go to\n       whatever lies under the finger from then on, and past the panel's edge\n       that is not the panel: the lamp would stay down for good. So the lift is\n       listened for on the window as well, and only while the finger is down. */\n    let watching = false\n    let lifting = false\n    const watch = () => {\n      const down = finger !== null\n      if (down !== lifting) {\n        lifting = down\n        const listen = down ? 'addEventListener' : 'removeEventListener'\n        window[listen]('pointerup', onUp, true)\n        window[listen]('pointercancel', onUp, true)\n      }\n      const on = hover || down\n      if (on === watching) return\n      watching = on\n      window[on ? 'addEventListener' : 'removeEventListener']('scroll', onScroll, { capture: true, passive: true })\n    }\n\n    const onMove = (e) => {\n      if (e.pointerType === 'touch') {\n        if (e.pointerId !== finger) return\n        cx = e.clientX\n        cy = e.clientY\n        aim(); wake()\n        return\n      }\n      cx = e.clientX\n      cy = e.clientY\n      // A release that never reached the panel — a context menu took it, or something\n      // on the metal kept it to itself — shows in the next move: the button is up.\n      if (pressing && e.type !== 'pointerdown' && !(e.buttons & 1)) pressing = false\n      if (!hover) { hover = true; watch() }\n      aim(); take('pointer'); wake()\n    }\n\n    const onDown = (e) => {\n      if (e.pointerType === 'touch') {\n        if (!e.isPrimary) return     // a second finger makes it a pinch, and that is the page's\n        clearTimeout(linger)\n        linger = 0\n        finger = e.pointerId\n        touching = true\n        cx = e.clientX\n        cy = e.clientY\n        aim(); take('finger', true); watch(); wake()\n        return\n      }\n      if (e.button !== 0) return\n      pressing = true\n      // Bare metal is held through a drag past its edge. Anything placed on it\n      // keeps the pointer, or its click would land on the panel instead.\n      if (e.target === root || e.target.parentNode === root) {\n        try { root.setPointerCapture(e.pointerId) } catch { /* capture is a nicety */ }\n      }\n      onMove(e)\n    }\n\n    const onUp = (e) => {\n      if (e.pointerType === 'touch') {\n        if (e.type === 'lostpointercapture' || e.pointerId !== finger) return\n        finger = null\n        touching = false\n        // Lifted, or taken by the page to scroll with: the light stays a moment, then goes home.\n        linger = setTimeout(() => { linger = 0; reconsider() }, LINGER)\n        watch(); wake()\n        return\n      }\n      if (e.type === 'lostpointercapture' && e.target !== root) return\n      if (!pressing) return\n      pressing = false\n      // Let go past the edge of the panel, or taken by the page, it is no longer over it.\n      // A capture lost says nothing of where the pointer is; the last move did.\n      const b = root.getBoundingClientRect()\n      const x = e.type === 'lostpointercapture' ? cx : e.clientX\n      const y = e.type === 'lostpointercapture' ? cy : e.clientY\n      const out = x < b.left || x >= b.right || y < b.top || y >= b.bottom\n      if (out || e.type === 'pointercancel') {\n        hover = false\n        watch(); reconsider()\n      }\n      wake()\n    }\n\n    const onLeave = (e) => {\n      if (e.pointerType === 'touch' || !hover) return   // follows every lift; not a leave\n      hover = false\n      pressing = false\n      watch(); reconsider()\n    }\n\n    const onFocusIn = (e) => {\n      const el = e.target\n      if (el === root || !keyboard(el)) return\n      focused = el\n      // A text field rings for a click as well. That focus is the press's doing: the\n      // light stays under the hand, and comes here when the hand has gone.\n      if (pressing || touching) return\n      aimAt(el); take('focus', true); wake()\n    }\n    /* The key that pressed comes up wherever focus is by then. What it pressed\n       may have taken itself off the page — a close button, a row's delete — and\n       the keyup then goes to the body, past the panel; nor does every browser\n       tell a thing removed that it lost focus. So it is listened for on the\n       window, and only while a key is down. */\n    const key = (on) => {\n      if (on === keying) return\n      keying = on\n      window[on ? 'addEventListener' : 'removeEventListener']('keyup', onKeyUp, true)\n    }\n    const onKeyUp = (e) => {\n      if (!isPress(e)) return\n      key(false)\n      if (focused && document.activeElement !== focused) {\n        focused = null\n        if (holder === 'focus') reconsider()\n      }\n      wake()\n    }\n    const onFocusOut = (e) => {\n      if (e.target !== focused) return\n      focused = null\n      key(false)\n      if (holder === 'focus') reconsider()\n      wake()\n    }\n    const onKeyDown = (e) => {\n      const el = e.target\n      // A key shows the ring on something a click had focused. The light goes where the ring is.\n      if (el !== focused && el !== root && keyboard(el)) {\n        focused = el\n        if (!pressing && !touching) { aimAt(el); take('focus', true) }\n        wake()\n      }\n      if (e.repeat || el !== focused || !presses(el, e)) return\n      key(true)\n      wake()\n    }\n\n    const onMotion = () => { still = motion.matches; wake() }\n\n    const ro = new ResizeObserver(() => {\n      const b = root.getBoundingClientRect()\n      // A light at rest stays at rest: it does not slide to a home the layout moved.\n      const home = !W || (!holder && X.x === W * REST_X && Y.x === H * REST_Y && !X.v && !Y.v)\n      W = Math.max(1, b.width)\n      H = Math.max(1, b.height)\n      glx.resize(W, H, cap)\n      if (home) {\n        X.x = wx = W * REST_X\n        Y.x = wy = H * REST_Y\n        X.v = Y.v = hx = hy = 0\n      }\n      // The metal has moved under a still hand, and the thing the keyboard is on with the layout.\n      if (holder === 'pointer' || holder === 'finger') {\n        tx = cx - b.left\n        ty = cy - b.top\n      } else if (holder === 'focus') {\n        if (focused && document.activeElement === focused) aimAt(focused)\n        else reconsider()\n      }\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('focusin', onFocusIn)\n    root.addEventListener('focusout', onFocusOut)\n    root.addEventListener('keydown', onKeyDown, { passive: true })\n    motion.addEventListener?.('change', onMotion)\n\n    return () => {\n      api.current = null\n      ro.disconnect()\n      loop.stop()\n      clearTimeout(linger)\n      hover = false\n      finger = null\n      watch()\n      key(false)\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('focusin', onFocusIn)\n      root.removeEventListener('focusout', onFocusOut)\n      root.removeEventListener('keydown', onKeyDown)\n      motion.removeEventListener?.('change', onMotion)\n      glx.dispose()\n    }\n  }, [])\n\n  /* Without WebGL2 a plain gradient stands in, and there is no light to carry.\n     Marked again after every render and not once: React writes the class\n     afresh whenever className changes, and would take the mark off with it.\n     And before the browser paints, or a frame of flat colour shows between. */\n  useIsomorphicLayoutEffect(() => {\n    if (flat.current) rootRef.current?.classList.add('is-fallback')\n  })\n\n  useEffect(() => {\n    api.current?.kick()\n  }, [angle, roughness, anisotropy, grain, color, intensity, height, press, resolution])\n\n  return (\n    <div\n      ref={rootRef}\n      className={`brushed-metal ${className}`.trim()}\n      style={{ '--metal-color': color, ...style }}\n      {...rest}\n    >\n      <canvas ref={canvasRef} className=\"brushed-metal__canvas\" aria-hidden=\"true\" />\n      <div className=\"brushed-metal__content\">{children}</div>\n    </div>\n  )\n}\n","type":"registry:component","target":"components/brushed-metal/BrushedMetal.jsx"},{"path":"src/components/BrushedMetal/BrushedMetal.css","content":"/* BrushedMetal — the canvas is the surface and never takes the pointer from\n   the content above it. The flat metal colour stands in where WebGL2 is\n   unavailable. */\n\n/* A finger dragged sideways carries the light; one dragged up or down still\n   scrolls the page, and two still zoom it. Inside something that scrolls\n   sideways — a carousel of metal cards — hand the swipe back with\n   style={{ touchAction: 'auto' }}: a tap still brings the light. */\n.brushed-metal {\n  position: relative;\n  overflow: hidden;\n  background: var(--metal-color, #c9cdd3);\n  isolation: isolate;\n  touch-action: pan-y pinch-zoom;\n}\n\n.brushed-metal__canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n  pointer-events: none;\n  z-index: -1;\n}\n\n.brushed-metal__content {\n  position: relative;\n  height: 100%;\n}\n\n/* No light to carry, so every gesture is the page's again. */\n.brushed-metal.is-fallback {\n  background: linear-gradient(160deg, #e7e9ec, var(--metal-color, #c9cdd3) 45%, #8d9197);\n  touch-action: auto;\n}\n\n.brushed-metal.is-fallback .brushed-metal__canvas {\n  display: none;\n}\n","type":"registry:file","target":"components/brushed-metal/BrushedMetal.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/BrushedMetal/BrushedMetal.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'\n\nexport interface BrushedMetalProps extends ComponentPropsWithoutRef<'div'> {\n  /** Grain direction, degrees clockwise like CSS rotate. 0 brushes left to right. */\n  angle?: number\n  /** Roughness across the grain, 0..1. Higher spreads the streak further. */\n  roughness?: number\n  /** How much smoother the metal is along the grain than across it, 0..1. 0 is polished, not brushed. */\n  anisotropy?: number\n  /** Depth of the scratches. 0 is a perfect brushed finish with no visible lines. */\n  grain?: number\n  /** The metal's reflectance at normal incidence (F0). Any CSS colour — gold is #ffc86e, copper #f6a882. */\n  color?: string\n  /** Brightness of the light, as it is at its resting height. */\n  intensity?: number\n  /** The light's height above the panel, px. Lower gives a tighter, more raking highlight. */\n  height?: number\n  /** How far a press lowers the light, as a share of its height, 0..0.9. Brought closer it lights the metal under it more brightly and draws the streak in. 0 turns it off. */\n  press?: number\n  /** Device-pixel-ratio cap for the canvas. */\n  resolution?: number\n  /** Content laid on the plate. It stays real DOM above the canvas. */\n  children?: ReactNode\n}\n\ndeclare const BrushedMetal: (props: BrushedMetalProps) => ReactElement\nexport default BrushedMetal\n","type":"registry:lib","target":"components/brushed-metal/BrushedMetal.d.ts"}],"type":"registry:component"}