{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"deploy-log-input","type":"registry:component","title":"Deploy Log Input","description":"An email field that answers with a streaming log: on submit, short lines stream under it with spinners turning into checks while a beam of light runs along its top edge, and the last line waits for your handler.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/deploy-log-input.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FormEvent } from \"react\";\n\n/**\n * DeployLogInput — an email field that answers with a streaming log.\n *\n * On submit it validates the address, then streams short log lines under\n * itself (a spinner turning into a check per step) while a beam of light\n * runs along its top edge, and finally confirms. The last step waits for\n * onSubmit, so the log never claims success before the work is done, and\n * a rejection shows its message in the log. Stale runs are ignored.\n *\n * Part of the Dark Precision kit: OLED black, 1px hairlines, one cool accent\n * used only as light (default #4fd1ff; also mint, amber, white or any\n * #rrggbb). Fonts come from CSS variables with Geist fallbacks. Respects\n * prefers-reduced-motion. No dependencies beyond React.\n */\n\nconst SANS = 'var(--font-sans, \"Geist\", \"Inter\", ui-sans-serif, system-ui, sans-serif)';\n\nconst MONO = 'var(--font-mono, \"Geist Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace)';\n\nconst INK = \"rgba(255,255,255,0.92)\";\n\nconst INK_2 = \"rgba(255,255,255,0.6)\";\n\nconst INK_3 = \"rgba(255,255,255,0.38)\";\n\nconst LINE_2 = \"rgba(255,255,255,0.14)\";\n\nconst WARN = \"#ff8a7a\";\n\nconst MORPH = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nconst HEX = /^#[0-9a-fA-F]{6}$/;\n\nexport const DP_ACCENTS = {\n  ice: \"#4fd1ff\",\n  mint: \"#5ef2c1\",\n  amber: \"#ffb45e\",\n  white: \"#f2f4f7\",\n} as const;\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\n/** A named accent or a #rrggbb hex; anything else falls back to ice. */\nfunction accentHex(value: string): string {\n  if (own(DP_ACCENTS, value)) return DP_ACCENTS[value];\n  return HEX.test(value) ? value : DP_ACCENTS.ice;\n}\n\nfunction rgbOf(hex: string): [number, number, number] {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction rgba(hex: string, a: number): string {\n  const [r, g, b] = rgbOf(hex);\n  return `rgba(${r},${g},${b},${a})`;\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.\nconst MOTION_CSS = \"@media (prefers-reduced-motion: reduce){.dp-motion{animation:none!important}}\";\n\n// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.\nconst FOCUS_CSS = \".dp-scope :focus-visible,.dp-scope:focus-visible{outline:2px solid var(--dp-accent);outline-offset:3px;border-radius:revert-layer}\";\n\nconst LOG_CSS = \"@keyframes dp-progress{from{transform:translateX(-100%)}to{transform:translateX(300%)}}@keyframes dp-spin{to{transform:rotate(360deg)}}\";\n\n/** The accent as the CSS variables the focus ring and the keyframes read. */\nfunction accentVars(hex: string): CSSProperties {\n  const [r, g, b] = rgbOf(hex);\n  return { \"--dp-accent\": hex, \"--dp-accent-rgb\": `${r} ${g} ${b}` } as CSSProperties;\n}\n\nexport type DeployLogInputProps = {\n  label?: string;\n  placeholder?: string;\n  button?: string;\n  steps?: string[];\n  /** Shown when done; \"{email}\" is replaced with the address. */\n  done?: string;\n  note?: string;\n  /**\n   * Called with the address; it resolves the last step, so wire it to your real endpoint.\n   * Reject with an Error whose message is safe to show: it is displayed as is.\n   */\n  onSubmit: (email: string) => Promise<void> | void;\n  accent?: string;\n  /** Milliseconds between log lines. */\n  stepMs?: number;\n};\n\n/**\n * An email field that, on submit, streams a short log under itself while a\n * beam of light runs along its top edge, then confirms. The last line waits\n * for onSubmit, so the log never claims success before the work is done.\n */\nexport function DeployLogInput({\n  label = \"Work email\",\n  placeholder = \"you@company.com\",\n  button = \"Get an API key\",\n  steps = [\"Checking the address\", \"Reserving a workspace\", \"Sending your key by email\"],\n  done = \"You're in. Check {email}.\",\n  note,\n  onSubmit,\n  accent = \"ice\",\n  stepMs = 850,\n}: DeployLogInputProps) {\n  const acc = accentHex(accent);\n  const reduced = useReducedMotion();\n  const uid = useId();\n  const [email, setEmail] = useState(\"\");\n  const [phase, setPhase] = useState<\"idle\" | \"invalid\" | \"running\" | \"failed\" | \"done\">(\"idle\");\n  const [shown, setShown] = useState(0);\n  const [error, setError] = useState(\"\");\n  const [logH, setLogH] = useState(0);\n  const run = useRef(0);\n  const logRef = useRef<HTMLOListElement>(null);\n\n  useEffect(() => {\n    const el = logRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setLogH(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  async function submit(e: FormEvent<HTMLFormElement>) {\n    e.preventDefault();\n    if (phase === \"running\" || phase === \"done\") return;\n    const value = email.trim();\n    if (!/^[^\\s@]+@[^\\s@.]+(?:\\.[^\\s@.]+)*\\.[^\\s@.]{2,}$/.test(value)) {\n      setPhase(\"invalid\");\n      return;\n    }\n    const me = ++run.current;\n    setPhase(\"running\");\n    setError(\"\");\n    setShown(1);\n    const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, reduced ? 0 : ms));\n    const job = Promise.resolve().then(() => onSubmit(value));\n    job.catch(() => undefined);\n    for (let i = 2; i <= steps.length; i++) {\n      await wait(stepMs);\n      if (run.current !== me) return;\n      setShown(i);\n    }\n    try {\n      await job;\n    } catch (err) {\n      if (run.current !== me) return;\n      setError(err instanceof Error && err.message ? err.message : \"That didn't go through. Try again in a moment.\");\n      setPhase(\"failed\");\n      return;\n    }\n    await wait(stepMs * 0.6);\n    if (run.current !== me) return;\n    setShown(steps.length + 1);\n    setPhase(\"done\");\n  }\n\n  const busy = phase === \"running\";\n  const bad = phase === \"invalid\" || phase === \"failed\";\n  const hintId = uid + \"-hint\";\n  return (\n    <form onSubmit={submit} noValidate className=\"dp-scope w-full max-w-[520px] text-left\" style={{ ...accentVars(acc), fontFamily: SANS }}>\n      <style>{FOCUS_CSS + LOG_CSS + MOTION_CSS}</style>\n      <label htmlFor={uid} className=\"sr-only\">\n        {label}\n      </label>\n      <div\n        className=\"relative flex items-center gap-2 overflow-hidden rounded-[14px] p-1.5\"\n        style={{\n          background: \"rgba(11,11,13,0.8)\",\n          backdropFilter: \"blur(8px)\",\n          WebkitBackdropFilter: \"blur(8px)\",\n          boxShadow: `inset 0 0 0 1px ${bad ? rgba(WARN, 0.6) : busy || phase === \"done\" ? rgba(acc, 0.45) : LINE_2}`,\n          transition: \"box-shadow 280ms ease\",\n        }}\n      >\n        {busy && !reduced && (\n          <span aria-hidden className=\"dp-motion pointer-events-none absolute left-0 top-0 h-px w-1/3\" style={{ background: `linear-gradient(90deg, transparent, ${acc}, #ffffff, ${acc}, transparent)`, animation: \"dp-progress 2200ms linear infinite\" }} />\n        )}\n        <input\n          id={uid}\n          type=\"email\"\n          maxLength={254}\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          required\n          value={email}\n          placeholder={placeholder}\n          disabled={busy || phase === \"done\"}\n          aria-invalid={bad}\n          aria-describedby={hintId}\n          onChange={(e) => {\n            setEmail(e.target.value);\n            if (bad) setPhase(\"idle\");\n          }}\n          className=\"h-11 min-w-0 flex-1 bg-transparent px-3 text-[15px] placeholder:text-white/30 disabled:opacity-60\"\n          style={{ color: INK, outline: \"none\" }}\n        />\n        <button\n          type=\"submit\"\n          disabled={busy || phase === \"done\"}\n          className=\"inline-flex h-11 shrink-0 items-center gap-2 rounded-[10px] px-4 text-[14px] font-medium transition-opacity disabled:cursor-default\"\n          style={{ background: \"#ffffff\", color: \"#050505\", opacity: busy ? 0.7 : 1 }}\n        >\n          {phase === \"done\" ? \"Sent\" : busy ? \"Working\" : button}\n        </button>\n      </div>\n      <div className=\"overflow-hidden\" style={{ height: shown > 0 ? logH : 0, transition: reduced ? \"none\" : `height 420ms ${MORPH}` }}>\n        <ol ref={logRef} role=\"log\" aria-live=\"polite\" className=\"space-y-1 px-2 pt-4 text-[13px]\" style={{ fontFamily: MONO, color: INK_2 }}>\n          {steps.slice(0, Math.min(shown, steps.length)).map((s, i) => {\n            const complete = i < shown - 1 || phase === \"done\";\n            const failedHere = phase === \"failed\" && i === shown - 1;\n            return (\n              <li key={s} className=\"flex items-center gap-2.5\">\n                <span aria-hidden className=\"flex h-4 w-4 items-center justify-center\">\n                  {complete ? (\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\">\n                      <path d=\"M3 7.5l2.5 2.5L11 4\" stroke={acc} strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                    </svg>\n                  ) : failedHere ? (\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\">\n                      <path d=\"M4 4l6 6M10 4l-6 6\" stroke={WARN} strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n                    </svg>\n                  ) : (\n                    <span className=\"dp-motion h-3 w-3 rounded-full\" style={{ boxShadow: `inset 0 0 0 1.5px ${LINE_2}`, borderTop: `1.5px solid ${acc}`, animation: \"dp-spin 900ms linear infinite\" }} />\n                  )}\n                </span>\n                <span style={{ color: complete ? INK_2 : INK }}>{s}</span>\n              </li>\n            );\n          })}\n          {phase === \"done\" && (\n            <li className=\"pt-2 text-[14px]\" style={{ fontFamily: SANS, color: INK }}>\n              {done.replace(\"{email}\", email.trim())}\n            </li>\n          )}\n        </ol>\n      </div>\n      <p id={hintId} className=\"mt-3 px-2 text-[13px]\" style={{ color: bad ? WARN : INK_3 }}>\n        {phase === \"invalid\" ? \"Enter a work email, like you@company.com.\" : phase === \"failed\" ? error : note ?? \"\"}\n      </p>\n    </form>\n  );\n}\n","type":"registry:component","target":"components/viberdy/deploy-log-input.tsx"}],"meta":{"tier":"free","tags":["form","input","email","waitlist","signup","loading","log","dark"],"href":"https://viberdy.dev/library/deploy-log-input","aiPrompt":"Build a React + Tailwind form, `DeployLogInput({ label, placeholder, button, steps, done, note, onSubmit, accent, stepMs })`: one email field that answers with a streaming log.\n\nThe field: a 14px-radius frame (dark translucent with backdrop blur, inset hairline that turns warm on error and accent while working or done) holding an input with an sr-only label, autocomplete email and aria-invalid/aria-describedby, and a white button inside. Submit: validate with a simple address pattern (else show a hint and aria-invalid); start a run (a counter ref, so a stale run bails after every await); show step 1, then reveal each further step every stepMs while onSubmit runs in parallel (start it immediately and attach a no-op catch so an early rejection is not unhandled). After the last step, await onSubmit: on rejection show its message and mark the step failed; on success wait a beat and append the done line with {email} replaced.\n\nThe log: an ordered list with role=\"log\" and aria-live polite, in mono, each line with a spinner (a ring with an accent top border, spinning) that becomes an accent check (or a warm cross on failure), inside a wrapper whose height eases to the list's measured height (ResizeObserver). While working, a third-width beam of light (transparent, accent, white, accent, transparent) runs along the top edge of the frame on a 2.2s loop. Keys are never printed on screen; say they arrive by email.\n\nDark Precision grammar: ground #050505, surfaces #0b0b0d and #121215, hairlines white 6% at rest and 14% lit (drawn as inset box-shadows or SVG strokes, never border colours), ink white 92/60/38%, one cool accent used only as light (default #4fd1ff; mint #5ef2c1, amber #ffb45e, white #f2f4f7 or any #rrggbb, validated against /^#[0-9a-fA-F]{6}$/), Geist never heavier than 600 with tight tracking, Geist Mono only for captions and data, fonts through CSS variables with fallbacks (var(--font-sans, \"Geist\", ...)). Morphs use cubic-bezier(0.16, 1, 0.3, 1); ambient loops are 30s or slower. Keyframes live in a small static <style> the component renders itself (nothing interpolated into it; the accent reaches it as --dp-accent and --dp-accent-rgb variables), plus a focus ring rule scoped to a .dp-scope class with border-radius: revert-layer. Respect prefers-reduced-motion. No dependencies beyond React.","license":"https://viberdy.dev/license"}}