{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "camera-capture",
  "title": "Camera Capture",
  "description": "A take-a-photo control that opens the device camera inline, shows a live preview, captures a still to a Blob, and — the part every hand-rolled version gets wrong — hands the camera back the moment it is done with it. Reach for it wherever a form needs a picture taken now rather than a file chosen from a gallery: a profile or avatar photo, ID and proof-of-address capture in a KYC or onboarding flow, a business card scanned into a CRM, stock and asset counts in a warehouse, site and installation records on a construction job, damage reports for a repair, an insurance claim or a delivery dispute, proof-of-delivery photos, receipt and invoice capture for expenses, meter readings, a VIN or serial plate, product photos for a marketplace listing, a whiteboard at the end of a meeting, and a check-in photo in a field-service or inspection app. Common asks it answers: \"react camera component\", \"take photo in browser react\", \"getUserMedia react hook\", \"webcam capture component\", \"react webcam alternative\", \"capture image from video stream\", \"shadcn camera\", \"photo capture for form upload\", \"scan document with phone camera react\", \"front and back camera switch react\", \"useUserMedia hook\", \"canvas toBlob from video\", \"selfie capture component\", \"camera permission denied react\", \"camera light stays on after stopping\". Official shadcn/ui has nothing for this and no combination of its parts reaches it: there is no camera, video, media or capture primitive anywhere in the library — getUserMedia, MediaStream, facingMode, srcObject, enumerateDevices, toBlob and even the string \"camera\" are each zero hits across all sixty-odd of its components. Distinct from the other image pieces in this registry, and composes with them rather than repeating them: file-dropzone takes a file that already exists, image-crop trims a picture after it has been obtained, image-zoom inspects one, upload-list shows what is in flight, and signature-pad is also a canvas but its input is a finger, not a lens. This one is the step that produces the image in the first place — pipe its blob straight into image-crop, or into a FormData with form.append(\"photo\", photo.blob, \"photo.jpg\"). Five things separate it from the twenty-line version. First, stopping. Setting video.srcObject to null blanks the preview and leaves the tracks live, so the operating system's recording indicator stays lit over a camera nobody is watching; only track.stop() ends the capture, and it runs on unmount, on an explicit stop, when a photo is taken, and before any restart. On a phone the camera is also exclusive, so the release has to happen before the next getUserMedia and not after it, or switching cameras fails with NotReadableError on exactly the devices that have two cameras to switch between — and a stream that arrives after the component has gone is stopped on arrival, because nothing else on the page can reach it any more. Second, facing. { exact: \"environment\" } fails outright on any device without a rear camera, which is every laptop, and plain \"environment\" never fails and silently returns the front camera instead, so a document scanner written either way is broken: one refuses to run, the other photographs the user's face and files it as their passport page. This asks for the ideal and then reads the live track back, so facing reports the camera actually in use and facingFallback says when it is not the one requested — the answer neither constraint spelling gives you. Third, the black photograph. loadedmetadata publishes videoWidth and videoHeight, so a canvas sized from them looks right, but no frame has been decoded yet and drawImage paints nothing; the result is a correctly sized, entirely black JPEG that passes every check a caller is likely to write. Capture is gated on readyState, not on dimensions. Fourth, permissions. NotAllowedError means four unrelated things — an http origin, an iframe without allow=\"camera\", a stored block, and a prompt closed without an answer — and only the stored block is worth sending somebody to their site settings for, so the other three say something true instead and are not offered a retry that cannot work. NotReadableError is untangled too: the camera exists and the permission is fine, and another app is holding it. A permission fixed in site settings is picked up live through the Permissions API change event rather than staying dead until a reload, and because MediaDevices is [SecureContext] the whole object is missing on http — where the obvious feature detect fires and produces the one wrong answer, \"this browser has no camera support\", so this component checks the context before it concludes anything. Fifth, mirroring. The preview is mirrored because a front camera shown unmirrored is disorienting, and the saved file is not, because a mirrored file makes every photographed document, badge, receipt and serial number read backwards; mirrorOutput is there for the selfie case and is off by default. Ships as a hook (useCameraCapture) plus a component, with videoProps to spread onto your own <video> so playsInline and muted — the two attributes an iPhone needs to keep the preview from going fullscreen over your page, and to be allowed to autoplay at all — cannot be forgotten. Audio is explicitly refused so the microphone indicator stays dark and only one permission is asked for. The camera list is read after a stream opens rather than on mount, because enumerateDevices answers before a grant with every label blank. Saved images can be capped with maxWidth and maxHeight, encode to JPEG by default, and report the type that actually came back rather than the one requested; the object URL is owned by the component and revoked on retake and unmount, so a capture screen used ten times does not pin ten images in memory. Controls are aria-disabled rather than disabled so a refusal stays reachable and can explain itself, with a permanently mounted polite live region for status and failures. Styled entirely with shadcn tokens (input, accent, ring, muted-foreground), so it follows light and dark mode, and its only dependency is lucide-react for the icons.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/camera-capture.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Camera, Loader2, RotateCcw, SwitchCamera, TriangleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The MediaDevices entry point, or null where there isn't one.\n *\n * Hand-written because TypeScript declares `readonly mediaDevices: MediaDevices` on `Navigator` —\n * not optional — so `navigator.mediaDevices.getUserMedia(...)` type-checks everywhere and then\n * throws a TypeError on the browsers, and the origins, that haven't got it.\n */\nfunction mediaDevicesOf(): MediaDevices | null {\n  if (typeof navigator === \"undefined\" || !(\"mediaDevices\" in navigator)) return null\n  const api: MediaDevices | undefined = navigator.mediaDevices\n  return api && typeof api.getUserMedia === \"function\" ? api : null\n}\n\n/**\n * Whether the camera API exists here at all.\n *\n * Worth knowing what this cannot tell apart, because it is the exact inverse of the trap\n * geolocation sets. `MediaDevices` *is* `[SecureContext]`, so on a plain-http origin the entire\n * object is genuinely missing and this returns false — the feature detect fires, which feels like\n * the happy case. It isn't. The message that detect leads to — \"this browser can't use a camera\" —\n * is false on a browser that uses cameras perfectly well and is only refusing this origin, and it\n * sends the developer looking at the http staging box hunting for a browser bug. `start()` asks\n * `insecureContext()` before it concludes anything from this.\n */\nexport function isCameraCaptureSupported(): boolean {\n  return mediaDevicesOf() !== null\n}\n\n/**\n * Whether this page is a non-secure context, where the camera is unavailable and always will be.\n */\nfunction insecureContext(): boolean {\n  // A browser old enough to lack `isSecureContext` must not be read as insecure — that would\n  // refuse to work on the very browsers this is meant to degrade for.\n  return typeof window !== \"undefined\" && window.isSecureContext === false\n}\n\n/**\n * Whether Permissions Policy forbids the camera in this document, where that can be asked.\n *\n * One of the four producers of an unexplained `NotAllowedError`. The default allowlist for\n * `camera` is `'self'`, so an `<iframe>` embedding this page without `allow=\"camera\"` is refused,\n * as is any origin serving `Permissions-Policy: camera=()`. Neither is something the person\n * looking at the screen can do anything about, and neither shows a prompt.\n *\n * Best effort: the accessor is absent outside Chromium and is not in the DOM typings, so false\n * means \"not known to be blocked\", never \"allowed\".\n */\nfunction blockedByPermissionsPolicy(): boolean {\n  if (typeof document === \"undefined\") return false\n  type Policy = { allowsFeature?: (feature: string) => boolean }\n  const doc = document as Document & { permissionsPolicy?: Policy; featurePolicy?: Policy }\n  const policy = doc.permissionsPolicy ?? doc.featurePolicy\n  if (!policy || typeof policy.allowsFeature !== \"function\") return false\n  try {\n    return !policy.allowsFeature(\"camera\")\n  } catch {\n    return false\n  }\n}\n\n/**\n * Reads the stored camera permission without prompting, or null where that cannot be asked.\n *\n * The only other way to learn the permission state is to call `getUserMedia`, and that *acts* — it\n * can put a prompt in front of somebody who never asked for one, and on a phone it lights the\n * camera. This answers the same question silently, which is what makes it possible to tell the\n * four different things `NotAllowedError` means apart.\n *\n * Guarded twice: `navigator.permissions` is declared non-optional by TypeScript and is genuinely\n * absent in older browsers, and a browser that has the Permissions API may still not recognise the\n * `camera` descriptor — Firefox does not — in which case `query` rejects instead of answering. Not\n * knowing is an ordinary answer here, and every path below works without it.\n */\nasync function queryCameraPermission(): Promise<PermissionStatus | null> {\n  if (typeof navigator === \"undefined\" || !(\"permissions\" in navigator)) return null\n  const permissions: Permissions | undefined = navigator.permissions\n  if (!permissions || typeof permissions.query !== \"function\") return null\n  try {\n    return await permissions.query({ name: \"camera\" as PermissionName })\n  } catch {\n    return null\n  }\n}\n\n/** Which way a camera points. */\nexport type CameraFacing = \"user\" | \"environment\"\n\n/**\n * The facing this page actually got.\n *\n * `unknown` is the ordinary answer on a laptop: `facingMode` is optional in `MediaTrackSettings`\n * and desktop webcams routinely omit it. The specification also defines `left` and `right`, which\n * are reported as `unknown` here — they are too rare to build a mirroring rule on and neither is\n * a front camera in the sense that matters below.\n */\nexport type ResolvedCameraFacing = CameraFacing | \"unknown\"\n\n/** One camera the browser is willing to name. */\nexport interface CameraDevice {\n  deviceId: string\n  /** Empty until a camera permission has been granted for this origin. See `devices`. */\n  label: string\n}\n\n/**\n * Why the camera failed — worked out, not guessed.\n *\n * Four of these arrive as the identical `NotAllowedError`, and they need four different things\n * from four different people: `denied` is the user's own stored choice, `dismissed` is a prompt\n * closed without an answer, `insecure-context` and `blocked-by-policy` are the developer's to fix\n * and are invisible to the user.\n */\nexport type CameraFailureCause =\n  | \"unsupported\"\n  | \"insecure-context\"\n  | \"blocked-by-policy\"\n  | \"denied\"\n  | \"dismissed\"\n  | \"no-camera\"\n  | \"in-use\"\n  | \"unsatisfiable\"\n  | \"interrupted\"\n  | \"capture-failed\"\n  | \"unknown\"\n\nexport interface CameraFailure {\n  cause: CameraFailureCause\n  /** The `DOMException.name` the browser used, or null when this was settled before any call. */\n  code: string | null\n  /** Wording safe to show as-is. Override per cause with the `messages` prop. */\n  message: string\n  /**\n   * Whether trying again, right now, can produce a different answer.\n   *\n   * False for `denied` is the point of the component. Once the camera is blocked for an origin the\n   * browser refuses without prompting, so a \"Try again\" is a button that cannot work: it returns\n   * the same error instantly for as long as the page is open. The only way out runs through the\n   * browser's own site settings, which is what the copy for that case says — and why the\n   * permission `change` listener below exists, so that coming back from those settings costs\n   * nothing.\n   */\n  retryable: boolean\n}\n\n/**\n * Where the camera has got to.\n *\n * `prompting` and `starting` are split because conflating them is a small lie: a spinner labelled\n * \"Starting the camera\" over an unanswered permission dialog is describing something that is not\n * happening, and the wait is unbounded because a person deciding whether to hand over their camera\n * is not a fault. `starting` is the same wait where the Permissions API could not say a prompt was\n * coming. `live` means a frame has actually arrived — see `onLoadedData` below, and note that it\n * is deliberately not `loadedmetadata`.\n */\nexport type CameraPhase =\n  | \"idle\"\n  | \"prompting\"\n  | \"starting\"\n  | \"live\"\n  | \"capturing\"\n  | \"captured\"\n  | \"error\"\n\nconst RETRYABLE: Record<CameraFailureCause, boolean> = {\n  unsupported: false,\n  \"insecure-context\": false,\n  \"blocked-by-policy\": false,\n  denied: false,\n  dismissed: true,\n  \"no-camera\": true,\n  \"in-use\": true,\n  // The constraints asked for something this device has not got. Asking again with the same\n  // constraints fails the same way; `switchCamera` or `selectDevice` is the move that can work.\n  unsatisfiable: false,\n  interrupted: true,\n  \"capture-failed\": true,\n  unknown: true,\n}\n\nconst DEFAULT_MESSAGES: Record<CameraFailureCause, string> = {\n  unsupported: \"This browser can't use a camera.\",\n  \"insecure-context\":\n    \"The camera needs a secure (https) connection, so the browser refused without asking.\",\n  \"blocked-by-policy\": \"This page isn't permitted to use the camera.\",\n  denied:\n    \"The camera is blocked for this site. Allow it in your browser's site settings — this page can't ask again.\",\n  dismissed: \"The camera request was dismissed.\",\n  \"no-camera\": \"No camera was found on this device.\",\n  \"in-use\": \"The camera is being used by another app.\",\n  unsatisfiable: \"No camera on this device matches what the page asked for.\",\n  interrupted: \"The camera stopped — it may have been unplugged or taken by another app.\",\n  \"capture-failed\": \"The photo couldn't be taken.\",\n  unknown: \"The camera couldn't be started.\",\n}\n\n/**\n * Maps a `getUserMedia` rejection to a cause. `NotAllowedError` is resolved separately.\n *\n * The legacy spellings are not decoration: `PermissionDeniedError`, `DevicesNotFoundError`,\n * `TrackStartError` and `ConstraintNotSatisfiedError` are what older Chrome and the prefixed\n * implementations threw, and a browser old enough to use the prefixed entry point is exactly the\n * one that will not be updated.\n */\nfunction causeForError(name: string): CameraFailureCause | null {\n  switch (name) {\n    case \"NotAllowedError\":\n    case \"PermissionDeniedError\":\n      return null\n    case \"NotFoundError\":\n    case \"DevicesNotFoundError\":\n      return \"no-camera\"\n    // The camera exists, the permission is fine, and the operating system will not hand it over —\n    // almost always because a video call in another app or another tab already holds it. There is\n    // no error on the platform that maps onto this one; it is the camera's own.\n    case \"NotReadableError\":\n    case \"TrackStartError\":\n      return \"in-use\"\n    case \"OverconstrainedError\":\n    case \"ConstraintNotSatisfiedError\":\n      return \"unsatisfiable\"\n    case \"AbortError\":\n      return \"interrupted\"\n    // Thrown where media support has been disabled at the browser level.\n    case \"SecurityError\":\n      return \"blocked-by-policy\"\n    default:\n      return \"unknown\"\n  }\n}\n\n/** The facing the browser actually gave us, read off the live track rather than assumed. */\nfunction resolveFacing(track: MediaStreamTrack | null | undefined): ResolvedCameraFacing {\n  const mode = track?.getSettings?.().facingMode\n  return mode === \"user\" || mode === \"environment\" ? mode : \"unknown\"\n}\n\n/** Stops every track on a stream. The only thing that turns the camera light off. */\nfunction stopTracks(stream: MediaStream | null | undefined): void {\n  for (const track of stream?.getTracks?.() ?? []) {\n    track.onended = null\n    track.stop()\n  }\n}\n\n/** Fits `width`x`height` inside the caps, keeping the aspect ratio. Rounded to whole pixels. */\nfunction fitWithin(\n  width: number,\n  height: number,\n  maxWidth?: number,\n  maxHeight?: number\n): { width: number; height: number } {\n  const scale = Math.min(\n    1,\n    maxWidth && maxWidth > 0 ? maxWidth / width : 1,\n    maxHeight && maxHeight > 0 ? maxHeight / height : 1\n  )\n  if (scale >= 1) return { width, height }\n  return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }\n}\n\n/** A photo taken from the live preview. */\nexport interface CapturedPhoto {\n  /** The encoded image. This is the thing to upload: `form.append(\"photo\", blob, \"photo.jpg\")`. */\n  blob: Blob\n  /**\n   * An object URL for showing it, owned by this component.\n   *\n   * Revoked on retake, on the next capture and on unmount, because an object URL pins its blob in\n   * memory until somebody releases it and a capture screen is used over and over. Anything that\n   * has to outlive the component — a preview elsewhere on the page, a value kept in form state —\n   * should make its own from `blob` and revoke that itself.\n   */\n  url: string\n  width: number\n  height: number\n  /** The encoding actually used. Not always what was asked for: see `imageType`. */\n  type: string\n}\n\n/** The props to spread onto the `<video>` a headless consumer lays out. */\nexport interface CameraVideoProps {\n  ref: React.RefObject<HTMLVideoElement | null>\n  autoPlay: boolean\n  playsInline: boolean\n  muted: boolean\n  onLoadedData: React.ReactEventHandler<HTMLVideoElement>\n  onPlaying: React.ReactEventHandler<HTMLVideoElement>\n}\n\nexport interface UseCameraCaptureOptions {\n  /**\n   * Which camera to ask for. Default `\"user\"`.\n   *\n   * Sent as `{ ideal: ... }`, never `{ exact: ... }`, and that choice is the whole of the second\n   * trap this component exists for. `{ exact: \"environment\" }` fails outright with\n   * `OverconstrainedError` on any device without a rear camera — every laptop — so a document\n   * scanner written that way is broken on desktop. Plain `\"environment\"` never fails and instead\n   * *silently hands back the front camera*, so the same scanner quietly photographs the user's\n   * face and uploads it as their passport page. Neither is acceptable, so this takes the third\n   * option: ask for the ideal, then read back what arrived and say so. `facing` reports the camera\n   * actually in use and `facingFallback` is true when it is not the one that was asked for, which\n   * is the signal a caller needs and that neither constraint spelling gives you.\n   */\n  facingMode?: CameraFacing\n  /**\n   * Pin one specific camera by id, from `devices`. Takes precedence over `facingMode`.\n   *\n   * Sent as `{ exact: ... }`, which is right here and wrong for facing: an `ideal` device id is\n   * advisory, so the browser is free to ignore it and open a different camera, which makes a\n   * \"choose your camera\" menu that does not choose.\n   */\n  deviceId?: string\n  /** Resolution to ask for, as an ideal. Default 1280x720. The browser may give something else. */\n  idealWidth?: number\n  idealHeight?: number\n  /**\n   * Cap on the saved image, in pixels. Undefined keeps the camera's own resolution.\n   *\n   * Worth setting for anything that gets uploaded. A modern phone hands back 1080p or better and\n   * re-encodes to a file of a few hundred kilobytes to a couple of megabytes per shot, which is\n   * fine once and is not fine for the eight photos of a damaged parcel taken on a train.\n   */\n  maxWidth?: number\n  maxHeight?: number\n  /**\n   * Encoding for the saved image. Default `\"image/jpeg\"`.\n   *\n   * JPEG rather than PNG deliberately: a camera frame is a photograph, and PNG stores it losslessly\n   * at roughly an order of magnitude more bytes for no visible gain. A browser that does not\n   * support the type asked for silently encodes PNG instead, which is why `CapturedPhoto.type`\n   * reports what came back rather than what was requested.\n   */\n  imageType?: string\n  /** 0 to 1, for lossy types. Default 0.92. */\n  imageQuality?: number\n  /**\n   * Mirror the preview. Defaults to mirroring anything that is not known to face away.\n   *\n   * A front camera shown unmirrored is disorienting — you reach left and the reflection goes\n   * right — which is why every video call mirrors your own tile. `unknown`, which is what a laptop\n   * webcam reports, is treated as front-facing for this purpose because that is what it almost\n   * always is.\n   */\n  mirrorPreview?: boolean\n  /**\n   * Mirror the *saved* image too. Default false, and it should usually stay false.\n   *\n   * This is the pair of settings people collapse into one, and collapsing them ruins the component\n   * for half its uses: mirror the output and every photographed document, name badge, serial\n   * number, whiteboard and receipt comes out with its text backwards. Selfies are the only case\n   * where a mirrored file is what the user expected, and even phone cameras default to saving the\n   * unmirrored frame.\n   */\n  mirrorOutput?: boolean\n  /**\n   * Keep the camera running after a photo is taken. Default false.\n   *\n   * Off by default because a recording indicator that stays lit next to a photo you have already\n   * taken is the complaint this component is built to avoid, and because on a phone an idle camera\n   * is a warm battery. The cost is that `retake` has to start the camera again, which takes about a\n   * second — no second permission prompt, since the grant is already stored. Turn it on for a flow\n   * that takes several shots in a row.\n   */\n  keepStreamAfterCapture?: boolean\n  /** Start the camera on mount instead of waiting for `start()`. Default false. */\n  autoStart?: boolean\n  onCapture?: (photo: CapturedPhoto) => void\n  /**\n   * Called when something fails.\n   *\n   * Not `onError`: that is a native DOM attribute React defines on every element, so a prop by\n   * that name collides with it as soon as these options are spread onto an element.\n   */\n  onFailure?: (failure: CameraFailure) => void\n}\n\nexport interface UseCameraCaptureResult {\n  phase: CameraPhase\n  /** The last failure, or null. Cleared when the camera starts or a photo is taken. */\n  failure: CameraFailure | null\n  /** The photo just taken, or null. */\n  photo: CapturedPhoto | null\n  /** The stored permission, or \"unknown\" where the Permissions API can't say. */\n  permission: PermissionState | \"unknown\"\n  /** Whether the API exists. Starts true so the server and first client render agree. */\n  isSupported: boolean\n  /** The facing of the camera actually running. */\n  facing: ResolvedCameraFacing\n  /** True when the camera that arrived is not the one that was asked for. See `facingMode`. */\n  facingFallback: boolean\n  /** Whether the preview is being mirrored. */\n  mirrored: boolean\n  /**\n   * The cameras this browser will name.\n   *\n   * Empty until the camera has been started once, and deliberately so: `enumerateDevices` answers\n   * before any permission has been granted, but every `label` is the empty string, so a camera\n   * menu built on mount is a list of blanks. Populated after a stream opens, when the labels are\n   * real.\n   */\n  devices: CameraDevice[]\n  activeDeviceId: string | null\n  /** Whether there is another camera to switch to. */\n  canSwitch: boolean\n  videoRef: React.RefObject<HTMLVideoElement | null>\n  /** Spread onto your own `<video>`; carries the attributes that are not optional. */\n  videoProps: CameraVideoProps\n  /** Open the camera. Safe to call while one is running — the old stream is released first. */\n  start: (override?: { facingMode?: CameraFacing; deviceId?: string }) => void\n  /** Release the camera and go back to idle. */\n  stop: () => void\n  /** Take a photo from the current frame. */\n  capture: () => Promise<CapturedPhoto | null>\n  /** Throw the photo away and go back to the preview, restarting the camera if it was released. */\n  retake: () => void\n  /** Flip between front and rear, or step to the next camera where facing is unknown. */\n  switchCamera: () => void\n  selectDevice: (deviceId: string) => void\n}\n\n/**\n * The whole behaviour, for a capture screen you lay out yourself.\n */\nexport function useCameraCapture(options: UseCameraCaptureOptions = {}): UseCameraCaptureResult {\n  const {\n    facingMode = \"user\",\n    deviceId,\n    autoStart = false,\n    mirrorPreview,\n  } = options\n\n  const [phase, setPhase] = React.useState<CameraPhase>(\"idle\")\n  const [failure, setFailure] = React.useState<CameraFailure | null>(null)\n  const [photo, setPhoto] = React.useState<CapturedPhoto | null>(null)\n  const [permission, setPermission] = React.useState<PermissionState | \"unknown\">(\"unknown\")\n  const [isSupported, setIsSupported] = React.useState(true)\n  const [facing, setFacing] = React.useState<ResolvedCameraFacing>(\"unknown\")\n  const [facingFallback, setFacingFallback] = React.useState(false)\n  const [devices, setDevices] = React.useState<CameraDevice[]>([])\n  const [activeDeviceId, setActiveDeviceId] = React.useState<string | null>(null)\n\n  const videoRef = React.useRef<HTMLVideoElement | null>(null)\n  const streamRef = React.useRef<MediaStream | null>(null)\n  const photoRef = React.useRef<CapturedPhoto | null>(null)\n  const mountedRef = React.useRef(true)\n  // Every start gets a number, and a continuation that is not the current one is dropped. There is\n  // no way to cancel a `getUserMedia` already in flight, so without this a stream from an abandoned\n  // attempt lands on top of a fresh one — and, worse, lands with nothing holding it, which is a\n  // camera left switched on that no control on the page can reach. See `settle` below.\n  const sessionRef = React.useRef(0)\n  /**\n   * Which camera the request in flight asked for, or null when none is.\n   *\n   * A second `getUserMedia` for the same camera while the first is still acquiring is never useful\n   * and is actively harmful: on a phone the first request already holds the camera, so the\n   * duplicate comes back `NotReadableError` and somebody who double-tapped Start is told another\n   * app has their camera. Stopping the old stream first cannot help, because at that moment there\n   * is no stream yet to stop. A request for a *different* camera is let through — that is a\n   * deliberate change of mind, and the orphaned stream is stopped in `settle` when it arrives.\n   */\n  const pendingKeyRef = React.useRef<string | null>(null)\n  const requestedFacingRef = React.useRef<CameraFacing>(facingMode)\n  const facingRef = React.useRef<ResolvedCameraFacing>(\"unknown\")\n  const devicesRef = React.useRef<CameraDevice[]>([])\n  const activeDeviceIdRef = React.useRef<string | null>(null)\n  const permissionRef = React.useRef<PermissionState | \"unknown\">(\"unknown\")\n  const failureRef = React.useRef<CameraFailure | null>(null)\n\n  // Options are read through a ref rather than closed over, so `start` and `capture` keep stable\n  // identities. A callback that changes when an inline `onCapture` or a fresh `messages` object\n  // changes would restart the camera on an unrelated re-render, and a camera that flickers off and\n  // on mid-session is a worse bug than any it could fix.\n  const optionsRef = React.useRef(options)\n  React.useEffect(() => {\n    optionsRef.current = options\n  })\n\n  // Declared before everything that reads it: under StrictMode the cleanups run and the effects run\n  // again, and if this came last the second pass would see `false` and quietly refuse to work.\n  React.useEffect(() => {\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  const active = React.useCallback((id: number) => mountedRef.current && sessionRef.current === id, [])\n\n  const fail = React.useCallback((cause: CameraFailureCause, code: string | null) => {\n    const next: CameraFailure = {\n      cause,\n      code,\n      message: DEFAULT_MESSAGES[cause],\n      retryable: RETRYABLE[cause],\n    }\n    failureRef.current = next\n    setFailure(next)\n    setPhase(\"error\")\n    optionsRef.current.onFailure?.(next)\n  }, [])\n\n  /**\n   * Settles support on mount, with the reason rather than a bare boolean.\n   *\n   * Reporting only `isSupported: false` is what produces the thing this component argues against\n   * everywhere else: a greyed-out button with nothing next to it saying why. Nobody has clicked\n   * anything yet, so there is no failure to describe, and the one person who cannot use the camera\n   * is the one told the least. Settling the cause here means the copy is on screen before the first\n   * click, and `onFailure` fires early enough for a form to drop the field entirely.\n   */\n  React.useEffect(() => {\n    if (isCameraCaptureSupported()) return\n    // Reported once. `fail` mints a fresh failure object every call, so an unguarded version\n    // re-announces itself through `onFailure` on every StrictMode remount — and a consumer that\n    // shows a toast per failure gets two of them for a browser that was never going to work.\n    if (failureRef.current) return\n    setIsSupported(false)\n    fail(insecureContext() ? \"insecure-context\" : \"unsupported\", null)\n  }, [fail])\n\n  /** Releases the object URL of the photo we are holding. */\n  const releasePhoto = React.useCallback(() => {\n    const held = photoRef.current\n    photoRef.current = null\n    if (held && typeof URL !== \"undefined\" && typeof URL.revokeObjectURL === \"function\") {\n      URL.revokeObjectURL(held.url)\n    }\n  }, [])\n\n  /**\n   * Hands the camera back.\n   *\n   * Setting `srcObject` to null is not this, and believing it is costs an afternoon: the preview\n   * goes black, the component looks stopped, and the operating system's camera light stays on\n   * because the tracks are still live. `track.stop()` is the only thing that ends the capture, and\n   * it has to happen on every path out — unmount, an explicit stop, a photo taken, and before any\n   * restart.\n   */\n  const stopStream = React.useCallback(() => {\n    const stream = streamRef.current\n    streamRef.current = null\n    stopTracks(stream)\n    const video = videoRef.current\n    if (video) video.srcObject = null\n    facingRef.current = \"unknown\"\n    activeDeviceIdRef.current = null\n    if (mountedRef.current) {\n      setFacing(\"unknown\")\n      setActiveDeviceId(null)\n      setFacingFallback(false)\n    }\n  }, [])\n\n  /**\n   * Re-reads the camera list.\n   *\n   * Called after a stream opens rather than on mount. `enumerateDevices` answers either way, but\n   * before a grant every `label` is the empty string and every `deviceId` may be too, so a menu\n   * built at mount time offers a column of blanks.\n   */\n  const refreshDevices = React.useCallback(async () => {\n    const api = mediaDevicesOf()\n    if (!api || typeof api.enumerateDevices !== \"function\") return\n    try {\n      const all = await api.enumerateDevices()\n      const cameras = all\n        .filter((device) => device.kind === \"videoinput\")\n        .map((device) => ({ deviceId: device.deviceId, label: device.label }))\n      devicesRef.current = cameras\n      if (mountedRef.current) setDevices(cameras)\n    } catch {\n      // Nothing here is worth failing a working camera over.\n    }\n  }, [])\n\n  const start = React.useCallback(\n    (override?: { facingMode?: CameraFacing; deviceId?: string }) => {\n      const opts = optionsRef.current\n      const api = mediaDevicesOf()\n      if (!api) {\n        setIsSupported(false)\n        // The object is missing on http as well as on a browser that has no camera support at all,\n        // and the two need opposite advice. Asked in this order so the http case says the true\n        // thing instead of blaming the browser.\n        fail(insecureContext() ? \"insecure-context\" : \"unsupported\", null)\n        return\n      }\n      if (insecureContext()) {\n        fail(\"insecure-context\", null)\n        return\n      }\n      if (blockedByPermissionsPolicy()) {\n        fail(\"blocked-by-policy\", null)\n        return\n      }\n\n      const wantDevice = override?.deviceId ?? opts.deviceId\n      const wantFacing = override?.facingMode ?? opts.facingMode ?? \"user\"\n      const key = wantDevice ? `device:${wantDevice}` : `facing:${wantFacing}`\n      if (pendingKeyRef.current === key) return\n      requestedFacingRef.current = wantFacing\n\n      // Released before asking, not after. On a phone the camera is exclusive: a second\n      // `getUserMedia` while the first stream is still live comes back `NotReadableError`, so a\n      // switch written as start-then-stop fails every time on the devices that have two cameras to\n      // switch between.\n      stopStream()\n\n      const id = sessionRef.current + 1\n      sessionRef.current = id\n      pendingKeyRef.current = key\n      failureRef.current = null\n      setFailure(null)\n      // \"prompt\" is the one state that reliably means a dialog is about to appear. Where the\n      // Permissions API could not answer, claiming to know would be the lie.\n      setPhase(permissionRef.current === \"prompt\" ? \"prompting\" : \"starting\")\n\n      const video: MediaTrackConstraints = {}\n      if (wantDevice) {\n        // Exact, and no facing alongside it: two constraints naming different cameras is how you\n        // get an OverconstrainedError out of a device that has both of them.\n        video.deviceId = { exact: wantDevice }\n      } else {\n        video.facingMode = { ideal: wantFacing }\n      }\n      video.width = { ideal: opts.idealWidth ?? 1280 }\n      video.height = { ideal: opts.idealHeight ?? 720 }\n\n      const done = () => {\n        if (sessionRef.current === id) pendingKeyRef.current = null\n      }\n\n      const settle = (stream: MediaStream) => {\n        done()\n        if (!active(id)) {\n          // Abandoned while in flight. Nothing is holding this stream and nothing else ever will,\n          // so it has to be stopped here or the camera stays on for the life of the page.\n          stopTracks(stream)\n          return\n        }\n        streamRef.current = stream\n        const [track] = stream.getVideoTracks?.() ?? []\n        const got = resolveFacing(track)\n        facingRef.current = got\n        setFacing(got)\n        setFacingFallback(!wantDevice && got !== \"unknown\" && got !== wantFacing)\n        const settings = track?.getSettings?.()\n        activeDeviceIdRef.current = settings?.deviceId ?? null\n        setActiveDeviceId(settings?.deviceId ?? null)\n\n        // The source ending on its own — a webcam unplugged, the camera seized by the operating\n        // system, the permission revoked from the browser's own UI while the page is open. Nothing\n        // else reports it, and without this the preview simply freezes on its last frame.\n        if (track) {\n          track.onended = () => {\n            if (!active(id)) return\n            stopStream()\n            fail(\"interrupted\", \"ended\")\n          }\n        }\n\n        const node = videoRef.current\n        if (node) {\n          node.srcObject = stream\n          // Belt and braces for a consumer who laid out their own <video> and dropped `muted`:\n          // an unmuted video is not allowed to autoplay, so the preview never starts and nothing\n          // says why. Setting the property is what counts — the attribute alone does not.\n          node.muted = true\n          const played = node.play?.()\n          // A rejection here is not fatal and must not be reported as one: `autoPlay` on a muted,\n          // inline video starts it regardless, and `play()` is rejected merely for being\n          // interrupted by the next load.\n          if (played && typeof played.catch === \"function\") played.catch(() => {})\n        }\n        void refreshDevices()\n      }\n\n      const reject = (error: unknown) => {\n        done()\n        if (!active(id)) return\n        const name = (error as DOMException | undefined)?.name ?? \"unknown\"\n        const mapped = causeForError(name)\n        if (mapped) {\n          fail(mapped, name)\n          return\n        }\n\n        // NotAllowedError, which is four different things.\n        //\n        // The two checked before the call are checked again, because a document can be moved into a\n        // frame that forbids the feature between the two moments, and because a browser reaching\n        // here when the pre-checks could not run still deserves the right answer. Then the stored\n        // state decides the rest, read now rather than taken from React state: pressing Block both\n        // fails this call and fires `change`, and there is no guarantee the event has arrived.\n        if (insecureContext()) {\n          fail(\"insecure-context\", name)\n          return\n        }\n        if (blockedByPermissionsPolicy()) {\n          fail(\"blocked-by-policy\", name)\n          return\n        }\n        void queryCameraPermission().then((status) => {\n          if (!active(id)) return\n          if (!status) {\n            // Nothing can tell these apart here, so the copy for `denied` carries the day — it is\n            // the only one of the two that stays broken, and advice to check site settings is\n            // harmless to somebody who merely closed the dialog.\n            fail(\"denied\", name)\n            return\n          }\n          setPermission(status.state)\n          permissionRef.current = status.state\n          if (status.state === \"denied\") {\n            fail(\"denied\", name)\n            return\n          }\n          // Refused while the stored answer is \"granted\" can only come from above the user — a\n          // frame or a header — so sending them to their own settings would be wrong.\n          if (status.state === \"granted\") {\n            fail(\"blocked-by-policy\", name)\n            return\n          }\n          // Still \"prompt\": nothing was stored, so the dialog was closed rather than answered.\n          // This is the one refusal worth offering a retry for, and asking again really re-prompts.\n          fail(\"dismissed\", name)\n        })\n      }\n\n      try {\n        const request = api.getUserMedia({ video, audio: false })\n        // `audio: false`, not omitted. Asking for audio as well lights the microphone indicator and\n        // puts a second permission in front of the user, for a component that takes photographs.\n        request.then(settle, reject)\n      } catch (error) {\n        // A browser that throws synchronously rather than rejecting — the prefixed implementations\n        // did, and a TypeError for malformed constraints still does.\n        reject(error)\n      }\n    },\n    [active, fail, refreshDevices, stopStream]\n  )\n\n  const stop = React.useCallback(() => {\n    // Bumping the id first orphans anything still in flight, so a stream that arrives after this\n    // is stopped by `settle` instead of quietly switching the camera back on.\n    sessionRef.current += 1\n    pendingKeyRef.current = null\n    stopStream()\n    failureRef.current = null\n    setFailure(null)\n    setPhase(\"idle\")\n  }, [stopStream])\n\n  const capture = React.useCallback(async (): Promise<CapturedPhoto | null> => {\n    const opts = optionsRef.current\n    const node = videoRef.current\n    if (!node || !streamRef.current) {\n      fail(\"capture-failed\", null)\n      return null\n    }\n\n    // HAVE_CURRENT_DATA. This is the gate, and `loadedmetadata` is not: metadata publishes\n    // `videoWidth` and `videoHeight`, so a canvas sized from them looks correct, but no frame has\n    // necessarily been decoded yet and `drawImage` then paints nothing. The result is a photo of\n    // exactly the right dimensions, entirely black, which passes every check a caller is likely to\n    // write — it has a blob, it has a size, it is a valid JPEG.\n    const width = node.videoWidth\n    const height = node.videoHeight\n    if ((node.readyState ?? 0) < 2 || !width || !height) {\n      fail(\"capture-failed\", null)\n      return null\n    }\n\n    setPhase(\"capturing\")\n    const size = fitWithin(width, height, opts.maxWidth, opts.maxHeight)\n    const canvas = document.createElement(\"canvas\")\n    canvas.width = size.width\n    canvas.height = size.height\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) {\n      fail(\"capture-failed\", null)\n      return null\n    }\n    // A camera frame is nearly always drawn smaller than it arrived, and the default resampling\n    // makes a downscale of that size crunchy in exactly the place people look at — a face.\n    ctx.imageSmoothingEnabled = true\n    ctx.imageSmoothingQuality = \"high\"\n    if (opts.mirrorOutput) {\n      ctx.translate(size.width, 0)\n      ctx.scale(-1, 1)\n    }\n    ctx.drawImage(node, 0, 0, size.width, size.height)\n\n    const type = opts.imageType ?? \"image/jpeg\"\n    const blob = await new Promise<Blob | null>((resolve) => {\n      canvas.toBlob(resolve, type, opts.imageQuality ?? 0.92)\n    })\n    if (!mountedRef.current) return null\n    if (!blob) {\n      fail(\"capture-failed\", null)\n      return null\n    }\n\n    releasePhoto()\n    const next: CapturedPhoto = {\n      blob,\n      url: URL.createObjectURL(blob),\n      width: size.width,\n      height: size.height,\n      // What came back, not what was asked for: a browser that cannot encode the requested type\n      // falls back to PNG without saying so, and a caller naming the upload \".jpg\" from the request\n      // ships a mislabelled file.\n      type: blob.type || type,\n    }\n    photoRef.current = next\n    setPhoto(next)\n    failureRef.current = null\n    setFailure(null)\n    setPhase(\"captured\")\n    if (!opts.keepStreamAfterCapture) stopStream()\n    opts.onCapture?.(next)\n    return next\n  }, [fail, releasePhoto, stopStream])\n\n  const retake = React.useCallback(() => {\n    releasePhoto()\n    setPhoto(null)\n    if (streamRef.current) {\n      setPhase(\"live\")\n      return\n    }\n    start()\n  }, [releasePhoto, start])\n\n  const switchCamera = React.useCallback(() => {\n    // Where the browser tells us which way the camera points, flip that — it is the request a phone\n    // understands, and it keeps working when device ids are reshuffled between sessions, which\n    // Safari does. Where facing is unknown, which is the ordinary answer on a laptop, step through\n    // the enumerated cameras instead.\n    if (facingRef.current !== \"unknown\") {\n      start({ facingMode: facingRef.current === \"user\" ? \"environment\" : \"user\" })\n      return\n    }\n    const list = devicesRef.current\n    if (list.length < 2) return\n    const at = list.findIndex((device) => device.deviceId === activeDeviceIdRef.current)\n    const next = list[(at + 1) % list.length]\n    if (next) start({ deviceId: next.deviceId })\n  }, [start])\n\n  const selectDevice = React.useCallback((id: string) => start({ deviceId: id }), [start])\n\n  /**\n   * Reads the stored permission and keeps the dead end honest.\n   *\n   * The `change` event is the reason this is a subscription rather than one read. A user sent to\n   * site settings by the `denied` message comes back to a page that is still open, and the browser\n   * fires `change` the moment they flip the switch. Handling it turns \"allow it in your settings\"\n   * into advice that visibly works; ignoring it leaves the button dead until a reload, which is the\n   * point at which people conclude the site is broken.\n   */\n  React.useEffect(() => {\n    let status: PermissionStatus | null = null\n    let cancelled = false\n\n    const handleChange = () => {\n      if (!status) return\n      const next = status.state\n      setPermission(next)\n      permissionRef.current = next\n      if (next === \"denied\") return\n      if (failureRef.current?.cause === \"denied\") {\n        failureRef.current = null\n        setFailure(null)\n        setPhase(\"idle\")\n      }\n    }\n\n    void queryCameraPermission().then((result) => {\n      if (cancelled || !result) return\n      status = result\n      setPermission(result.state)\n      permissionRef.current = result.state\n      result.addEventListener(\"change\", handleChange)\n    })\n\n    return () => {\n      cancelled = true\n      status?.removeEventListener(\"change\", handleChange)\n    }\n  }, [])\n\n  // The camera and the object URL both belong to the browser, not to this component, so navigating\n  // away inside a single-page app is exactly the moment they would be leaked: the tracks keep the\n  // camera light on with no control left that could turn it off, and the blob stays in memory.\n  React.useEffect(\n    () => () => {\n      sessionRef.current += 1\n      stopStream()\n      releasePhoto()\n    },\n    [releasePhoto, stopStream]\n  )\n\n  React.useEffect(() => {\n    if (autoStart) start()\n  }, [autoStart, start])\n\n  // A `facingMode` prop that changes while the camera is live is a request to turn it round. Guarded\n  // against what it asked for last, not against what arrived, so a device that falls back to the\n  // only camera it has does not sit here restarting forever.\n  React.useEffect(() => {\n    if (!streamRef.current) return\n    if (facingMode === requestedFacingRef.current) return\n    start({ facingMode })\n  }, [facingMode, start])\n\n  React.useEffect(() => {\n    if (!streamRef.current || !deviceId) return\n    if (deviceId === activeDeviceIdRef.current) return\n    start({ deviceId })\n  }, [deviceId, start])\n\n  const videoProps = React.useMemo<CameraVideoProps>(\n    () => ({\n      ref: videoRef,\n      autoPlay: true,\n      // Without this an iPhone takes the preview fullscreen the moment it plays, covering the page\n      // and the capture button with a native video player.\n      playsInline: true,\n      // Required for autoplay to be allowed at all.\n      muted: true,\n      // `loadeddata`, not `loadedmetadata`: metadata means the dimensions are known, which is not\n      // the same as a frame existing. See `capture`, where the difference is a black photograph.\n      onLoadedData: () => {\n        if (streamRef.current) setPhase(\"live\")\n      },\n      // Belt and braces: `loadeddata` is the event that means a frame exists, and `playing` is the\n      // one every browser fires for a live stream. Taking either avoids a preview that runs while\n      // the component still says it is starting.\n      onPlaying: () => {\n        if (streamRef.current) setPhase(\"live\")\n      },\n    }),\n    []\n  )\n\n  const mirrored = mirrorPreview ?? facing !== \"environment\"\n  const canSwitch = facing !== \"unknown\" || devices.length > 1\n\n  return {\n    phase,\n    failure,\n    photo,\n    permission,\n    isSupported,\n    facing,\n    facingFallback,\n    mirrored,\n    devices,\n    activeDeviceId,\n    canSwitch,\n    videoRef,\n    videoProps,\n    start,\n    stop,\n    capture,\n    retake,\n    switchCamera,\n    selectDevice,\n  }\n}\n\nexport interface CameraCaptureProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onCapture\">,\n    UseCameraCaptureOptions {\n  /** Accessible name for the whole capture area. */\n  label?: string\n  /** Resting label on the primary button. */\n  startLabel?: string\n  captureLabel?: string\n  retakeLabel?: string\n  switchLabel?: string\n  retryLabel?: string\n  /** Alt text for the photo just taken. */\n  photoAlt?: string\n  /** Per-cause wording, merged over the defaults. */\n  messages?: Partial<Record<CameraFailureCause, string>>\n  /** Status wording. Each one is announced through the live region as it becomes true. */\n  promptingMessage?: string\n  startingMessage?: string\n  liveMessage?: string\n  capturedMessage?: string\n  /** Shown when the device had no camera facing the way this asked. See `facingMode`. */\n  facingFallbackMessage?: string\n  /** Hide the message under the controls. It stays in the live region either way. */\n  hideMessage?: boolean\n  /** Class for the preview frame. `className` goes to the wrapper. */\n  previewClassName?: string\n}\n\n/**\n * A \"take a photo\" control that hands the camera back when it is done with it.\n */\nexport function CameraCapture({\n  label = \"Camera\",\n  startLabel = \"Start camera\",\n  captureLabel = \"Take photo\",\n  retakeLabel = \"Retake\",\n  switchLabel = \"Switch camera\",\n  retryLabel = \"Try again\",\n  photoAlt = \"The photo you just took\",\n  messages,\n  promptingMessage = \"Waiting for camera permission…\",\n  startingMessage = \"Starting the camera…\",\n  liveMessage = \"The camera is on.\",\n  capturedMessage = \"Photo taken.\",\n  facingFallbackMessage = \"This device has only one camera, so it stayed on the one it has.\",\n  hideMessage = false,\n  previewClassName,\n  facingMode,\n  deviceId,\n  idealWidth,\n  idealHeight,\n  maxWidth,\n  maxHeight,\n  imageType,\n  imageQuality,\n  mirrorPreview,\n  mirrorOutput,\n  keepStreamAfterCapture,\n  autoStart,\n  onCapture,\n  onFailure,\n  className,\n  ...props\n}: CameraCaptureProps) {\n  const {\n    phase,\n    failure,\n    photo,\n    facingFallback,\n    mirrored,\n    canSwitch,\n    videoProps,\n    start,\n    capture,\n    retake,\n    switchCamera,\n  } = useCameraCapture({\n    facingMode,\n    deviceId,\n    idealWidth,\n    idealHeight,\n    maxWidth,\n    maxHeight,\n    imageType,\n    imageQuality,\n    mirrorPreview,\n    mirrorOutput,\n    keepStreamAfterCapture,\n    autoStart,\n    onCapture,\n    onFailure,\n  })\n\n  // The message is referenced by id from the primary button, so it needs one that survives\n  // hydration.\n  const messageId = React.useId()\n\n  const busy = phase === \"prompting\" || phase === \"starting\" || phase === \"capturing\"\n  const live = phase === \"live\"\n\n  const message = failure\n    ? (messages?.[failure.cause] ?? failure.message)\n    : phase === \"prompting\"\n      ? promptingMessage\n      : phase === \"starting\"\n        ? startingMessage\n        : phase === \"captured\"\n          ? capturedMessage\n          : live\n            ? [liveMessage, facingFallback ? facingFallbackMessage : \"\"].filter(Boolean).join(\" \")\n            : \"\"\n\n  // The only states where pressing the primary button is a real offer. A failure marked\n  // non-retryable keeps the control reachable and says why, rather than dangling an action that\n  // cannot work.\n  const actionable = !busy && failure?.retryable !== false\n\n  const primaryLabel = photo ? retakeLabel : live ? captureLabel : failure?.retryable ? retryLabel : startLabel\n  const PrimaryIcon = busy ? Loader2 : photo ? RotateCcw : failure && !failure.retryable ? TriangleAlert : Camera\n\n  function handlePrimary() {\n    if (!actionable) return\n    if (photo) {\n      retake()\n      return\n    }\n    if (live) {\n      void capture()\n      return\n    }\n    start()\n  }\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={cn(\"flex w-full max-w-sm flex-col gap-2\", className)}\n      {...props}\n    >\n      <div\n        className={cn(\n          \"relative aspect-[4/3] w-full overflow-hidden rounded-md border border-input bg-muted\",\n          previewClassName\n        )}\n      >\n        {/*\n          Mounted from the first render and never conditionally rendered. A <video> that only\n          appears once there is a stream is not in the tree when `getUserMedia` resolves, so the ref\n          is null, the stream is never attached, and what you get is a camera that is switched on\n          with no preview and no obvious reason why. A live preview also carries nothing for a\n          screen reader — there is no text in it — so it is hidden from the accessibility tree and\n          the live region below does the talking.\n        */}\n        <video\n          {...videoProps}\n          aria-hidden=\"true\"\n          className={cn(\n            \"h-full w-full object-cover\",\n            mirrored && \"scale-x-[-1]\",\n            (photo || phase === \"idle\" || phase === \"error\") && \"invisible\"\n          )}\n        />\n        {photo ? (\n          <img\n            src={photo.url}\n            alt={photoAlt}\n            className=\"absolute inset-0 h-full w-full bg-background object-contain\"\n          />\n        ) : null}\n        {phase === \"idle\" || phase === \"error\" ? (\n          <div className=\"absolute inset-0 grid place-items-center text-muted-foreground\">\n            <Camera className=\"h-8 w-8\" aria-hidden=\"true\" />\n          </div>\n        ) : null}\n        {busy ? (\n          <div className=\"absolute inset-0 grid place-items-center bg-background/60\">\n            <Loader2 className=\"h-6 w-6 animate-spin text-muted-foreground\" aria-hidden=\"true\" />\n          </div>\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <button\n          type=\"button\"\n          onClick={handlePrimary}\n          // `aria-disabled` rather than `disabled`, so the control keeps its place in the tab order\n          // and can still be reached and read. A real `disabled` button is skipped entirely, which\n          // means the one explanation of why the camera is unavailable is delivered to everyone\n          // except the people who most need it.\n          aria-disabled={actionable ? undefined : true}\n          aria-busy={busy || undefined}\n          aria-describedby={message ? messageId : undefined}\n          data-phase={phase}\n          data-cause={failure?.cause}\n          className=\"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:pointer-events-none aria-disabled:opacity-50\"\n        >\n          <PrimaryIcon className={cn(\"h-4 w-4\", busy && \"animate-spin\")} aria-hidden=\"true\" />\n          {primaryLabel}\n        </button>\n        {live && canSwitch ? (\n          <button\n            type=\"button\"\n            onClick={switchCamera}\n            aria-label={switchLabel}\n            className=\"inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-transparent transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <SwitchCamera className=\"h-4 w-4\" aria-hidden=\"true\" />\n          </button>\n        ) : null}\n      </div>\n\n      {/*\n        Mounted from the start and left empty, never conditionally rendered, and never `hidden`.\n        A live region inserted into the document already holding its text is not reliably announced,\n        and `hidden`, `display:none` and `aria-hidden` all take it out of the accessibility tree,\n        which is the same silence as not rendering it. `sr-only` is the one way to hide it visually\n        and keep it speaking.\n      */}\n      <p\n        id={messageId}\n        role=\"status\"\n        aria-live=\"polite\"\n        className={cn(\"text-sm text-muted-foreground\", (hideMessage || !message) && \"sr-only\")}\n      >\n        {message}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
