{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-strength-tide",
  "title": "Password Strength Tide",
  "description": "Password field with a canvas water tank behind the masked text: entropy raises the tide on a damped spring, keystrokes slosh a real 1D heightfield wave, deletions pull the level back down.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/password-strength-tide/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// TideGaugePassword — password field with a canvas water tank behind the\n// masked text. Live entropy score drives the water level through a damped\n// spring (k=90 s^-2, zeta=0.55, ~8% overshoot), each keystroke sloshes a real\n// 1D heightfield wave at the caret's approximate x, deletions bump the surface\n// and let the tide recede. Fill hue runs muted -> accent with strength. Full\n// clear + redraw each frame (no accumulating alpha), canvas sized with\n// explicit style.width/height (replaced-element dpr trap), rAF sleeps at a\n// velocity epsilon with a 1.2 s forced-settle deadline, pauses offscreen. All\n// ink derives from CSS tokens at mount and re-derives live on documentElement\n// class changes. Input semantics are fully standard: real <input>, visible\n// label, polite live region for strength, reveal toggle with aria-pressed.\n// ---------------------------------------------------------------------------\n\nconst DX = 4; // heightfield column pitch px\nconst WAVE_C = 140; // wave speed px/s\nconst DAMP = 0.985; // per-frame velocity damping\nconst KEY_IMPULSE = -6; // px surface dip per keystroke\nconst DEL_IMPULSE = 4; // px surface bump per deletion\nconst TOGGLE_IMPULSE = 4; // px ripple at the eye icon\nconst SPRING_K = 90; // s^-2\nconst SPRING_ZETA = 0.55; // ~8% overshoot then settle\nconst MAX_FILL = 0.94; // strength 100 fills to 94% so the surface line shows\nconst SETTLE_MS = 1200; // forced-settle deadline after last excitement\nconst HARD_SNAP_MS = 2000; // absolute cutoff — snap flat, sleep\nconst V_EPS = 0.02; // px/frame sleep epsilon\nconst LEVEL_EPS = 0.3; // px distance-to-target sleep epsilon\nconst FILL_ALPHA = 0.2;\nconst LINE_ALPHA = 0.6;\nconst RIM_ALPHA = 0.3; // faint empty-tank baseline shown at zero entropy\n// splash spread across five neighboring columns\nconst KERNEL = [0.25, 0.6, 1, 0.6, 0.25] as const;\n\ntype Vec3 = readonly [number, number, number];\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);\n      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);\n      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nfunction mix(a: Vec3, b: Vec3, t: number): Vec3 {\n  return [\n    Math.round(a[0] + (b[0] - a[0]) * t),\n    Math.round(a[1] + (b[1] - a[1]) * t),\n    Math.round(a[2] + (b[2] - a[2]) * t),\n  ];\n}\n\n// length + charset-class entropy, mapped 0-100 (72 bits caps the gauge)\nfunction scorePassword(v: string): number {\n  if (!v) return 0;\n  let pool = 0;\n  if (/[a-z]/.test(v)) pool += 26;\n  if (/[A-Z]/.test(v)) pool += 26;\n  if (/[0-9]/.test(v)) pool += 10;\n  if (/[^a-zA-Z0-9]/.test(v)) pool += 33;\n  const bits = v.length * Math.log2(pool || 1);\n  return Math.max(0, Math.min(100, (bits / 72) * 100));\n}\n\ntype Band = \"Weak\" | \"Fair\" | \"Strong\" | null;\n\nfunction bandOf(score: number): Band {\n  if (score <= 0) return null;\n  if (score < 34) return \"Weak\";\n  if (score < 67) return \"Fair\";\n  return \"Strong\";\n}\n\nexport function TideGaugePassword({\n  label = \"Password\",\n  name = \"password\",\n  placeholder = \"\",\n  defaultValue = \"\",\n  value,\n  onValueChange,\n  autoComplete = \"new-password\",\n  required = false,\n  disabled = false,\n  className = \"\",\n}: {\n  /** visible label text above the field */\n  label?: string;\n  /** name for the underlying `<input>`, for native form submission */\n  name?: string;\n  /** placeholder text for the field */\n  placeholder?: string;\n  /** uncontrolled initial value */\n  defaultValue?: string;\n  /** controlled value; omit for uncontrolled */\n  value?: string;\n  /** called on every keystroke with the raw input value */\n  onValueChange?: (value: string) => void;\n  /** passed straight through to the underlying `<input autoComplete>` */\n  autoComplete?: string;\n  /** marks the underlying `<input>` as `required` for native form validation */\n  required?: boolean;\n  /** blocks the underlying `<input>` */\n  disabled?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const uid = useId();\n  const inputId = `tide-pw-${uid}`;\n  const liveId = `tide-pw-live-${uid}`;\n  const frameRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const toggleRef = useRef<HTMLButtonElement>(null);\n  const engineRef = useRef<{\n    input: (v: string, instant?: boolean) => void;\n    ripple: (x: number) => void;\n  } | null>(null);\n\n  const isControlled = value !== undefined;\n  const initial = isControlled ? value : defaultValue;\n  const [revealed, setRevealed] = useState(false);\n  const [band, setBand] = useState<Band>(() => bandOf(scorePassword(initial)));\n\n  useEffect(() => {\n    const frame = frameRef.current;\n    const canvas = canvasRef.current;\n    const input = inputRef.current;\n    if (!frame || !canvas || !input) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const motionMq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = motionMq.matches;\n\n    // -- token-derived ink: read at mount, re-derived on theme class change --\n    let mutedC: Vec3 = [143, 143, 143];\n    let accentC: Vec3 = [0, 107, 255];\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      mutedC = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? mutedC;\n      accentC = parseColor(cs.getPropertyValue(\"--ns-accent\")) ?? accentC;\n    };\n    derive();\n\n    // -- hot-path state: locals only, never React state ---------------------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let cols = 0;\n    let hf = new Float32Array(0); // surface offset px, + is up\n    let vf = new Float32Array(0); // px/s\n    let level = 0; // water height px\n    let levelV = 0; // px/s\n    let target = 0; // px\n    let exciteAt = 0; // performance.now() of last impulse/target change\n    let prevLen = 0;\n    let maskW = 8; // measured mask-glyph advance px\n    let padLeft = 12;\n\n    const measure = () => {\n      const csIn = getComputedStyle(input);\n      padLeft = parseFloat(csIn.paddingLeft) || 12;\n      ctx.font = `${csIn.fontWeight} ${csIn.fontSize} ${csIn.fontFamily}`;\n      const m = ctx.measureText(\"•\").width;\n      if (m > 0) maskW = m;\n    };\n\n    const drawFrame = () => {\n      if (!sized) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h); // full clear + redraw — nothing accumulates\n      if (level < 0.5) {\n        // zero-entropy idle: no fill, but a faint rim a couple px above the\n        // frame's own border (the outermost row is clipped by the frame's\n        // overflow-hidden) so the tank floor reads as a gauge even at rest.\n        ctx.beginPath();\n        ctx.moveTo(0, h - 3);\n        ctx.lineTo(w, h - 3);\n        ctx.strokeStyle = `rgba(${mutedC[0]},${mutedC[1]},${mutedC[2]},${RIM_ALPHA})`;\n        ctx.lineWidth = 1;\n        ctx.stroke();\n        return;\n      }\n      const yBase = h - level;\n      const t = Math.max(0, Math.min(1, level / (h * MAX_FILL)));\n      const c = mix(mutedC, accentC, t);\n      // fill body\n      ctx.beginPath();\n      ctx.moveTo(0, yBase - (hf[0] ?? 0));\n      for (let i = 1; i < cols; i++) ctx.lineTo(i * DX, yBase - (hf[i] ?? 0));\n      ctx.lineTo(w, yBase - (hf[cols - 1] ?? 0));\n      ctx.lineTo(w, h);\n      ctx.lineTo(0, h);\n      ctx.closePath();\n      ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${FILL_ALPHA})`;\n      ctx.fill();\n      // surface line\n      ctx.beginPath();\n      ctx.moveTo(0, yBase - (hf[0] ?? 0));\n      for (let i = 1; i < cols; i++) ctx.lineTo(i * DX, yBase - (hf[i] ?? 0));\n      ctx.lineTo(w, yBase - (hf[cols - 1] ?? 0));\n      ctx.strokeStyle = `rgba(${c[0]},${c[1]},${c[2]},${LINE_ALPHA})`;\n      ctx.lineWidth = 1.5;\n      ctx.lineJoin = \"round\";\n      ctx.stroke();\n    };\n\n    const snapFlat = () => {\n      hf.fill(0);\n      vf.fill(0);\n      level = target;\n      levelV = 0;\n      drawFrame();\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!sized || !visible || document.hidden) return; // wake() resumes\n      // dt clamp respects the wave CFL bound: c*dt/dx = 140*0.025/4 < 1,\n      // so dropped frames slow the sim instead of blowing up the heightfield\n      const dt = last ? Math.min(0.025, (now - last) / 1000) : 1 / 60;\n      last = now;\n\n      if (now - exciteAt > HARD_SNAP_MS) {\n        snapFlat(); // absolute forced-settle — sleep flat\n        return;\n      }\n      const overdue = now - exciteAt > SETTLE_MS;\n\n      // damped level spring\n      const cDamp = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n      levelV += (-SPRING_K * (level - target) - cDamp * levelV) * dt;\n      level += levelV * dt;\n\n      // 1D heightfield: neighbor coupling, per-frame damping\n      const damp = overdue ? 0.85 : DAMP;\n      const c2 = (WAVE_C * WAVE_C) / (DX * DX);\n      let vMax = 0;\n      for (let i = 0; i < cols; i++) {\n        const l = hf[Math.max(0, i - 1)] ?? 0;\n        const r = hf[Math.min(cols - 1, i + 1)] ?? 0;\n        const acc = c2 * (l + r - 2 * (hf[i] ?? 0));\n        vf[i] = ((vf[i] ?? 0) + acc * dt) * damp;\n      }\n      for (let i = 0; i < cols; i++) {\n        hf[i] = (hf[i] ?? 0) + (vf[i] ?? 0) * dt;\n        if (overdue) hf[i] = (hf[i] ?? 0) * 0.9; // pull flat past deadline\n        const pf = Math.abs((vf[i] ?? 0) * dt);\n        if (pf > vMax) vMax = pf;\n      }\n\n      drawFrame();\n\n      const settled =\n        vMax < V_EPS &&\n        Math.abs(level - target) < LEVEL_EPS &&\n        Math.abs(levelV * dt) < V_EPS;\n      if (settled) {\n        snapFlat();\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf && !reduced && visible && !document.hidden) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const splash = (x: number, amp: number) => {\n      if (!sized) return;\n      const i0 = Math.round(x / DX);\n      for (let k = 0; k < KERNEL.length; k++) {\n        const i = i0 + k - 2;\n        if (i < 0 || i >= cols) continue;\n        hf[i] = (hf[i] ?? 0) + amp * (KERNEL[k] ?? 0);\n      }\n    };\n\n    const caretX = (len: number) =>\n      Math.max(4, Math.min(w - 8, padLeft + len * maskW));\n\n    const inputFn = (v: string, instant = false) => {\n      const s = scorePassword(v);\n      target = h * MAX_FILL * (s / 100);\n      const dLen = v.length - prevLen;\n      prevLen = v.length;\n      if (reduced || instant || !sized) {\n        // static gauge: instant height, no waves\n        hf.fill(0);\n        vf.fill(0);\n        level = target;\n        levelV = 0;\n        drawFrame();\n        return;\n      }\n      // keystroke sloshes at the caret's approximate x; delete bumps + recedes\n      if (dLen > 0) splash(caretX(v.length), KEY_IMPULSE);\n      else if (dLen < 0) splash(caretX(v.length), DEL_IMPULSE);\n      exciteAt = performance.now();\n      wake();\n    };\n\n    const rippleFn = (x: number) => {\n      if (reduced) return;\n      splash(x, TOGGLE_IMPULSE);\n      exciteAt = performance.now();\n      wake();\n    };\n\n    const resize = () => {\n      const rect = frame.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false; // zero-size guard — never simulate into nothing\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      // replaced element: inset does not size it — explicit CSS size + bitmap\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      const nextCols = Math.floor(w / DX) + 1;\n      if (nextCols !== cols) {\n        cols = nextCols;\n        hf = new Float32Array(cols);\n        vf = new Float32Array(cols);\n      }\n      sized = true;\n      measure();\n      // re-anchor level to the (possibly new) height\n      const s = scorePassword(input.value);\n      target = h * MAX_FILL * (s / 100);\n      if (!raf) level = target;\n      drawFrame();\n    };\n    resize();\n\n    // -- init: seed level from the initial value, no waves ------------------\n    prevLen = input.value.length;\n    inputFn(input.value, true);\n\n    engineRef.current = { input: inputFn, ripple: rippleFn };\n\n    // -- landing-page card demo (autoplay) -----------------------------------\n    // The shared autoplay driver (app/preview/[name]/autoplay-driver.tsx) only\n    // synthesises Pointer/Mouse events. This component's entire demonstration\n    // is value-driven — typed characters via <input onChange> — which no\n    // pointer/scroll/press/drag descriptor can reach, and the one\n    // pointer-reachable surface (the reveal toggle -> ripple) is invisible on\n    // an empty tank. So meta.json declares \"mode\": \"none\" (no shared driver\n    // mounts, no data-autoplay-root/inert change) and, gated strictly on the\n    // ?autoplay=1 query param the embed sets, this scripts a password being\n    // typed and deleted by calling the exact same `inputFn` real typing\n    // already calls — no new rendering path, no bespoke canvas animation.\n    // Real users never see this: the param is absent on both the plain\n    // /preview page and on plain ?embed=1.\n    let scriptTimer = 0;\n    let scriptCancelled = false;\n    if (!isControlled && !reduced) {\n      const isAutoplay =\n        new URLSearchParams(window.location.search).get(\"autoplay\") === \"1\";\n      if (isAutoplay) {\n        const script = \"Tr0ub4dor&3\";\n        const TYPE_MS = 190;\n        const DELETE_MS = 110;\n        const HOLD_MS = 850;\n        const REST_MS = 700;\n        const setVal = (v: string) => {\n          input.value = v;\n          setBand((prev) => {\n            const b = bandOf(scorePassword(v));\n            return prev === b ? prev : b;\n          });\n          inputFn(v);\n        };\n        const typeStep = (i: number) => {\n          if (scriptCancelled) return;\n          setVal(script.slice(0, i));\n          scriptTimer = window.setTimeout(\n            () => (i < script.length ? typeStep(i + 1) : deleteStep(script.length)),\n            i < script.length ? TYPE_MS : HOLD_MS\n          );\n        };\n        const deleteStep = (len: number) => {\n          if (scriptCancelled) return;\n          const next = len - 1;\n          setVal(script.slice(0, Math.max(0, next)));\n          scriptTimer = window.setTimeout(\n            () => (next > 0 ? deleteStep(next) : typeStep(1)),\n            next > 0 ? DELETE_MS : REST_MS\n          );\n        };\n        scriptTimer = window.setTimeout(() => typeStep(1), 500);\n      }\n    }\n\n    // -- observers ----------------------------------------------------------\n    const ro = new ResizeObserver(resize);\n    ro.observe(frame);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n    });\n    io.observe(frame);\n    // live theme re-derive: watch documentElement class flips\n    const mo = new MutationObserver(() => {\n      derive();\n      if (raf) return; // running loop repaints next frame anyway\n      drawFrame();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n    // live OS-setting re-derive, mirroring the documentElement class observer\n    const onMotionChange = () => {\n      reduced = motionMq.matches;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        snapFlat();\n      }\n    };\n    motionMq.addEventListener(\"change\", onMotionChange);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      scriptCancelled = true;\n      window.clearTimeout(scriptTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      motionMq.removeEventListener(\"change\", onMotionChange);\n      engineRef.current = null;\n    };\n  }, []);\n\n  // controlled value set programmatically (autofill, reset) re-syncs the tide\n  useEffect(() => {\n    if (isControlled) engineRef.current?.input(value ?? \"\");\n  }, [isControlled, value]);\n\n  const handleInput = (v: string) => {\n    const b = bandOf(scorePassword(v));\n    setBand((prev) => (prev === b ? prev : b));\n    engineRef.current?.input(v);\n    onValueChange?.(v);\n  };\n\n  const handleToggle = () => {\n    setRevealed((r) => !r);\n    const btn = toggleRef.current;\n    if (btn) engineRef.current?.ripple(btn.offsetLeft + btn.offsetWidth / 2);\n  };\n\n  return (\n    <div className={className}>\n      <label\n        htmlFor={inputId}\n        className=\"mb-1.5 block text-sm font-medium text-foreground\"\n      >\n        {label}\n        {required ? <span className=\"text-ns-muted\"> *</span> : null}\n      </label>\n      <div\n        ref={frameRef}\n        className={`relative overflow-hidden rounded-sm border border-border bg-surface transition-colors focus-within:border-ns-accent/60 focus-within:ring-2 focus-within:ring-ns-accent/30 ${\n          disabled ? \"opacity-60\" : \"hover:border-foreground/25\"\n        }`}\n      >\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className=\"pointer-events-none absolute left-0 top-0\"\n        />\n        <input\n          ref={inputRef}\n          id={inputId}\n          type={revealed ? \"text\" : \"password\"}\n          name={name}\n          placeholder={placeholder}\n          autoComplete={autoComplete}\n          required={required}\n          disabled={disabled}\n          aria-describedby={liveId}\n          spellCheck={false}\n          {...(isControlled ? { value } : { defaultValue })}\n          onChange={(e) => handleInput(e.currentTarget.value)}\n          className=\"relative z-10 w-full bg-transparent py-2 pl-3 pr-11 font-mono text-sm text-foreground outline-none placeholder:text-ns-muted/70\"\n        />\n        <button\n          ref={toggleRef}\n          type=\"button\"\n          aria-pressed={revealed}\n          aria-label={revealed ? \"Hide password\" : \"Show password\"}\n          disabled={disabled}\n          onClick={handleToggle}\n          className=\"absolute right-1.5 top-1/2 z-10 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-sm text-ns-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent disabled:pointer-events-none\"\n        >\n          {revealed ? (\n            <svg\n              width=\"15\"\n              height=\"15\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n            >\n              <path d=\"M9.88 9.88a3 3 0 1 0 4.24 4.24\" />\n              <path d=\"M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68\" />\n              <path d=\"M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61\" />\n              <line x1=\"2\" x2=\"22\" y1=\"2\" y2=\"22\" />\n            </svg>\n          ) : (\n            <svg\n              width=\"15\"\n              height=\"15\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n            >\n              <path d=\"M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z\" />\n              <circle cx=\"12\" cy=\"12\" r=\"3\" />\n            </svg>\n          )}\n        </button>\n      </div>\n      <div className=\"mt-1.5 flex min-h-4 items-center justify-end\">\n        <span\n          aria-hidden\n          className={`font-mono text-[11px] uppercase tracking-[0.14em] transition-colors ${\n            band === \"Strong\"\n              ? \"text-ns-accent\"\n              : band === \"Fair\"\n                ? \"text-foreground/70\"\n                : band === \"Weak\"\n                  ? \"text-ns-muted\"\n                  : \"text-transparent\"\n          }`}\n        >\n          {band ?? \"·\"}\n        </span>\n      </div>\n      <span id={liveId} aria-live=\"polite\" className=\"sr-only\">\n        {band ? `Password strength: ${band}` : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/password-strength-tide.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "input",
      "password",
      "form",
      "canvas",
      "physics",
      "micro-interaction"
    ],
    "instruction": "Build a password field (real <input type=password>, visible label, standard semantics untouched) with a canvas 2D water tank behind the masked text, clipped to the field's rounded-sm 6px bounds. Size the canvas with EXPLICIT style.width/height plus dpr-scaled bitmap (replaced element — inset does not size it). Strength = length + charset-class entropy (pool 26/26/10/33, 72 bits caps the gauge) mapped 0-100 to water level percent; drive the level with a damped spring k=90 s^-2, zeta=0.55 (~8% overshoot then settle). Simulate a 1D heightfield with one column per 4 px: neighbor-coupling wave speed ~140 px/s, per-frame velocity damping 0.985; each keystroke injects a -6 px surface impulse at the caret's approximate x (charCount * measured mask-glyph width, spread over a 5-column kernel), deletions inject +4 px and lower the target, and the reveal toggle drops a 4 px ripple at the eye-icon x. Fill = mix(--ns-muted, --ns-accent, level fraction) at 20% alpha with a 1.5 px surface line at 60% alpha; full clear + redraw every frame — never accumulating destination-in alpha. rAF sleeps when max column |v| < 0.02 px/frame AND |level-target| < 0.3 px, with a 1.2 s forced-settle deadline (extra damping pulls flat, 2 s hard snap) and pauses offscreen via IntersectionObserver plus document visibility; guard zero-size containers before simulating. All canvas ink is read via getComputedStyle at mount and re-derived by a MutationObserver watching documentElement class changes so both themes render correctly. Reveal toggle is a real button with aria-pressed; aria-describedby points at a visually-hidden polite live region announcing Weak/Fair/Strong, mirrored by a small aria-hidden mono readout. Accent focus-within ring and token-relative hover border. At zero entropy the tank draws no fill but a faint --ns-muted rim line at the base so the empty gauge silhouette still reads at rest. prefers-reduced-motion renders a static fill bar at target height with instant height changes and no waves, and is watched live via a matchMedia change listener (not just read once at mount) so a mid-session OS toggle snaps the tank flat immediately. Tear down every listener, observer, and rAF on unmount."
  },
  "type": "registry:ui"
}