{
  "name": "netflix-sign-in-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion",
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "files": [
    {
      "path": "components/craftui/blocks/netflix-sign-in/default.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertCircleIcon,\n  Download01Icon,\n  Search01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport {\n  AnimatePresence,\n  MotionConfig,\n  arc,\n  motion,\n  useAnimationControls,\n  useMotionValue,\n  useReducedMotion,\n  type TargetAndTransition,\n  type Transition,\n} from \"motion/react\";\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD — profile gate\n *\n *  scene 1   five faces in a row. Hover lifts one and brightens its name.\n *  on pick   THE CHOSEN FACE ARCS TO THE CENTRE AND GROWS. It is one node the\n *            whole way: the roster slot goes invisible but keeps its place,\n *            and a single traveller flies between the two rectangles.\n *\n *            The curve is Motion's own `arc()`, handed to `transition.path`.\n *            x and y are given nothing but their destination; the path bends\n *            the line between. It also owns the lean — `rotate` follows the\n *            tangent, so the tilt is the curve's rather than a keyframe we\n *            keep in sync by hand.\n *\n *            The measuring is still hand-rolled FLIP, but that is now a\n *            choice rather than a constraint: `arc()` works on layout\n *            animations too, so this could be a `layoutId` pair with\n *            `transition.layout.path` and no measuring at all. The reason it\n *            is not is that the roster slot must keep its rectangle for the\n *            return, which means never unmounting it — and a layoutId pair\n *            wants exactly one of the two mounted at a time.\n *\n *            The other four do not leave at all. The picked face is removed\n *            from the row and popLayout pulls it out of flow on frame one, so\n *            the remaining four close the gap while the traveller is still\n *            climbing.\n *  scene 2   four boxes below the face, growing downward out of it from a\n *            top-edge origin. It starts at 80% OF THE ASCENT, not on landing:\n *            waiting for the face to stop would make scene 2 a screen that\n *            loads after an animation instead of the end of one. Four boxes,\n *            but ONE input — see the pad below for why. The fourth digit\n *            submits by itself.\n *  on wrong  the pad shakes, clears, and says so. The shake is imperative:\n *            the same PIN failing twice in a row is a value that did not\n *            change, and a declarative animate would do nothing the second\n *            time.\n *  on right  the pad crossfades to a welcome where it stands, and the whole\n *            bottom of the stage changes hands: the roster crossfades out, a\n *            three-tab bar crossfades in, and the face flies down into the\n *            Profile tab — all on the same clock, so it is one event and not\n *            three. The tab bar is mounted from the start at opacity 0 rather\n *            than swapped in, because the descent has to be AIMED at the\n *            Profile tab in the same commit the fade begins, and you cannot\n *            measure a rectangle that is not in the document yet.\n *\n *  scene 3   the pad leaves the moment the descent begins and is gone within\n *            the first fifth of it, so the face travels home over an empty\n *            stage rather than past a panel still fading.\n *\n *            The face arcs home and shrinks — and takes a different route\n *            back for free. `direction` is locked to the direction of TRAVEL,\n *            so the same \"cw\" on both legs bulges to opposite sides of the\n *            screen once the journey reverses. A path that retraces itself\n *            reads as a rewind; a different one reads as an object that went\n *            somewhere and came back. The row closes in behind it, nearest\n *            face last.\n *\n *  reduced   No arc at all. Arcs are precisely the vestibular trigger, so\n *  motion    under reduced motion the traveller cross-dissolves between the\n *            two rectangles and the row fades without moving. MotionConfig\n *            makes the transforms instant; the opacity the traveller starts\n *            at is what turns that jump into a dissolve.\n *\n * This component is the SCREEN only. The phone around it — body, bezel,\n * island, status bar, home indicator — lives in the preview, because it is\n * device chrome and not part of the interaction. The screen fills whatever\n * box it is given and measures everything against itself, so it works inside\n * a phone frame, a browser mock, or full-bleed without a line changing. The\n * one thing a host must not do is put a CSS transform on an ancestor: every\n * position here comes from getBoundingClientRect, and a scaled ancestor\n * would scale the measurements while leaving the traveller's own translate\n * in unscaled local space — the face would fly to a systematically wrong\n * place. Size the host in real pixels instead.\n * ───────────────────────────────────────────────────────── */\n\n/* ── Geometry ─────────────────────────────────────────────\n * The avatar is rendered ONCE at its small size and scaled up, rather than\n * resized: scale is a composited transform, width/height is layout. The size\n * itself is the `avatarSize` prop; this is only its default. */\nconst DEFAULT_AVATAR = 50;\n/* Corner radius as a FRACTION of the box, never a fixed px. The face is one\n * size in the row and another in the air, and a constant radius reads as a\n * different shape at each. Lower this for squarer corners: 0.5 is a circle,\n * ~0.25 is a squircle, 0 is a square. */\nconst RADIUS_RATIO = 0.18;\n/* Focus rings sit 6px outside the face, so their radius is the face's plus\n * that offset — concentric, not merely similar. */\nconst RING_INSET = 6;\n/* Clear air between the bottom of the grown face and the top of the pad. Small\n * on purpose: the pad unfolds FROM the face, and a wide gap makes it read as a\n * separate panel that happened to appear nearby. */\nconst PAD_GAP = 2;\n\n/* ── The flight ───────────────────────────────────────────\n * The arc is Motion's own `arc()`, handed to `transition.path`. Everything\n * that used to be hand-keyframed here — an apex, two easings per leg, a\n * bow measured in pixels — is four options on a path factory now, and the\n * curve is a real arc rather than a y-channel bent until it looked like one.\n *\n * Reuse matters: the docs are explicit that a fresh `arc()` has no memory of\n * its own continuity, so each leg's instance is memoised on its options. */\ntype Bulge = \"cw\" | \"ccw\" | \"auto\";\n\ntype Leg = {\n  /* How far the arc bulges perpendicular to the straight line, as a fraction\n     of the distance travelled. 0 is a straight line; 1 peaks at the full\n     travel distance. */\n  strength: number;\n  /* Where along the leg (0–1) the bulge peaks. 0.5 is symmetric; push it\n     early and the face flings out and coasts in. */\n  peak: number;\n  /* Which side it bulges toward, LOCKED TO THE DIRECTION OF TRAVEL. This is\n     what gives the return its own road for free: the same \"cw\" reverses in\n     screen space when the journey does. */\n  direction: Bulge;\n  /* Tangent following, 0–1. The lean is no longer a keyframe we maintain —\n     the path knows its own slope. */\n  rotate: number;\n  duration: number;\n};\n\ntype Flight = {\n  /* Where the face comes to rest, as a fraction of stage height. */\n  rise: number;\n  /* How much bigger it gets. 2.19 = 50px → ~110px. */\n  growth: number;\n  out: Leg;\n  home: Leg;\n  /* Pacing ALONG the arc. The path decides the shape, this decides how fast\n     you move over it. */\n  ease: [number, number, number, number];\n  /* The tap before the launch. Its own node and its own tween: a dip that has\n     to be SEEN before the rise cannot be left to a spring, which would blend\n     it into the climb. `handoff` is how long the growth waits for it. */\n  anticipate: { scale: number; duration: number; handoff: number };\n  /* Scale rides its own spring, independent of the journey. */\n  grow: { visualDuration: number; bounce: number };\n  shrink: { visualDuration: number; bounce: number };\n  /* The pad is timed as a FRACTION of the leg it accompanies, not in seconds,\n     so retiming a flight retimes the pad with it and the two can never drift\n     apart. */\n  pad: {\n    /* How far into the ascent the pad starts arriving. 0.8 = it begins in the\n       last fifth, as the face is already settling. */\n    enterAt: number;\n    /* How much of the descent it has to be gone by. 0.2 = it leaves in the\n       first fifth, so the face travels home over an empty stage. */\n    exitBy: number;\n    visualDuration: number;\n    bounce: number;\n  };\n};\n\n/* Tuned live in a DialKit panel, then frozen here. Every value below was a\n * slider; the panel and its route are gone, and putting them back means\n * wrapping this object in useDialKit again — nothing else in the block knew\n * the panel existed, which is what made it removable in one commit. */\nconst FLIGHT: Flight = {\n  rise: 0.25,\n  growth: 2.19,\n  /* An early peak: the face is flung out of the row and coasts into the\n     centre, rather than sailing symmetrically. Its lean is stronger than the\n     way home because it is travelling further off the straight line. */\n  out: {\n    strength: 0.38,\n    peak: 0.21,\n    direction: \"cw\",\n    rotate: 0.5,\n    duration: 0.62,\n  },\n  home: {\n    strength: 0.5,\n    peak: 0.5,\n    direction: \"cw\",\n    rotate: 0.35,\n    duration: 0.58,\n  },\n  ease: [0.32, 0, 0.24, 1],\n  anticipate: { scale: 0.86, duration: 0.3, handoff: 0.07 },\n  /* Bouncier than a settle. The face is being thrown, and an overshoot on\n     arrival is what says so. */\n  grow: { visualDuration: 0.46, bounce: 0.34 },\n  /* The shrink is quicker and bouncier than the grow: going home is not a\n     decision, so it snaps rather than settles. */\n  shrink: { visualDuration: 0.3, bounce: 0.4 },\n  pad: { enterAt: 0.8, exitBy: 0.2, visualDuration: 0.34, bounce: 0.2 },\n};\n\n/* One instance per leg, built once. The docs are blunt about this: a fresh\n * arc() has no memory, so rebuilding it would throw away the continuity\n * closure that keeps an interrupted flight from snapping. Module scope is\n * exactly where they say to put it, now that the options are fixed. */\nconst ARC_OUT = arc({\n  strength: FLIGHT.out.strength,\n  peak: FLIGHT.out.peak,\n  direction: FLIGHT.out.direction === \"auto\" ? undefined : FLIGHT.out.direction,\n  rotate: FLIGHT.out.rotate,\n});\n\nconst ARC_HOME = arc({\n  strength: FLIGHT.home.strength,\n  peak: FLIGHT.home.peak,\n  direction:\n    FLIGHT.home.direction === \"auto\" ? undefined : FLIGHT.home.direction,\n  rotate: FLIGHT.home.rotate,\n});\n\n/* ── Transitions that are not the flight ──────────────────\n * A plain spring for everything that is neither the flight nor the pad. */\nconst PAD: Transition = {\n  type: \"spring\",\n  duration: 0.42,\n  bounce: 0.16,\n  delay: 0.08,\n};\n\n/* A digit landing in its box. The one place with real pop: it is the only\n * feedback that the keypress registered. */\nconst DIGIT: Transition = { type: \"spring\", duration: 0.28, bounce: 0.42 };\n\n/* Leaving is always faster than arriving. */\nconst LEAVE: Transition = { type: \"spring\", duration: 0.16, bounce: 0 };\n\n/* ── The faces ────────────────────────────────────────────\n * Hand-drawn on a 0 0 100 100 grid so they stay crisp through the 2.19×\n * scale-up — the whole reason these are vector rather than bitmap. */\nfunction Ari({ ink }: { ink: string }) {\n  return (\n    <>\n      <circle cx=\"36\" cy=\"44\" r=\"6.5\" fill={ink} />\n      <circle cx=\"64\" cy=\"44\" r=\"6.5\" fill={ink} />\n      <path\n        d=\"M32 62 Q50 78 68 62\"\n        stroke={ink}\n        strokeWidth=\"6\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n    </>\n  );\n}\n\nfunction Nova({ ink }: { ink: string }) {\n  return (\n    <>\n      {/* One eye winking — a line where a circle should be is the cheapest\n          way to give a face an attitude. */}\n      <path\n        d=\"M28 44 Q36 37 44 44\"\n        stroke={ink}\n        strokeWidth=\"5.5\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n      <circle cx=\"64\" cy=\"43\" r=\"6.5\" fill={ink} />\n      <path d=\"M38 62 Q50 74 62 62 Q50 68 38 62 Z\" fill={ink} />\n    </>\n  );\n}\n\nfunction Pip({ ink }: { ink: string }) {\n  return (\n    <>\n      <circle cx=\"35\" cy=\"45\" r=\"11\" stroke={ink} strokeWidth=\"4\" fill=\"none\" />\n      <circle cx=\"65\" cy=\"45\" r=\"11\" stroke={ink} strokeWidth=\"4\" fill=\"none\" />\n      <path d=\"M46 45 H54\" stroke={ink} strokeWidth=\"4\" strokeLinecap=\"round\" />\n      <circle cx=\"35\" cy=\"45\" r=\"3.5\" fill={ink} />\n      <circle cx=\"65\" cy=\"45\" r=\"3.5\" fill={ink} />\n      <path\n        d=\"M40 66 Q50 72 60 66\"\n        stroke={ink}\n        strokeWidth=\"5\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n    </>\n  );\n}\n\nfunction Wren({ ink }: { ink: string }) {\n  return (\n    <>\n      {/* Lids, not eyes. Half-closed reads as calm at any size. */}\n      <path\n        d=\"M28 46 Q36 39 44 46\"\n        stroke={ink}\n        strokeWidth=\"5.5\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n      <path\n        d=\"M56 46 Q64 39 72 46\"\n        stroke={ink}\n        strokeWidth=\"5.5\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n      <circle cx=\"50\" cy=\"66\" r=\"5\" fill={ink} />\n      <circle cx=\"26\" cy=\"58\" r=\"4.5\" fill={ink} opacity=\"0.28\" />\n      <circle cx=\"74\" cy=\"58\" r=\"4.5\" fill={ink} opacity=\"0.28\" />\n    </>\n  );\n}\n\nfunction Bo({ ink }: { ink: string }) {\n  return (\n    <>\n      <path\n        d=\"M27 38 Q35 33 43 37\"\n        stroke={ink}\n        strokeWidth=\"4.5\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n      <path\n        d=\"M57 37 Q65 33 73 38\"\n        stroke={ink}\n        strokeWidth=\"4.5\"\n        strokeLinecap=\"round\"\n        fill=\"none\"\n      />\n      <circle cx=\"35\" cy=\"48\" r=\"6\" fill={ink} />\n      <circle cx=\"65\" cy=\"48\" r=\"6\" fill={ink} />\n      {/* An open mouth with a tongue: the only face here that is mid-laugh. */}\n      <path d=\"M34 62 Q50 60 66 62 Q60 78 50 78 Q40 78 34 62 Z\" fill={ink} />\n      <path d=\"M44 73 Q50 68 56 73 Q50 79 44 73 Z\" fill=\"#fff\" opacity=\"0.55\" />\n    </>\n  );\n}\n\n/* The five drawn faces, cycled by index for any profile that brings no\n * `avatar` of its own. A custom avatar replaces the drawing but keeps the\n * ground colour unless the profile sets `color`. */\nconst DRAWN = [\n  { ground: \"#e8503a\", ink: \"#2a0b06\", Face: Ari },\n  { ground: \"#2fa8ff\", ink: \"#04213b\", Face: Nova },\n  { ground: \"#f0a03c\", ink: \"#3b2405\", Face: Pip },\n  { ground: \"#4cc85c\", ink: \"#062c11\", Face: Wren },\n  { ground: \"#8b5cf6\", ink: \"#1c0a44\", Face: Bo },\n];\n\ntype HugeIcon = ComponentProps<typeof HugeiconsIcon>[\"icon\"];\n\nexport interface Profile {\n  /* Label under the face. Also read into \"Enter {name}'s PIN\" and the\n     Profile tab's accessible name. */\n  name: string;\n  /* The answer the default verifier checks against. A profile with no `pin`\n     is unlocked: pick it and it flies, pauses, and signs in — no pad. Pass\n     `locked` to keep the pad while checking the PIN somewhere else. */\n  pin?: string;\n  /* Whether the gate asks for a PIN at all. Defaults to `pin !== undefined`,\n     so it only needs setting when the answer lives on a server. */\n  locked?: boolean;\n  /* Ground colour of the avatar box, and of the glow it throws on the screen\n     while it is up. Defaults to the drawn face's own colour. */\n  color?: string;\n  /* Replaces the drawn face. An <img> is sized to fill the box; anything\n     else is your own concern. Rendered at `avatarSize` and scaled, never\n     resized, so keep it vector or comfortably above 2× that size. */\n  avatar?: ReactNode;\n}\n\nexport interface Tab {\n  name: string;\n  icon: HugeIcon;\n  onClick?: () => void;\n}\n\nexport interface NetflixSignInProps {\n  /* The roster. Five fit a 340px-wide host; more need a wider one. */\n  profiles?: Profile[];\n  /* The tabs AFTER Profile. Profile is always first — it is the landing pad\n     the face flies into, so it is not the consumer's to move. */\n  tabs?: Tab[];\n  /* Decides whether a PIN is right. Replace the default comparison against\n     `profile.pin` with a request; the pad stays locked until it resolves.\n     A rejection or a throw counts as wrong. */\n  onVerify?: (\n    pin: string,\n    profile: Profile,\n    index: number,\n  ) => boolean | Promise<boolean>;\n  /* Fires when the face lands in the Profile tab — the flow is over. */\n  onSignIn?: (profile: Profile, index: number) => void;\n  /* Fires when Back or Escape has returned the face to the row. */\n  onDismiss?: () => void;\n  heading?: string;\n  /* A function, not a string, because the name is interpolated. */\n  pinLabel?: (name: string) => string;\n  errorMessage?: string;\n  welcome?: { title: string; subtitle?: string };\n  backLabel?: string;\n  profileTabLabel?: string;\n  /* The margin note that leaks each profile's PIN. Demo scaffolding — the\n     preview turns it on, an installed block should not. */\n  showHint?: boolean;\n  /* Number of PIN boxes. The last one submits by itself. */\n  pinLength?: number;\n  /* Face size in the row, in px. Everything else — radius, ring offset, the\n     grown size — derives from it. */\n  avatarSize?: number;\n  /* Appended to the screen's classes. The block fills its container. */\n  className?: string;\n}\n\nconst DEFAULT_PROFILES: Profile[] = [\n  { name: \"Ari\", pin: \"1234\" },\n  { name: \"Nova\", pin: \"2580\" },\n  { name: \"Pip\", pin: \"1379\" },\n  { name: \"Wren\", pin: \"4321\" },\n  { name: \"Bo\", pin: \"0000\" },\n];\n\n/* The bar that replaces the roster once you are through the gate. Profile is\n * first because it is the one with a destination in it — the other two are\n * here to make it a bar rather than a button. */\nconst DEFAULT_TABS: Tab[] = [\n  { name: \"Search\", icon: Search01Icon },\n  { name: \"Downloads\", icon: Download01Icon },\n];\n\n/* How long the default verifier pretends to think. Only used when no\n * `onVerify` is passed — a real request sets its own pace. */\nconst FAKE_VERIFY_MS = 900;\n\n/* An unlocked profile still pauses at the centre before it is let through,\n * so the pick reads as a pick and not as a face that bounced off. */\nconst UNLOCKED_HOLD_MS = 320;\n\nfunction isLocked(profile: Profile) {\n  return profile.locked ?? profile.pin !== undefined;\n}\n\nfunction groundOf(profile: Profile, index: number) {\n  return profile.color ?? DRAWN[index % DRAWN.length].ground;\n}\n\ntype Phase =\n  \"idle\" | \"rising\" | \"pin\" | \"verifying\" | \"granted\" | \"falling\" | \"home\";\n\ntype Travel = {\n  from: { x: number; y: number };\n  stage: { w: number; h: number };\n};\n\n/* The avatar itself. Rendered at `size` px everywhere and scaled by\n * transform, so the roster slot and the traveller are provably the same\n * object. */\nfunction Avatar({\n  profile,\n  index,\n  size,\n  elementRef,\n}: {\n  profile: Profile;\n  index: number;\n  size: number;\n  elementRef?: React.Ref<HTMLSpanElement>;\n}) {\n  const { ink, Face } = DRAWN[index % DRAWN.length];\n\n  return (\n    <span\n      ref={elementRef}\n      className=\"block overflow-hidden [&>img]:h-full [&>img]:w-full [&>img]:object-cover\"\n      style={{\n        width: size,\n        height: size,\n        background: groundOf(profile, index),\n        borderRadius: size * RADIUS_RATIO,\n      }}\n    >\n      {profile.avatar ?? (\n        <svg viewBox=\"0 0 100 100\" className=\"block h-full w-full\">\n          <Face ink={ink} />\n        </svg>\n      )}\n    </span>\n  );\n}\n\nexport default function NetflixSignIn({\n  profiles = DEFAULT_PROFILES,\n  tabs = DEFAULT_TABS,\n  onVerify,\n  onSignIn,\n  onDismiss,\n  heading = \"Who’s watching?\",\n  pinLabel = (name) => `Enter ${name}’s PIN`,\n  errorMessage = \"That PIN doesn’t match.\",\n  welcome = { title: \"Welcome back!\", subtitle: \"Enjoy your weekend.\" },\n  backLabel = \"← Back\",\n  profileTabLabel = \"Profile\",\n  showHint = false,\n  pinLength = 4,\n  avatarSize = DEFAULT_AVATAR,\n  className,\n}: NetflixSignInProps) {\n  const reduced = useReducedMotion();\n  const AVATAR = avatarSize;\n  const RADIUS = AVATAR * RADIUS_RATIO;\n\n  /* Callbacks live in a ref so the flight effect below can call them at\n     landing without listing them as deps — a consumer passing a fresh arrow\n     function every render must not restart a leg in progress. */\n  const callbacks = useRef({ onSignIn, onDismiss });\n  callbacks.current = { onSignIn, onDismiss };\n  /* Bumped whenever a verification is superseded — a dismiss, a new pick —\n     so a slow `onVerify` resolving late cannot act on a gate that has moved\n     on. */\n  const verifyId = useRef(0);\n\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [chosen, setChosen] = useState<number | null>(null);\n  const [travel, setTravel] = useState<Travel | null>(null);\n  /* Where the descent ENDS. Usually the slot the face came from, but a\n     correct PIN sends it somewhere else entirely, so it cannot be baked into\n     the measurement the way the origin is. */\n  const [homePoint, setHomePoint] = useState<{ x: number; y: number } | null>(\n    null,\n  );\n  /* The bar that is showing. Both bars stay mounted and crossfade, which is\n     also what keeps the Profile tab measurable before it is ever visible —\n     you cannot fly something to a rectangle that is not in the document. */\n  const [signedIn, setSignedIn] = useState(false);\n  const [pin, setPin] = useState(\"\");\n  /* Whether the hidden input holds focus the user can SEE. Tailwind's `peer-*`\n     only reaches siblings, and the boxes are grandchildren of the input's\n     sibling, so the ring cannot be expressed in CSS from here — the state has\n     to come back into React to land on one box.\n\n     `:focus-visible` is the right test even though the boxes should light up\n     on a click: the spec matches it on text inputs however focus arrived, so\n     this stays true for the mouse and still suppresses nothing a keyboard user\n     needs. */\n  const [showFocus, setShowFocus] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const stageRef = useRef<HTMLDivElement>(null);\n  const slots = useRef<(HTMLButtonElement | null)[]>([]);\n  /* The FACE, not the button around it. The button is a flex column holding\n     the avatar and its name, so its centre sits well below the avatar's — fly\n     from that and the face drops ~13px before it has gone anywhere. */\n  const faceSlots = useRef<(HTMLSpanElement | null)[]>([]);\n  const profileSlot = useRef<HTMLSpanElement | null>(null);\n  const input = useRef<HTMLInputElement>(null);\n  const timers = useRef<number[]>([]);\n\n  /* The shake lives on the pad's wrapper, never on the traveller: a keyframed\n     x and the flight's own x would be the same transform fighting. */\n  const shake = useAnimationControls();\n\n  /* Owned rather than left to Motion, so the ascent can be seeded at the\n     slot it is leaving before the leg starts. */\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const gating = phase !== \"idle\";\n  const locked = phase === \"verifying\" || phase === \"granted\";\n  /* The phases the pad belongs to, listed rather than expressed as \"not\n     falling\". `home` is also not falling, and testing for the absence of one\n     phase quietly re-mounted the whole pad the moment the face landed in the\n     tab bar — the welcome played once on the way out and then a second time\n     after it had already gone. */\n  const needsPin = chosen !== null && isLocked(profiles[chosen]);\n  const padOpen =\n    needsPin &&\n    (phase === \"rising\" ||\n      phase === \"pin\" ||\n      phase === \"verifying\" ||\n      phase === \"granted\");\n\n  /* Big only while it is up at the centre. Listed rather than derived from\n     `gating`, because `home` is also a gated phase and is emphatically not\n     big — it is a 50px face sitting in a tab bar. */\n  const grown =\n    phase === \"rising\" ||\n    phase === \"pin\" ||\n    phase === \"verifying\" ||\n    phase === \"granted\";\n\n  /* Measured against the STAGE, not the viewport, so the block works at any\n     panel size — and would work full-bleed without a line changing.\n\n     Only MEASUREMENTS are stored. Where the face lands is derived below from\n     the flight, so dragging a dial moves it without re-measuring anything. */\n  /* Any element's centre, in stage coordinates. */\n  function centreOf(element: Element | null) {\n    const stage = stageRef.current?.getBoundingClientRect();\n    const box = element?.getBoundingClientRect();\n    if (!stage || !box) return null;\n    return {\n      x: box.left - stage.left + box.width / 2,\n      y: box.top - stage.top + box.height / 2,\n    };\n  }\n\n  function measure(index: number): Travel | null {\n    const stage = stageRef.current?.getBoundingClientRect();\n    const slot = faceSlots.current[index]?.getBoundingClientRect();\n    if (!stage || !slot) return null;\n\n    return {\n      from: {\n        x: slot.left - stage.left + slot.width / 2,\n        y: slot.top - stage.top + slot.height / 2,\n      },\n      stage: { w: stage.width, h: stage.height },\n    };\n  }\n\n  const big = AVATAR * FLIGHT.growth;\n\n  const target = useMemo(() => {\n    if (!travel) return null;\n    const to = {\n      x: travel.stage.w / 2,\n      /* Held off the top edge so the face never crowds the heading, and low\n         enough that the pad below it stays inside the stage. */\n      y: Math.max(24 + big / 2, travel.stage.h * FLIGHT.rise),\n    };\n    return { to, padTop: to.y + big / 2 + PAD_GAP };\n  }, [travel, big]);\n\n  /* A measured FLIP is only correct for the size it was measured at, so the\n     geometry is recomputed whenever the stage changes shape mid-flight. */\n  useEffect(() => {\n    if (chosen === null) return;\n    const onResize = () => {\n      /* While the face is away its slot is not in the row to be measured, so\n         a resize mid-flight keeps the home point it already has rather than\n         clearing it and stranding the traveller. */\n      const next = measure(chosen);\n      if (next) setTravel(next);\n    };\n    window.addEventListener(\"resize\", onResize);\n    return () => window.removeEventListener(\"resize\", onResize);\n  }, [chosen]);\n\n  useEffect(\n    () => () => {\n      timers.current.forEach(window.clearTimeout);\n      verifyId.current += 1;\n    },\n    [],\n  );\n\n  function later(fn: () => void, ms: number) {\n    timers.current.push(window.setTimeout(fn, ms));\n  }\n\n  function pick(index: number) {\n    if (gating) return;\n    const next = measure(index);\n    if (!next) return;\n    verifyId.current += 1;\n    setTravel(next);\n    setChosen(index);\n    setPin(\"\");\n    setError(null);\n    setPhase(\"rising\");\n  }\n\n  function dismiss() {\n    if (!gating || locked || phase === \"home\") return;\n    verifyId.current += 1;\n    setHomePoint(null);\n    setPhase(\"falling\");\n  }\n\n  /* Let the face through. `hold` is how long it stays put first: long enough\n     for the boxes to go green and be read as the reason the face is leaving,\n     or — with no pad — just long enough to register as a pause. */\n  function grant(hold: number) {\n    setPhase(\"granted\");\n    later(() => {\n      /* All three in one commit: the bars begin crossfading on exactly the\n         frame the face starts down, and the descent already knows it is\n         aiming at the Profile tab rather than the row it came from. */\n      const profile = centreOf(profileSlot.current);\n      if (profile) setHomePoint(profile);\n      setSignedIn(true);\n      setPhase(\"falling\");\n    }, hold);\n  }\n\n  function refuse() {\n    setPhase(\"pin\");\n    setPin(\"\");\n    /* Focus returns to the field below, which is also what puts this in\n       front of a screen reader: it is wired to the input through\n       aria-describedby rather than shouted as an alert, because it is\n       validation for one field and not a page-level event. */\n    setError(errorMessage);\n    shake.start({\n      x: [0, -8, 7, -4, 0],\n      transition: { duration: 0.3, ease: \"easeOut\" },\n    });\n    input.current?.focus();\n  }\n\n  async function verify(value: string) {\n    if (chosen === null) return;\n    const id = ++verifyId.current;\n    setPhase(\"verifying\");\n\n    let ok = false;\n    try {\n      ok = onVerify\n        ? await onVerify(value, profiles[chosen], chosen)\n        : /* No verifier: compare locally, after a pause that stands in for\n             the round trip a real one would take. */\n          await new Promise<boolean>((resolve) =>\n            later(\n              () => resolve(value === profiles[chosen].pin),\n              FAKE_VERIFY_MS,\n            ),\n          );\n    } catch {\n      ok = false;\n    }\n    /* Superseded while waiting — dismissed, or unmounted. Say nothing. */\n    if (id !== verifyId.current) return;\n\n    if (ok) grant(720);\n    else refuse();\n  }\n\n  function onPin(value: string) {\n    const digits = value.replace(/\\D/g, \"\").slice(0, pinLength);\n    setPin(digits);\n    if (error) setError(null);\n    if (digits.length === pinLength) verify(digits);\n  }\n\n  /* Focus lands only once the face has finished flying. Focusing mid-flight\n     makes the browser scroll to a rectangle that is still moving. */\n  useEffect(() => {\n    if (phase === \"pin\") input.current?.focus();\n  }, [phase]);\n\n  /* Handing Motion the POSE OBJECT rather than a variant label is what makes\n     the dials live. Motion re-runs a variant only when the label changes —\n     \"up\" is still \"up\" no matter what the numbers underneath it say — so with\n     labels, turning a dial rebuilt the pose and nothing moved. Against an\n     object it diffs the values, and the memo below keeps the identity stable\n     so unrelated re-renders cannot restart the flight.\n\n     Two plain targets. No keyframes, no apex, no second easing: x and y go\n     straight to their destination and `path` bends the line between. */\n  const poses = useMemo<\n    { up: TargetAndTransition; down: TargetAndTransition } | undefined\n  >(() => {\n    if (!travel || !target) return undefined;\n    const { from } = travel;\n    const { to } = target;\n    const half = AVATAR / 2;\n\n    return {\n      /* x, y and nothing else. `arc()` takes the whole target object, drives\n         x and y off a single progress value, and then DELETES them from it —\n         the source does this literally — so anything else riding along in\n         there is at the mercy of that handshake. Scale is animated on its own\n         motion value below instead, which is also more honest: the size is\n         not part of the journey. */\n      up: {\n        x: to.x - half,\n        y: to.y - half,\n        opacity: 1,\n        transition: {\n          duration: FLIGHT.out.duration,\n          ease: FLIGHT.ease,\n          path: ARC_OUT,\n          opacity: { duration: 0.2 },\n        },\n      },\n      down: {\n        x: (homePoint ?? from).x - half,\n        y: (homePoint ?? from).y - half,\n        opacity: 1,\n        transition: {\n          duration: FLIGHT.home.duration,\n          ease: FLIGHT.ease,\n          path: ARC_HOME,\n        },\n      },\n    };\n  }, [travel, target, homePoint]);\n\n  /* The flight is driven imperatively, and it has to be. Handing Motion a\n     target object only re-animates when the VALUES change — and every dial on\n     the arc changes the transition, not the destination. Turning `strength`\n     would have moved the face to exactly where it already was. Starting the\n     leg by hand means any change to the path replays it. */\n  const fly = useAnimationControls();\n  /* Null while nothing is travelling. Without this, arriving in the nav bar\n     (`home`) would read as \"not falling\" and fly the face straight back up. */\n  const leg: \"up\" | \"down\" | null =\n    phase === \"idle\" || phase === \"home\"\n      ? null\n      : phase === \"falling\"\n        ? \"down\"\n        : \"up\";\n\n  useEffect(() => {\n    if (!poses || !travel || chosen === null || !leg) return;\n    let stale = false;\n\n    /* Seed the ascent at the slot it is leaving. These motion values belong to\n       the block, not to the traveller, so they OUTLIVE a flight — without this\n       a second pick starts from wherever the last face came to rest, which is\n       the previous slot, not this one. Seeding is also what makes a dial\n       change replay the whole leg rather than re-aiming from mid-air.\n\n       The descent is deliberately not seeded: it has to start from wherever\n       the face actually is, so an interrupted ascent turns round instead of\n       snapping to the centre first. */\n    if (leg === \"up\") {\n      x.set(travel.from.x - AVATAR / 2);\n      y.set(travel.from.y - AVATAR / 2);\n    }\n\n    /* Spread, because `arc()` mutates the target it is handed. Passing the\n       memoised pose straight in would let the first flight strip x and y out\n       of it, and every flight after that would animate nothing. */\n    fly.start({ ...poses[leg] }).then(() => {\n      /* A leg that was interrupted resolves too, so the guard is what stops\n         a replaced flight from reporting that it landed. */\n      if (stale) return;\n      if (leg === \"up\") {\n        /* An unlocked profile has nothing to type: it pauses where it is and\n           goes straight through. */\n        if (!isLocked(profiles[chosen])) {\n          grant(UNLOCKED_HOLD_MS);\n          return;\n        }\n        setPhase((current) => (current === \"rising\" ? \"pin\" : current));\n        return;\n      }\n      /* Landing in the nav bar is not a reset: the face stays, the Profile\n         tab takes it over, and `chosen` is what tells the tab whose face to\n         render. One commit either way, so the traveller unmounts in the same\n         frame its destination becomes visible — no flash of two faces or\n         none. */\n      if (signedIn) {\n        setPhase(\"home\");\n        callbacks.current.onSignIn?.(profiles[chosen], chosen);\n        return;\n      }\n      setPhase(\"idle\");\n      setChosen(null);\n      setTravel(null);\n      setHomePoint(null);\n      setPin(\"\");\n      setError(null);\n      callbacks.current.onDismiss?.();\n    });\n\n    return () => {\n      stale = true;\n    };\n    // `phase` is deliberately absent: rising → pin must not restart the leg,\n    // and `signedIn` is read at landing time rather than subscribed to: it is\n    // set in the same commit that starts the descent and must not restart it.\n    // `profiles` is read at landing too — a consumer rebuilding the array\n    // each render must not replay the flight.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [poses, leg, chosen, fly, travel, x, y]);\n\n  /* The row closes and reopens on exactly the clock of the leg in flight, so\n     the gap and the face are one movement rather than two that happen to\n     overlap. */\n  const rowTravel: Transition = {\n    duration: phase === \"falling\" ? FLIGHT.home.duration : FLIGHT.out.duration,\n    ease: FLIGHT.ease,\n  };\n\n  /* One bar out, one bar in, over exactly the descent. Not a hand-off with a\n     gap in it: the face is in the air for this whole duration, so both bars\n     being half-visible under it is what makes the swap feel like one event\n     rather than two. */\n  const barFade: Transition = {\n    duration: FLIGHT.home.duration,\n    ease: FLIGHT.ease,\n  };\n\n  /* The pad arrives in the last fifth of the ascent — late enough that the\n     face is already settling under it, early enough that the two read as one\n     movement rather than a screen that loads after an animation. */\n  const padIn: Transition = {\n    type: \"spring\",\n    visualDuration: FLIGHT.pad.visualDuration,\n    bounce: FLIGHT.pad.bounce,\n    delay: FLIGHT.pad.enterAt * FLIGHT.out.duration,\n  };\n\n  /* And leaves in the first fifth of the descent, with no delay at all: the\n     moment the face starts for home the pad is already going, so the journey\n     back happens over an empty stage. A pad still fading at the halfway point\n     would be a thing the face has to fly past. */\n  const padOut: Transition = {\n    duration: FLIGHT.pad.exitBy * FLIGHT.home.duration,\n    ease: \"easeIn\",\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div\n        ref={stageRef}\n        onKeyDown={(event) => {\n          /* Escape backs out of the gate. Past it there is nothing to back\n             out of — signing in is the end of the flow. */\n          if (event.key !== \"Escape\") return;\n          dismiss();\n        }}\n        /* A white screen. Everything on it is therefore INK on paper: the\n           faces keep their colour and carry the whole palette, and every\n           other element is a value of near-black. The top ~50px is left\n           clear for whatever status bar the host draws over it. */\n        className={`relative h-full w-full overflow-hidden bg-white text-[#0b0b0b] ${className ?? \"\"}`}\n      >\n        {/* ── The glow ──\n            The face's own colour, thrown onto the screen behind it. It is\n            what stops the middle of the screen from being an empty white\n            field while the pad is open, and it costs one static gradient —\n            no blur filter, so nothing is recomputed per frame. */}\n        {chosen !== null && target && (\n          <motion.div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute\"\n            style={{\n              top: target.to.y,\n              left: \"50%\",\n              width: 440,\n              height: 440,\n              x: \"-50%\",\n              y: \"-50%\",\n              background: `radial-gradient(closest-side, ${groundOf(profiles[chosen], chosen)}24, transparent 72%)`,\n            }}\n            animate={{ opacity: grown ? 1 : 0 }}\n            transition={barFade}\n          />\n        )}\n\n        {/* ── Heading ── */}\n        <motion.h3\n          animate={{ opacity: gating ? 0 : 1, y: gating ? -8 : 0 }}\n          transition={gating ? LEAVE : { ...PAD, delay: 0.12 }}\n          className=\"absolute inset-x-0 top-[54px] text-center text-[22px] font-bold tracking-tight\"\n        >\n          {heading}\n        </motion.h3>\n\n        {/* ── The row ──\n            The chosen face LEAVES the row rather than fading in place, and\n            popLayout is what makes that read correctly: the exiting item is\n            pulled out of flow on frame one, so the rest close the gap while\n            the traveller is still climbing instead of after it.\n\n            Coming home it is re-inserted the moment the descent STARTS, not\n            when it ends — held at opacity 0, so all it does is take its slot\n            back and push the others apart. The face lands into a space that\n            has already been made for it, which is the difference between the\n            row reacting to the landing and the row being part of it. */}\n        <motion.ul\n          animate={{ opacity: signedIn ? 0 : 1 }}\n          transition={barFade}\n          style={{ pointerEvents: signedIn ? \"none\" : \"auto\" }}\n          aria-hidden={signedIn}\n          className=\"absolute inset-x-0 bottom-10 flex items-start justify-center gap-3\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {profiles.map((face, index) => {\n              /* Away means the traveller is carrying it. It is only absent on\n                 the way out and while parked — on the way home it is back in\n                 the list, invisible, holding its rectangle. */\n              if (index === chosen && !(phase === \"falling\" && !signedIn))\n                return null;\n              const returning = index === chosen && !signedIn;\n\n              return (\n                <motion.li\n                  key={face.name}\n                  layout\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: returning ? 0 : 1 }}\n                  /* No exit animation at all. The traveller takes over on the\n                     same frame, and two copies of one face crossfading past\n                     each other is the one thing that would give the trick\n                     away. */\n                  exit={{ opacity: 0, transition: { duration: 0 } }}\n                  transition={{\n                    layout: rowTravel,\n                    opacity: { duration: 0 },\n                  }}\n                >\n                  <motion.button\n                    ref={(node) => {\n                      slots.current[index] = node;\n                    }}\n                    type=\"button\"\n                    onClick={() => pick(index)}\n                    disabled={gating}\n                    aria-label={\n                      isLocked(face)\n                        ? `${face.name}. Locked profile, enter a PIN.`\n                        : face.name\n                    }\n                    whileHover={gating ? undefined : { scale: 1.06 }}\n                    whileTap={gating ? undefined : { scale: 0.97 }}\n                    style={{ borderRadius: RADIUS + RING_INSET }}\n                    className=\"group flex cursor-pointer flex-col items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-[#0b0b0b] focus-visible:ring-offset-4 focus-visible:ring-offset-white disabled:pointer-events-none\"\n                  >\n                    <Avatar\n                      profile={face}\n                      index={index}\n                      size={AVATAR}\n                      elementRef={(node) => {\n                        faceSlots.current[index] = node;\n                      }}\n                    />\n                    <span className=\"text-[12px] font-medium text-[#0b0b0b]/40 transition-colors duration-150 ease-out group-hover:text-[#0b0b0b]\">\n                      {face.name}\n                    </span>\n                  </motion.button>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </motion.ul>\n\n        {/* ── The greeting ──\n            Its own section, not something the pad turns into. It is tied to\n            `signedIn`, which is the same flag the two bars read, and it runs\n            on the same `barFade` — so the roster leaving, the tab bar\n            arriving and this appearing are one event with one clock rather\n            than three things that were each given the same number.\n\n            It takes the place the face is vacating: anchored to the centre\n            the face has just left, so the greeting arrives where you were\n            already looking. */}\n        <AnimatePresence>\n          {signedIn && target && (\n            <motion.div\n              key=\"greeting\"\n              className=\"absolute inset-x-0 flex -translate-y-1/2 flex-col items-center gap-1.5 text-center\"\n              style={{ top: target.to.y }}\n              initial={{ opacity: 0, y: 10 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: barFade }}\n              transition={barFade}\n            >\n              <p className=\"text-[22px] font-bold tracking-tight\">\n                {welcome.title}\n              </p>\n              {welcome.subtitle && (\n                <p className=\"text-[15px] font-medium text-[#0b0b0b]/45\">\n                  {welcome.subtitle}\n                </p>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {/* ── The bar on the other side ──\n            Mounted from the very start at opacity 0, never conditionally\n            rendered. Two reasons: the crossfade is a true dissolve rather\n            than one bar waiting for the other to leave, and the Profile tab\n            has a rectangle to measure BEFORE it is visible — the descent is\n            aimed at it in the same commit that starts the fade, and you\n            cannot fly something to an element that is not in the document. */}\n        <motion.nav\n          aria-label=\"Profile navigation\"\n          aria-hidden={!signedIn}\n          animate={{ opacity: signedIn ? 1 : 0 }}\n          transition={barFade}\n          style={{ pointerEvents: signedIn ? \"auto\" : \"none\" }}\n          className=\"absolute inset-x-0 bottom-10 flex items-start justify-center gap-8\"\n        >\n          <button\n            type=\"button\"\n            /* The tab you are already on. Activating it is a no-op by\n               design — the same as pressing the current tab anywhere else —\n               which is why it announces a state and not an action. */\n            aria-current=\"page\"\n            aria-label={\n              chosen === null\n                ? profileTabLabel\n                : `${profileTabLabel}, signed in as ${profiles[chosen].name}`\n            }\n            style={{ borderRadius: RADIUS + RING_INSET }}\n            className=\"group flex cursor-pointer flex-col items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-[#0b0b0b] focus-visible:ring-offset-4 focus-visible:ring-offset-white\"\n          >\n            {/* The landing pad. Fixed at AVATAR so it holds the rectangle\n                whether or not there is a face in it yet. */}\n            <span\n              ref={profileSlot}\n              className=\"grid place-items-center\"\n              style={{ width: AVATAR, height: AVATAR }}\n            >\n              {/* Instant, not a fade: the traveller unmounts in the same\n                  commit this appears, and a crossfade between two copies of\n                  one face is the one thing that would give it away. */}\n              <motion.span\n                animate={{ opacity: phase === \"home\" ? 1 : 0 }}\n                transition={{ duration: 0 }}\n              >\n                {chosen !== null && (\n                  <Avatar\n                    profile={profiles[chosen]}\n                    index={chosen}\n                    size={AVATAR}\n                  />\n                )}\n              </motion.span>\n            </span>\n            <span className=\"text-[12px] font-medium text-[#0b0b0b] transition-colors duration-150 ease-out\">\n              {profileTabLabel}\n            </span>\n          </button>\n\n          {tabs.map(({ name, icon, onClick }) => (\n            <button\n              key={name}\n              type=\"button\"\n              onClick={onClick}\n              style={{ borderRadius: RADIUS + RING_INSET }}\n              className=\"group flex cursor-pointer flex-col items-center gap-2.5 outline-none focus-visible:ring-2 focus-visible:ring-[#0b0b0b] focus-visible:ring-offset-4 focus-visible:ring-offset-white\"\n            >\n              <span\n                className=\"grid place-items-center bg-[#0b0b0b]/[0.055] transition-colors duration-150 ease-out group-hover:bg-[#0b0b0b]/[0.1]\"\n                style={{\n                  width: AVATAR,\n                  height: AVATAR,\n                  borderRadius: RADIUS,\n                }}\n              >\n                <HugeiconsIcon icon={icon} size={22} strokeWidth={2} />\n              </span>\n              <span className=\"text-[12px] font-medium text-[#0b0b0b]/40 transition-colors duration-150 ease-out group-hover:text-[#0b0b0b]\">\n                {name}\n              </span>\n            </button>\n          ))}\n        </motion.nav>\n\n        {/* ── The traveller ──\n            One node from the moment you click to the moment it is home. */}\n        <AnimatePresence>\n          {chosen !== null && travel && target && poses && phase !== \"home\" && (\n            <motion.div\n              key=\"traveller\"\n              className=\"pointer-events-none absolute top-0 left-0 origin-center\"\n              /* x and y are OUR motion values, not Motion's internal ones,\n                   so anything that wants to know where the face is on a given\n                   frame can read them — the path drawn is the path flown. */\n              style={{ width: AVATAR, height: AVATAR, x, y }}\n              initial={{ opacity: reduced ? 0 : 1 }}\n              animate={fly}\n            >\n              {/* THREE nodes, three jobs, and the scales multiply.\n                      x/y  — the journey, owned by arc()\n                      dip  — the tap: a quick squash and release\n                      grow — the size, on a bouncy spring\n\n                    They are separate because they are different physics. One\n                    scale carrying both would have to blend a fixed-length dip\n                    into an open-ended spring, and whichever won, the squash\n                    would stop being its own beat. Multiplied, the dip happens\n                    underneath the climb without either knowing about the\n                    other.\n\n                    (The size was split off the journey for a different\n                    reason: it used to ride in the same target as x and y,\n                    which arc() takes ownership of and mutates.) */}\n              <motion.div\n                className=\"h-full w-full\"\n                initial={{ scale: 1 }}\n                /* Down and back. The release is not a separate beat — by\n                     the time it returns to 1 the spring below has taken over,\n                     so what you see is a press that turns into a launch. */\n                animate={{\n                  scale: grown ? [1, FLIGHT.anticipate.scale, 1] : 1,\n                }}\n                transition={{\n                  duration: FLIGHT.anticipate.duration,\n                  times: [0, 0.3, 1],\n                  ease: [\"easeOut\", \"easeOut\"],\n                }}\n              >\n                <motion.div\n                  className=\"h-full w-full\"\n                  initial={{ scale: 1 }}\n                  animate={{ scale: grown ? FLIGHT.growth : 1 }}\n                  transition={{\n                    type: \"spring\",\n                    ...(grown ? FLIGHT.grow : FLIGHT.shrink),\n                    /* Long enough for the dip to be its own beat. Without\n                         it the rise starts on the same frame as the squash\n                         and cancels it. */\n                    delay: grown ? FLIGHT.anticipate.handoff : 0,\n                  }}\n                >\n                  <Avatar\n                    profile={profiles[chosen]}\n                    index={chosen}\n                    size={AVATAR}\n                  />\n                </motion.div>\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {/* ── The pad ──\n            Scene 2. It grows out of the face rather than fading up into\n            place: transform-origin is the TOP EDGE, so it unfolds downward\n            from directly under the avatar and the two read as one object.\n            Origin anywhere else and the pad appears to arrive from somewhere\n            the face is not. */}\n        <AnimatePresence>\n          {padOpen && chosen !== null && travel && target && (\n            <motion.div\n              key=\"pad\"\n              animate={shake}\n              className=\"absolute left-1/2 flex -translate-x-1/2 flex-col items-center gap-4\"\n              style={{ top: target.padTop }}\n            >\n              <motion.div\n                style={{ transformOrigin: \"top center\" }}\n                initial={{ opacity: 0, scale: 0.82 }}\n                animate={{ opacity: 1, scale: 1 }}\n                /* Exit fires the instant `falling` unmounts this, which is\n                   the same instant the descent starts — the pad and the face\n                   leave together, the pad just finishes first. */\n                exit={{ opacity: 0, scale: 0.88, transition: padOut }}\n                transition={padIn}\n                className=\"flex flex-col items-center gap-4\"\n              >\n                <p\n                  id=\"gate-pin-label\"\n                  className=\"text-[15px] font-medium text-[#0b0b0b]/50\"\n                >\n                  {pinLabel(profiles[chosen].name)}\n                </p>\n\n                {/* FOUR BOXES, ONE INPUT. Four inputs is the usual answer and\n                    it is the wrong one: paste breaks, backspace needs manual\n                    focus juggling, mobile keyboards fight the focus moves, and\n                    a screen reader is handed four unlabelled fields instead of\n                    one. The boxes below are pure decoration over this. */}\n                <div className=\"relative\">\n                  <input\n                    ref={input}\n                    value={pin}\n                    onChange={(event) => onPin(event.target.value)}\n                    disabled={locked}\n                    inputMode=\"numeric\"\n                    autoComplete=\"one-time-code\"\n                    maxLength={pinLength}\n                    /* The VISIBLE label, not a duplicate of it in an\n                       aria-label — the accessible name has to contain the\n                       text a sighted user is reading. */\n                    aria-labelledby=\"gate-pin-label\"\n                    aria-invalid={error ? true : undefined}\n                    aria-describedby={error ? \"gate-pin-error\" : undefined}\n                    onFocus={(event) =>\n                      setShowFocus(\n                        event.currentTarget.matches(\":focus-visible\"),\n                      )\n                    }\n                    onBlur={() => setShowFocus(false)}\n                    className=\"absolute inset-0 z-10 w-full cursor-pointer text-transparent caret-transparent opacity-0 outline-none\"\n                  />\n\n                  {/* Decoration over the real field. Hidden from assistive\n                      tech so the value is announced once, by the input,\n                      rather than as four loose digits. It holds nothing\n                      focusable, which is what makes aria-hidden safe here. */}\n                  <div aria-hidden=\"true\" className=\"flex gap-3 p-1\">\n                    {Array.from({ length: pinLength }, (_, slot) => {\n                      const digit = pin[slot];\n                      const active = pin.length === slot && !locked;\n\n                      return (\n                        <div\n                          key={slot}\n                          /* An outline, not a Tailwind ring: rings are\n                             box-shadows, and box-shadows are dropped entirely\n                             in forced-colors mode. 2px solid at full ink is\n                             the minimum perimeter the guidelines ask for, and\n                             it only ever sits on the box the next keypress\n                             lands in. */\n                          style={\n                            showFocus && active\n                              ? {\n                                  outline: \"2px solid #0b0b0b\",\n                                  outlineOffset: 2,\n                                }\n                              : undefined\n                          }\n                          /* The verdict lands ON the digits, where you were\n                             looking: green while the face is being let\n                             through, red when it is not. Both are the same\n                             tint-and-ink treatment, so a right answer and a\n                             wrong one read as two values of one thing. */\n                          className={[\n                            \"grid h-[62px] w-[52px] place-items-center rounded-xl text-[22px] font-bold tabular-nums transition-colors duration-150 ease-out\",\n                            phase === \"granted\"\n                              ? \"bg-[#4cc85c]/15 text-[#2f9a3e]\"\n                              : error\n                                ? \"bg-[#c0392b]/10 text-[#c0392b]\"\n                                : active\n                                  ? \"bg-[#0b0b0b]/[0.1] text-[#0b0b0b]\"\n                                  : \"bg-[#0b0b0b]/[0.055] text-[#0b0b0b]\",\n                          ].join(\" \")}\n                        >\n                          <AnimatePresence mode=\"popLayout\" initial={false}>\n                            {digit ? (\n                              <motion.span\n                                key={`d-${slot}`}\n                                initial={{ opacity: 0, scale: 0.4, y: 6 }}\n                                animate={{ opacity: 1, scale: 1, y: 0 }}\n                                exit={{\n                                  opacity: 0,\n                                  scale: 0.6,\n                                  transition: LEAVE,\n                                }}\n                                transition={DIGIT}\n                              >\n                                {digit}\n                              </motion.span>\n                            ) : active ? (\n                              /* A caret, not a placeholder: it says where the\n                                 next key goes without pretending to be one. */\n                              <motion.span\n                                key={`c-${slot}`}\n                                className=\"block h-6 w-[2px] rounded-full bg-[#0b0b0b]\"\n                                initial={{ opacity: 0 }}\n                                animate={{ opacity: [1, 1, 0, 0, 1] }}\n                                exit={{ opacity: 0, transition: LEAVE }}\n                                transition={{\n                                  duration: 1.1,\n                                  repeat: Infinity,\n                                  ease: \"linear\",\n                                }}\n                              />\n                            ) : null}\n                          </AnimatePresence>\n                        </div>\n                      );\n                    })}\n                  </div>\n                </div>\n\n                {/* A STABLE region, already in the document before its text\n                    changes — a live region created at the same moment as its\n                    content is announced inconsistently. Polite, because the\n                    message is not urgent enough to interrupt. */}\n                <div aria-live=\"polite\" className=\"relative h-5\">\n                  <AnimatePresence mode=\"wait\" initial={false}>\n                    <motion.p\n                      key={error ?? phase}\n                      id={error ? \"gate-pin-error\" : undefined}\n                      className={`absolute inset-x-0 flex items-center justify-center gap-1.5 text-[12px] whitespace-nowrap ${\n                        error\n                          ? \"font-medium text-[#c0392b]\"\n                          : \"text-[#0b0b0b]/35\"\n                      }`}\n                      initial={{ opacity: 0, y: -4 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, y: 4, transition: LEAVE }}\n                      transition={{ duration: 0.18, ease: \"easeOut\" }}\n                    >\n                      {/* Errors only. \"Checking…\" used to live here and said\n                          nothing the interface was not already saying — the\n                          boxes are full and the field has gone disabled. The\n                          PIN hint is a note ABOUT the demo, not product copy,\n                          so it lives in the margin below instead. */}\n                      {/* The glyph is decoration on a message the live region\n                          already reads out; aria-hidden keeps it from being\n                          announced as a stray \"alert circle\". */}\n                      {error && (\n                        <HugeiconsIcon\n                          icon={AlertCircleIcon}\n                          size={14}\n                          strokeWidth={2.2}\n                          aria-hidden=\"true\"\n                          className=\"shrink-0\"\n                        />\n                      )}\n                      {error ?? \"\"}\n                    </motion.p>\n                  </AnimatePresence>\n                </div>\n\n                {/* ── The hint ──\n                    Drawn as a margin note rather than set as UI copy: a\n                    dashed leader and a monospaced label read as something\n                    written ON the mock, which is what it is. It hangs off the\n                    pad's own box, so it arrives and leaves with the pad and\n                    needs no timing of its own. */}\n                {showHint && profiles[chosen].pin !== undefined && (\n                  <svg\n                    aria-hidden=\"true\"\n                    viewBox=\"0 0 176 112\"\n                    /* Inline, not a Tailwind arbitrary value: `calc()` needs\n                     whitespace around its operator, and\n                     `top-[calc(100%-6px)]` is invalid CSS that is silently\n                     dropped. The 56px lifts the box so the arrowhead lands\n                     just under the row of boxes rather than 50px adrift below\n                     the status line. */\n                    style={{ top: \"calc(100% - 56px)\", right: -44 }}\n                    className=\"pointer-events-none absolute h-28 w-44 overflow-visible text-[#0b0b0b]/40\"\n                  >\n                    <defs>\n                      <marker\n                        id=\"gate-hint-arrow\"\n                        markerHeight=\"8\"\n                        markerWidth=\"8\"\n                        orient=\"auto\"\n                        refX=\"6\"\n                        refY=\"4\"\n                      >\n                        <path\n                          d=\"M1 1L7 4L1 7\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.5\"\n                        />\n                      </marker>\n                    </defs>\n                    {/* Runs from the label UP to the boxes — the reverse of the\n                      usual leader, because the thing being pointed at is\n                      above the note. */}\n                    <path\n                      d=\"M74 84C48 76 22 58 16 22\"\n                      fill=\"none\"\n                      markerEnd=\"url(#gate-hint-arrow)\"\n                      stroke=\"currentColor\"\n                      strokeDasharray=\"3 4\"\n                      strokeLinecap=\"round\"\n                      strokeWidth=\"1.5\"\n                    />\n                    <text\n                      className=\"font-mono text-[11px]\"\n                      fill=\"currentColor\"\n                      x=\"46\"\n                      y=\"100\"\n                    >\n                      PIN · {profiles[chosen].pin}\n                    </text>\n                  </svg>\n                )}\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {/* Escape is the mouse-free way out, and this is how anyone finds out\n            it exists. */}\n        <AnimatePresence>\n          {gating && !locked && phase !== \"home\" && (\n            <motion.button\n              key=\"back\"\n              type=\"button\"\n              onClick={dismiss}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: LEAVE }}\n              transition={{ ...PAD, delay: 0.24 }}\n              className=\"absolute top-[50px] left-4 cursor-pointer rounded-full px-3 py-1.5 text-[12px] font-medium text-[#0b0b0b]/45 transition-colors duration-150 ease-out hover:text-[#0b0b0b] focus-visible:ring-2 focus-visible:ring-[#0b0b0b] focus-visible:outline-none\"\n            >\n              {backLabel}\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n    </MotionConfig>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/netflix-sign-in/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Netflix Sign In - Netflix Sign In",
    "description": "A profile-picker gate where the chosen avatar flies on an `arc()` path between the roster and the centre, a PIN pad grows out of it, and success hands the bottom of the screen over to a tab bar the face lands in.",
    "category": "sign-in",
    "tags": [
      "sign-in",
      "profile",
      "netflix",
      "pin",
      "otp",
      "avatar",
      "arc",
      "flip",
      "shared-element",
      "click",
      "spring",
      "mobile"
    ],
    "animationType": "click"
  }
}