{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "auth-05-panel",
  "title": "Passkey 与多因素验证（面板）",
  "description": "auth-05 的验证内容部分（Auth05Panel）：没有页面外壳和示例页，可嵌进已有的登录布局。",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "avatar",
    "button",
    "input",
    "input-otp",
    "https://yiui.dev/r/toggle-switch.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/auth-05/auth-05-panel.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Auth05AccountContent,\n  Auth05Alternatives,\n  Auth05Announcer,\n  Auth05HelpLink,\n  Auth05Intro,\n  Auth05MethodBody,\n  Auth05RememberDevice,\n} from \"./parts\"\nimport type { Auth05Props } from \"./types\"\nimport { useAuth05 } from \"./use-auth-05\"\n\n/**\n * 只有验证内容的版本：没有页面外壳，宽度跟随父容器，适合嵌进已有的登录布局（例如分屏登录页的右侧）。\n * 「其他验证方式」一律排在当前方式下方，单列；嵌入的容器通常很窄，不做两栏。\n */\nfunction Auth05Panel(props: Auth05Props) {\n  const auth = useAuth05(props)\n  const { className, ...rootProps } = auth.rootProps\n  const hasFooter = auth.showRememberDevice || Boolean(auth.helpHref)\n\n  return (\n    <div\n      data-slot=\"auth-05-content\"\n      data-method={auth.method}\n      data-state={auth.done ? \"success\" : \"idle\"}\n      className={cn(\"flex w-full min-w-0 flex-col gap-6\", className)}\n      {...rootProps}\n    >\n      <div>\n        <Auth05Intro auth={auth} defaultLevel={2} />\n      </div>\n      {/* 账号摘要放在标题之后：先说明要做什么，再说明是哪个账号 */}\n      <div\n        data-slot=\"auth-05-account\"\n        className=\"flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-edge px-3 py-2\"\n      >\n        <Auth05AccountContent auth={auth} />\n      </div>\n      <div>\n        <Auth05MethodBody auth={auth} />\n      </div>\n      {!auth.done && auth.others.length > 0 && (\n        <Auth05Alternatives\n          auth={auth}\n          className=\"border-t border-dashed border-edge pt-6\"\n        />\n      )}\n      {!auth.done && hasFooter && (\n        <div className=\"flex flex-col gap-3\">\n          <Auth05RememberDevice auth={auth} />\n          <div>\n            <Auth05HelpLink auth={auth} />\n          </div>\n        </div>\n      )}\n      <Auth05Announcer auth={auth} />\n    </div>\n  )\n}\n\nexport { Auth05Panel }\n",
      "type": "registry:component",
      "target": "@components/auth-05/auth-05-panel.tsx"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:hook",
      "target": "hooks/use-controllable-state.ts"
    },
    {
      "path": "src/registry/blocks/auth-05/types.ts",
      "content": "import type * as React from \"react\"\nimport type { Auth05Labels } from \"./config\"\n\ntype Auth05Method = \"passkey\" | \"totp\" | \"sms\" | \"recovery\"\n\ntype Auth05AccountFields = {\n  /** 邮箱，没有 `name`、`username` 时脱敏后显示。 */\n  email?: string\n  /** 短信验证用的手机号（11 位数字），脱敏后显示在短信面板；没有其他标识时也用它作账号摘要。 */\n  phone?: string\n  /** 显示名，原样显示，不脱敏；优先于邮箱和手机号。 */\n  name?: string\n  /** 用户名，原样显示，不脱敏；和 `name` 同时存在时作为小字补充。 */\n  username?: string\n  /** 头像图片；没有时显示实际显示的那个名字的首字。 */\n  avatar?: string\n}\n\n/**\n * 已通过第一步认证的账号。`email`、`phone`、`name`、`username` 至少要有一个：\n * 用联合类型表达，漏传时在编译期就能发现；运行时仍会兜底（摘要为空、头像显示 ?），不会崩溃。\n */\ntype Auth05Account = Auth05AccountFields &\n  (\n    | { email: string }\n    | { phone: string }\n    | { name: string }\n    | { username: string }\n  )\n\ntype Auth05RecoveryCode = {\n  /**\n   * 掩码，`X` 代表一个字母或数字，其他字符原样插入，如 `\"XXXX-XXXX\"`、`\"XXXXX-XXXXX\"`。\n   * 传 `false` 不做格式化：输入框里是什么就是什么，提交时只去掉首尾空白。\n   * @default \"XXXX-XXXX\"\n   */\n  format?: string | false\n  /**\n   * 输入时就把输入框里的值转成大写（不是只靠 CSS 显示），提交的值也是大写。\n   * 有掩码时默认 `true`，`format` 为 `false` 时默认 `false`（原样输入）。\n   */\n  uppercase?: boolean\n  /**\n   * 最大字符数，仅在 `format` 为 `false` 时生效；有掩码时长度由掩码决定。\n   * 按输入框里的原始字符计数，连字符和空格都算；长度判断用去掉首尾空白之后的值。\n   * 超过时不截断，输入框下方显示错误并禁止提交，删回范围内错误就消失。\n   */\n  maxLength?: number\n  /** 输入框占位文字。@default 根据 format 生成 */\n  placeholder?: string\n}\n\ntype Auth05Props = Omit<React.ComponentProps<\"div\">, \"onSuccess\"> & {\n  /** 已通过第一步认证的账号。 */\n  account: Auth05Account\n  /** 可用的验证方式，顺序即「其他验证方式」列表的顺序。@default [\"passkey\",\"totp\",\"sms\",\"recovery\"] */\n  methods?: readonly Auth05Method[]\n  /**\n   * 通行密钥验证。在这里调用 WebAuthn；失败时抛出 Error。\n   * `name` 为 `NotAllowedError` 的错误会被当作「已取消」，其他错误显示 `message`。\n   */\n  onPasskey?: () => Promise<void>\n  /** 发送短信验证码，失败时抛出 Error。 */\n  onSendCode?: () => Promise<void>\n  /** 验证验证码或恢复码，失败时抛出 Error。 */\n  onVerify?: (values: {\n    method: Exclude<Auth05Method, \"passkey\">\n    code: string\n  }) => Promise<void>\n  /** 验证成功后的通知。 */\n  onSuccess?: (values: {\n    method: Auth05Method\n    rememberDevice: boolean\n  }) => void\n  /** 点击「切换账号」。 */\n  onSwitchAccount?: () => void\n  /** 「联系管理员」链接地址；不传时不显示这个入口。 */\n  helpHref?: string\n  /** 覆盖浏览器能力检测，方便演示和测试；不传时在挂载后检测 `PublicKeyCredential`。 */\n  passkeySupported?: boolean\n  /** 验证码位数。@default 6 */\n  codeLength?: number\n  /** 短信重发间隔秒数。@default 60 */\n  resendSeconds?: number\n  /** 备用码的格式；不传时为 `XXXX-XXXX`。 */\n  recoveryCode?: Auth05RecoveryCode\n  /**\n   * 是否显示「记住此设备」开关。后端不支持时设为 `false`：开关不渲染，`onSuccess` 里的 `rememberDevice` 固定为 `false`。\n   * @default true\n   */\n  showRememberDevice?: boolean\n  /**\n   * 主标题「验证你的身份」用的标题级别。嵌进已有页面时，宿主页通常已有 h1，用 2 或 3 保持层级连续。\n   * @default Auth05 为 1，Auth05Panel 为 2\n   */\n  headingLevel?: 1 | 2 | 3\n  /** 品牌名称和标志。 */\n  brand?: { name: string; logo?: React.ReactNode }\n  /** 覆盖默认中文文案。 */\n  labels?: Partial<Auth05Labels>\n  /** 受控的当前验证方式。 */\n  method?: Auth05Method\n  /** 初始验证方式。@default 第一个可用方式 */\n  defaultMethod?: Auth05Method\n  /** 验证方式变化通知。 */\n  onMethodChange?: (method: Auth05Method) => void\n  /** 受控的「记住此设备」。 */\n  rememberDevice?: boolean\n  /** 初始的「记住此设备」。@default false */\n  defaultRememberDevice?: boolean\n  onRememberDeviceChange?: (value: boolean) => void\n}\n\nexport type { Auth05Account, Auth05Method, Auth05Props, Auth05RecoveryCode }\n",
      "type": "registry:component",
      "target": "@components/auth-05/types.ts"
    },
    {
      "path": "src/registry/blocks/auth-05/config.ts",
      "content": "const DEFAULT_LABELS = {\n  title: \"验证你的身份\",\n  description: \"为了保护账号安全，请再完成一步验证\",\n  accountSwitch: \"不是你？切换账号\",\n  accountLabel: \"当前账号\",\n  alternatives: \"其他验证方式\",\n  rememberDevice: \"记住此设备 30 天\",\n  help: \"都无法使用？联系管理员\",\n\n  passkeyName: \"通行密钥\",\n  passkeyHint: \"使用指纹、面容或设备密码\",\n  passkeyTitle: \"使用通行密钥验证\",\n  passkeyDescription: \"通过指纹、面容或设备锁屏密码确认是你本人。\",\n  passkeyAction: \"使用通行密钥验证\",\n  passkeyWaiting: \"请在浏览器或系统弹窗中完成验证\",\n  passkeyWaitingButton: \"等待验证…\",\n  passkeyCancelled: \"已取消，可以重试\",\n  passkeyUnsupported: \"当前浏览器不支持通行密钥\",\n  passkeySuggest: \"改用{method}\",\n\n  totpName: \"身份验证器 App\",\n  totpHint: \"输入 App 里显示的验证码\",\n  totpTitle: \"输入身份验证器验证码\",\n  totpDescription: \"打开身份验证器 App，输入当前显示的 {length} 位验证码。\",\n\n  smsName: \"短信验证码\",\n  smsHint: \"发送到绑定的手机\",\n  smsTitle: \"输入短信验证码\",\n  smsDescription: \"我们会向 {phone} 发送一条验证码短信。\",\n  smsSend: \"发送验证码\",\n  smsSending: \"发送中…\",\n  smsResend: \"重新发送\",\n  smsCountdown: \"{seconds} 秒后可重新发送\",\n  smsSent: \"验证码已发送至 {phone}\",\n\n  recoveryName: \"恢复码\",\n  recoveryHint: \"使用保存好的一次性恢复码\",\n  recoveryTitle: \"输入恢复码\",\n  recoveryDescription: \"输入创建账号时保存的恢复码。\",\n  recoveryLabel: \"恢复码\",\n  recoveryPlaceholder: \"{format}\",\n  recoveryNote: \"每个恢复码只能用一次\",\n  recoveryError: \"请输入完整的恢复码，格式为 {format}\",\n  recoveryEmpty: \"请输入恢复码\",\n  recoveryTooLong:\n    \"备用码最多 {max} 个字符，当前 {length} 个，请检查是否多粘贴了内容\",\n  recoveryTrimmed: \"已按格式保留前 {count} 位\",\n\n  code: \"验证码，共 {length} 位\",\n  verify: \"验证\",\n  verifying: \"验证中…\",\n  failed: \"请求失败，请稍后重试\",\n  success: \"验证成功\",\n  successDescription: \"身份验证已完成。\",\n  successAnnounce: \"验证成功，身份验证已完成\",\n}\n\ntype Auth05Labels = typeof DEFAULT_LABELS\n\nfunction formatLabel(\n  template: string,\n  values: Record<string, string | number>,\n) {\n  return template.replace(/\\{(\\w+)\\}/g, (_, key: string) =>\n    String(values[key] ?? \"\"),\n  )\n}\n\nexport { DEFAULT_LABELS, formatLabel }\nexport type { Auth05Labels }\n",
      "type": "registry:component",
      "target": "@components/auth-05/config.ts"
    },
    {
      "path": "src/registry/blocks/auth-05/mask.ts",
      "content": "import type { Auth05Account, Auth05RecoveryCode } from \"./types\"\n\n/** 邮箱脱敏：保留前 3 位，如 lin***@example.com；不足 3 位时只保留首位。 */\nfunction maskEmail(email: string) {\n  const at = email.lastIndexOf(\"@\")\n  if (at < 1) return email\n  const name = email.slice(0, at)\n  const keep = name.length > 3 ? 3 : 1\n  return `${name.slice(0, keep)}***${email.slice(at)}`\n}\n\n/** 手机号脱敏：138 **** 8000。 */\nfunction maskPhone(phone: string) {\n  const digits = phone.replace(/\\D/g, \"\")\n  if (digits.length !== 11) return phone\n  return `${digits.slice(0, 3)} **** ${digits.slice(7)}`\n}\n\n/**\n * 账号摘要的显示规则：\n * 有 name 或 username 就原样显示（不脱敏）；否则脱敏邮箱，再否则脱敏手机号。\n * 头像首字取实际显示的那个名字，用 Array.from 按字符取，避免把中文或表情拆坏。\n */\nfunction describeAccount(account: Partial<Auth05Account>) {\n  const name = account.name?.trim()\n  const username = account.username?.trim()\n  const email = account.email?.trim()\n  const phone = account.phone?.trim()\n  let primary = \"\"\n  let secondary: string | undefined\n  let masked = false\n  if (name || username) {\n    primary = name || username || \"\"\n    if (name && username)\n      secondary = username.startsWith(\"@\") ? username : `@${username}`\n  } else if (email) {\n    primary = maskEmail(email)\n    masked = true\n  } else if (phone) {\n    primary = maskPhone(phone)\n    masked = true\n  }\n  const source = name || username || email || phone || \"\"\n  const initial = (Array.from(source.replace(/^@/, \"\"))[0] ?? \"?\").toUpperCase()\n  return { primary, secondary, masked, initial }\n}\n\nconst DEFAULT_RECOVERY_FORMAT = \"XXXX-XXXX\"\n\ntype RecoveryRule = {\n  /** 掩码；`false` 表示不格式化。 */\n  format: string | false\n  uppercase: boolean\n  maxLength?: number\n}\n\n/** 整理 recoveryCode 配置：没有 `X` 的掩码没有意义，退回默认。 */\nfunction resolveRecoveryRule(options?: Auth05RecoveryCode): RecoveryRule {\n  const format =\n    options?.format === false\n      ? false\n      : typeof options?.format === \"string\" && options.format.includes(\"X\")\n        ? options.format\n        : DEFAULT_RECOVERY_FORMAT\n  const maxLength =\n    typeof options?.maxLength === \"number\" && options.maxLength > 0\n      ? Math.floor(options.maxLength)\n      : undefined\n  return {\n    format,\n    // 不格式化时默认原样输入，显式设为 true 才转大写\n    uppercase: options?.uppercase ?? format !== false,\n    maxLength,\n  }\n}\n\n/**\n * 恢复码整理。有掩码：去掉非字母数字，按掩码逐位填入（`X` 取一个字符，其他字符原样插入，\n * 输入没写到那一位时不提前插入分隔符，所以退格不会卡在分隔符上），多出的字符不进入。\n * 无掩码：只按 uppercase 转大写，其余原样返回；超过 maxLength 也不截断，由 recoveryOverflow 报告。\n */\nfunction formatRecoveryCode(raw: string, rule: RecoveryRule) {\n  if (rule.format === false) return rule.uppercase ? raw.toUpperCase() : raw\n  let clean = raw.replace(/[^a-zA-Z0-9]/g, \"\")\n  if (rule.uppercase) clean = clean.toUpperCase()\n  let output = \"\"\n  let index = 0\n  for (const char of rule.format) {\n    if (index >= clean.length) break\n    if (char === \"X\") output += clean[index++]\n    else output += char\n  }\n  return output\n}\n\n/** 掩码里能填的字符数（`X` 的个数）。 */\nfunction recoveryCapacity(format: string) {\n  return Array.from(format).filter((char) => char === \"X\").length\n}\n\n/** 有掩码时，输入里被丢掉的字符数（字母数字比掩码容量多出来的部分）；无掩码或没有多出时为 0。 */\nfunction recoveryDropped(raw: string, rule: RecoveryRule) {\n  if (rule.format === false) return 0\n  const length = raw.replace(/[^a-zA-Z0-9]/g, \"\").length\n  return Math.max(0, length - recoveryCapacity(rule.format))\n}\n\n/**\n * 无掩码且设了 maxLength 时，输入是否超长：长度按去掉首尾空白后的原始字符数计（连字符、空格都算）。\n * 超长返回 `{ max, length }`，否则返回 null。\n */\nfunction recoveryOverflow(code: string, rule: RecoveryRule) {\n  if (rule.format !== false || !rule.maxLength) return null\n  const length = code.trim().length\n  return length > rule.maxLength ? { max: rule.maxLength, length } : null\n}\n\n/** 恢复码是否可以提交：有掩码要填满；无掩码要求非空，且设了 maxLength 时不能超长。 */\nfunction isRecoveryComplete(code: string, rule: RecoveryRule) {\n  return rule.format === false\n    ? code.trim().length > 0 && !recoveryOverflow(code, rule)\n    : code.length === rule.format.length\n}\n\nexport {\n  describeAccount,\n  formatRecoveryCode,\n  isRecoveryComplete,\n  maskEmail,\n  maskPhone,\n  recoveryCapacity,\n  recoveryDropped,\n  recoveryOverflow,\n  resolveRecoveryRule,\n}\nexport type { RecoveryRule }\n",
      "type": "registry:component",
      "target": "@components/auth-05/mask.ts"
    },
    {
      "path": "src/registry/blocks/auth-05/use-auth-form.ts",
      "content": "\"use client\"\nimport * as React from \"react\"\n/** 用同步锁拦截自动提交与回车的重复请求。 */\nfunction useAsyncAction(fallback: string) {\n  const lock = React.useRef(false)\n  const [pending, setPending] = React.useState(false)\n  const [error, setError] = React.useState(\"\")\n  async function run(action: () => Promise<void>) {\n    if (lock.current) return false\n    lock.current = true\n    setPending(true)\n    setError(\"\")\n    try {\n      await action()\n      return true\n    } catch (caught) {\n      setError(caught instanceof Error ? caught.message : fallback)\n      return false\n    } finally {\n      lock.current = false\n      setPending(false)\n    }\n  }\n  return { pending, error, setError, run }\n}\nexport { useAsyncAction }\n",
      "type": "registry:component",
      "target": "@components/auth-05/use-auth-form.ts"
    },
    {
      "path": "src/registry/blocks/auth-05/form-parts.tsx",
      "content": "\"use client\"\n\nimport {\n  InputOTP,\n  InputOTPGroup,\n  InputOTPSlot,\n} from \"@/components/ui/input-otp\"\n\n/** 6 位数字验证码输入；写法与 auth-03 保持一致：整串粘贴、满位自动提交、错误态同步到每个格子。 */\nfunction Auth05CodeInput({\n  length,\n  value,\n  error,\n  describedBy,\n  label,\n  disabled,\n  onChange,\n  onComplete,\n}: {\n  length: number\n  value: string\n  error: boolean\n  describedBy?: string\n  label: string\n  disabled: boolean\n  onChange: (value: string) => void\n  onComplete: (value: string) => void\n}) {\n  return (\n    <div data-slot=\"auth-05-code-input\">\n      <InputOTP\n        maxLength={length}\n        value={value}\n        onChange={onChange}\n        onComplete={onComplete}\n        pattern=\"^[0-9]*$\"\n        autoComplete=\"one-time-code\"\n        inputMode=\"numeric\"\n        aria-label={label}\n        aria-invalid={error || undefined}\n        aria-describedby={describedBy}\n        disabled={disabled}\n        containerClassName=\"w-full\"\n      >\n        <InputOTPGroup className=\"w-full\">\n          {Array.from({ length }, (_, index) => (\n            <InputOTPSlot\n              key={index}\n              index={index}\n              aria-invalid={error || undefined}\n              className=\"h-9 min-w-0 flex-1 border-edge text-base data-[active=true]:border-foreground/25 data-[active=true]:bg-muted/40 data-[active=true]:ring-0 pointer-coarse:h-11\"\n            />\n          ))}\n        </InputOTPGroup>\n      </InputOTP>\n    </div>\n  )\n}\n\nexport { Auth05CodeInput }\n",
      "type": "registry:component",
      "target": "@components/auth-05/form-parts.tsx"
    },
    {
      "path": "src/registry/blocks/auth-05/panels.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2 } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { cn } from \"@/lib/utils\"\nimport { formatLabel, type Auth05Labels } from \"./config\"\nimport { Auth05CodeInput } from \"./form-parts\"\nimport {\n  formatRecoveryCode,\n  isRecoveryComplete,\n  maskPhone,\n  recoveryCapacity,\n  recoveryDropped,\n  recoveryOverflow,\n  resolveRecoveryRule,\n} from \"./mask\"\nimport type { Auth05Props } from \"./types\"\nimport { useAsyncAction } from \"./use-auth-form\"\n\ntype PanelBase = {\n  labels: Auth05Labels\n  /** 请求进行中时通知父级，用于锁定「其他验证方式」，避免结果落在已卸载的面板上。 */\n  onBusyChange: (busy: boolean) => void\n}\n\nfunction useBusy(busy: boolean, onBusyChange: (busy: boolean) => void) {\n  React.useEffect(() => {\n    onBusyChange(busy)\n    return () => onBusyChange(false)\n  }, [busy, onBusyChange])\n}\n\ntype PasskeyStatus = \"idle\" | \"waiting\" | \"cancelled\" | \"error\"\n\n/** 通行密钥：WebAuthn 由接入方在 onPasskey 里实现，这里只按回调结果显示状态。 */\nfunction Auth05PasskeyPanel({\n  labels,\n  onBusyChange,\n  supported,\n  onPasskey,\n  onDone,\n  suggestion,\n}: PanelBase & {\n  /** `null` 表示尚未检测（服务端渲染和水合期间）。 */\n  supported: boolean | null\n  onPasskey?: () => Promise<void>\n  onDone: () => void\n  suggestion?: { name: string; onSelect: () => void }\n}) {\n  const [status, setStatus] = React.useState<PasskeyStatus>(\"idle\")\n  const [message, setMessage] = React.useState(\"\")\n  const lock = React.useRef(false)\n  // 忙状态在事件里同步通知父级（不经过 effect），列表在同一次渲染里就被锁住；卸载时兜底解锁\n  React.useEffect(() => () => onBusyChange(false), [onBusyChange])\n\n  async function start() {\n    if (lock.current || supported === false) return\n    lock.current = true\n    onBusyChange(true)\n    setStatus(\"waiting\")\n    setMessage(\"\")\n    try {\n      if (!onPasskey) throw new Error(labels.failed)\n      await onPasskey()\n      onDone()\n    } catch (caught) {\n      // 用户关闭系统弹窗、超时或拒绝时，WebAuthn 统一抛 NotAllowedError，不算故障。\n      if ((caught as { name?: string } | null)?.name === \"NotAllowedError\") {\n        setStatus(\"cancelled\")\n      } else {\n        setStatus(\"error\")\n        setMessage(caught instanceof Error ? caught.message : labels.failed)\n      }\n    } finally {\n      lock.current = false\n      onBusyChange(false)\n    }\n  }\n\n  const waiting = status === \"waiting\"\n  // 状态文字放在始终存在的 aria-live 容器里，变化时才会被播报\n  const liveText =\n    supported === false\n      ? labels.passkeyUnsupported\n      : waiting\n        ? labels.passkeyWaiting\n        : status === \"cancelled\"\n          ? labels.passkeyCancelled\n          : \"\"\n\n  return (\n    <div data-slot=\"auth-05-passkey\" data-status={status} className=\"space-y-4\">\n      <p className=\"text-sm leading-7 text-muted-foreground\">\n        {labels.passkeyDescription}\n      </p>\n      <p\n        aria-live=\"polite\"\n        data-slot=\"auth-05-passkey-status\"\n        className=\"min-h-5 text-sm text-muted-foreground\"\n      >\n        {liveText}\n      </p>\n      {status === \"error\" && message && (\n        <p role=\"alert\" className=\"text-xs text-destructive\">\n          {message}\n        </p>\n      )}\n      <Button\n        type=\"button\"\n        size=\"lg\"\n        className=\"w-full\"\n        disabled={waiting || supported === false}\n        aria-busy={waiting || undefined}\n        onClick={() => void start()}\n      >\n        {waiting ? (\n          <>\n            <Loader2\n              aria-hidden\n              className=\"animate-spin motion-reduce:animate-none\"\n            />\n            {labels.passkeyWaitingButton}\n          </>\n        ) : (\n          labels.passkeyAction\n        )}\n      </Button>\n      {supported === false && suggestion && (\n        <Button\n          type=\"button\"\n          size=\"lg\"\n          variant=\"outline\"\n          className=\"w-full\"\n          onClick={suggestion.onSelect}\n        >\n          {formatLabel(labels.passkeySuggest, { method: suggestion.name })}\n        </Button>\n      )}\n    </div>\n  )\n}\n\n/** 验证器 App（totp）与短信共用：6 位数字，满位自动提交；短信多一步「发送」和倒计时。 */\nfunction Auth05CodePanel({\n  labels,\n  onBusyChange,\n  kind,\n  length,\n  phone,\n  resendSeconds,\n  onSendCode,\n  onVerify,\n  onDone,\n}: PanelBase & {\n  kind: \"totp\" | \"sms\"\n  length: number\n  phone?: string\n  resendSeconds: number\n  onSendCode?: () => Promise<void>\n  onVerify?: Auth05Props[\"onVerify\"]\n  onDone: () => void\n}) {\n  const id = React.useId()\n  const [code, setCode] = React.useState(\"\")\n  const [sent, setSent] = React.useState(false)\n  const [deadline, setDeadline] = React.useState(0)\n  const [remaining, setRemaining] = React.useState(0)\n  const sending = useAsyncAction(labels.failed)\n  const verifying = useAsyncAction(labels.failed)\n  const pending = sending.pending || verifying.pending\n  useBusy(pending, onBusyChange)\n  const masked = phone ? maskPhone(phone) : \"\"\n\n  React.useEffect(() => {\n    if (!deadline) return\n    const tick = () =>\n      setRemaining(Math.max(0, Math.ceil((deadline - Date.now()) / 1000)))\n    tick()\n    const timer = window.setInterval(tick, 250)\n    return () => window.clearInterval(timer)\n  }, [deadline])\n\n  async function send() {\n    if (pending || remaining > 0) return\n    const ok = await sending.run(async () => {\n      if (!onSendCode) throw new Error(labels.failed)\n      await onSendCode()\n    })\n    if (!ok) return\n    setSent(true)\n    setCode(\"\")\n    verifying.setError(\"\")\n    const seconds = Math.max(0, resendSeconds)\n    setRemaining(seconds)\n    // 仅在发送成功的事件回调中读取时间，不参与服务端渲染。\n    setDeadline(Date.now() + seconds * 1000)\n  }\n\n  async function verify(value: string) {\n    if (pending || value.length !== length) return\n    const ok = await verifying.run(async () => {\n      if (!onVerify) throw new Error(labels.failed)\n      await onVerify({ method: kind, code: value })\n    })\n    if (ok) onDone()\n  }\n\n  const showCode = kind === \"totp\" || sent\n  const description =\n    kind === \"totp\"\n      ? formatLabel(labels.totpDescription, { length })\n      : formatLabel(labels.smsDescription, { phone: masked })\n\n  return (\n    <form\n      noValidate\n      data-slot={`auth-05-${kind}`}\n      onSubmit={(event) => {\n        event.preventDefault()\n        void verify(code)\n      }}\n      className=\"space-y-4\"\n    >\n      <p className=\"text-sm leading-7 text-muted-foreground\">{description}</p>\n      {kind === \"sms\" && (\n        <>\n          <Button\n            type=\"button\"\n            size=\"lg\"\n            variant={sent ? \"outline\" : \"default\"}\n            className=\"w-full\"\n            disabled={pending || remaining > 0}\n            onClick={() => void send()}\n          >\n            {sending.pending\n              ? labels.smsSending\n              : remaining > 0\n                ? formatLabel(labels.smsCountdown, { seconds: remaining })\n                : sent\n                  ? labels.smsResend\n                  : labels.smsSend}\n          </Button>\n          <p\n            aria-live=\"polite\"\n            className=\"min-h-5 font-mono text-xs text-muted-foreground\"\n          >\n            {sent ? formatLabel(labels.smsSent, { phone: masked }) : \"\"}\n          </p>\n          {sending.error && (\n            <p role=\"alert\" className=\"text-xs text-destructive\">\n              {sending.error}\n            </p>\n          )}\n        </>\n      )}\n      {showCode && (\n        <>\n          <div className=\"space-y-2\">\n            <Auth05CodeInput\n              length={length}\n              value={code}\n              label={formatLabel(labels.code, { length })}\n              disabled={pending}\n              error={!!verifying.error}\n              describedBy={verifying.error ? `${id}-error` : undefined}\n              onChange={(value) => {\n                setCode(value)\n                verifying.setError(\"\")\n              }}\n              onComplete={(value) => void verify(value)}\n            />\n            {verifying.error && (\n              <p\n                role=\"alert\"\n                id={`${id}-error`}\n                className=\"text-xs text-destructive\"\n              >\n                {verifying.error}\n              </p>\n            )}\n          </div>\n          <Button\n            type=\"submit\"\n            size=\"lg\"\n            className=\"w-full\"\n            disabled={pending || code.length !== length}\n          >\n            {verifying.pending ? labels.verifying : labels.verify}\n          </Button>\n        </>\n      )}\n    </form>\n  )\n}\n\n/**\n * 恢复码：默认输入时自动转大写并插入短横线（XXXX-XXXX），格式可通过 recoveryCode 配置，也可以完全不格式化。\n * 内容不会被悄悄改掉：不格式化时超过 maxLength 保留原样并报错、禁止提交；\n * 有掩码时多出的字符会被丢掉，并给出一条会被读屏播报的提示。\n */\nfunction Auth05RecoveryPanel({\n  labels,\n  onBusyChange,\n  onVerify,\n  onDone,\n  recoveryCode,\n}: PanelBase & {\n  onVerify?: Auth05Props[\"onVerify\"]\n  onDone: () => void\n  recoveryCode?: Auth05Props[\"recoveryCode\"]\n}) {\n  const rule = resolveRecoveryRule(recoveryCode)\n  const masked = rule.format !== false\n  const id = React.useId()\n  const [code, setCode] = React.useState(\"\")\n  const complete = isRecoveryComplete(code, rule)\n  // 超长是从当前值直接算出来的：删回范围内错误自然消失，不用另外清除\n  const overflow = recoveryOverflow(code, rule)\n  const [notice, setNotice] = React.useState(\"\")\n  const placeholder =\n    recoveryCode?.placeholder ??\n    formatLabel(labels.recoveryPlaceholder, { format: rule.format || \"\" })\n  const [localError, setLocalError] = React.useState(\"\")\n  const verifying = useAsyncAction(labels.failed)\n  useBusy(verifying.pending, onBusyChange)\n  const overflowError = overflow\n    ? formatLabel(labels.recoveryTooLong, overflow)\n    : \"\"\n  const error = overflowError || localError || verifying.error\n  const inputRef = React.useRef<HTMLInputElement>(null)\n\n  async function submit() {\n    if (verifying.pending) return\n    if (overflow) {\n      // 回车提交时按钮虽然禁用也可能走到这里：拦下并把焦点还给输入框\n      inputRef.current?.focus()\n      return\n    }\n    if (!complete) {\n      setLocalError(\n        masked\n          ? formatLabel(labels.recoveryError, { format: rule.format as string })\n          : labels.recoveryEmpty,\n      )\n      return\n    }\n    const ok = await verifying.run(async () => {\n      if (!onVerify) throw new Error(labels.failed)\n      // 不格式化时只去掉首尾空白，其余原样提交\n      await onVerify({ method: \"recovery\", code: code.trim() })\n    })\n    if (ok) onDone()\n  }\n\n  return (\n    <form\n      noValidate\n      data-slot=\"auth-05-recovery\"\n      onSubmit={(event) => {\n        event.preventDefault()\n        void submit()\n      }}\n      className=\"space-y-4\"\n    >\n      <p className=\"text-sm leading-7 text-muted-foreground\">\n        {labels.recoveryDescription}\n      </p>\n      <div className=\"space-y-2\">\n        <label\n          htmlFor={`${id}-input`}\n          className=\"text-xs text-muted-foreground\"\n        >\n          {labels.recoveryLabel}\n        </label>\n        <Input\n          ref={inputRef}\n          id={`${id}-input`}\n          value={code}\n          onChange={(event) => {\n            const raw = event.target.value\n            setCode(formatRecoveryCode(raw, rule))\n            // 有掩码时多出来的字符不会进入输入框，要让用户知道；下一次输入重新计算，没有多出就清除\n            const dropped = recoveryDropped(raw, rule)\n            setNotice(\n              dropped > 0 && rule.format !== false\n                ? formatLabel(labels.recoveryTrimmed, {\n                    count: recoveryCapacity(rule.format),\n                  })\n                : \"\",\n            )\n            setLocalError(\"\")\n            verifying.setError(\"\")\n          }}\n          placeholder={placeholder}\n          autoComplete=\"off\"\n          autoCapitalize={rule.uppercase ? \"characters\" : \"off\"}\n          autoCorrect=\"off\"\n          spellCheck={false}\n          disabled={verifying.pending}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={`${id}-note${notice ? ` ${id}-notice` : \"\"}${error ? ` ${id}-error` : \"\"}`}\n          className={cn(\n            \"h-9 font-mono focus-visible:border-foreground/25 focus-visible:bg-background focus-visible:ring-0 aria-invalid:focus-visible:ring-3 pointer-coarse:h-11\",\n            masked && \"tracking-widest\",\n          )}\n        />\n        <p id={`${id}-note`} className=\"text-xs text-muted-foreground\">\n          {labels.recoveryNote}\n        </p>\n        {/* 常驻的播报区：内容变化时读屏才会播报；没有内容时不占位 */}\n        <p\n          id={`${id}-notice`}\n          role=\"status\"\n          data-slot=\"auth-05-recovery-notice\"\n          className=\"text-xs text-muted-foreground empty:hidden\"\n        >\n          {notice}\n        </p>\n        {error && (\n          <p\n            role=\"alert\"\n            id={`${id}-error`}\n            className=\"text-xs text-destructive\"\n          >\n            {error}\n          </p>\n        )}\n      </div>\n      <Button\n        type=\"submit\"\n        size=\"lg\"\n        className=\"w-full\"\n        disabled={verifying.pending || !complete}\n      >\n        {verifying.pending ? labels.verifying : labels.verify}\n      </Button>\n    </form>\n  )\n}\n\nexport { Auth05CodePanel, Auth05PasskeyPanel, Auth05RecoveryPanel }\n",
      "type": "registry:component",
      "target": "@components/auth-05/panels.tsx"
    },
    {
      "path": "src/registry/blocks/auth-05/parts.tsx",
      "content": "\"use client\"\n\nimport {\n  Check,\n  ChevronRight,\n  Fingerprint,\n  LifeBuoy,\n  MessageSquareText,\n  ShieldCheck,\n  type LucideIcon,\n} from \"lucide-react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport * as React from \"react\"\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\nimport { ToggleSwitch } from \"@/components/ui/toggle-switch/toggle-switch\"\nimport type { Auth05Labels } from \"./config\"\nimport { describeAccount } from \"./mask\"\nimport {\n  Auth05CodePanel,\n  Auth05PasskeyPanel,\n  Auth05RecoveryPanel,\n} from \"./panels\"\nimport type { Auth05State } from \"./use-auth-05\"\nimport type { Auth05Method } from \"./types\"\n\nconst METHOD_ICONS: Record<Auth05Method, LucideIcon> = {\n  passkey: Fingerprint,\n  totp: ShieldCheck,\n  sms: MessageSquareText,\n  recovery: LifeBuoy,\n}\n\n/** 方式名称、说明、标题都按 `<method>Name` 这类键从 labels 取，使用者可以逐项覆盖。 */\nfunction methodText(labels: Auth05Labels, method: Auth05Method) {\n  return {\n    name: labels[`${method}Name`],\n    hint: labels[`${method}Hint`],\n    title: labels[`${method}Title`],\n  }\n}\n\n/** 品牌（可选）、标题和说明。外面的容器由整页版和嵌入版各自决定。 */\nfunction Auth05Intro({\n  auth,\n  defaultLevel,\n}: {\n  auth: Auth05State\n  /** 使用者没传 headingLevel 时的级别：整页版 1，嵌入版 2 */\n  defaultLevel: 1 | 2 | 3\n}) {\n  const { brand, labels } = auth\n  const Heading = `h${auth.headingLevel ?? defaultLevel}` as const\n  return (\n    <>\n      {brand && (\n        <div className=\"mb-6 flex items-center gap-2 text-sm font-medium\">\n          {brand.logo}\n          {brand.name}\n        </div>\n      )}\n      <Heading className=\"text-2xl font-semibold tracking-tight\">\n        {labels.title}\n      </Heading>\n      <p className=\"mt-2 text-sm leading-7 text-muted-foreground\">\n        {labels.description}\n      </p>\n    </>\n  )\n}\n\n/** 账号摘要的内容：头像、账号文字、切换账号按钮。 */\nfunction Auth05AccountContent({ auth }: { auth: Auth05State }) {\n  const { account, labels, onSwitchAccount } = auth\n  const { primary, secondary, masked, initial } = describeAccount(account)\n  return (\n    <>\n      <Avatar size=\"sm\">\n        {account.avatar && <AvatarImage src={account.avatar} alt=\"\" />}\n        <AvatarFallback>{initial}</AvatarFallback>\n      </Avatar>\n      <p\n        className={cn(\n          \"min-w-44 flex-1 truncate text-sm\",\n          masked && \"font-mono\",\n        )}\n      >\n        <span className=\"sr-only\">{labels.accountLabel}：</span>\n        {primary}\n        {secondary && (\n          <span className=\"ms-1.5 text-xs text-muted-foreground\">\n            {secondary}\n          </span>\n        )}\n      </p>\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"ghost\"\n        className=\"text-muted-foreground\"\n        onClick={() => onSwitchAccount?.()}\n      >\n        {labels.accountSwitch}\n      </Button>\n    </>\n  )\n}\n\n/** 当前方式的面板（含标题和成功状态）。切换方式或成功时整块淡入。 */\nfunction Auth05MethodBody({ auth }: { auth: Auth05State }) {\n  const {\n    labels,\n    method,\n    done,\n    others,\n    titleRef,\n    setBusy,\n    setMethod,\n    supported,\n    complete,\n  } = auth\n  const reduced = useReducedMotion()\n  const text = methodText(labels, method)\n  const suggestedMethod = others[0]\n  return (\n    <motion.div\n      key={done ? \"success\" : method}\n      data-slot=\"auth-05-panel\"\n      initial={false}\n      animate={\n        reduced ? { opacity: 1, y: 0 } : { opacity: [0.7, 1], y: [4, 0] }\n      }\n      transition={{ duration: reduced ? 0 : 0.18 }}\n    >\n      <h2\n        ref={titleRef}\n        tabIndex={-1}\n        className=\"mb-4 flex items-center gap-2 text-lg font-medium outline-none focus-visible:underline\"\n      >\n        {done && (\n          <Check\n            aria-hidden\n            className=\"size-5 rounded-full bg-primary p-1 text-primary-foreground\"\n          />\n        )}\n        {done ? labels.success : text.title}\n      </h2>\n      {done ? (\n        <p className=\"text-sm leading-7 text-muted-foreground\">\n          {labels.successDescription}\n        </p>\n      ) : method === \"passkey\" ? (\n        <Auth05PasskeyPanel\n          labels={labels}\n          onBusyChange={setBusy}\n          supported={supported}\n          onPasskey={auth.onPasskey}\n          onDone={complete}\n          suggestion={\n            suggestedMethod\n              ? {\n                  name: methodText(labels, suggestedMethod).name,\n                  onSelect: () => setMethod(suggestedMethod),\n                }\n              : undefined\n          }\n        />\n      ) : method === \"recovery\" ? (\n        <Auth05RecoveryPanel\n          labels={labels}\n          onBusyChange={setBusy}\n          onVerify={auth.onVerify}\n          onDone={complete}\n          recoveryCode={auth.recoveryCode}\n        />\n      ) : (\n        <Auth05CodePanel\n          labels={labels}\n          onBusyChange={setBusy}\n          kind={method}\n          length={auth.codeLength}\n          phone={auth.account.phone}\n          resendSeconds={auth.resendSeconds}\n          onSendCode={auth.onSendCode}\n          onVerify={auth.onVerify}\n          onDone={complete}\n        />\n      )}\n    </motion.div>\n  )\n}\n\n/** 「其他验证方式」列表。外层的边框、内边距由调用方通过 className 给。 */\nfunction Auth05Alternatives({\n  auth,\n  className,\n}: {\n  auth: Auth05State\n  className?: string\n}) {\n  const { labels, others, busy, setMethod } = auth\n  const alternativesId = React.useId()\n  return (\n    <nav\n      data-slot=\"auth-05-alternatives\"\n      aria-labelledby={alternativesId}\n      className={className}\n    >\n      <h2\n        id={alternativesId}\n        className=\"mb-3 text-xs font-medium text-muted-foreground\"\n      >\n        {labels.alternatives}\n      </h2>\n      <ul className=\"space-y-1.5\">\n        {others.map((item) => {\n          const Icon = METHOD_ICONS[item]\n          const info = methodText(labels, item)\n          return (\n            <li key={item}>\n              <button\n                type=\"button\"\n                data-method={item}\n                disabled={busy}\n                onClick={() => setMethod(item)}\n                className=\"group/alt flex min-h-12 w-full items-center gap-3 rounded-lg border border-edge bg-background px-3 py-2 text-start transition-colors outline-none hover:bg-muted/60 focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-14\"\n              >\n                <Icon\n                  aria-hidden\n                  className=\"size-4 shrink-0 text-muted-foreground\"\n                />\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate text-sm font-medium\">\n                    {info.name}\n                  </span>\n                  <span className=\"block truncate text-xs text-muted-foreground\">\n                    {info.hint}\n                  </span>\n                </span>\n                <ChevronRight\n                  aria-hidden\n                  className=\"size-4 shrink-0 text-muted-foreground transition-transform group-hover/alt:translate-x-0.5 motion-reduce:transition-none\"\n                />\n              </button>\n            </li>\n          )\n        })}\n      </ul>\n    </nav>\n  )\n}\n\n/** 「记住此设备」开关；`showRememberDevice` 为 false 时不渲染。 */\nfunction Auth05RememberDevice({ auth }: { auth: Auth05State }) {\n  if (!auth.showRememberDevice) return null\n  return (\n    <ToggleSwitch\n      label={auth.labels.rememberDevice}\n      checked={auth.remember}\n      onCheckedChange={auth.setRemember}\n    />\n  )\n}\n\n/** 帮助链接；没传 `helpHref` 时不渲染。 */\nfunction Auth05HelpLink({ auth }: { auth: Auth05State }) {\n  if (!auth.helpHref) return null\n  return (\n    <a\n      data-slot=\"auth-05-help\"\n      href={auth.helpHref}\n      className=\"text-sm text-muted-foreground underline underline-offset-4 outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50\"\n    >\n      {auth.labels.help}\n    </a>\n  )\n}\n\n/** 成功是整块内容被替换，单独用一个常驻的播报区，读屏才会读出来。 */\nfunction Auth05Announcer({ auth }: { auth: Auth05State }) {\n  return (\n    <p data-slot=\"auth-05-announcer\" className=\"sr-only\" aria-live=\"polite\">\n      {auth.done ? auth.labels.successAnnounce : \"\"}\n    </p>\n  )\n}\n\nexport {\n  Auth05AccountContent,\n  Auth05Alternatives,\n  Auth05Announcer,\n  Auth05HelpLink,\n  Auth05Intro,\n  Auth05MethodBody,\n  Auth05RememberDevice,\n}\n",
      "type": "registry:component",
      "target": "@components/auth-05/parts.tsx"
    },
    {
      "path": "src/registry/blocks/auth-05/use-auth-05.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { DEFAULT_LABELS } from \"./config\"\nimport type { Auth05Method, Auth05Props } from \"./types\"\n\nconst ALL_METHODS: readonly Auth05Method[] = [\n  \"passkey\",\n  \"totp\",\n  \"sms\",\n  \"recovery\",\n]\n\n// 服务端和水合阶段拿不到浏览器能力，快照是 null（未知）；水合完成后 React 自动换成真实值，不会产生不匹配。\nconst subscribeNothing = () => () => {}\nconst detectPasskey = (): boolean =>\n  typeof window.PublicKeyCredential !== \"undefined\"\nconst unknownPasskey = (): null => null\n\n/**\n * 登录第二步的全部状态与逻辑，整页版 Auth05 和嵌入版 Auth05Panel 共用，两者只是排版不同。\n * `rootProps` 是没被消费的其余 props（className 和 div 原生属性），由调用方展开到根元素上。\n */\nfunction useAuth05({\n  account,\n  methods: methodsProp,\n  onPasskey,\n  onSendCode,\n  onVerify,\n  onSuccess,\n  onSwitchAccount,\n  helpHref,\n  passkeySupported,\n  codeLength = 6,\n  resendSeconds = 60,\n  recoveryCode,\n  showRememberDevice = true,\n  headingLevel,\n  brand,\n  labels: labelsProp,\n  method: methodProp,\n  defaultMethod,\n  onMethodChange,\n  rememberDevice: rememberProp,\n  defaultRememberDevice = false,\n  onRememberDeviceChange,\n  ...rootProps\n}: Auth05Props) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const methods = React.useMemo(() => {\n    const list = (methodsProp ?? ALL_METHODS).filter(\n      (item, index, all) =>\n        ALL_METHODS.includes(item) && all.indexOf(item) === index,\n    )\n    return list.length > 0 ? list : ALL_METHODS\n  }, [methodsProp])\n  const first: Auth05Method = methods[0] ?? \"passkey\"\n  const [method, setMethod] = useControllableState<Auth05Method>({\n    prop: methodProp,\n    defaultProp: defaultMethod ?? first,\n    onChange: onMethodChange,\n  })\n  // 当前方式不在可用列表里时退回第一项，避免出现空面板\n  const active = methods.includes(method) ? method : first\n  const others = methods.filter((item) => item !== active)\n  const [remember, setRemember] = useControllableState<boolean>({\n    prop: rememberProp,\n    defaultProp: defaultRememberDevice,\n    onChange: onRememberDeviceChange,\n  })\n  const [done, setDone] = React.useState<Auth05Method | null>(null)\n  const [busy, setBusy] = React.useState(false)\n  const detected = React.useSyncExternalStore(\n    subscribeNothing,\n    detectPasskey,\n    unknownPasskey,\n  )\n  const supported = passkeySupported ?? detected\n  const titleRef = React.useRef<HTMLHeadingElement>(null)\n  const previous = React.useRef({ active, done })\n  const length = Math.max(1, Math.min(12, Math.floor(codeLength) || 6))\n\n  // 切换方式或验证成功后把焦点交给新内容的标题，读屏和键盘用户从这里继续\n  React.useEffect(() => {\n    if (previous.current.active !== active || previous.current.done !== done)\n      titleRef.current?.focus()\n    previous.current = { active, done }\n  }, [active, done])\n\n  function complete() {\n    setDone(active)\n    // 不显示开关时，受控值也不应传出去：后端并不支持记住设备\n    onSuccess?.({\n      method: active,\n      rememberDevice: showRememberDevice ? remember : false,\n    })\n  }\n\n  return {\n    account,\n    labels,\n    brand,\n    helpHref,\n    showRememberDevice,\n    headingLevel,\n    recoveryCode,\n    resendSeconds,\n    codeLength: length,\n    method: active,\n    others,\n    setMethod,\n    remember,\n    setRemember,\n    done,\n    busy,\n    setBusy,\n    supported,\n    titleRef,\n    complete,\n    onPasskey,\n    onSendCode,\n    onVerify,\n    onSwitchAccount,\n    rootProps,\n  }\n}\n\ntype Auth05State = ReturnType<typeof useAuth05>\n\nexport { useAuth05 }\nexport type { Auth05State }\n",
      "type": "registry:component",
      "target": "@components/auth-05/use-auth-05.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:component"
}
