{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"axis-press-button","type":"registry:component","title":"Axis Press Button","description":"A button whose label is the control: letters under the pointer swell heavier and wider while the rest condense, so the button never changes size, and a press clenches every letter and springs it back.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/axis-press-button.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from \"react\";\n\n/**\n * AxisPressButton — a button whose label is the control.\n *\n * The letters under the pointer swell heavier and wider while the rest\n * condense to make room, so the label moves but the button never changes\n * size; a press (or Enter or Space) clenches every letter to full weight\n * and contrast and release springs them back past rest. Solid or quiet, on\n * paper or ink. Renders a link when given an href, else a button.\n *\n * Part of the Kinetic Type kit: paper #f0ede2 and ink #131315 (an inverted\n * ink stage for loud sections), one accent that letters take only under\n * load (cobalt #2b3bff by default), and Science Gothic through\n * var(--font-science), whose weight (100-900), width (50-200), slant (-10-0)\n * and contrast (CTRS 0-85) axes are the interface. Respects\n * prefers-reduced-motion. No dependencies beyond React. Paste it as its own\n * file: it repeats the kit's small helpers and type engine, which would\n * clash in one module. Load Science Gothic with next/font (axes: [\"wdth\",\n * \"slnt\", \"CTRS\"], variable: \"--font-science\") on a parent; elsewhere, load\n * it from Google Fonts or @fontsource and set --font-science yourself. In a\n * face without a width axis, flush lines fall back to fitting by size.\n */\n\nconst DISPLAY = 'var(--font-science, \"Science Gothic\", var(--font-sans, \"Geist\"), ui-sans-serif, system-ui, sans-serif)';\n\nconst HEX = /^#[0-9a-fA-F]{6}$/;\n\n/** The two grounds: paper, and the inverted ink stage for one loud section. */\nexport type KtTone = \"paper\" | \"ink\";\n\nconst TONES = {\n  paper: {\n    ground: \"#f0ede2\",\n    panel: \"#e8e4d6\",\n    ink: \"#131315\",\n    ink2: \"rgba(19,19,21,0.68)\",\n    ink3: \"rgba(19,19,21,0.46)\",\n    line: \"rgba(19,19,21,0.14)\",\n    line2: \"rgba(19,19,21,0.3)\",\n  },\n  ink: {\n    ground: \"#131315\",\n    panel: \"#1c1c20\",\n    ink: \"#f0ede2\",\n    ink2: \"rgba(240,237,226,0.7)\",\n    ink3: \"rgba(240,237,226,0.48)\",\n    line: \"rgba(240,237,226,0.14)\",\n    line2: \"rgba(240,237,226,0.3)\",\n  },\n} as const;\n\n/** The accent letters take under load, per ground (the ink stage needs a lighter one to read). */\nexport const KT_PALETTES = {\n  cobalt: { paper: \"#2b3bff\", ink: \"#8f98ff\" },\n  vermilion: { paper: \"#d9360b\", ink: \"#ff6a3d\" },\n  mono: { paper: \"#131315\", ink: \"#f0ede2\" },\n} as const;\n\nexport type KtPaletteName = keyof typeof KT_PALETTES;\n\nfunction own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {\n  return Object.prototype.hasOwnProperty.call(map, key);\n}\n\nfunction toneOf(tone: string) {\n  return own(TONES, tone) ? TONES[tone] : TONES.paper;\n}\n\n/** The accent for a palette on a ground. */\nfunction accentOf(palette: string, tone: string): string {\n  const p = own(KT_PALETTES, palette) ? KT_PALETTES[palette] : KT_PALETTES.cobalt;\n  return tone === \"ink\" ? p.ink : p.paper;\n}\n\nfunction rgbOf(hex: string): [number, number, number] {\n  const n = parseInt((HEX.test(hex) ? hex : \"#131315\").slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction mixHex(a: string, b: string, k: number): string {\n  const x = rgbOf(a);\n  const y = rgbOf(b);\n  const t = Math.max(0, Math.min(1, k));\n  const c = x.map((v, i) => Math.round(v + (y[i] - v) * t));\n  return `rgb(${c[0]},${c[1]},${c[2]})`;\n}\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: string): string {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"#\";\n  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;\n  if (/^[/#?]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"#\";\n}\n\nfunction subscribeMotion(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** True when the visitor asked for reduced motion. False on the server. */\nfunction useReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n// Each part carries only the CSS it needs, so any one of them works on its own.\n// Nothing is interpolated into these sheets; colours arrive as CSS variables.\n// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.\nconst FOCUS_CSS = \".kt-scope :focus-visible,.kt-scope:focus-visible{outline:2px solid var(--kt-ring,#2b3bff);outline-offset:3px;border-radius:revert-layer}\";\n\n/** A point in the face's design space: weight, width, slant and contrast. */\nexport type KtAxes = { wght: number; wdth: number; slnt: number; CTRS: number };\n\n/** A setting: weight, width, contrast and slant. */\nfunction ax(wght: number, wdth: number, CTRS = 0, slnt = 0): KtAxes {\n  return { wght, wdth, slnt, CTRS };\n}\n\nfunction mixAxes(a: KtAxes, b: KtAxes, k: number): KtAxes {\n  return {\n    wght: a.wght + (b.wght - a.wght) * k,\n    wdth: a.wdth + (b.wdth - a.wdth) * k,\n    slnt: a.slnt + (b.slnt - a.slnt) * k,\n    CTRS: a.CTRS + (b.CTRS - a.CTRS) * k,\n  };\n}\n\n/** The CSS for a setting, clamped to the face's ranges (wght 100-900, wdth 50-200, slnt -10-0, CTRS 0-85). */\nfunction fvs(a: KtAxes): string {\n  return `\"wght\" ${clamp(a.wght, 100, 900).toFixed(1)}, \"wdth\" ${clamp(a.wdth, 50, 200).toFixed(2)}, \"slnt\" ${clamp(a.slnt, -10, 0).toFixed(2)}, \"CTRS\" ${clamp(a.CTRS, 0, 85).toFixed(1)}`;\n}\n\n/** What a part knows about one letter when it sets its target. */\nexport type KtLetter = {\n  /** Its line, its place in that line, and the line's letter count. */\n  line: number;\n  i: number;\n  n: number;\n  /** Its place across the whole part. */\n  k: number;\n  /** Its centre in the part's own pixels, as last laid out, and its line's font size. */\n  x: number;\n  y: number;\n  size: number;\n  /** The width offset its flush line adds to every letter, wdth units (0 for a free line). */\n  o: number;\n  /** Whatever the part stamped on it (data-kt-tag). */\n  tag: string;\n};\n\n/** The frame a target is set in. */\nexport type KtFrame = {\n  now: number;\n  dt: number;\n  /** The pointer in the part's pixels, or null when it is away. */\n  px: number | null;\n  py: number | null;\n  /** Smoothed horizontal pointer speed, px/s. */\n  vx: number;\n  w: number;\n  h: number;\n};\n\n/** A letter's goal: a setting, how far it is under load (0-1, ink to accent), and its opacity. */\nexport type KtTarget = { a: KtAxes; load?: number; op?: number };\n\nexport type KtSpec = {\n  /** The resting setting: flush lines are sized at it. */\n  rest: (l: KtLetter) => KtAxes;\n  /** The goal this frame; null rests. */\n  target?: (l: KtLetter, f: KtFrame) => KtTarget | null;\n  /** Where a letter seen for the first time starts (an entrance). Defaults to rest. */\n  enter?: (l: KtLetter) => KtTarget;\n  ink: string;\n  accent: string;\n  /** Keep the loop awake though nothing is settling (a pointer inside, a scroll-linked part). */\n  busy?: (f: KtFrame) => boolean;\n  /** Called once a frame after the letters are written (a readout). */\n  onFrame?: (f: KtFrame) => void;\n};\n\ntype KtState = { cur: KtAxes; vel: KtAxes; load: number; lv: number; op: number; ov: number; out: string; col: string; opa: string };\n\ntype KtLineState = {\n  box: HTMLElement;\n  run: HTMLElement;\n  flush: boolean;\n  group: string;\n  max: number;\n  letters: HTMLElement[];\n  metas: KtLetter[];\n  /** Width offset added to every letter (wdth units); the size it should have and the size it shows (eased when text changes); a size factor for when width alone can't reach; px per wdth unit at 100px. */\n  o: number;\n  size: number;\n  s: number;\n  z: number;\n  k: number;\n  /** Letter-spacing in px, the last resort for a line that is short even at the widest setting. */\n  sp: number;\n  lo: number;\n  hi: number;\n  T: number;\n  W: number;\n};\n\n// Springs with mass: stiffness 220, damping 24 (a slight overshoot, settled in ~400ms).\nconst K_SPRING = 220;\n\nconst C_SPRING = 24;\n\nconst AXIS_KEYS = [\"wght\", \"wdth\", \"slnt\", \"CTRS\"] as const;\n\nfunction springTo(s: KtState, t: KtTarget, dt: number, still: boolean): boolean {\n  const load = t.load ?? 0;\n  const op = t.op ?? 1;\n  if (still) {\n    s.cur = { ...t.a };\n    s.vel = ax(0, 0);\n    s.load = load;\n    s.op = op;\n    return false;\n  }\n  let moving = false;\n  const steps = dt > 1 / 60 ? 2 : 1;\n  const h = dt / steps;\n  for (let n = 0; n < steps; n++) {\n    for (const key of AXIS_KEYS) {\n      const x = s.cur[key];\n      const v = s.vel[key] + (K_SPRING * (t.a[key] - x) - C_SPRING * s.vel[key]) * h;\n      s.vel[key] = v;\n      s.cur[key] = x + v * h;\n    }\n    s.lv += (K_SPRING * (load - s.load) - C_SPRING * s.lv) * h;\n    s.load += s.lv * h;\n    s.ov += (K_SPRING * (op - s.op) - C_SPRING * s.ov) * h;\n    s.op += s.ov * h;\n  }\n  for (const key of AXIS_KEYS) if (Math.abs(t.a[key] - s.cur[key]) > 0.3 || Math.abs(s.vel[key]) > 0.6) moving = true;\n  if (Math.abs(load - s.load) > 0.004 || Math.abs(op - s.op) > 0.004) moving = true;\n  return moving;\n}\n\n/**\n * The kit's engine. Every letter under the host (a span with data-kt-g inside\n * a line with data-kt-line) springs toward the setting its part asks for,\n * frame by frame; a line marked \"flush\" is kept exactly as wide as its box by\n * solving one width offset for all its letters (and, only when width alone\n * can't reach, its size). Each frame writes every letter first and then reads\n * every width and position once, so one layout serves the whole part. It\n * sleeps when everything has settled and wakes on input, resize or new fonts,\n * and only runs while the host is near the screen.\n */\nfunction useKinetic(hostRef: RefObject<HTMLElement | null>, spec: KtSpec, key: string) {\n  const specRef = useRef(spec);\n  useEffect(() => {\n    specRef.current = spec;\n  });\n  const reduced = useReducedMotion();\n  const wakeRef = useRef<() => void>(() => {});\n  const rescanRef = useRef<() => void>(() => {});\n  const syncRef = useRef<() => void>(() => {});\n  const [wake] = useState(() => () => wakeRef.current());\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    let alive = true;\n    const states = new WeakMap<HTMLElement, KtState>();\n    let lines: KtLineState[] = [];\n    let raf = 0;\n    let last = 0;\n    let visible = false;\n    let px: number | null = null;\n    let py: number | null = null;\n    let vx = 0;\n    let lastX = 0;\n    let lastT = 0;\n    let rect = host.getBoundingClientRect();\n    let scale = 1;\n\n    const mine = (el: Element) => el.closest(\"[data-kt-host]\") === host;\n\n    // Under reduced motion the pointer is ignored; discrete states still apply, without travel.\n    const frameOf = (now: number, dt: number): KtFrame => ({\n      now,\n      dt,\n      px: reduced ? null : px,\n      py: reduced ? null : py,\n      vx: reduced ? 0 : vx,\n      w: rect.width / scale,\n      h: rect.height / scale,\n    });\n\n    const targetOf = (m: KtLetter, f: KtFrame): KtTarget => {\n      const sp = specRef.current;\n      return sp.target?.(m, f) ?? { a: sp.rest(m) };\n    };\n\n    const readPositions = () => {\n      rect = host.getBoundingClientRect();\n      scale = rect.width / (host.offsetWidth || 1) || 1;\n      for (const L of lines) {\n        const fsz = L.flush ? L.s * L.z : parseFloat(getComputedStyle(L.box).fontSize) || 16;\n        for (let i = 0; i < L.letters.length; i++) {\n          const r = L.letters[i].getBoundingClientRect();\n          const m = L.metas[i];\n          m.x = (r.left + r.width / 2 - rect.left) / scale;\n          m.y = (r.top + r.height / 2 - rect.top) / scale;\n          m.size = fsz;\n          m.o = L.flush ? L.o : 0;\n        }\n        if (L.flush) {\n          L.W = L.run.getBoundingClientRect().width - L.sp * scale;\n          L.T = L.box.getBoundingClientRect().width;\n        }\n      }\n    };\n\n    const scan = () => {\n      const prev = lines;\n      let k = 0;\n      lines = Array.from(host.querySelectorAll<HTMLElement>(\"[data-kt-line]\"))\n        .filter(mine)\n        .map((box, li) => {\n          const run = box.querySelector<HTMLElement>(\"[data-kt-run]\") ?? box;\n          const letters = Array.from(run.querySelectorAll<HTMLElement>(\"[data-kt-g]\")).filter(mine);\n          const old = prev.find((p) => p.box === box);\n          const metas = letters.map((el, i) => ({ line: li, i, n: letters.length, k: k++, x: 0, y: 0, size: 16, o: 0, tag: el.dataset.ktTag ?? \"\" }));\n          if (old) {\n            old.letters.forEach((el, i) => {\n              const j = letters.indexOf(el);\n              if (j >= 0) {\n                metas[j].x = old.metas[i].x;\n                metas[j].y = old.metas[i].y;\n                metas[j].size = old.metas[i].size;\n              }\n            });\n          }\n          return {\n            box,\n            run,\n            flush: box.dataset.ktLine === \"flush\",\n            group: box.dataset.ktGroup ?? \"\",\n            max: Number(box.dataset.ktMax) || 1e6,\n            letters,\n            metas,\n            o: old?.o ?? 0,\n            size: old?.size ?? 0,\n            s: old?.s ?? 0,\n            z: old?.z ?? 1,\n            sp: old?.sp ?? 0,\n            k: old?.k ?? 0,\n            lo: old?.lo ?? -50,\n            hi: old?.hi ?? 100,\n            T: 0,\n            W: 0,\n          };\n        });\n      const sp = specRef.current;\n      const f = frameOf(performance.now(), 0);\n      for (const L of lines) {\n        L.letters.forEach((el, i) => {\n          if (states.has(el)) return;\n          const m = L.metas[i];\n          const t = reduced ? targetOf(m, f) : (sp.enter?.(m) ?? targetOf(m, f));\n          states.set(el, { cur: { ...t.a }, vel: ax(0, 0), load: t.load ?? 0, lv: 0, op: t.op ?? 1, ov: 0, out: \"\", col: \"\", opa: \"\" });\n        });\n      }\n    };\n\n    const writeAll = () => {\n      const sp = specRef.current;\n      for (const L of lines) {\n        for (const el of L.letters) {\n          const s = states.get(el);\n          if (!s) continue;\n          const out = fvs(L.flush ? { ...s.cur, wdth: s.cur.wdth + L.o } : s.cur);\n          if (out !== s.out) {\n            el.style.fontVariationSettings = out;\n            s.out = out;\n          }\n          const col = s.load > 0.004 ? mixHex(sp.ink, sp.accent, s.load) : \"\";\n          if (col !== s.col) {\n            el.style.color = col;\n            s.col = col;\n          }\n          const opa = s.op < 0.996 ? clamp(s.op, 0, 1).toFixed(3) : \"\";\n          if (opa !== s.opa) {\n            el.style.opacity = opa;\n            s.opa = opa;\n          }\n        }\n        if (L.flush) {\n          const fs = `${(L.s * L.z).toFixed(2)}px`;\n          if (L.box.style.fontSize !== fs) L.box.style.fontSize = fs;\n          const ls = L.sp > 0.05 ? `${L.sp.toFixed(2)}px` : \"\";\n          if (L.run.style.letterSpacing !== ls) {\n            L.run.style.letterSpacing = ls;\n            L.run.style.marginRight = ls ? `-${ls}` : \"\";\n          }\n        }\n      }\n    };\n\n    // Sizes every flush line from two measurements at its rest setting and 100px, with no offset\n    // and with +20 wdth: that gives its width, its px per wdth unit, and so its size and offset.\n    const size = () => {\n      const sp = specRef.current;\n      rect = host.getBoundingClientRect();\n      scale = rect.width / (host.offsetWidth || 1) || 1;\n      const flush = lines.filter((L) => L.flush && L.letters.length);\n      if (flush.length) {\n        const rests = flush.map((L) => L.metas.map((m) => sp.rest(m)));\n        flush.forEach((L) => {\n          L.sp = 0;\n          L.run.style.letterSpacing = \"\";\n          L.run.style.marginRight = \"\";\n        });\n        const write = (d: number) =>\n          flush.forEach((L, li) => {\n            L.box.style.fontSize = \"100px\";\n            L.letters.forEach((el, i) => {\n              el.style.fontVariationSettings = fvs({ ...rests[li][i], wdth: rests[li][i].wdth + d });\n            });\n          });\n        write(0);\n        const T = flush.map((L) => L.box.getBoundingClientRect().width);\n        const w0 = flush.map((L) => L.run.getBoundingClientRect().width);\n        write(20);\n        const w1 = flush.map((L) => L.run.getBoundingClientRect().width);\n        flush.forEach((L) => {\n          L.max = Number(L.box.dataset.ktMax) || 1e6;\n        });\n        const want = flush.map((L, i) => Math.min(L.max, (100 * T[i]) / Math.max(1, w0[i])));\n        const groups = new Map<string, number>();\n        flush.forEach((L, i) => {\n          if (L.group) groups.set(L.group, Math.min(groups.get(L.group) ?? 1e6, want[i]));\n        });\n        flush.forEach((L, i) => {\n          const s = Math.max(6, L.group ? (groups.get(L.group) ?? want[i]) : want[i]);\n          const k100 = (w1[i] - w0[i]) / 20;\n          const widths = rests[i].map((a) => a.wdth);\n          L.lo = 50 - Math.max(...widths);\n          L.hi = 200 - Math.min(...widths);\n          L.size = s;\n          // New text eases to its new size (the width solve keeps it flush meanwhile); a first sizing is immediate.\n          if (!L.s || reduced) L.s = s;\n          // A face without a width axis measures the same twice: then only the size can fit the line.\n          L.k = k100 > 0.0005 * w0[i] ? k100 : 0;\n          if (L.k > 0) {\n            L.o = clamp(((100 * T[i]) / s - w0[i]) / k100, L.lo, L.hi);\n            const w = (s / 100) * (w0[i] + k100 * L.o);\n            L.z = clamp(T[i] / Math.max(1, w), 0.5, 1);\n            // Still short at the widest setting: spread what is left between the letters.\n            if (T[i] > w) L.sp = (T[i] - w) / scale / Math.max(1, L.letters.length - 1);\n          } else {\n            L.o = 0;\n            L.z = clamp(T[i] / Math.max(1, (s / 100) * w0[i]), 0.3, 3);\n          }\n          L.letters.forEach((el) => {\n            const st = states.get(el);\n            if (st) st.out = \"\";\n          });\n        });\n      }\n      writeAll();\n      readPositions();\n    };\n\n    // One step of the width solve per frame, a little under-relaxed; the size moves only when width is pinned.\n    const solve = (): boolean => {\n      let fitting = false;\n      for (const L of lines) {\n        if (!L.flush || L.T <= 0 || L.W <= 0) continue;\n        const err = L.T - L.W;\n        if (Math.abs(err) <= Math.max(0.6, L.T * 0.0006)) {\n          if (L.z !== 1 && L.k > 0 && L.o > L.lo + 1 && L.o < L.hi - 1) {\n            L.z += (1 - L.z) * 0.2;\n            if (Math.abs(L.z - 1) < 0.002) L.z = 1;\n            fitting = true;\n          }\n          continue;\n        }\n        fitting = true;\n        if (L.k > 0) {\n          const pinnedWide = L.o >= L.hi - 0.01;\n          if (pinnedWide && err > 0) {\n            L.sp += (0.85 * err) / scale / Math.max(1, L.letters.length - 1);\n            continue;\n          }\n          if (L.sp > 0 && err < 0) {\n            L.sp = Math.max(0, L.sp + (0.85 * err) / scale / Math.max(1, L.letters.length - 1));\n            continue;\n          }\n          const next = clamp(L.o + (0.85 * err) / ((L.k * L.s * L.z) / 100), L.lo, L.hi);\n          // Pinned at the axis limit: the size may shrink a line that would overflow, never grow one (a short line just stays short).\n          if (next === L.lo || next === L.hi) L.z = clamp(L.z * (1 + (0.6 * err) / L.W), 0.5, 1);\n          L.o = next;\n        } else {\n          L.z = clamp(L.z * (1 + (0.6 * err) / L.W), 0.3, 3);\n        }\n      }\n      return fitting;\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(1 / 30, (now - last) / 1000) : 1 / 60;\n      last = now;\n      vx *= Math.exp(-dt * 6);\n      const sp = specRef.current;\n      const f = frameOf(now, dt);\n      let moving = false;\n      for (const L of lines) {\n        // The offset may take the widest letter down to 50 or the narrowest up to 200: the range follows the letters' current settings.\n        if (L.flush && L.letters.length) {\n          let lo = 200;\n          let hi = 50;\n          for (const el of L.letters) {\n            const st = states.get(el);\n            if (!st) continue;\n            if (st.cur.wdth < lo) lo = st.cur.wdth;\n            if (st.cur.wdth > hi) hi = st.cur.wdth;\n          }\n          L.lo = 50 - hi;\n          L.hi = 200 - lo;\n        }\n        if (L.flush && L.s !== L.size) {\n          L.s += (L.size - L.s) * (1 - Math.exp(-dt * 14));\n          if (Math.abs(L.s - L.size) < 0.05) L.s = L.size;\n          moving = true;\n        }\n        L.letters.forEach((el, i) => {\n          const s = states.get(el);\n          if (s && springTo(s, targetOf(L.metas[i], f), dt, reduced)) moving = true;\n        });\n      }\n      writeAll();\n      readPositions();\n      const fitting = solve();\n      sp.onFrame?.(f);\n      const busy = !reduced && (sp.busy?.(f) ?? false);\n      if ((moving || fitting || busy) && visible && !document.hidden) raf = requestAnimationFrame(tick);\n      else last = 0;\n    };\n\n    const kick = () => {\n      if (alive && !raf && visible && !document.hidden) raf = requestAnimationFrame(tick);\n    };\n    wakeRef.current = kick;\n    rescanRef.current = () => {\n      scan();\n      size();\n      kick();\n    };\n    // A render may have written a letter's resting style over the engine's: write everything again.\n    syncRef.current = () => {\n      for (const L of lines) {\n        for (const el of L.letters) {\n          const st = states.get(el);\n          if (st) {\n            st.out = \"\";\n            st.col = \"\";\n            st.opa = \"\";\n          }\n        }\n      }\n      writeAll();\n    };\n\n    const toLocal = (e: PointerEvent) => {\n      rect = host.getBoundingClientRect();\n      scale = rect.width / (host.offsetWidth || 1) || 1;\n      const x = (e.clientX - rect.left) / scale;\n      const t = performance.now();\n      if (px !== null && t > lastT) vx = vx * 0.7 + ((x - lastX) / (t - lastT)) * 1000 * 0.3;\n      lastX = x;\n      lastT = t;\n      px = x;\n      py = (e.clientY - rect.top) / scale;\n      kick();\n    };\n    const onLeave = () => {\n      px = null;\n      py = null;\n      kick();\n    };\n    // A finger lifted is a pointer gone; a mouse stays until it leaves.\n    const onUp = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\") onLeave();\n    };\n    host.addEventListener(\"pointermove\", toLocal);\n    host.addEventListener(\"pointerdown\", toLocal);\n    host.addEventListener(\"pointerup\", onUp);\n    host.addEventListener(\"pointerleave\", onLeave);\n    host.addEventListener(\"pointercancel\", onLeave);\n\n    const refit = () => {\n      if (!alive) return;\n      size();\n      kick();\n    };\n    const ro = new ResizeObserver(refit);\n    ro.observe(host);\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[entries.length - 1].isIntersecting;\n        kick();\n      },\n      { rootMargin: \"120px\" },\n    );\n    io.observe(host);\n    const fonts = document.fonts;\n    fonts?.addEventListener?.(\"loadingdone\", refit);\n    fonts?.ready.then(refit);\n    const onVis = () => kick();\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    scan();\n    size();\n\n    return () => {\n      alive = false;\n      cancelAnimationFrame(raf);\n      wakeRef.current = () => {};\n      rescanRef.current = () => {};\n      syncRef.current = () => {};\n      host.removeEventListener(\"pointermove\", toLocal);\n      host.removeEventListener(\"pointerdown\", toLocal);\n      host.removeEventListener(\"pointerup\", onUp);\n      host.removeEventListener(\"pointerleave\", onLeave);\n      host.removeEventListener(\"pointercancel\", onLeave);\n      ro.disconnect();\n      io.disconnect();\n      fonts?.removeEventListener?.(\"loadingdone\", refit);\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [hostRef, reduced]);\n\n  // New letters or new text: find them, size the flush lines again, and let the springs run.\n  useEffect(() => {\n    rescanRef.current();\n  }, [key]);\n\n  // After every render: React owns each letter's first style, the engine owns every one after.\n  useEffect(() => {\n    syncRef.current();\n  });\n\n  return wake;\n}\n\n/** Splits text into letters (spaces kept as non-breaking, so a flush line never wraps). */\nfunction lettersOf(text: string): string[] {\n  return Array.from(text).map((ch) => (ch === \" \" ? \"\\u00a0\" : ch));\n}\n\n/**\n * One line of letters for the engine. `flush` sets it exactly as wide as its\n * box; lines in the same `group` share one size, so they stand at one cap\n * height and differ only in width. The letters are aria-hidden: name the\n * line on its parent (an h1's aria-label, a button's text).\n */\nfunction KtLine({\n  text,\n  rest,\n  flush = false,\n  group,\n  max,\n  tag,\n  className,\n  style,\n}: {\n  text: string;\n  rest: KtAxes | ((i: number) => KtAxes);\n  flush?: boolean;\n  group?: string;\n  /** Largest size a flush line may take, px. */\n  max?: number;\n  tag?: (i: number) => string;\n  /** Classes on the line; give a flush line its first size here (for example \"text-[18cqw]\"), the engine then sets it in px. */\n  className?: string;\n  style?: CSSProperties;\n}) {\n  const letters = lettersOf(text);\n  const glyph = (ch: string, i: number) => (\n    <span key={i} data-kt-g=\"\" data-kt-tag={tag?.(i)} className=\"inline-block whitespace-pre\" style={{ fontVariationSettings: fvs(typeof rest === \"function\" ? rest(i) : rest) }}>\n      {ch}\n    </span>\n  );\n  // A free line may wrap, but only between words: each word is one unbreakable run of letters.\n  const words: ReactNode[] = [];\n  if (!flush) {\n    let i = 0;\n    text.split(\" \").forEach((word, w) => {\n      if (w > 0) {\n        words.push(\" \");\n        i++;\n      }\n      const start = i;\n      words.push(\n        <span key={\"w\" + w} className=\"inline-block whitespace-nowrap\">\n          {Array.from(word).map((ch, j) => glyph(ch, start + j))}\n        </span>,\n      );\n      i += Array.from(word).length;\n    });\n  }\n  return (\n    <span\n      data-kt-line={flush ? \"flush\" : \"free\"}\n      data-kt-group={group}\n      data-kt-max={max}\n      className={(flush ? \"block whitespace-nowrap \" : \"\") + (className ?? \"\")}\n      style={{ fontFamily: DISPLAY, ...style }}\n    >\n      <span data-kt-run=\"\" aria-hidden className={flush ? \"inline-block\" : undefined}>\n        {flush ? letters.map(glyph) : words}\n      </span>\n    </span>\n  );\n}\n\n/** Pressure: 1 under the pointer, falling off as a gaussian over `reach` sizes of the line's type (wider than tall). */\nfunction pressureAt(l: KtLetter, f: KtFrame, reach = 1.1): number {\n  if (f.px === null || f.py === null) return 0;\n  const r = Math.max(24, l.size * reach);\n  const dx = (l.x - f.px) / r;\n  const dy = (l.y - f.py) / (r * 1.4);\n  return Math.exp(-(dx * dx + dy * dy));\n}\n\n/** A lean from the pointer's sideways speed: letters slant into a fast sweep and spring back. */\nfunction leanOf(f: KtFrame, p: number): number {\n  return -clamp(Math.abs(f.vx) / 900, 0, 1) * 10 * Math.min(1, p * 1.6);\n}\n\nconst BUTTON_REST = ax(560, 104);\n\nexport type AxisPressButtonProps = {\n  children: string;\n  href?: string;\n  onClick?: () => void;\n  type?: \"button\" | \"submit\";\n  /** \"solid\" (ink) for the main action, \"quiet\" (a hairline) for the second. */\n  variant?: \"solid\" | \"quiet\";\n  size?: \"lg\" | \"md\";\n  palette?: KtPaletteName;\n  /** The ground the button sits on. */\n  tone?: KtTone;\n  disabled?: boolean;\n  className?: string;\n};\n\n/**\n * A button whose label is the control. The letters under the pointer swell\n * heavier and wider while the rest condense to make room, so the label moves\n * but the button never changes size; a press (or Enter or Space) clenches\n * every letter to full weight and contrast, and release springs them back\n * past rest. The label is set flush inside a box sized by its resting width.\n * Renders a link when given an href, else a button.\n */\nexport function AxisPressButton({ children, href, onClick, type = \"button\", variant = \"solid\", size = \"lg\", palette = \"cobalt\", tone = \"paper\", disabled, className }: AxisPressButtonProps) {\n  const solid = variant === \"solid\";\n  // A solid button is ink on its ground, so its letters sit on the other tone.\n  const face: KtTone = solid ? (tone === \"ink\" ? \"paper\" : \"ink\") : tone;\n  const t = toneOf(tone);\n  const f = toneOf(face);\n  const aRef = useRef<HTMLAnchorElement>(null);\n  const bRef = useRef<HTMLButtonElement>(null);\n  const down = useRef(false);\n  const wake = useKinetic(\n    href ? aRef : bRef,\n    {\n      rest: () => BUTTON_REST,\n      target: (l, fr) => {\n        if (disabled) return null;\n        if (down.current) return { a: ax(900, 72, 85), load: 1 };\n        const p = pressureAt(l, fr, 1.7);\n        return { a: mixAxes(BUTTON_REST, ax(860, 140, 46, leanOf(fr, p)), p), load: p * 0.9 };\n      },\n      ink: f.ink,\n      accent: accentOf(palette, face),\n    },\n    children,\n  );\n  const press = (on: boolean) => {\n    if (disabled && on) return;\n    down.current = on;\n    wake();\n  };\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    if (!e.repeat && (e.key === \"Enter\" || e.key === \" \")) press(true);\n  };\n  const cls =\n    \"kt-scope relative inline-flex select-none items-center justify-center rounded-[12px] uppercase outline-none transition-[background-color,border-color] duration-200 \" +\n    (size === \"lg\" ? \"h-14 px-7 text-[17px] \" : \"h-11 px-5 text-[14px] \") +\n    (disabled ? \"pointer-events-none opacity-45 \" : \"cursor-pointer \") +\n    (className ?? \"\");\n  const style = {\n    background: solid ? t.ink : \"transparent\",\n    color: f.ink,\n    border: `1px solid ${solid ? t.ink : t.line2}`,\n    touchAction: \"manipulation\",\n    \"--kt-ring\": accentOf(palette, tone),\n  } as CSSProperties;\n  const label = (\n    <span className=\"relative block leading-none\">\n      <span aria-hidden className=\"invisible block whitespace-pre\" style={{ fontFamily: DISPLAY, fontVariationSettings: fvs(BUTTON_REST) }}>\n        {children}\n      </span>\n      <span className=\"absolute inset-0 flex items-center\">\n        <KtLine text={children} rest={BUTTON_REST} flush className=\"w-full\" style={{ lineHeight: 1 }} />\n      </span>\n    </span>\n  );\n  const handlers = {\n    onPointerDown: () => press(true),\n    onPointerUp: () => press(false),\n    onPointerLeave: () => press(false),\n    onPointerCancel: () => press(false),\n    onKeyDown,\n    onKeyUp: () => press(false),\n    onBlur: () => press(false),\n  };\n  if (href) {\n    return (\n      <>\n        <style>{FOCUS_CSS}</style>\n        <a ref={aRef} data-kt-host=\"\" href={safeHref(href)} onClick={onClick} aria-label={children} className={cls} style={style} {...handlers}>\n          {label}\n        </a>\n      </>\n    );\n  }\n  return (\n    <>\n      <style>{FOCUS_CSS}</style>\n      <button ref={bRef} data-kt-host=\"\" type={type} onClick={onClick} disabled={disabled} aria-label={children} className={cls} style={style} {...handlers}>\n        {label}\n      </button>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/axis-press-button.tsx"}],"meta":{"tier":"free","tags":["button","variable font","kinetic type","press","hover","spring"],"href":"https://viberdy.dev/library/axis-press-button","aiPrompt":"Build a React + Tailwind button, `AxisPressButton({ children, href, onClick, type, variant, size, palette, tone, disabled, className })`.\n\nThe engine (a hook over a host element): every letter is a span inside a line; each frame it asks the part for each letter's goal (a setting, a load for the accent, an opacity), springs every axis toward it, writes font-variation-settings, colour and opacity for every letter, then reads every line's width and letter position once (all writes before all reads, so one layout a frame). A flush line is kept exactly as wide as its box by one width offset added to all its letters: sized first by measuring the line at its rest setting at 100px with no offset and with +20 wdth (which gives its width and its px per wdth unit, so its size and offset), then corrected a step each frame (85% of the error); only when the offset is pinned at the axis limits does the size move. New text eases its size over ~200ms while the offset keeps it flush. A face without a width axis measures the same twice, so those lines fit by size alone. It sleeps once everything has settled, wakes on pointer, scroll, resize or new fonts, and runs only near the screen.\n\nHere: inside the pill (radius 12px, h-14 px-7 at 17px or h-11 px-5 at 14px, uppercase) sits an invisible copy of the label at wght 560, wdth 104 that fixes the width, and over it a flush line of the same label. Goals: rest mixed toward wght 860, wdth 140, CTRS 46 by pressure (r = 1.7 sizes), slanted by the sweep speed, load 0.9 p; while pressed (pointerdown, or keydown Enter/Space, until up, leave or blur) every letter goes to wght 900, wdth 72, CTRS 85, load 1, and the flush solve turns that into a clench. Solid is ink with paper letters; quiet is a 30% hairline. The component's <style> sits beside the control, not inside it.\n\nKinetic Type grammar: paper #f0ede2 with ink #131315 (ink at 68/46% for secondary text, hairlines at 14% and 30%), and an inverted ink stage (#131315 ground, #f0ede2 ink) for one loud section. One accent that letters take only under load, ink to accent by how hard they are pressed: cobalt #2b3bff on paper (#8f98ff on ink), or vermilion #d9360b (#ff6a3d), or mono. Science Gothic through var(--font-science, \"Science Gothic\", ...) for display, with four axes that are the interface: wght 100-900, wdth 50-200, slnt -10-0 and CTRS (contrast) 0-85; Geist for reading and Geist Mono uppercase for readouts and labels. Three named settings: quiet (wght 160, wdth 72), set (420, 100) and loud (860, 150, CTRS 70). Display lines are set flush: exactly as wide as their box, the width axis solved live, and lines that share a size stand at one cap height. Radius 0 on type, 12px on cards and controls. Motion is springs (stiffness 220, damping 24, a slight overshoot), rebuilds staggered 24ms a letter, pointer pressure as a gaussian over about one letter height, sweeps leaning letters by up to 10 degrees of slant. Letters are real, selectable text named once for assistive tech (aria-label on a heading or button, else a visually hidden copy) with the split letters aria-hidden. Respect prefers-reduced-motion (settings still apply, without travel; no pressure, no entrances). No dependencies beyond React.","license":"https://viberdy.dev/license"}}