{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wet-ink","title":"Wet Ink","description":"A word written in ink that bleeds along the paper's fibres by anisotropic diffusion and dries darker at its rim, stepped under the explicit stability limit. Canvas 2D.","dependencies":[],"files":[{"path":"src/components/WetInk/index.js","content":"export { default } from './WetInk.jsx'\n","type":"registry:component","target":"components/wet-ink/index.js"},{"path":"src/components/WetInk/WetInk.jsx","content":"'use client'\n\nimport { useEffect, useRef } from 'react'\nimport './WetInk.css'\n\n/* =========================================================\n   WetInk\n   A word that writes itself in ink on paper. The ink is laid\n   down wet behind a moving pen, creeps outward along the\n   fibres of the paper, and dries where it stops.\n\n   Faking this with a blurred stroke gives an even halo, and\n   real bleed is nothing like even: paper is a mat of fibres,\n   and liquid wicks along a fibre far faster than across one.\n   So the ink here is a quantity on a grid that moves by\n   anisotropic diffusion,\n\n     ∂u/∂t = ∇·(D ∇u) − k·u\n\n   where D is a tensor aligned with the local fibre direction\n   — conductance a along the fibre, b across it — taken from a\n   smooth orientation field with a dominant grain, as machine-\n   made paper has. Split onto the grid's axes, an edge along x\n   conducts b + (a − b)·cos²θ and one along y b + (a − b)·sin²θ.\n\n   The −k·u term is the paper drinking: wet ink is absorbed\n   into the fibres at rate k, becomes pigment, and stops moving.\n   It is absorbed a little faster where the film is thin, so\n   pigment gathers at the drying edge — the darker rim of every\n   ink blot, and of every coffee ring.\n\n   The update is explicit, which is only stable while\n   Δt ≤ h² / (4·Dmax); the step is subdivided to stay under\n   that limit, since breaking it does not blur the ink but\n   explodes it into checkerboard noise.\n\n   The pen is a slanted front sweeping the word left to right,\n   and every cell of the word takes the same load of ink as\n   the tip crosses it — measured by how far the tip moved over\n   that cell, not by which frames happened to land there.\n   Dosing per frame stripes the word: at 60 Hz a cell sits\n   under the tip for one frame or two, a 120 Hz screen lays it\n   down differently, and a fast pen skips cells outright.\n\n   A pen at an even pace reads as a scanner. A hand leans into\n   the heavy strokes and quickens across the gaps between\n   letters, so the tip takes its pace from the ink just ahead\n   of it — the word's ink summed along the slant and smoothed\n   over about a stroke — and slows where there is more to lay\n   down. Under that sits a ceiling that rises from a third of\n   the usual pace as the pen touches down and sinks again into\n   the last stroke, so it starts from rest and lands rather\n   than sweeping on and off. The pace is normalised so the\n   word takes exactly as long as an even sweep at `speed`;\n   `rhythm` 0 is that even sweep. Everything runs on elapsed\n   time, clamped after a stall, so a 120 Hz screen writes the\n   same word in the same time as a 60 Hz one.\n\n   Writing again starts a fresh sheet. What is on the paper,\n   wet or dry, clears over a fifth of a second — at an even\n   rate, so the first frame already shows it going and none of\n   it lingers faint — and the new pen touches down as the last\n   of it goes. The same word again is a command and is answered\n   that fast. A different word waits a beat longer, until the\n   old one has gone, and so does any request that follows hard\n   on another: each sets the wait again, so a run of them —\n   typing, a dragged control — is one word written when the\n   hand stops, not a row of false starts.\n\n   A new size keeps the word: once the size has held for a\n   moment it is laid out again and run forward to the point it\n   had reached — dry, drying or half-written — in slices\n   between frames, rather than written over from the start.\n   The old picture is held meanwhile, whole and at its own\n   proportions, and crosses into the new one over the same\n   fifth of a second: ink bleeds a set distance into the\n   paper, not a share of the letter, so a smaller word is a\n   softer one and a cut between the two would show. A box\n   with no area at all — hidden, or collapsed — is not a new\n   size: the sheet keeps what it has until the box is back,\n   and one that has never had a size waits for one. Colours\n   are not ink; changing them repaints what is there.\n\n   The pen waits to be watched. It touches down once half the\n   sheet is in view — or after a second in view at all, for a\n   sheet cropped or larger than its window — so a word below\n   the fold is written as the reader arrives at it, not as its\n   top edge does. Offscreen nothing runs, and the loop stops\n   once the last of the ink has soaked in.\n\n   Under reduced motion nothing is seen to move: the word is\n   worked out of sight — in view or not, so it is there on\n   arrival — and shown dry, the old picture standing until\n   the new one is ready.\n   ========================================================= */\n\nconst SLANT = 0.3   // the pen's lean: a cell's turn comes at x + (rows − y)·SLANT\nconst BAND = 5      // cells behind the tip still taking ink\nconst LOAD = 1.8    // ink each cell of the word takes as the tip crosses it\nconst SOAK = 1.9    // absorption, 1/s\nconst DAMP = 0.01   // wet ink thinner than this reads as dry\nconst WICK = 8      // bleed past this has long left the letters, and only costs steps\nconst SWAY = 4      // rhythm past this is a pen that stalls on every stem\nconst FADE = 0.2    // s, the old picture leaving\nconst LEAD = 0.14   // s after a request for the same word when the new pen touches down\nconst LULL = 0.3    // s it waits instead to write a different one, or one of a run\nconst HOLD = 120    // ms a new size must hold before the word is laid out again\nconst PEEK = 1000   // ms a sheet less than half in view waits before it is written anyway\nconst INK = [26, 35, 64]        // what a colour that is not #rrggbb falls back to\nconst PAPER = [243, 238, 227]\n\nconst hash = (x, y) => {\n  const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453\n  return s - Math.floor(s)\n}\nconst smooth = (x, y) => {\n  const xi = Math.floor(x), yi = Math.floor(y)\n  const fx = x - xi, fy = y - yi\n  const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy)\n  const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1)\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v\n}\nconst ease = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t))\nconst num = (v, fallback) => {\n  const n = Number(v)\n  return Number.isFinite(n) ? n : fallback\n}\nconst rgb = (hex, fallback) => {\n  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim())\n  if (!m) return fallback\n  const n = parseInt(m[1], 16)\n  return [n >> 16, (n >> 8) & 255, n & 255]\n}\n// Beer–Lambert: pigment darkens multiplicatively; wet ink reads a touch darker.\nconst cover = (dry, wet) => (dry || wet ? 1 - Math.exp(-(dry * 1.6 + wet * 1.1)) : 0)\n\n// The old picture carried onto a grid of another size: whole, centred and\n// undistorted, as the canvas holds it while the box is changing.\nconst resample = (src, sw, sh, dw, dh) => {\n  const out = new Float32Array(dw * dh)\n  const k = Math.max(sw / dw, sh / dh)                  // old cells to a new one\n  for (let y = 0; y < dh; y++) {\n    const fy = (y + 0.5 - dh / 2) * k + sh / 2 - 0.5\n    if (fy < -0.5 || fy > sh - 0.5) continue            // past the old sheet: bare paper\n    const y0 = Math.min(sh - 1, Math.max(0, Math.floor(fy))), y1 = Math.min(sh - 1, y0 + 1)\n    const ty = Math.min(1, Math.max(0, fy - y0))\n    for (let x = 0; x < dw; x++) {\n      const fx = (x + 0.5 - dw / 2) * k + sw / 2 - 0.5\n      if (fx < -0.5 || fx > sw - 0.5) continue\n      const x0 = Math.min(sw - 1, Math.max(0, Math.floor(fx))), x1 = Math.min(sw - 1, x0 + 1)\n      const tx = Math.min(1, Math.max(0, fx - x0))\n      const top = src[y0 * sw + x0] + (src[y0 * sw + x1] - src[y0 * sw + x0]) * tx\n      const bot = src[y1 * sw + x0] + (src[y1 * sw + x1] - src[y1 * sw + x0]) * tx\n      out[y * dw + x] = top + (bot - top) * ty\n    }\n  }\n  return out\n}\n\nexport default function WetInk({\n  text = 'Morphiq',\n  fontFamily = 'Georgia, \"Times New Roman\", serif',\n  fontWeight = 400,\n  fontSize = 'auto',       // px, or 'auto' to fit the box\n  ink = '#1a2340',         // hex\n  paper = '#f3eee3',       // hex\n  speed = 360,             // mean pen speed, px/s\n  bleed = 0.4,             // how far ink wicks before it dries\n  grain = 0,               // the paper's machine direction, degrees\n  cell = 2,                // px per simulation cell; smaller is finer and costlier\n  rhythm = 1,              // how much the pen keeps a hand's pace; 0 sweeps evenly\n  replay,                  // change to write the word again\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const canvasRef = useRef(null)\n  const engine = useRef(null)\n  const word = text == null ? '' : String(text)\n\n  const live = useRef(null)\n  // Synced after every render and before the effects below, so the simulation\n  // reads colours and pace without restarting the word.\n  useEffect(() => {\n    live.current = { ink, paper, speed, bleed, rhythm }\n  })\n\n  // The sheet: one simulation for the life of the element. What it writes\n  // arrives through write(), so a new word continues from what is on the paper\n  // instead of tearing the page down.\n  useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const ctx = canvas?.getContext('2d')\n    if (!root || !ctx) return\n\n    const calm = window.matchMedia('(prefers-reduced-motion: reduce)')\n    const off = document.createElement('canvas')\n    const o = off.getContext('2d', { willReadFrequently: true })\n\n    let px = 2, cols = 0, rows = 0\n    let mask, wet, dry, next, cx, cy, image\n    // The previous picture, leaving: cleared from under a new word, or — `swap` —\n    // crossed into the same word at a new size. Held whole while fadeAt is\n    // Infinity. `kept` is the same picture on the grid it was taken from.\n    let ghost = null, kept = null, kw = 0, kh = 0, swap = false, fadeAt = 0\n    let woven = NaN                            // the grain the sheet's fibres were laid at\n    let heavy = null, o0 = 0, o1 = 0, end = 0  // ink ahead of the tip, and its run\n    let rampIn = 1, rampOut = 1, typical = 1, norm = 1, normFor = NaN\n    let pen = 0, sim = 0, doneAt = -1, inked = false\n    let spec = null, wanted = null, ticket = 0, pending = false\n    let clock = 0, penAt = 0, asked = -Infinity\n    let forward = null                         // work done out of sight, in slices\n    let aim = null                             // where that work stops; null is dry\n    let raf = 0, last = 0, timer = 0, peek = 0, dead = false\n    let seen = typeof IntersectionObserver !== 'function'   // any of the sheet in view\n    let shown = seen                                        // enough of it to watch\n    let sized = false                                       // the box has had an area\n\n    // The laid-out box, not the painted one: a transform on the way in — a card\n    // scaling up as it enters — must not lay the word on a smaller sheet. A box\n    // with no area — not rendered, or collapsed — is not a new size: the sheet\n    // keeps the grid it has, picture and all, until the box is back.\n    const dims = () => {\n      const w = root.clientWidth, h = root.clientHeight\n      if (!w || !h) return [cols || 8, rows || 8]\n      sized = true\n      return [Math.max(8, Math.round(w / px)), Math.max(8, Math.round(h / px))]\n    }\n\n    const alloc = (c, r) => {\n      if (ghost) ghost = resample(kept, kw, kh, c, r)\n      cols = c\n      rows = r\n      const n = c * r\n      mask = new Float32Array(n)\n      wet = new Float32Array(n)\n      dry = new Float32Array(n)\n      next = new Float32Array(n)\n      cx = new Float32Array(n)            // conductance to the right-hand neighbour\n      cy = new Float32Array(n)            // and to the one below\n      woven = NaN\n    }\n\n    // The word, its paper and the pen's run, at the current size.\n    const lay = (s) => {\n      spec = s\n      px = Math.min(64, Math.max(1, num(s.cell, 0) || 2))\n      const [c, r] = dims()\n      if (c !== cols || r !== rows) alloc(c, r)\n\n      off.width = cols\n      off.height = rows\n      // A face the canvas cannot parse is ignored, and what was set before it\n      // stands: a plain serif at the size asked, not the canvas's own 10px.\n      const typeset = (size) => {\n        o.font = `400 ${size}px serif`\n        o.font = `${s.fontWeight} ${size}px ${s.fontFamily}`\n      }\n      const fit = () => {\n        typeset(100)\n        const w = o.measureText(s.text).width || 1\n        return Math.min((cols * 0.88 * 100) / w, rows * 0.72)\n      }\n      // 'auto', or any size that is not one, fits the box.\n      const size = num(s.fontSize, 0)\n      const em = size > 0 ? Math.max(1, size / px) : fit()\n      typeset(em)\n      o.textAlign = 'center'\n      o.textBaseline = 'middle'\n      o.fillStyle = '#fff'\n      o.fillText(s.text, cols / 2, rows / 2)\n      const alpha = o.getImageData(0, 0, cols, rows).data\n\n      // Fibres: a smooth orientation field around the grain, and a density that\n      // mottles how readily each patch of paper takes ink. Measured from the\n      // middle of the sheet, where the word sits, so a box that only widens\n      // keeps the same paper under the same letters. A new word on the same\n      // sheet is written on the same paper: the fibres are the costly half of\n      // laying a word out, and are only woven again for a new size or grain.\n      const g = num(s.grain, 0) * (Math.PI / 180)\n      const weave = woven !== g\n      woven = g\n      const mx = Math.round(cols / 2), my = Math.round(rows / 2)\n      const span = cols + Math.ceil(rows * SLANT) + BAND + 2\n      const along = new Float32Array(span)\n      let lo = span, hi = -1\n      for (let y = 0; y < rows; y++) {\n        for (let x = 0; x < cols; x++) {\n          const i = y * cols + x\n          const m = alpha[i * 4 + 3] / 255\n          mask[i] = m\n          if (m > 0.05) {\n            const k = (x + (rows - y) * SLANT) | 0\n            along[k] += m\n            if (k < lo) lo = k\n            if (k > hi) hi = k\n          }\n          if (!weave) continue\n          const fx = x - mx, fy = y - my\n          const theta = g + (smooth(fx / 9 + 500, fy / 9 + 500) - 0.5) * 1.6\n          const density = 0.55 + 0.9 * smooth(fx / 3.1 + 540, fy / 3.1 + 500)\n          const cc = Math.cos(theta) ** 2\n          cx[i] = density * (0.12 + 0.88 * cc)\n          cy[i] = density * (0.12 + 0.88 * (1 - cc))\n        }\n      }\n\n      // The pen's run, from the first cell of ink until the tip has carried a\n      // full band past the last, and how heavy the word is along it.\n      wet.fill(0)\n      dry.fill(0)\n      inked = false\n      sim = 0\n      if (hi < 0) {\n        heavy = null\n        o0 = o1 = end = pen = 0\n        doneAt = 0\n        return\n      }\n      o0 = lo\n      end = hi + 1\n      o1 = end + BAND\n      pen = o0\n      doneAt = -1\n      rampIn = Math.max(2, em * 0.5)\n      rampOut = Math.max(2, em * 0.55)\n      const sigma = Math.max(1.5, em * 0.13), reach = Math.ceil(sigma * 2.5)\n      heavy = new Float32Array(span)\n      let peak = 0\n      for (let k = o0; k < o1; k++) {\n        let acc = 0, wsum = 0\n        for (let j = -reach; j <= reach; j++) {\n          const q = k + j\n          if (q < 0 || q >= span) continue\n          const wgt = Math.exp((-j * j) / (2 * sigma * sigma))\n          acc += along[q] * wgt\n          wsum += wgt\n        }\n        heavy[k] = acc / wsum\n        if (heavy[k] > peak) peak = heavy[k]\n      }\n      // Squared, so the heavy strokes stand out from the ordinary run of the word.\n      if (peak > 0) for (let k = o0; k < o1; k++) heavy[k] = (heavy[k] / peak) ** 2\n      normFor = NaN\n    }\n\n    // The tip's pace at one cell of its run, before normalising: slower where\n    // the word is heavy, and held under a ceiling that rises as it touches down\n    // and falls again into its last stroke, so it starts and stops like a hand.\n    const hand = (k, r) => 1 / (1 + 1.2 * r * heavy[k])\n    const rawPace = (k, r) => {\n      const w = Math.min(1, r)\n      const down = 1 - 0.65 * w, up = 1 - 0.45 * w\n      const touch = down + (3 - down) * ease((k - o0) / rampIn)\n      const lift = up + (3 - up) * ease((end - k) / rampOut)\n      return Math.min(hand(k, r), typical * touch, typical * lift)\n    }\n    // Scaled so the run takes exactly as long as an even sweep at `speed`.\n    const paceAt = (q, r) => {\n      if (!heavy) return 1\n      if (r !== normFor) {\n        let t = 0\n        for (let k = o0; k < o1; k++) t += 1 / hand(k, r)\n        typical = (o1 - o0) / t\n        t = 0\n        for (let k = o0; k < o1; k++) t += 1 / rawPace(k, r)\n        norm = t / (o1 - o0)\n        normFor = r\n      }\n      const p = Math.min(o1 - 1, Math.max(o0, q))\n      const k = Math.floor(p), f = p - k\n      const a = rawPace(k, r), b = k + 1 < o1 ? rawPace(k + 1, r) : a\n      return (a + (b - a) * f) * norm\n    }\n\n    const step = (dt, floor = 0) => {\n      const { speed: v, bleed: bl, rhythm: rh } = live.current\n      // Each held to a range: unbounded bleed is unbounded sub-steps, and a pace\n      // that is not a number is a pen that never arrives.\n      const D = 26 * Math.min(WICK, Math.max(0, Number(bl) || 0))   // cells²/s at full conductance\n      const vc = Math.max(floor, Math.max(1, num(v, 360)) / px)     // cells/s\n      const r = Math.min(SWAY, Math.max(0, Number(rh) || 0))\n\n      const from = pen\n      if (pen < o1) {\n        const mid = pen + 0.5 * vc * paceAt(pen, r) * dt\n        pen = Math.min(o1, pen + vc * paceAt(mid, r) * dt)\n        if (pen > o0) inked = true\n      }\n      sim += dt\n      if (doneAt < 0 && pen >= o1) doneAt = sim\n\n      // Explicit diffusion is stable only for Δt ≤ h²/(4·Dmax); h is one cell.\n      const limit = D > 0 ? 0.9 / (4 * D * 1.45) : dt\n      const subs = Math.max(1, Math.ceil(dt / limit))\n      const h = dt / subs\n      const dose = LOAD / BAND\n      for (let s = 0; s < subs; s++) {\n        // The stretch of the run the tip crossed in this slice.\n        const pa = from + ((pen - from) * s) / subs\n        const pb = from + ((pen - from) * (s + 1)) / subs\n        const moving = pb > pa\n        for (let y = 0; y < rows; y++) {\n          const lean = (rows - y) * SLANT\n          for (let x = 0; x < cols; x++) {\n            const i = y * cols + x\n            let u = wet[i]\n            // Fresh ink, in proportion to how much of this cell's band the tip crossed.\n            if (moving && mask[i] > 0) {\n              const at = x + lean\n              const crossed = Math.min(pb, at + BAND) - Math.max(pa, at)\n              if (crossed > 0) u += mask[i] * dose * crossed\n            }\n            // Dry paper among dry paper stays dry: most of the sheet, skipped.\n            if (u === 0 &&\n                (x === 0 || wet[i - 1] === 0) && (x === cols - 1 || wet[i + 1] === 0) &&\n                (y === 0 || wet[i - cols] === 0) && (y === rows - 1 || wet[i + cols] === 0)) {\n              next[i] = 0\n              continue\n            }\n            // Flux to each neighbour through the shared edge's conductance.\n            let flux = 0\n            if (x > 0) flux += cx[i - 1] * (wet[i - 1] - wet[i])\n            if (x < cols - 1) flux += cx[i] * (wet[i + 1] - wet[i])\n            if (y > 0) flux += cy[i - cols] * (wet[i - cols] - wet[i])\n            if (y < rows - 1) flux += cy[i] * (wet[i + cols] - wet[i])\n            // Thin film soaks in faster: pigment gathers at the drying rim.\n            const soak = SOAK * (1 + 1.4 * Math.exp(-u * 6)) * u\n            let w = u + (D * flux - soak) * h\n            dry[i] += soak * h\n            // A film too thin to see soaks in at once, rather than spread forever.\n            if (w < 1e-5) {\n              if (w > 0) dry[i] += w\n              w = 0\n            }\n            next[i] = w\n          }\n        }\n        const t = wet; wet = next; next = t\n      }\n    }\n\n    const wetAnywhere = () => {\n      for (let i = 0; i < wet.length; i++) if (wet[i] > DAMP) return true\n      return false\n    }\n    // The last of the wet soaks in where it lies; the sheet is now only pigment.\n    const settle = () => {\n      for (let i = 0; i < wet.length; i++) {\n        dry[i] += wet[i]\n        wet[i] = 0\n      }\n    }\n    const moving = () => pen < o1 || wetAnywhere()\n    // Work done out of sight, a step at a time: the word run forward to where\n    // it had got to — the tip's share of its run, then its time drying — or,\n    // with nowhere to stop, written out and dried.\n    const reaching = function* (to) {\n      const hurry = (o1 - o0) / 8\n      const tip = to ? o0 + to.share * (o1 - o0) : o1\n      for (let i = 0; i < 2400 && pen < tip; i++) {\n        step(1 / 30, hurry)\n        yield\n      }\n      const until = to ? (doneAt < 0 ? 0 : doneAt + to.drying) : Infinity\n      for (let i = 0; i < 2400 && sim < until && wetAnywhere(); i++) {\n        step(1 / 30)\n        yield\n      }\n      if (!to) settle()\n    }\n    // It runs in slices between frames, the old picture held meanwhile, so a\n    // new size never stalls the page for the length of a word.\n    const slice = (budget) => {\n      const t0 = performance.now()\n      while (forward) {\n        if (forward.next().done) forward = null\n        else if (performance.now() - t0 > budget) break\n      }\n      return !forward\n    }\n\n    const fading = () => (ghost ? Math.min(1, Math.max(0, 1 - (clock - fadeAt) / FADE)) : 0)\n    // One cell of the picture: the ink on the paper, and the old picture over\n    // it — crossing into the same word, or clearing from under a new one.\n    const shade = (i, g) => {\n      const a = cover(dry[i], wet[i])\n      if (g <= 0) return a\n      return swap ? a + (ghost[i] - a) * g : 1 - (1 - g * ghost[i]) * (1 - a)\n    }\n\n    const paint = () => {\n      const { ink: inkHex, paper: paperHex } = live.current\n      const [ir, ig, ib] = rgb(inkHex, INK)\n      const [pr, pg, pb] = rgb(paperHex, PAPER)\n      const g = fading()\n      if (!image || image.width !== cols || image.height !== rows) {\n        canvas.width = cols\n        canvas.height = rows\n        image = ctx.createImageData(cols, rows)\n      }\n      const d = image.data\n      for (let i = 0, n = cols * rows; i < n; i++) {\n        const a = shade(i, g)\n        d[i * 4] = pr + (ir - pr) * a\n        d[i * 4 + 1] = pg + (ig - pg) * a\n        d[i * 4 + 2] = pb + (ib - pb) * a\n        d[i * 4 + 3] = 255\n      }\n      ctx.putImageData(image, 0, 0)\n    }\n\n    // What is on the paper now becomes the old picture: held, to cross into\n    // the same word at a new size, or leaving at once from under a new word.\n    // Nothing fades under reduced motion, nor where nobody is looking.\n    const keep = (crossing) => {\n      const g = fading()\n      const old = new Float32Array(cols * rows)\n      let any = false\n      for (let i = 0; i < old.length; i++) {\n        old[i] = shade(i, g)\n        if (old[i] > 0.004) any = true\n      }\n      ghost = kept = any && !calm.matches && (crossing || seen) ? old : null\n      kw = cols\n      kh = rows\n      swap = crossing\n      fadeAt = crossing ? Infinity : clock\n    }\n\n    // A fresh sheet: the old one clears from where it stands.\n    const clear = () => {\n      keep(false)\n      penAt = ghost ? clock + LEAD : clock\n      wet.fill(0)\n      dry.fill(0)\n      heavy = null\n      o0 = o1 = end = pen = 0\n      doneAt = 0\n      inked = false\n    }\n\n    // The pen's first touch waits to be watched; once down, it only needs to be seen.\n    const busy = () => !!ghost || (!pending && (inked || shown) && moving())\n\n    const frame = (now) => {\n      raf = 0\n      // Reduced motion: whatever was moving is finished out of sight instead,\n      // and the old picture stays up until there is a new one to show.\n      if (calm.matches && !forward) {\n        if (pending) return\n        if (ghost || (mask && moving())) {\n          ghost = kept = null\n          aim = null\n          forward = reaching(aim)\n        }\n      }\n      if (forward) {\n        if (slice(seen ? 8 : 3)) {\n          fadeAt = clock                           // the held picture starts to leave\n          paint()\n        }\n        last = 0\n        raf = requestAnimationFrame(frame)\n        return\n      }\n      const dt = last ? Math.min(1 / 30, (now - last) / 1000) : 1 / 60\n      last = now\n      const was = clock\n      clock += dt\n      if (ghost && clock - fadeAt >= FADE) ghost = kept = null\n      if (!pending && mask && (inked || shown) && clock > penAt) {\n        step(Math.min(dt, clock - Math.max(was, penAt)))\n      }\n      if (mask) paint()\n      if (busy()) {\n        raf = requestAnimationFrame(frame)\n      } else {\n        settle()\n        if (mask) paint()\n        last = 0\n      }\n    }\n\n    // Out of view nothing runs — except a word to be shown dry, which is\n    // worked out all the same, to be there on arrival. Nor does anything run\n    // on a sheet that has never had a size: there is nothing yet to write on.\n    const wake = () => {\n      if (raf || dead || !sized || !(seen || (forward && calm.matches))) return\n      last = 0\n      raf = requestAnimationFrame(frame)\n    }\n\n    const write = (s) => {\n      const t = ++ticket\n      const now = performance.now()\n      // The same word again is a command, and answered at once. A different\n      // one, or one request of several in quick succession, is likely not the\n      // last: the pen waits a longer beat after each.\n      const patient =\n        wanted && (now - asked < LULL * 1000 || Object.keys(s).some((k) => s[k] !== wanted[k]))\n      asked = now\n      wanted = s\n      // Once the pen has touched the paper, a new word needs a fresh sheet;\n      // before that, it only changes what the waiting pen will write. Work\n      // still catching up to a new size is dropped: the picture held for it is\n      // the one on screen, and the one to clear from.\n      if (inked || forward) {\n        forward = null\n        clear()\n      }\n      if (patient) penAt = clock + LULL\n      const go = () => {\n        if (dead || t !== ticket) return\n        pending = false\n        lay(s)\n        if (calm.matches) {\n          ghost = kept = null\n          aim = null\n          forward = reaching(aim)\n        } else {\n          paint()\n        }\n        wake()\n      }\n      // Written once the face has arrived, or the fallback font is what dries.\n      const face = `${s.fontWeight} 100px ${s.fontFamily}`\n      let ready\n      try {\n        ready = !document.fonts?.check || document.fonts.check(face, s.text || ' ')\n      } catch {\n        ready = true                                 // a font string the browser cannot parse\n      }\n      if (ready) return go()\n      pending = true\n      document.fonts.load(face, s.text || ' ').then(go, go)\n      wake()                                         // the old sheet clears meanwhile\n    }\n\n    // A new size: lay the word out again and run it forward, out of sight, to\n    // where it had got to, the picture as it stood held until it is there.\n    const refit = () => {\n      timer = 0\n      if (dead || !spec || pending) return\n      const [c, r] = dims()\n      if (c === cols && r === rows) return\n      // Work this interrupts was not yet the picture: its aim, and the picture\n      // held for it, still stand.\n      if (!forward) {\n        // Nothing on the paper yet: only the sheet changes under the waiting pen.\n        if (!inked && !calm.matches) {\n          lay(spec)\n          paint()\n          return wake()\n        }\n        aim = moving()\n          ? { share: (pen - o0) / (o1 - o0), drying: doneAt < 0 ? 0 : sim - doneAt }\n          : null\n        keep(true)\n      }\n      lay(spec)\n      forward = reaching(calm.matches ? null : aim)\n      wake()\n    }\n\n    const ro = new ResizeObserver(() => {\n      if (!spec || pending) return\n      const first = !sized\n      const [c, r] = dims()\n      if (c === cols && r === rows) return\n      clearTimeout(timer)\n      // Nothing on the paper yet, or no paper until now: lay it out at once.\n      // Otherwise the picture there rides along with the box until the size\n      // holds, and the word is then rebuilt once.\n      if (first || (!inked && !ghost && !forward)) refit()\n      else timer = setTimeout(refit, HOLD)\n    })\n    ro.observe(root)\n\n    const io = seen\n      ? null\n      : new IntersectionObserver(\n          (entries) => {\n            const e = entries[entries.length - 1]\n            seen = e.isIntersecting\n            if (!seen || e.intersectionRatio >= 0.5) {\n              shown = seen\n              clearTimeout(peek)\n              peek = 0\n            } else if (!shown && !peek) {\n              peek = setTimeout(() => {\n                peek = 0\n                shown = true\n                wake()\n              }, PEEK)\n            }\n            if (seen) wake()\n            else if (raf && !(forward && calm.matches)) {\n              cancelAnimationFrame(raf)\n              raf = 0\n            }\n          },\n          { threshold: [0, 0.5] },\n        )\n    io?.observe(root)\n\n    engine.current = {\n      write,\n      // Not while a picture is being held for work still out of sight — a word\n      // on its way under reduced motion, or its face: the new colours arrive with it.\n      repaint: () => {\n        if (mask && !dead && !forward && !(pending && calm.matches)) paint()\n      },\n    }\n\n    return () => {\n      dead = true\n      engine.current = null\n      ro.disconnect()\n      io?.disconnect()\n      clearTimeout(timer)\n      clearTimeout(peek)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [])\n\n  // What is written. Any change writes it again, on a fresh sheet.\n  useEffect(() => {\n    engine.current?.write({\n      text: word,\n      fontFamily,\n      fontWeight,\n      fontSize,\n      grain,\n      cell,\n    })\n  }, [word, fontFamily, fontWeight, fontSize, grain, cell, replay])\n\n  // Colours are not ink: a new one repaints the word as it stands.\n  useEffect(() => {\n    engine.current?.repaint()\n  }, [ink, paper])\n\n  // The word is the picture's name; a sheet with nothing on it is only decoration.\n  const named = word.trim() !== ''\n  // The box behind the canvas is the paper the canvas paints, fallback and all:\n  // it shows beside a picture held at its own proportions.\n  const sheet = `rgb(${rgb(paper, PAPER).join(', ')})`\n  return (\n    <div\n      ref={rootRef}\n      className={`wet-ink ${className}`.trim()}\n      role={named ? 'img' : undefined}\n      aria-label={named ? word : undefined}\n      aria-hidden={named ? undefined : true}\n      style={{ '--wet-ink-paper': sheet, ...style }}\n      {...rest}\n    >\n      <canvas ref={canvasRef} className=\"wet-ink__canvas\" aria-hidden=\"true\" />\n    </div>\n  )\n}\n","type":"registry:component","target":"components/wet-ink/WetInk.jsx"},{"path":"src/components/WetInk/WetInk.css","content":"/* WetInk — the simulation runs at one cell per `cell` px and the canvas is\n   scaled up smoothly, which softens the grid the way paper softens ink. A box\n   without a height gets a wordmark's proportions. While the box is changing\n   size the picture already there is held whole, at its own proportions, on\n   paper of the same colour — never stretched to the new shape. */\n\n.wet-ink {\n  position: relative;\n  overflow: hidden;\n  aspect-ratio: 3.2 / 1;\n  background: var(--wet-ink-paper, #f3eee3);\n}\n\n.wet-ink__canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n  object-fit: contain;\n}\n","type":"registry:file","target":"components/wet-ink/WetInk.css"},{"path":"src/components/WetInk/WetInk.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport interface WetInkProps extends ComponentPropsWithoutRef<'div'> {\n  /** The word to write. Also the element's accessible name. A new word is written on a fresh sheet once the old one has cleared; of a run of changes, only the last is written. Under reduced motion each word is shown dry as soon as it is worked out, so a slow run may show the ones between. */\n  text?: string\n  /** CSS font family. The word waits for the face to load rather than drying in the fallback. */\n  fontFamily?: string\n  /** CSS font weight. */\n  fontWeight?: number | string\n  /** px, or 'auto' to fit the box. */\n  fontSize?: number | 'auto'\n  /** Ink colour, #rrggbb; anything else falls back to the default. A change repaints the word as it stands. */\n  ink?: string\n  /** Paper colour, #rrggbb; anything else falls back to the default. A change repaints the word as it stands. */\n  paper?: string\n  /** Mean pen speed, px/s: the word takes as long as an even sweep at this speed would. Held to 1 or more, so 0 is a very slow pen, not a stopped one. */\n  speed?: number\n  /** How far ink wicks through the fibres before it dries. 0 is a dry pen; held to 0–8. Acts on ink still wet. */\n  bleed?: number\n  /** The paper's machine direction, degrees. Ink bleeds furthest along it. */\n  grain?: number\n  /** px per simulation cell. Smaller is finer and costs more; held to 1–64. */\n  cell?: number\n  /** How much the pen keeps a hand's pace — touching down slowly, leaning into heavy strokes, quickening across gaps, easing into the last stroke. 0 sweeps evenly; held to 0–4. The word takes the same time at any value. */\n  rhythm?: number\n  /** Change to write the word again: what is on the paper clears in a fifth of a second as the new pen touches down. Under reduced motion it is worked out again and shown dry, with nothing seen to move. */\n  replay?: unknown\n}\n\ndeclare const WetInk: (props: WetInkProps) => ReactElement\nexport default WetInk\n","type":"registry:lib","target":"components/wet-ink/WetInk.d.ts"}],"type":"registry:component"}