{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "age-gate",
  "title": "Age Gate",
  "description": "A date-of-birth gate with three explicit boxes, a real calendar behind them, and a minimum-age rule whose refusal is honest and always offers a way out.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input",
    "label",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/age-gate.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarDays, Check, LogOut, ShieldAlert, ShieldCheck, TriangleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\n\n/* -------------------------------------------------------------------- types */\n\nexport type AgeGateField = \"day\" | \"month\" | \"year\"\n\n/** The three orders in use worldwide. `auto` reads it out of the locale. */\nexport type AgeGateFieldOrder = \"dmy\" | \"mdy\" | \"ymd\"\nexport type AgeGateFieldOrderOption = AgeGateFieldOrder | \"auto\"\n\n/** A plain civil date. A birthday is a calendar fact, so it carries no offset. */\nexport interface CivilDate {\n  year: number\n  month: number\n  day: number\n}\n\n/** What the three boxes hold: strings, because \"07\" and \"7\" are different keystrokes. */\nexport interface AgeGateDraft {\n  day: string\n  month: string\n  year: string\n}\n\nexport type AgeGateRefusalCode =\n  | \"incomplete\"\n  | \"year-digits\"\n  | \"month-range\"\n  | \"day-range\"\n  | \"impossible-date\"\n  | \"future\"\n  | \"implausible\"\n  | \"no-clock\"\n  | \"underage\"\n\nexport interface AgeGateRefusal {\n  code: AgeGateRefusalCode\n  /** Which box gets focus. `null` when the answer as a whole is the problem. */\n  field: AgeGateField | null\n  /** Whole years elapsed. Only \"underage\" carries one; every other code fails before an age exists. */\n  age: number | null\n}\n\nexport type AgeGateCheck =\n  | { ok: true; birthDate: CivilDate; age: number }\n  | { ok: false; refusal: AgeGateRefusal }\n\n/** What `onVerified` receives. `remember` is the visitor's consent, not a decision the gate made. */\nexport interface AgeGateResult {\n  birthDate: CivilDate\n  age: number\n  remember: boolean\n}\n\n/**\n * Where the gate starts. Uncontrolled initial value only.\n * - `asking` — the boxes, nothing refused yet.\n * - `attempted` — the draft has been checked once, so a field-level refusal shows.\n * - `decided` — the verdict is in: the confirmation panel or the refusal panel.\n *\n * `decided` cannot disagree with the draft, because the verdict is DERIVED from\n * it: a `decided` gate whose draft has a typo falls back to `attempted`.\n */\nexport type AgeGatePhase = \"asking\" | \"attempted\" | \"decided\"\n\n/* ---------------------------------------------------------------- constants */\n\nconst EMPTY_DRAFT: AgeGateDraft = { day: \"\", month: \"\", year: \"\" }\n\n/** Visual order per field order. Focus, paste and \"which box is empty\" all read it. */\nconst FIELD_SEQUENCE: Record<AgeGateFieldOrder, AgeGateField[]> = {\n  dmy: [\"day\", \"month\", \"year\"],\n  mdy: [\"month\", \"day\", \"year\"],\n  ymd: [\"year\", \"month\", \"day\"],\n}\n\nconst FIELD_LENGTH: Record<AgeGateField, number> = { day: 2, month: 2, year: 4 }\n\n/** Real autofill tokens: a browser that stored a birthday can fill all three. */\nconst FIELD_AUTOCOMPLETE: Record<AgeGateField, string> = {\n  day: \"bday-day\",\n  month: \"bday-month\",\n  year: \"bday-year\",\n}\n\nconst MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]\n\n/* ---------------------------------------------------------------------- copy */\n\nexport interface AgeGateCopy {\n  title: string\n  description: string\n  /** Accessible name of the three-box group; visually hidden. */\n  legend: string\n  dayLabel: string\n  monthLabel: string\n  yearLabel: string\n  dayPlaceholder: string\n  monthPlaceholder: string\n  yearPlaceholder: string\n  submitLabel: string\n  rememberLabel: string\n  footnote: string\n  grantedTitle: string\n  grantedBody: string\n  rememberedNote: string\n  refusalTitle: string\n  refusalBody: string\n  enteredLabel: string\n  exitLabel: string\n  correctionLabel: string\n  refusals: Record<AgeGateRefusalCode, string>\n}\n\n/**\n * Every visible string, with `{token}` slots filled at render time. Nothing here\n * scolds, jokes or implies a second guess would work — a gate that taunts is\n * asking to be lied to.\n */\nexport const DEFAULT_AGE_GATE_COPY: AgeGateCopy = {\n  correctionLabel: \"I typed the wrong date\",\n  dayLabel: \"Day\",\n  dayPlaceholder: \"DD\",\n  description:\n    \"This page is for people aged {minimumAge} and over. Enter the date you were born to continue.\",\n  enteredLabel: \"You entered {birthDate}.\",\n  exitLabel: \"Leave this page\",\n  footnote:\n    \"Your date of birth is checked in this browser. This form does not send it anywhere on its own.\",\n  grantedBody: \"The date you gave clears the {minimumAge}+ rule. Nothing was checked against a document.\",\n  grantedTitle: \"Thanks — you’re through\",\n  legend: \"Date of birth\",\n  monthLabel: \"Month\",\n  monthPlaceholder: \"MM\",\n  refusalBody:\n    \"Thanks for answering honestly. We can’t let you in, and nothing you typed was sent anywhere.\",\n  refusalTitle: \"You need to be {minimumAge} or older to enter\",\n  refusals: {\n    \"day-range\": \"The day has to be between 1 and 31.\",\n    \"impossible-date\": \"That date doesn’t exist — {monthName} has {monthDays} days.\",\n    \"month-range\": \"The month has to be between 1 and 12.\",\n    \"year-digits\": \"Enter the year in full, all four digits.\",\n    future: \"That date hasn’t happened yet.\",\n    implausible: \"Check the year — this form only accepts the last {maximumAge} years.\",\n    incomplete: \"Fill in all three boxes to continue.\",\n    \"no-clock\": \"We can’t check the date right now. Try again in a moment.\",\n    underage: \"You need to be {minimumAge} or older to enter.\",\n  },\n  rememberLabel: \"Remember this answer on this device\",\n  rememberedNote: \"This device will be remembered if the site stores your answer.\",\n  submitLabel: \"Confirm and enter\",\n  title: \"Confirm your date of birth\",\n  yearLabel: \"Year\",\n  yearPlaceholder: \"YYYY\",\n}\n\nexport type AgeGateCopyOverrides = Partial<Omit<AgeGateCopy, \"refusals\">> & {\n  refusals?: Partial<Record<AgeGateRefusalCode, string>>\n}\n\n/** `{token}` substitution. An unknown token is left alone rather than blanked. */\nfunction fill(template: string, values: Record<string, string | number>): string {\n  return template.replace(/\\{(\\w+)\\}/g, (match, key: string) =>\n    key in values ? String(values[key]) : match,\n  )\n}\n\nfunction mergeCopy(overrides?: AgeGateCopyOverrides): AgeGateCopy {\n  if (!overrides) return DEFAULT_AGE_GATE_COPY\n  const { refusals, ...rest } = overrides\n  const merged: AgeGateCopy = {\n    ...DEFAULT_AGE_GATE_COPY,\n    refusals: { ...DEFAULT_AGE_GATE_COPY.refusals, ...refusals },\n  }\n  // Copied key by key, not by spreading `rest`: an explicit `undefined` from a\n  // partial translation object would otherwise erase the default string.\n  for (const key of Object.keys(rest) as (keyof typeof rest)[]) {\n    const value = rest[key]\n    if (typeof value === \"string\") merged[key] = value\n  }\n  return merged\n}\n\n/* --------------------------------------------------------------------- maths */\n\nexport function isLeapYear(year: number): boolean {\n  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0\n}\n\n/**\n * Table lookup, never `new Date(year, month, day)`: that constructor rolls\n * 30 February into 2 March and rewrites years 0-99 as 19xx — the two silent\n * corrections this component exists to refuse.\n */\nexport function daysInMonth(year: number, month: number): number {\n  // The integer guard is load-bearing: NaN fails BOTH comparisons below, so\n  // without it a non-numeric month would index the table with NaN and hand back\n  // undefined typed as a number.\n  if (!Number.isInteger(month) || month < 1 || month > 12) return 0\n  if (month === 2) return isLeapYear(year) ? 29 : 28\n  return MONTH_LENGTHS[month - 1]\n}\n\nfunction compareCivil(left: CivilDate, right: CivilDate): number {\n  return left.year - right.year || left.month - right.month || left.day - right.day\n}\n\n/**\n * Whole years elapsed on the civil calendar. No millisecond arithmetic, so no\n * daylight-saving hour can move a birthday across midnight.\n *\n * A 29 February birthday completes its year on 1 March in a common year: on\n * 28 February the month matches and the day is short, so the year has not been\n * finished yet. Some jurisdictions read it as 28 February — flip the `<` on the\n * day comparison to `<=` and the whole component follows.\n */\nexport function ageOn(birthDate: CivilDate, today: CivilDate): number {\n  const beforeBirthday =\n    today.month < birthDate.month ||\n    (today.month === birthDate.month && today.day < birthDate.day)\n  return today.year - birthDate.year - (beforeBirthday ? 1 : 0)\n}\n\n/**\n * The civil date an injected instant falls on, in one zone. `null` means the\n * instant did not parse — which is a refusal, never a silent pass.\n *\n * Gregorian and latin digits are pinned in the locale tag rather than in the\n * options bag: on a host whose default calendar is Buddhist the year would come\n * back as 2569 and every visitor would be 543 years old.\n */\nexport function civilDateIn(instant: string, timeZone: string): CivilDate | null {\n  const ms = Date.parse(instant)\n  if (!Number.isFinite(ms)) return null\n  for (const zone of [timeZone, \"UTC\"]) {\n    try {\n      const parts = new Intl.DateTimeFormat(\"en-US-u-ca-gregory-nu-latn\", {\n        day: \"numeric\",\n        month: \"numeric\",\n        timeZone: zone,\n        year: \"numeric\",\n      }).formatToParts(ms)\n      const read = (type: \"day\" | \"month\" | \"year\") =>\n        Number(parts.find(part => part.type === type)?.value)\n      const date: CivilDate = { day: read(\"day\"), month: read(\"month\"), year: read(\"year\") }\n      if (Number.isFinite(date.day) && Number.isFinite(date.month) && Number.isFinite(date.year)) {\n        return date\n      }\n    } catch {\n      // An unknown IANA zone throws a RangeError at construction. Fall through\n      // to UTC: a config typo must not take the gate down.\n    }\n  }\n  return null\n}\n\n/** Which box goes where, read out of the locale's own numeric date pattern. */\nexport function resolveFieldOrder(\n  order: AgeGateFieldOrderOption,\n  locale: string,\n): AgeGateFieldOrder {\n  if (order !== \"auto\") return order\n  try {\n    const parts = new Intl.DateTimeFormat(locale, {\n      day: \"numeric\",\n      month: \"numeric\",\n      timeZone: \"UTC\",\n      year: \"numeric\",\n    }).formatToParts(Date.UTC(2001, 1, 3))\n    const sequence = parts\n      .filter(part => part.type === \"day\" || part.type === \"month\" || part.type === \"year\")\n      .map(part => part.type.charAt(0))\n      .join(\"\")\n    if (sequence === \"dmy\" || sequence === \"mdy\" || sequence === \"ymd\") return sequence\n  } catch {\n    // A malformed locale tag throws; day-first is the world's majority order.\n  }\n  return \"dmy\"\n}\n\nexport interface AgeGateCheckOptions {\n  /** Today in the visitor's zone, or `null` when the injected instant did not parse. */\n  today: CivilDate | null\n  minimumAge: number\n  maximumAge: number\n  /** Visual order — decides which empty box is reported first. */\n  order?: AgeGateFieldOrder\n}\n\n/**\n * The whole verdict as one pure function: same draft, same instant, same answer.\n * Exported so a server route can re-run the identical rule instead of growing a\n * second opinion about who is old enough.\n */\nexport function checkAgeGate(draft: AgeGateDraft, options: AgeGateCheckOptions): AgeGateCheck {\n  const { maximumAge, minimumAge, order = \"dmy\", today } = options\n  const refuse = (\n    code: AgeGateRefusalCode,\n    field: AgeGateField | null,\n    age: number | null = null,\n  ): AgeGateCheck => ({ ok: false, refusal: { age, code, field } })\n\n  const values: Record<AgeGateField, string> = {\n    day: draft.day.trim(),\n    month: draft.month.trim(),\n    year: draft.year.trim(),\n  }\n  for (const field of FIELD_SEQUENCE[order]) {\n    if (values[field] === \"\") return refuse(\"incomplete\", field)\n  }\n\n  // \"96\" is never expanded to 1996: guessing a century here is how a gate lets\n  // through the person it was built to stop.\n  if (!/^\\d{4}$/.test(values.year)) return refuse(\"year-digits\", \"year\")\n  if (!/^\\d{1,2}$/.test(values.month)) return refuse(\"month-range\", \"month\")\n  if (!/^\\d{1,2}$/.test(values.day)) return refuse(\"day-range\", \"day\")\n\n  const year = Number(values.year)\n  const month = Number(values.month)\n  const day = Number(values.day)\n  if (month < 1 || month > 12) return refuse(\"month-range\", \"month\")\n  if (day < 1 || day > 31) return refuse(\"day-range\", \"day\")\n  // 30 February and 31 April die here, leap years included.\n  if (day > daysInMonth(year, month)) return refuse(\"impossible-date\", \"day\")\n\n  const birthDate: CivilDate = { day, month, year }\n  // No usable clock means no verdict. Failing open would defeat the component.\n  if (!today) return refuse(\"no-clock\", null)\n  if (compareCivil(birthDate, today) > 0) return refuse(\"future\", \"year\")\n  if (year < today.year - maximumAge) return refuse(\"implausible\", \"year\")\n\n  const age = ageOn(birthDate, today)\n  if (age < minimumAge) return refuse(\"underage\", null, age)\n  return { age, birthDate, ok: true }\n}\n\n/** `Date.UTC` maps years 0-99 into 1900-1999; a four-digit \"0087\" has to stay 87. */\nfunction toUtcMs(date: CivilDate): number {\n  const value = new Date(Date.UTC(date.year, date.month - 1, date.day))\n  if (date.year >= 0 && date.year < 100) value.setUTCFullYear(date.year)\n  return value.getTime()\n}\n\nfunction formatCivil(date: CivilDate, locale: string): string {\n  const options: Intl.DateTimeFormatOptions = {\n    day: \"numeric\",\n    month: \"long\",\n    timeZone: \"UTC\",\n    year: \"numeric\",\n  }\n  const ms = toUtcMs(date)\n  try {\n    return new Intl.DateTimeFormat(locale, options).format(ms)\n  } catch {\n    return new Intl.DateTimeFormat(\"en-US\", options).format(ms)\n  }\n}\n\nfunction monthNamesIn(locale: string): string[] {\n  const build = (tag: string) => {\n    const formatter = new Intl.DateTimeFormat(tag, { month: \"long\", timeZone: \"UTC\" })\n    return Array.from({ length: 12 }, (_, index) => formatter.format(Date.UTC(2001, index, 1)))\n  }\n  try {\n    return build(locale)\n  } catch {\n    return build(\"en-US\")\n  }\n}\n\n/* ----------------------------------------------------------------- component */\n\nexport interface AgeGateProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"defaultValue\" | \"onSubmit\"> {\n  /**\n   * The instant \"now\" is read from, as an ISO string. The gate never calls\n   * Date.now(): a render-time clock read makes the same payload produce two\n   * different verdicts and desyncs SSR from hydration.\n   */\n  now: string\n  /** IANA zone the instant is turned into a calendar day in. Default \"UTC\". */\n  timeZone?: string\n  /** BCP-47 tag for month names, the printed date and the automatic field order. Default \"en-US\". */\n  locale?: string\n  /** Years required to pass. Default 18. */\n  minimumAge?: number\n  /** Oldest accepted age; beyond it the year is treated as a typo. Default 120. */\n  maximumAge?: number\n  /** Box order. Default \"auto\" — taken from `locale`. */\n  fieldOrder?: AgeGateFieldOrderOption\n  /** Pre-filled boxes. Uncontrolled initial value only. */\n  defaultValue?: Partial<AgeGateDraft>\n  /** Starting phase. Uncontrolled initial value only. Default \"asking\". */\n  defaultPhase?: AgeGatePhase\n  /** Initial state of the consent checkbox. Default false. */\n  defaultRemember?: boolean\n  /** Drop the consent checkbox entirely. Default true. */\n  showRemember?: boolean\n  /**\n   * Let a refused visitor return to the boxes. Default true. Turning it off does\n   * not make the gate stronger — a reload clears everything — it only makes an\n   * honest typo unfixable.\n   */\n  allowCorrection?: boolean\n  /** Jump to the next box once one is full. Default true. */\n  autoAdvance?: boolean\n  /** Where the refusal's exit link points when `onExit` is not given. Default \"/\". */\n  exitHref?: string\n  /** Router-driven exit; when given, the exit affordance is a button instead of a link. */\n  onExit?: () => void\n  /** Required: a gate whose success does nothing is theatre with no second act. */\n  onVerified: (result: AgeGateResult) => void\n  /** Every rejected submit, field-level ones included. The `code` is there to filter on. */\n  onRefused?: (refusal: AgeGateRefusal) => void\n  /** Fires on every toggle of the consent checkbox. */\n  onRememberChange?: (remember: boolean) => void\n  /** Per-string overrides; anything omitted keeps the default. */\n  copy?: AgeGateCopyOverrides\n  /** Optional brand mark above the panel. */\n  mark?: React.ReactNode\n}\n\n/**\n * A date-of-birth gate: three explicit boxes, a real calendar behind them, and\n * one minimum-age rule.\n *\n * Four rules shape it:\n *\n * 1. **The clock is an input.** `now` is a prop, so the same draft always gets\n *    the same verdict — on the server too.\n * 2. **The verdict is derived, never stored.** `checkAgeGate(draft, …)` is a\n *    pure function; the component only remembers whether the visitor has pressed\n *    the button. That is why no combination of props can show a confirmation\n *    panel above a date that fails.\n * 3. **Nothing is guessed.** 30 February, a two-digit year and an unparseable\n *    `now` are all refusals. A gate that silently corrects input is a gate that\n *    lets the wrong person through.\n * 4. **No dark patterns.** The button is never disabled, the refusal never\n *    taunts and always offers a way out, and this component stores nothing.\n */\nexport const AgeGate = React.forwardRef<HTMLElement, AgeGateProps>(\n  (\n    {\n      allowCorrection = true,\n      autoAdvance = true,\n      className,\n      copy: copyOverrides,\n      defaultPhase = \"asking\",\n      defaultRemember = false,\n      defaultValue,\n      exitHref = \"/\",\n      fieldOrder = \"auto\",\n      locale = \"en-US\",\n      mark,\n      maximumAge = 120,\n      minimumAge = 18,\n      now,\n      onExit,\n      onRefused,\n      onRememberChange,\n      onVerified,\n      showRemember = true,\n      timeZone = \"UTC\",\n      ...rest\n    },\n    ref,\n  ) => {\n    const baseId = React.useId()\n    const headingId = `${baseId}-heading`\n    const messageId = `${baseId}-message`\n    const rememberId = `${baseId}-remember`\n\n    const copy = React.useMemo(() => mergeCopy(copyOverrides), [copyOverrides])\n    const [draft, setDraft] = React.useState<AgeGateDraft>(() => ({ ...EMPTY_DRAFT, ...defaultValue }))\n    const [phase, setPhase] = React.useState<AgeGatePhase>(defaultPhase)\n    const [remember, setRemember] = React.useState(defaultRemember)\n\n    const today = React.useMemo(() => civilDateIn(now, timeZone), [now, timeZone])\n    const order = React.useMemo(() => resolveFieldOrder(fieldOrder, locale), [fieldOrder, locale])\n    const monthNames = React.useMemo(() => monthNamesIn(locale), [locale])\n    const check = React.useMemo(\n      () => checkAgeGate(draft, { maximumAge, minimumAge, order, today }),\n      [draft, maximumAge, minimumAge, order, today],\n    )\n\n    const fieldRefs = React.useRef<Record<AgeGateField, HTMLInputElement | null>>({\n      day: null,\n      month: null,\n      year: null,\n    })\n    // One decision per press. The guard is a ref read AND written synchronously\n    // inside the handler, because a state flag is only visible after a re-render\n    // and a held Enter key repeats faster than that.\n    const decisionLockRef = React.useRef(false)\n    // Focus never moves on mount — only when this component moved something out\n    // from under the visitor.\n    const pendingFocusRef = React.useRef<\"panel\" | \"first-field\" | null>(null)\n    const panelHeadingRef = React.useRef<HTMLHeadingElement>(null)\n\n    const refusal = check.ok ? null : check.refusal\n    const view: \"form\" | \"granted\" | \"refused\" =\n      phase !== \"decided\"\n        ? \"form\"\n        : check.ok\n          ? \"granted\"\n          : refusal?.code === \"underage\"\n            ? \"refused\"\n            : \"form\"\n    const showRefusal = view === \"form\" && phase !== \"asking\" && refusal !== null\n\n    const tokens = {\n      birthDate: check.ok ? formatCivil(check.birthDate, locale) : \"\",\n      maximumAge,\n      minimumAge,\n      monthDays: daysInMonth(Number(draft.year), Number(draft.month)),\n      monthName: monthNames[Number(draft.month) - 1] ?? \"\",\n    }\n    const refusalMessage = refusal ? fill(copy.refusals[refusal.code], tokens) : \"\"\n\n    const focusField = (\n      field: AgeGateField | null,\n      caret: \"select\" | \"start\" | \"end\" = \"select\",\n    ): boolean => {\n      const input = field ? fieldRefs.current[field] : null\n      if (!input) return false\n      input.focus()\n      // Only ever a text input: setSelectionRange throws on type=\"number\".\n      if (caret === \"select\") input.select()\n      else if (caret === \"end\") input.setSelectionRange(input.value.length, input.value.length)\n      else input.setSelectionRange(0, 0)\n      return true\n    }\n\n    const focusNeighbour = (\n      field: AgeGateField,\n      step: 1 | -1,\n      caret: \"select\" | \"start\" | \"end\",\n    ): boolean => {\n      const sequence = FIELD_SEQUENCE[order]\n      const index = sequence.indexOf(field) + step\n      if (index < 0 || index >= sequence.length) return false\n      return focusField(sequence[index], caret)\n    }\n\n    // Runs after every render but acts only on a queued request, so a gate that\n    // mounts already decided (a host restoring its own record) never steals\n    // focus from whatever the visitor was doing.\n    React.useEffect(() => {\n      const pending = pendingFocusRef.current\n      if (!pending) return\n      pendingFocusRef.current = null\n      if (pending === \"panel\") panelHeadingRef.current?.focus()\n      else focusField(FIELD_SEQUENCE[order][0])\n    })\n\n    const commitField = (field: AgeGateField, raw: string): string => {\n      const next = raw.replace(/\\D+/g, \"\").slice(0, FIELD_LENGTH[field])\n      setDraft(previous => (previous[field] === next ? previous : { ...previous, [field]: next }))\n      return next\n    }\n\n    const handleChange =\n      (field: AgeGateField) => (event: React.ChangeEvent<HTMLInputElement>) => {\n        const before = draft[field]\n        const next = commitField(field, event.target.value)\n        // Advance only when the box GREW into being full: deleting the last\n        // digit, or retyping inside a full box, must not throw focus forward.\n        if (autoAdvance && next.length === FIELD_LENGTH[field] && next.length > before.length) {\n          focusNeighbour(field, 1, \"select\")\n        }\n      }\n\n    const handleKeyDown =\n      (field: AgeGateField) => (event: React.KeyboardEvent<HTMLInputElement>) => {\n        const input = event.currentTarget\n        const collapsed = input.selectionStart === input.selectionEnd\n        const caret = input.selectionStart ?? 0\n        if (event.key === \"Backspace\" && input.value === \"\") {\n          if (focusNeighbour(field, -1, \"end\")) event.preventDefault()\n          return\n        }\n        if (event.key === \"ArrowLeft\" && collapsed && caret === 0) {\n          if (focusNeighbour(field, -1, \"end\")) event.preventDefault()\n          return\n        }\n        if (event.key === \"ArrowRight\" && collapsed && caret === input.value.length) {\n          if (focusNeighbour(field, 1, \"start\")) event.preventDefault()\n        }\n      }\n\n    // A browser or password manager hands the whole date over as one string.\n    // Three numeric groups fill all three boxes; anything else is left to the\n    // browser so a normal single-box paste still works.\n    const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {\n      const groups = event.clipboardData\n        .getData(\"text\")\n        .split(/\\D+/)\n        .filter(group => group !== \"\")\n      if (groups.length !== 3) return\n      event.preventDefault()\n      const sequence = FIELD_SEQUENCE[order]\n      // A four-digit group is the year wherever it sits, so \"1996-05-04\" pasted\n      // into a day-first gate is still 1996. A LEADING year settles the other\n      // two as well: a year-first string is ISO, therefore month then day —\n      // reading them in the gate's own order would silently swap 4 May for\n      // 5 April. With the year trailing or absent the source order is\n      // unknowable, so the gate's order is used and the boxes show the result.\n      const yearIndex = groups.findIndex(group => group.length === 4)\n      const others: AgeGateField[] =\n        yearIndex === 0 ? [\"month\", \"day\"] : sequence.filter(field => field !== \"year\")\n      let cursor = 0\n      const slots: AgeGateField[] =\n        yearIndex === -1\n          ? sequence\n          : groups.map((_, index): AgeGateField =>\n              index === yearIndex ? \"year\" : others[cursor++],\n            )\n      const next = { ...draft }\n      slots.forEach((field, index) => {\n        next[field] = groups[index].slice(0, FIELD_LENGTH[field])\n      })\n      setDraft(next)\n    }\n\n    const handleRemember = (event: React.ChangeEvent<HTMLInputElement>) => {\n      setRemember(event.target.checked)\n      onRememberChange?.(event.target.checked)\n    }\n\n    const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n      event.preventDefault()\n      if (decisionLockRef.current) return\n      if (check.ok) {\n        decisionLockRef.current = true\n        pendingFocusRef.current = \"panel\"\n        setPhase(\"decided\")\n        onVerified({ age: check.age, birthDate: check.birthDate, remember })\n        return\n      }\n      if (check.refusal.code === \"underage\") {\n        decisionLockRef.current = true\n        pendingFocusRef.current = \"panel\"\n        setPhase(\"decided\")\n        onRefused?.(check.refusal)\n        return\n      }\n      // Recoverable: the boxes stay, the lock stays open, and focus lands on the\n      // box the message is about — which is also what announces the message.\n      setPhase(\"attempted\")\n      focusField(check.refusal.field ?? FIELD_SEQUENCE[order][0])\n      onRefused?.(check.refusal)\n    }\n\n    const handleCorrection = () => {\n      decisionLockRef.current = false\n      pendingFocusRef.current = \"first-field\"\n      // Back to \"asking\", with the typed date intact: the visitor is fixing a\n      // typo, not being asked to guess again.\n      setPhase(\"asking\")\n    }\n\n    const fieldLabel: Record<AgeGateField, string> = {\n      day: copy.dayLabel,\n      month: copy.monthLabel,\n      year: copy.yearLabel,\n    }\n    const fieldPlaceholder: Record<AgeGateField, string> = {\n      day: copy.dayPlaceholder,\n      month: copy.monthPlaceholder,\n      year: copy.yearPlaceholder,\n    }\n\n    const refusedDate: CivilDate | null =\n      refusal?.code === \"underage\"\n        ? { day: Number(draft.day), month: Number(draft.month), year: Number(draft.year) }\n        : null\n\n    const headingClass =\n      \"flex items-center gap-2 rounded-md text-lg font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50\"\n\n    return (\n      <section\n        aria-labelledby={headingId}\n        className={cn(\"flex w-full justify-center text-foreground\", className)}\n        ref={ref}\n        {...rest}\n      >\n        <div className=\"flex w-full max-w-md flex-col gap-5 rounded-xl border bg-card p-6 text-card-foreground\">\n          {mark && <div className=\"flex items-center\">{mark}</div>}\n\n          {view === \"form\" && (\n            <form className=\"flex flex-col gap-5\" noValidate onSubmit={handleSubmit}>\n              <div className=\"flex flex-col gap-2\">\n                <h2 className={headingClass} id={headingId}>\n                  <CalendarDays aria-hidden=\"true\" className=\"size-5 shrink-0 text-muted-foreground\" />\n                  <span className=\"min-w-0 wrap-anywhere\">{copy.title}</span>\n                </h2>\n                <p className=\"text-sm text-muted-foreground wrap-anywhere\">\n                  {fill(copy.description, tokens)}\n                </p>\n              </div>\n\n              {/* One answer, so one group: min-w-0 defeats the fieldset's\n                  default min-inline-size, which would otherwise refuse to\n                  shrink below its content on a narrow screen. */}\n              <fieldset className=\"flex min-w-0 flex-col gap-2\">\n                <legend className=\"sr-only\">{copy.legend}</legend>\n                <div className=\"flex items-end gap-2\">\n                  {FIELD_SEQUENCE[order].map(field => (\n                    <div\n                      className={cn(\n                        \"flex min-w-0 flex-1 flex-col gap-1.5\",\n                        field === \"year\" && \"flex-[1.6]\",\n                      )}\n                      key={field}\n                    >\n                      <Label\n                        className=\"text-xs font-normal text-muted-foreground\"\n                        htmlFor={`${baseId}-${field}`}\n                      >\n                        {fieldLabel[field]}\n                      </Label>\n                      <Input\n                        aria-describedby={showRefusal ? messageId : undefined}\n                        aria-invalid={(showRefusal && refusal?.field === field) || undefined}\n                        autoComplete={FIELD_AUTOCOMPLETE[field]}\n                        className=\"h-9 text-center tabular-nums\"\n                        id={`${baseId}-${field}`}\n                        inputMode=\"numeric\"\n                        maxLength={FIELD_LENGTH[field]}\n                        onChange={handleChange(field)}\n                        onKeyDown={handleKeyDown(field)}\n                        onPaste={handlePaste}\n                        placeholder={fieldPlaceholder[field]}\n                        ref={node => {\n                          fieldRefs.current[field] = node\n                        }}\n                        type=\"text\"\n                        value={draft[field]}\n                      />\n                    </div>\n                  ))}\n                </div>\n                {showRefusal && (\n                  <p\n                    className=\"flex items-start gap-1.5 text-sm text-destructive wrap-anywhere\"\n                    id={messageId}\n                  >\n                    <TriangleAlert aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n                    {refusalMessage}\n                  </p>\n                )}\n              </fieldset>\n\n              {showRemember && (\n                <div className=\"flex items-start gap-2.5\">\n                  <input\n                    checked={remember}\n                    className={cn(\n                      \"mt-0.5 size-4 shrink-0 cursor-pointer rounded-sm accent-primary\",\n                      \"outline-none focus-visible:ring-3 focus-visible:ring-ring/50\",\n                    )}\n                    id={rememberId}\n                    onChange={handleRemember}\n                    type=\"checkbox\"\n                  />\n                  <Label\n                    className=\"cursor-pointer text-sm leading-snug font-normal text-muted-foreground\"\n                    htmlFor={rememberId}\n                  >\n                    {copy.rememberLabel}\n                  </Label>\n                </div>\n              )}\n\n              {/* Never disabled: an inert button with no explanation is the\n                  oldest dark pattern in the form. Pressing it says what is\n                  missing instead. */}\n              <Button className=\"w-full\" size=\"lg\" type=\"submit\">\n                {copy.submitLabel}\n              </Button>\n\n              <p className=\"text-xs text-muted-foreground wrap-anywhere\">{copy.footnote}</p>\n            </form>\n          )}\n\n          {view === \"granted\" && (\n            <div className=\"flex flex-col gap-3 motion-safe:animate-in motion-safe:fade-in-0\">\n              <h2 className={headingClass} id={headingId} ref={panelHeadingRef} tabIndex={-1}>\n                <ShieldCheck aria-hidden=\"true\" className=\"size-5 shrink-0 text-primary\" />\n                <span className=\"min-w-0 wrap-anywhere\">{copy.grantedTitle}</span>\n              </h2>\n              <p className=\"text-sm text-muted-foreground wrap-anywhere\">\n                {fill(copy.grantedBody, tokens)}\n              </p>\n              {check.ok && (\n                <p className=\"text-xs text-muted-foreground wrap-anywhere\">\n                  {fill(copy.enteredLabel, tokens)}\n                </p>\n              )}\n              {remember && (\n                <p className=\"flex items-start gap-1.5 text-xs text-muted-foreground wrap-anywhere\">\n                  <Check aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />\n                  {copy.rememberedNote}\n                </p>\n              )}\n            </div>\n          )}\n\n          {view === \"refused\" && (\n            <div className=\"flex flex-col gap-4 motion-safe:animate-in motion-safe:fade-in-0\">\n              <h2 className={headingClass} id={headingId} ref={panelHeadingRef} tabIndex={-1}>\n                <ShieldAlert aria-hidden=\"true\" className=\"size-5 shrink-0 text-destructive\" />\n                <span className=\"min-w-0 wrap-anywhere\">{fill(copy.refusalTitle, tokens)}</span>\n              </h2>\n              <p className=\"text-sm text-muted-foreground wrap-anywhere\">{copy.refusalBody}</p>\n              {refusedDate && (\n                <p className=\"text-xs text-muted-foreground wrap-anywhere\">\n                  {fill(copy.enteredLabel, { birthDate: formatCivil(refusedDate, locale) })}\n                </p>\n              )}\n              <div className=\"flex flex-col gap-2 sm:flex-row\">\n                {/* The way out is the primary action. A refusal with no exit is\n                    a trap, not a gate. */}\n                {onExit ? (\n                  <Button className=\"w-full sm:w-auto\" onClick={onExit} type=\"button\">\n                    <LogOut aria-hidden=\"true\" />\n                    {copy.exitLabel}\n                  </Button>\n                ) : (\n                  <Button asChild className=\"w-full sm:w-auto\">\n                    <a href={exitHref}>\n                      <LogOut aria-hidden=\"true\" />\n                      {copy.exitLabel}\n                    </a>\n                  </Button>\n                )}\n                {allowCorrection && (\n                  <Button\n                    className=\"w-full sm:w-auto\"\n                    onClick={handleCorrection}\n                    type=\"button\"\n                    variant=\"ghost\"\n                  >\n                    {copy.correctionLabel}\n                  </Button>\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n      </section>\n    )\n  },\n)\n\nAgeGate.displayName = \"AgeGate\"\n\nexport default AgeGate\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}