{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-download",
  "title": "App Download",
  "description": "A get-the-app block that promotes the store matching the visitor's device, encodes the smart link into a scannable QR for desktop visitors, and renders the store badges you supply instead of redrawing brand marks.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/app-download.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Copy, Download, Monitor, MonitorSmartphone, Smartphone, TriangleAlert } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/* ------------------------------------------------------------- QR ENCODER */\n/* The block encodes its own smart link so a marketing page does not have to  */\n/* ship a QR library, and so the symbol exists on the very first paint (a     */\n/* library that resolves in an effect leaves a hole under the caption). Byte  */\n/* mode only: a lowercase URL is not alphanumeric-mode eligible anyway.       */\n/* -- qr-encoder-start -- */\n\n/** Error correction level. Higher survives a dirtier print at the cost of density. */\nexport type QrEccLevel = \"L\" | \"M\" | \"Q\" | \"H\"\n\nexport interface QrMatrix {\n  /** Modules per side, WITHOUT the quiet zone. */\n  size: number\n  /** Row-major, `true` = dark module. */\n  modules: boolean[][]\n  /** Symbol version, 1-40. */\n  version: number\n}\n\n/** ECC codewords per block, indexed by version (index 0 is an unused pad). */\nconst ECC_CODEWORDS_PER_BLOCK: Record<QrEccLevel, number[]> = {\n  L: [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],\n  M: [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],\n  Q: [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],\n  H: [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],\n}\n\n/** Number of ECC blocks the data is split into, indexed by version. */\nconst ECC_BLOCK_COUNT: Record<QrEccLevel, number[]> = {\n  L: [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],\n  M: [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],\n  Q: [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],\n  H: [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],\n}\n\n/** The two bits each level contributes to the format information. */\nconst ECC_FORMAT_BITS: Record<QrEccLevel, number> = { L: 1, M: 0, Q: 3, H: 2 }\n\n/** Multiplication in GF(256) with the QR field generator 0x11D. */\nfunction gfMultiply(x: number, y: number): number {\n  let z = 0\n  for (let i = 7; i >= 0; i -= 1) {\n    z = (z << 1) ^ ((z >>> 7) * 0x11d)\n    z ^= ((y >>> i) & 1) * x\n  }\n  return z & 0xff\n}\n\n/** Coefficients of the Reed-Solomon generator polynomial of the given degree. */\nfunction reedSolomonDivisor(degree: number): number[] {\n  const result = new Array<number>(degree).fill(0)\n  result[degree - 1] = 1\n  let root = 1\n  for (let i = 0; i < degree; i += 1) {\n    for (let j = 0; j < degree; j += 1) {\n      result[j] = gfMultiply(result[j], root)\n      if (j + 1 < degree) result[j] ^= result[j + 1]\n    }\n    root = gfMultiply(root, 0x02)\n  }\n  return result\n}\n\n/** The ECC codewords for one data block. */\nfunction reedSolomonRemainder(data: number[], divisor: number[]): number[] {\n  const result = new Array<number>(divisor.length).fill(0)\n  for (const byte of data) {\n    const factor = byte ^ result[0]\n    result.shift()\n    result.push(0)\n    for (let i = 0; i < divisor.length; i += 1) result[i] ^= gfMultiply(divisor[i], factor)\n  }\n  return result\n}\n\n/** Total data + ECC bits available in a symbol, minus every function pattern. */\nfunction rawDataModules(version: number): number {\n  let result = (16 * version + 128) * version + 64\n  if (version >= 2) {\n    const alignCount = Math.floor(version / 7) + 2\n    result -= (25 * alignCount - 10) * alignCount - 55\n    if (version >= 7) result -= 36\n  }\n  return result\n}\n\n/** Data codewords (payload, no ECC) a version/level pair can carry. */\nfunction dataCodewords(version: number, ecc: QrEccLevel): number {\n  return (\n    Math.floor(rawDataModules(version) / 8) -\n    ECC_CODEWORDS_PER_BLOCK[ecc][version] * ECC_BLOCK_COUNT[ecc][version]\n  )\n}\n\n/** Row/column centres of the alignment patterns; version 1 has none. */\nfunction alignmentPositions(version: number): number[] {\n  if (version === 1) return []\n  const count = Math.floor(version / 7) + 2\n  const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2\n  const result = [6]\n  for (let pos = version * 4 + 10; result.length < count; pos -= step) result.splice(1, 0, pos)\n  return result\n}\n\n/** Byte mode spends 8 bits on the length below version 10 and 16 bits above it. */\nfunction characterCountBits(version: number): number {\n  return version < 10 ? 8 : 16\n}\n\nfunction getBit(value: number, index: number): boolean {\n  return ((value >>> index) & 1) !== 0\n}\n\n/** Split the payload into blocks, append ECC to each, then interleave them. */\nfunction addEccAndInterleave(data: number[], version: number, ecc: QrEccLevel): number[] {\n  const blockCount = ECC_BLOCK_COUNT[ecc][version]\n  const blockEccLength = ECC_CODEWORDS_PER_BLOCK[ecc][version]\n  const rawCodewords = Math.floor(rawDataModules(version) / 8)\n  const shortBlockCount = blockCount - (rawCodewords % blockCount)\n  const shortBlockLength = Math.floor(rawCodewords / blockCount)\n\n  const divisor = reedSolomonDivisor(blockEccLength)\n  const blocks: number[][] = []\n  let offset = 0\n  for (let i = 0; i < blockCount; i += 1) {\n    const length = shortBlockLength - blockEccLength + (i < shortBlockCount ? 0 : 1)\n    const chunk = data.slice(offset, offset + length)\n    offset += length\n    const parity = reedSolomonRemainder(chunk, divisor)\n    // Short blocks get a hole here so every block interleaves at the same width.\n    if (i < shortBlockCount) chunk.push(0)\n    blocks.push(chunk.concat(parity))\n  }\n\n  const result: number[] = []\n  for (let i = 0; i < blocks[0].length; i += 1) {\n    for (let j = 0; j < blocks.length; j += 1) {\n      if (i !== shortBlockLength - blockEccLength || j >= shortBlockCount) result.push(blocks[j][i])\n    }\n  }\n  return result\n}\n\n/**\n * Penalty rules 1 and 3 for one line (a row or a column). Rule 3 counts the\n * 1:1:3:1:1 finder-lookalike; the quiet zone counts as light, which is why the\n * first and last runs are padded by the symbol size.\n */\nfunction linePenalty(line: boolean[], size: number): number {\n  const history = [0, 0, 0, 0, 0, 0, 0]\n  const pushRun = (length: number) => {\n    // The run touching the edge is preceded by the quiet zone, which counts as\n    // light: without this the finder pattern in the corner scores nothing.\n    const padded = history[0] === 0 ? length + size : length\n    history.pop()\n    history.unshift(padded)\n  }\n  const countFinders = (): number => {\n    const n = history[1]\n    const core =\n      n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n\n    return (\n      (core && history[0] >= n * 4 && history[6] >= n ? 1 : 0) +\n      (core && history[6] >= n * 4 && history[0] >= n ? 1 : 0)\n    )\n  }\n\n  let result = 0\n  let runColor = false\n  let runLength = 0\n  for (let i = 0; i < size; i += 1) {\n    if (line[i] === runColor) {\n      runLength += 1\n      if (runLength === 5) result += 3\n      else if (runLength > 5) result += 1\n    } else {\n      pushRun(runLength)\n      if (!runColor) result += countFinders() * 40\n      runColor = line[i]\n      runLength = 1\n    }\n  }\n  if (runColor) {\n    pushRun(runLength)\n    runLength = 0\n  }\n  pushRun(runLength + size)\n  return result + countFinders() * 40\n}\n\n/** The four spec penalty rules; the lowest scoring mask is the one applied. */\nfunction penaltyScore(modules: boolean[][], size: number): number {\n  let result = 0\n  for (let y = 0; y < size; y += 1) result += linePenalty(modules[y], size)\n  for (let x = 0; x < size; x += 1) {\n    result += linePenalty(\n      modules.map(row => row[x]),\n      size,\n    )\n  }\n  for (let y = 0; y < size - 1; y += 1) {\n    for (let x = 0; x < size - 1; x += 1) {\n      const cell = modules[y][x]\n      if (cell === modules[y][x + 1] && cell === modules[y + 1][x] && cell === modules[y + 1][x + 1]) {\n        result += 3\n      }\n    }\n  }\n  let dark = 0\n  for (const row of modules) for (const cell of row) if (cell) dark += 1\n  const total = size * size\n  return result + (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10\n}\n\n/**\n * Encode `text` (UTF-8, byte mode) into a QR matrix, choosing the smallest\n * version that fits and the mask with the lowest penalty.\n *\n * Returns `null` for an empty string and for anything that will not fit in a\n * version-40 symbol (about 2.9 KB at level L) — a refusal, never a crash: the\n * caller falls back to printing the link.\n */\nexport function encodeQr(text: string, ecc: QrEccLevel = \"M\"): QrMatrix | null {\n  if (text.length === 0) return null\n  const bytes = Array.from(new TextEncoder().encode(text))\n\n  let version = 0\n  for (let candidate = 1; candidate <= 40; candidate += 1) {\n    if (4 + characterCountBits(candidate) + bytes.length * 8 <= dataCodewords(candidate, ecc) * 8) {\n      version = candidate\n      break\n    }\n  }\n  if (version === 0) return null\n\n  const bits: number[] = []\n  const pushBits = (value: number, length: number) => {\n    for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1)\n  }\n  pushBits(0b0100, 4) // byte mode\n  pushBits(bytes.length, characterCountBits(version))\n  for (const byte of bytes) pushBits(byte, 8)\n\n  const capacity = dataCodewords(version, ecc) * 8\n  pushBits(0, Math.min(4, capacity - bits.length)) // terminator\n  pushBits(0, (8 - (bits.length % 8)) % 8) // byte alignment\n  for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) pushBits(pad, 8)\n\n  const codewords: number[] = []\n  for (let i = 0; i < bits.length; i += 8) {\n    let byte = 0\n    for (let j = 0; j < 8; j += 1) byte = (byte << 1) | bits[i + j]\n    codewords.push(byte)\n  }\n  const payload = addEccAndInterleave(codewords, version, ecc)\n\n  const size = version * 4 + 17\n  const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))\n  // Function modules are never masked and never carry data.\n  const reserved: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))\n  const setFunction = (x: number, y: number, dark: boolean) => {\n    modules[y][x] = dark\n    reserved[y][x] = true\n  }\n\n  for (let i = 0; i < size; i += 1) {\n    setFunction(6, i, i % 2 === 0)\n    setFunction(i, 6, i % 2 === 0)\n  }\n  const drawFinder = (cx: number, cy: number) => {\n    for (let dy = -4; dy <= 4; dy += 1) {\n      for (let dx = -4; dx <= 4; dx += 1) {\n        const x = cx + dx\n        const y = cy + dy\n        const distance = Math.max(Math.abs(dx), Math.abs(dy))\n        if (x >= 0 && x < size && y >= 0 && y < size) {\n          setFunction(x, y, distance !== 2 && distance !== 4)\n        }\n      }\n    }\n  }\n  drawFinder(3, 3)\n  drawFinder(size - 4, 3)\n  drawFinder(3, size - 4)\n\n  const align = alignmentPositions(version)\n  for (let i = 0; i < align.length; i += 1) {\n    for (let j = 0; j < align.length; j += 1) {\n      // The three corners already hold finder patterns.\n      const corner =\n        (i === 0 && j === 0) ||\n        (i === 0 && j === align.length - 1) ||\n        (i === align.length - 1 && j === 0)\n      if (corner) continue\n      for (let dy = -2; dy <= 2; dy += 1) {\n        for (let dx = -2; dx <= 2; dx += 1) {\n          setFunction(align[i] + dx, align[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)\n        }\n      }\n    }\n  }\n\n  const drawFormat = (mask: number) => {\n    const value = (ECC_FORMAT_BITS[ecc] << 3) | mask\n    let remainder = value\n    for (let i = 0; i < 10; i += 1) remainder = (remainder << 1) ^ ((remainder >>> 9) * 0x537)\n    const format = ((value << 10) | remainder) ^ 0x5412\n    for (let i = 0; i <= 5; i += 1) setFunction(8, i, getBit(format, i))\n    setFunction(8, 7, getBit(format, 6))\n    setFunction(8, 8, getBit(format, 7))\n    setFunction(7, 8, getBit(format, 8))\n    for (let i = 9; i < 15; i += 1) setFunction(14 - i, 8, getBit(format, i))\n    for (let i = 0; i < 8; i += 1) setFunction(size - 1 - i, 8, getBit(format, i))\n    for (let i = 8; i < 15; i += 1) setFunction(8, size - 15 + i, getBit(format, i))\n    setFunction(8, size - 8, true) // the always-dark module\n  }\n  drawFormat(0)\n\n  if (version >= 7) {\n    let remainder = version\n    for (let i = 0; i < 12; i += 1) remainder = (remainder << 1) ^ ((remainder >>> 11) * 0x1f25)\n    const versionBits = (version << 12) | remainder\n    for (let i = 0; i < 18; i += 1) {\n      const bit = getBit(versionBits, i)\n      const a = size - 11 + (i % 3)\n      const b = Math.floor(i / 3)\n      setFunction(a, b, bit)\n      setFunction(b, a, bit)\n    }\n  }\n\n  // Zig-zag the codewords upward and downward through every free module.\n  let cursor = 0\n  for (let right = size - 1; right >= 1; right -= 2) {\n    if (right === 6) right = 5 // the vertical timing column is skipped\n    for (let vertical = 0; vertical < size; vertical += 1) {\n      for (let j = 0; j < 2; j += 1) {\n        const x = right - j\n        const upward = ((right + 1) & 2) === 0\n        const y = upward ? size - 1 - vertical : vertical\n        if (!reserved[y][x] && cursor < payload.length * 8) {\n          modules[y][x] = getBit(payload[cursor >>> 3], 7 - (cursor & 7))\n          cursor += 1\n        }\n      }\n    }\n  }\n\n  const maskAt = (mask: number, x: number, y: number): boolean => {\n    switch (mask) {\n      case 0:\n        return (x + y) % 2 === 0\n      case 1:\n        return y % 2 === 0\n      case 2:\n        return x % 3 === 0\n      case 3:\n        return (x + y) % 3 === 0\n      case 4:\n        return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0\n      case 5:\n        return ((x * y) % 2) + ((x * y) % 3) === 0\n      case 6:\n        return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0\n      default:\n        return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0\n    }\n  }\n  const applyMask = (mask: number) => {\n    for (let y = 0; y < size; y += 1) {\n      for (let x = 0; x < size; x += 1) {\n        if (!reserved[y][x] && maskAt(mask, x, y)) modules[y][x] = !modules[y][x]\n      }\n    }\n  }\n\n  let bestMask = 0\n  let bestPenalty = Number.POSITIVE_INFINITY\n  for (let mask = 0; mask < 8; mask += 1) {\n    applyMask(mask)\n    drawFormat(mask)\n    const score = penaltyScore(modules, size)\n    if (score < bestPenalty) {\n      bestPenalty = score\n      bestMask = mask\n    }\n    applyMask(mask) // XOR again to undo\n  }\n  applyMask(bestMask)\n  drawFormat(bestMask)\n\n  return { modules, size, version }\n}\n\n/** One SVG path for the whole symbol; horizontal runs merge into one rect. */\nexport function qrPathData(matrix: QrMatrix, quietZone: number): string {\n  const parts: string[] = []\n  for (let y = 0; y < matrix.size; y += 1) {\n    let x = 0\n    while (x < matrix.size) {\n      if (!matrix.modules[y][x]) {\n        x += 1\n        continue\n      }\n      let run = 1\n      while (x + run < matrix.size && matrix.modules[y][x + run]) run += 1\n      parts.push(`M${x + quietZone} ${y + quietZone}h${run}v1h-${run}z`)\n      x += run\n    }\n  }\n  return parts.join(\"\")\n}\n\n/* -- qr-encoder-end -- */\n\n/* --------------------------------------------------------------- platform */\n\nexport type DevicePlatform = \"ios\" | \"android\" | \"macos\" | \"windows\" | \"linux\" | \"unknown\"\n\ninterface UserAgentDataLike {\n  platform?: string\n}\n\n/** The three fields detection reads, so it can be unit tested without a browser. */\nexport interface NavigatorLike {\n  userAgent?: string\n  platform?: string\n  maxTouchPoints?: number\n  userAgentData?: UserAgentDataLike\n}\n\nconst PLATFORM_WORD: Record<DevicePlatform, string> = {\n  android: \"Android\",\n  ios: \"iOS\",\n  linux: \"Linux\",\n  macos: \"macOS\",\n  unknown: \"this device\",\n  windows: \"Windows\",\n}\n\n/** A phone or tablet — the devices for which a QR code is pointless. */\nexport function isHandheld(platform: DevicePlatform): boolean {\n  return platform === \"ios\" || platform === \"android\"\n}\n\n/**\n * Best-effort platform sniffing. It reads the user agent, which the server does\n * not have, so it is only ever reached through the client snapshot of\n * `useSyncExternalStore` — never on the server render, never during hydration.\n *\n * Order matters. iPadOS 13+ reports itself as a Mac and only the touch points\n * give it away; every Android user agent also contains the word \"Linux\".\n */\nexport function detectPlatform(navigatorLike: NavigatorLike | null | undefined): DevicePlatform {\n  if (!navigatorLike) return \"unknown\"\n  const ua = typeof navigatorLike.userAgent === \"string\" ? navigatorLike.userAgent : \"\"\n  const legacy = typeof navigatorLike.platform === \"string\" ? navigatorLike.platform : \"\"\n  const touchPoints =\n    typeof navigatorLike.maxTouchPoints === \"number\" ? navigatorLike.maxTouchPoints : 0\n  const hinted =\n    typeof navigatorLike.userAgentData?.platform === \"string\"\n      ? navigatorLike.userAgentData.platform.toLowerCase()\n      : \"\"\n\n  if (/iphone|ipad|ipod/i.test(ua) || hinted === \"ios\") return \"ios\"\n  if (/^mac/i.test(legacy) && touchPoints > 1) return \"ios\"\n  if (/android/i.test(ua) || hinted === \"android\") return \"android\"\n  if (hinted.includes(\"windows\")) return \"windows\"\n  if (hinted.includes(\"mac\")) return \"macos\"\n  if (hinted.includes(\"linux\") || hinted.includes(\"chrome os\") || hinted.includes(\"chromium os\")) {\n    return \"linux\"\n  }\n  if (/windows|win32|win64/i.test(ua)) return \"windows\"\n  if (/mac os x|macintosh/i.test(ua)) return \"macos\"\n  if (/linux|x11|cros/i.test(ua)) return \"linux\"\n  return \"unknown\"\n}\n\n/** The user agent never changes under a mounted page, so there is nothing to subscribe to. */\nconst subscribeToNothing = () => () => {}\n\n/** Neutral shape for a store that ships no badge — a device, never a brand mark. */\nfunction PlatformIcon({ className, platforms }: { className?: string; platforms: DevicePlatform[] }) {\n  const handheld = platforms.some(isHandheld)\n  const desktop = platforms.some(platform => platform !== \"unknown\" && !isHandheld(platform))\n  if (handheld && desktop) return <MonitorSmartphone aria-hidden=\"true\" className={className} />\n  if (handheld) return <Smartphone aria-hidden=\"true\" className={className} />\n  if (desktop) return <Monitor aria-hidden=\"true\" className={className} />\n  return <Download aria-hidden=\"true\" className={className} />\n}\n\n/* ---------------------------------------------------------------- helpers */\n\n/** How long the copy button holds its result before returning to idle. */\nconst COPY_RESET_MS = 1600\n/** Modules of light margin around the symbol. Four is the spec minimum. */\nconst QUIET_ZONE = 4\n\n/**\n * Stores print app sizes in decimal units (\"96.4 MB\" is 96,400,000 bytes), so\n * that is what this formats. Non-finite or negative input is not a size.\n */\nexport function formatAppSize(bytes: number): string | null {\n  if (!Number.isFinite(bytes) || bytes < 0) return null\n  if (bytes < 1000) return `${Math.round(bytes)} B`\n  const units = [\"KB\", \"MB\", \"GB\"]\n  let value = bytes / 1000\n  let unit = 0\n  while (value >= 1000 && unit < units.length - 1) {\n    value /= 1000\n    unit += 1\n  }\n  // One decimal is how a store prints \"96.4 MB\"; past three digits it is noise.\n  return `${value < 100 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`\n}\n\nfunction parseInstant(iso: string): number | null {\n  const ms = Date.parse(iso)\n  return Number.isFinite(ms) ? ms : null\n}\n\n/**\n * An unknown IANA zone or a malformed BCP-47 tag makes Intl throw a RangeError\n * at construction; a download page must not blank out over a config typo.\n */\nfunction createDateFormatter(locale: string, timeZone: string): Intl.DateTimeFormat {\n  const base: Intl.DateTimeFormatOptions = { year: \"numeric\", month: \"short\", day: \"numeric\" }\n  for (const options of [{ ...base, timeZone }, base]) {\n    try {\n      return new Intl.DateTimeFormat(locale, options)\n    } catch {\n      // fall through to the looser attempt\n    }\n  }\n  return new Intl.DateTimeFormat(\"en-US\", base)\n}\n\n/**\n * Relative when an `asOf` instant was injected, absolute otherwise — never\n * `Date.now()`, so the same props always render the same sentence.\n */\nexport function formatUpdated(\n  updatedAt: string,\n  asOf: string | undefined,\n  formatter: Intl.DateTimeFormat,\n): string {\n  const updated = parseInstant(updatedAt)\n  if (updated === null) return updatedAt // print it verbatim rather than \"Invalid Date\"\n  const now = asOf === undefined ? null : parseInstant(asOf)\n  if (now !== null && updated <= now) {\n    const days = Math.floor((now - updated) / 86_400_000)\n    if (days <= 0) return \"today\"\n    if (days === 1) return \"yesterday\"\n    if (days < 30) return `${days} days ago`\n  }\n  return formatter.format(updated)\n}\n\n/** `useLayoutEffect` warns when it runs on the server, and this block SSRs. */\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nconst focusRing =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n\nconst quietButton = cn(\n  \"inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium\",\n  \"transition-colors motion-reduce:transition-none hover:bg-accent hover:text-accent-foreground\",\n  focusRing,\n)\n\nconst chip = \"inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs\"\n\n/* ------------------------------------------------------------------ types */\n\nexport interface AppDownloadStore {\n  /** Stable key, also what `onStoreSelect` reports. */\n  id: string\n  /** Store name as it should be read aloud: \"App Store\", \"Google Play\". */\n  name: string\n  /** Platforms this build serves; detection promotes the first store listing the visitor's. */\n  platforms: DevicePlatform[]\n  /** Where the badge goes. A store tile that leads nowhere is worse than no tile. */\n  href: string\n  /**\n   * The official store badge, supplied by YOU as a node (`<img>`, inline\n   * `<svg>`, `next/image`). Brand marks are never redrawn from tokens here —\n   * Apple and Google both forbid it. Omit it and a neutral tokenised tile with\n   * the store name is rendered instead.\n   */\n  badge?: React.ReactNode\n  /** Minimum OS line: \"Requires iOS 16\". */\n  requirement?: string\n  /** Build number for this store; falls back to the block-level `version`. */\n  version?: string\n  /** Build size in BYTES; falls back to the block-level `sizeBytes`. */\n  sizeBytes?: number\n  /** ISO instant of this build's release; falls back to the block-level `updatedAt`. */\n  updatedAt?: string\n  /** Present = listed but not actionable, and this string is the reason why. */\n  unavailable?: string\n}\n\ntype CopyState = \"idle\" | \"copied\" | \"failed\"\n\nexport interface AppDownloadProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"title\" | \"children\"> {\n  /** Heading of the block. */\n  title: string\n  /** One supporting sentence under the heading. */\n  description?: string\n  /** Small label above the heading. */\n  eyebrow?: string\n  /** Every place the app can be got. Source order breaks promotion ties. */\n  stores: AppDownloadStore[]\n  /** The one URL that redirects per device — this is what the QR encodes. */\n  smartLink: string\n  /** \"desktop\" (default) hides the QR on phones, where it is useless. */\n  qrVisibility?: \"desktop\" | \"always\" | \"never\"\n  /** Error correction of the symbol. Default \"M\". */\n  qrEcc?: QrEccLevel\n  /** Caption under the symbol. */\n  qrCaption?: string\n  /** Controlled platform. Passing it turns detection off entirely. */\n  platform?: DevicePlatform\n  /** First-paint platform, used by the server and by hydration. Default \"unknown\". */\n  defaultPlatform?: DevicePlatform\n  /** Version shown for stores that do not carry their own. */\n  version?: string\n  /** Size in bytes shown for stores that do not carry their own. */\n  sizeBytes?: number\n  /** ISO instant shown for stores that do not carry their own. */\n  updatedAt?: string\n  /** Injected \"now\". With it, release dates read \"3 days ago\"; without it, absolute. */\n  asOf?: string\n  /** BCP-47 tag for the date. Default \"en-US\". */\n  locale?: string\n  /** IANA zone the date is printed in. Default \"UTC\", so SSR and the client agree. */\n  timeZone?: string\n  /** Small print under the tiles: \"Free · no account needed\". */\n  footnote?: React.ReactNode\n  /** Analytics only — the tiles are real links and navigate on their own. */\n  onStoreSelect?: (store: AppDownloadStore) => void\n  /** Fired once per copy burst, after the clipboard actually accepted the link. */\n  onCopyLink?: (link: string) => void\n}\n\n/* ------------------------------------------------------------- fragments */\n\ninterface StoreTileProps {\n  emphasis: \"primary\" | \"secondary\"\n  meta: string | null\n  onSelect: (store: AppDownloadStore) => void\n  onUnavailable: (store: AppDownloadStore) => void\n  store: AppDownloadStore\n}\n\n/**\n * One store. A live store is a real `<a>` (so middle-click and \"open in new\n * tab\" behave); an unavailable one is a button carrying `aria-disabled` plus a\n * handler guard — never the native `disabled` attribute, which would blur a\n * keyboard user straight to `<body>`.\n */\nfunction StoreTile({ emphasis, meta, onSelect, onUnavailable, store }: StoreTileProps) {\n  const primary = emphasis === \"primary\"\n  const label = store.unavailable ? `${store.name} — ${store.unavailable}` : store.name\n\n  const shell = cn(\n    \"group flex items-center gap-3 rounded-xl border text-left\",\n    \"transition-colors motion-reduce:transition-none\",\n    focusRing,\n    primary ? \"bg-card px-4 py-3\" : \"bg-card px-3 py-2\",\n    store.unavailable\n      ? \"cursor-not-allowed border-dashed opacity-70\"\n      : \"cursor-pointer hover:bg-accent hover:text-accent-foreground\",\n  )\n\n  const body = (\n    <>\n      {store.badge ? (\n        // Your asset, rendered untouched: no colour, no radius, no redraw.\n        <span className=\"flex shrink-0 items-center\">{store.badge}</span>\n      ) : (\n        <>\n          <PlatformIcon\n            className={cn(\"shrink-0\", primary ? \"size-6\" : \"size-4\")}\n            platforms={store.platforms}\n          />\n          {/* The requirement lives in the meta line, not here: a tile carrying\n              your badge art has no room for it, and printing it twice on the\n              tiles that do have room reads like a bug. */}\n          <span\n            className={cn(\"min-w-0 font-medium wrap-anywhere\", primary ? \"text-base\" : \"text-sm\")}\n          >\n            {store.name}\n          </span>\n        </>\n      )}\n      {store.unavailable && (\n        <span className={cn(chip, \"ml-auto text-muted-foreground\")}>Not yet</span>\n      )}\n    </>\n  )\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1\">\n      {store.unavailable ? (\n        <button\n          aria-disabled=\"true\"\n          aria-label={label}\n          className={shell}\n          data-app-download-focus=\"\"\n          onClick={() => onUnavailable(store)}\n          type=\"button\"\n        >\n          {body}\n        </button>\n      ) : (\n        <a\n          aria-label={label}\n          className={shell}\n          data-app-download-focus=\"\"\n          href={store.href}\n          onClick={() => onSelect(store)}\n        >\n          {body}\n        </a>\n      )}\n      {store.unavailable && (\n        <p className=\"text-xs text-muted-foreground wrap-anywhere\">{store.unavailable}</p>\n      )}\n      {meta && <p className=\"text-xs text-muted-foreground wrap-anywhere\">{meta}</p>}\n    </div>\n  )\n}\n\ninterface QrPanelProps {\n  caption: string\n  copyState: CopyState\n  matrix: QrMatrix | null\n  onCopy: () => void\n  onFocusEscape: () => void\n  smartLink: string\n}\n\n/**\n * The desktop half of the block: the symbol, the link in plain text and a copy\n * button. It hands focus back before it unmounts, because a controlled\n * platform flip can pull it out from under whoever is standing in it.\n */\nfunction QrPanel({ caption, copyState, matrix, onCopy, onFocusEscape, smartLink }: QrPanelProps) {\n  const panelRef = React.useRef<HTMLDivElement>(null)\n  const escapeRef = React.useRef(onFocusEscape)\n  React.useEffect(() => {\n    escapeRef.current = onFocusEscape\n  }, [onFocusEscape])\n\n  // Empty deps on purpose: a dependency change would run this cleanup while\n  // the panel is still on screen and steal focus for no reason. With no\n  // dependencies it runs exactly once, on unmount, during the mutation phase —\n  // the node is still in the DOM, so `contains` can still answer.\n  useIsomorphicLayoutEffect(\n    () => () => {\n      const node = panelRef.current\n      if (node && node.contains(document.activeElement)) escapeRef.current()\n    },\n    [],\n  )\n\n  const CopyIcon = copyState === \"copied\" ? Check : copyState === \"failed\" ? TriangleAlert : Copy\n\n  return (\n    <div\n      className=\"flex w-full shrink-0 flex-col items-center gap-3 rounded-xl border bg-card p-4 sm:w-auto\"\n      ref={panelRef}\n    >\n      {matrix ? (\n        <div\n          // Fixed polarity in both themes: plenty of camera apps refuse an\n          // inverted symbol, so dark mode swaps the pair rather than the eye.\n          className=\"rounded-lg bg-background p-3 text-foreground dark:bg-foreground dark:text-background\"\n        >\n          <svg\n            aria-label={`QR code for ${smartLink}`}\n            className=\"size-36 sm:size-40\"\n            role=\"img\"\n            shapeRendering=\"crispEdges\"\n            viewBox={`0 0 ${matrix.size + QUIET_ZONE * 2} ${matrix.size + QUIET_ZONE * 2}`}\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path d={qrPathData(matrix, QUIET_ZONE)} fill=\"currentColor\" />\n          </svg>\n        </div>\n      ) : (\n        <div className=\"flex size-36 flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-3 text-center sm:size-40\">\n          <TriangleAlert aria-hidden=\"true\" className=\"size-5 text-muted-foreground\" />\n          <p className=\"text-xs text-muted-foreground\">\n            This link is too long to fit in a QR code. Copy it instead.\n          </p>\n        </div>\n      )}\n\n      <p className=\"max-w-44 text-center text-xs text-muted-foreground\">{caption}</p>\n\n      <div className=\"flex w-full max-w-64 items-center gap-2 rounded-md border bg-muted/40 py-1 pr-1 pl-2\">\n        <span className=\"min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground\">\n          {smartLink}\n        </span>\n        <button className={cn(quietButton, \"border-0\")} onClick={onCopy} type=\"button\">\n          <CopyIcon aria-hidden=\"true\" className=\"size-3.5\" />\n          {copyState === \"copied\" ? \"Copied\" : copyState === \"failed\" ? \"Copy failed\" : \"Copy\"}\n        </button>\n      </div>\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------- component */\n\n/**\n * The \"get the app\" block: store tiles, a QR code for the visitor sitting at a\n * desktop, and the version/size line an app store would print.\n *\n * Three rules shape it:\n *\n * 1. **Brand marks arrive as props.** Every store badge is a node you pass in;\n *    the block only lays it out. Without one it falls back to a neutral tile\n *    with the store's name — never a redrawn Apple or Play logo.\n * 2. **Detection promotes, it never hides.** The store matching the visitor's\n *    platform is lifted into the primary slot and every other store stays one\n *    tab stop away, because sniffing is a guess and a wrong guess must not cost\n *    anyone their download.\n * 3. **The first paint is the same everywhere.** The user agent is read through\n *    a `useSyncExternalStore` snapshot whose server value is `defaultPlatform`,\n *    and every date comes from an injected `asOf`, so the server and the\n *    browser always agree on what they drew.\n */\nexport const AppDownload = React.forwardRef<HTMLElement, AppDownloadProps>(\n  (\n    {\n      asOf,\n      className,\n      defaultPlatform = \"unknown\",\n      description,\n      eyebrow,\n      footnote,\n      locale = \"en-US\",\n      onCopyLink,\n      onStoreSelect,\n      platform: platformProp,\n      qrCaption = \"Point your phone camera at the code to install the app.\",\n      qrEcc = \"M\",\n      qrVisibility = \"desktop\",\n      sizeBytes,\n      smartLink,\n      stores,\n      timeZone = \"UTC\",\n      title,\n      updatedAt,\n      version,\n      ...rest\n    },\n    ref,\n  ) => {\n    const headingId = `${React.useId()}-title`\n\n    // The user agent is an external system React cannot render: the server\n    // snapshot is the injected default (so SSR and hydration draw the same\n    // markup) and the client snapshot is the sniffed platform, which React\n    // swaps in on the render right after hydration. Doing this with an effect\n    // + setState would cascade an extra render for the same result.\n    const readPlatform = React.useCallback(\n      () => detectPlatform(typeof navigator === \"undefined\" ? null : navigator),\n      [],\n    )\n    const readDefaultPlatform = React.useCallback(() => defaultPlatform, [defaultPlatform])\n    const detected = React.useSyncExternalStore(subscribeToNothing, readPlatform, readDefaultPlatform)\n    const platform = platformProp ?? detected\n\n    // \"Show every platform\" is the visitor overruling the guess, so it sticks\n    // even if the host later changes the platform underneath.\n    const [showAll, setShowAll] = React.useState(false)\n    const [announcement, setAnnouncement] = React.useState(\"\")\n\n    const dateFormatter = React.useMemo(\n      () => createDateFormatter(locale, timeZone),\n      [locale, timeZone],\n    )\n\n    // An unavailable store still wins the promoted slot when it is the\n    // visitor's platform: hiding the build they are waiting for is worse than\n    // telling them it is not ready.\n    const promoted = React.useMemo(() => {\n      if (showAll) return null\n      return stores.find(store => store.platforms.includes(platform)) ?? null\n    }, [platform, showAll, stores])\n    const others = promoted ? stores.filter(store => store !== promoted) : stores\n\n    const storesRef = React.useRef<HTMLDivElement>(null)\n    const focusPendingRef = React.useRef(false)\n    /** The first tile in the column — the deliberate successor for any control that leaves. */\n    const focusSuccessor = React.useCallback(() => {\n      storesRef.current?.querySelector<HTMLElement>(\"[data-app-download-focus]\")?.focus()\n    }, [])\n    useIsomorphicLayoutEffect(() => {\n      if (!focusPendingRef.current) return\n      focusPendingRef.current = false\n      focusSuccessor()\n    })\n\n    const handleShowAll = () => {\n      // This button is about to unmount with the promoted slot, so focus is\n      // handed to the first tile of the list it just revealed.\n      focusPendingRef.current = true\n      setShowAll(true)\n      setAnnouncement(`Showing all ${stores.length} platforms.`)\n    }\n\n    const [copyState, setCopyState] = React.useState<CopyState>(\"idle\")\n    const copyLockRef = React.useRef(false)\n    const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n    const aliveRef = React.useRef(true)\n    React.useEffect(() => {\n      aliveRef.current = true\n      return () => {\n        aliveRef.current = false\n        if (copyTimerRef.current) clearTimeout(copyTimerRef.current)\n      }\n    }, [])\n\n    const settleCopy = React.useCallback((state: CopyState, sentence: string) => {\n      if (!aliveRef.current) return\n      setCopyState(state)\n      setAnnouncement(sentence)\n      if (copyTimerRef.current) clearTimeout(copyTimerRef.current)\n      copyTimerRef.current = setTimeout(() => {\n        copyTimerRef.current = null\n        copyLockRef.current = false\n        setCopyState(\"idle\")\n      }, COPY_RESET_MS)\n    }, [])\n\n    const handleCopy = () => {\n      // The guard is a ref read AND written synchronously: a state flag only\n      // becomes visible after a re-render, and a double click lands first.\n      if (copyLockRef.current) return\n      copyLockRef.current = true\n      const clipboard = typeof navigator === \"undefined\" ? undefined : navigator.clipboard\n      if (!clipboard?.writeText) {\n        // Insecure origin, or a browser that never had the API. The link is on\n        // screen as selectable text, so say that instead of failing silently.\n        settleCopy(\"failed\", \"Copying is not available here. Select the link and copy it manually.\")\n        return\n      }\n      clipboard.writeText(smartLink).then(\n        () => {\n          onCopyLink?.(smartLink)\n          settleCopy(\"copied\", \"Download link copied to the clipboard.\")\n        },\n        () => {\n          settleCopy(\"failed\", \"The clipboard refused the link. Select it and copy it manually.\")\n        },\n      )\n    }\n\n    const handleUnavailable = (store: AppDownloadStore) => {\n      // aria-disabled keeps the tile focusable, so pressing it has to explain\n      // itself rather than swallow the press. The reason is the host's prose and\n      // may already end in a full stop — appending a second one announces \"…link..\".\n      const reason = store.unavailable ?? \"not available yet\"\n      setAnnouncement(`${store.name}: ${/[.!?]$/.test(reason) ? reason : `${reason}.`}`)\n    }\n\n    const storeMeta = (store: AppDownloadStore): string | null => {\n      const storeVersion = store.version ?? version\n      const bytes = store.sizeBytes ?? sizeBytes\n      const released = store.updatedAt ?? updatedAt\n      const parts = [\n        storeVersion ? `Version ${storeVersion}` : null,\n        bytes === undefined ? null : formatAppSize(bytes),\n        released ? `Updated ${formatUpdated(released, asOf, dateFormatter)}` : null,\n        store.requirement ?? null,\n      ].filter((part): part is string => part !== null)\n      return parts.length === 0 ? null : parts.join(\" · \")\n    }\n\n    // \"desktop\" shows the code for desktops AND for the unknown platform, so it\n    // is there on the first paint; a phone drops it, because scanning a code\n    // with the phone in your hand is theatre.\n    const showQr =\n      smartLink !== \"\" &&\n      (qrVisibility === \"always\" || (qrVisibility === \"desktop\" && !isHandheld(platform)))\n    // Encoded only when it will be shown: a phone should not spend the work.\n    const qrMatrix = React.useMemo(\n      () => (showQr ? encodeQr(smartLink, qrEcc) : null),\n      [qrEcc, showQr, smartLink],\n    )\n\n    return (\n      <section\n        aria-labelledby={headingId}\n        className={cn(\"flex w-full flex-col gap-6 text-foreground\", className)}\n        ref={ref}\n        {...rest}\n      >\n        <div className=\"flex flex-col gap-1\">\n          {eyebrow && (\n            <span className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\">\n              {eyebrow}\n            </span>\n          )}\n          <h2 className=\"text-xl font-semibold wrap-anywhere\" id={headingId}>\n            {title}\n          </h2>\n          {description && (\n            <p className=\"max-w-prose text-sm text-muted-foreground wrap-anywhere\">{description}</p>\n          )}\n        </div>\n\n        <div className=\"flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between\">\n          <div className=\"flex min-w-0 flex-1 flex-col gap-4\" ref={storesRef}>\n            {stores.length === 0 ? (\n              <div\n                className=\"flex flex-col items-center gap-2 rounded-xl border border-dashed bg-card px-4 py-10 text-center\"\n                data-app-download-focus=\"\"\n                tabIndex={-1}\n              >\n                <Download aria-hidden=\"true\" className=\"size-5 text-muted-foreground\" />\n                <p className=\"text-sm font-medium\">No download is published yet</p>\n                <p className=\"max-w-prose text-sm text-muted-foreground\">\n                  Add a store and its badge and this block turns into the download panel.\n                </p>\n              </div>\n            ) : promoted ? (\n              <>\n                <div className=\"flex max-w-md flex-col gap-2\">\n                  <StoreTile\n                    emphasis=\"primary\"\n                    meta={storeMeta(promoted)}\n                    onSelect={store => onStoreSelect?.(store)}\n                    onUnavailable={handleUnavailable}\n                    store={promoted}\n                  />\n                </div>\n                {others.length > 0 && (\n                  <div className=\"flex flex-col gap-2\">\n                    <p className=\"text-xs text-muted-foreground\">Also available on</p>\n                    <ul className=\"flex flex-wrap gap-2\" role=\"list\">\n                      {others.map(store => (\n                        <li key={store.id}>\n                          <StoreTile\n                            emphasis=\"secondary\"\n                            meta={null}\n                            onSelect={selected => onStoreSelect?.(selected)}\n                            onUnavailable={handleUnavailable}\n                            store={store}\n                          />\n                        </li>\n                      ))}\n                    </ul>\n                  </div>\n                )}\n                {/* Only when there is something else to show — \"every platform\"\n                    with nothing behind it is a lie the visitor can press. */}\n                {others.length > 0 && (\n                  <button\n                    className={cn(quietButton, \"self-start\")}\n                    onClick={handleShowAll}\n                    type=\"button\"\n                  >\n                    Not on {PLATFORM_WORD[platform]}? Show every platform\n                  </button>\n                )}\n              </>\n            ) : (\n              <>\n                {!showAll && platform !== \"unknown\" && (\n                  <p className=\"text-sm text-muted-foreground wrap-anywhere\">\n                    There is no {PLATFORM_WORD[platform]} build yet. Here is where the app does run.\n                  </p>\n                )}\n                <ul className=\"grid gap-2 sm:grid-cols-2\" role=\"list\">\n                  {stores.map(store => (\n                    <li className=\"min-w-0\" key={store.id}>\n                      <StoreTile\n                        emphasis=\"primary\"\n                        meta={storeMeta(store)}\n                        onSelect={selected => onStoreSelect?.(selected)}\n                        onUnavailable={handleUnavailable}\n                        store={store}\n                      />\n                    </li>\n                  ))}\n                </ul>\n              </>\n            )}\n          </div>\n\n          {showQr && (\n            <QrPanel\n              caption={qrCaption}\n              copyState={copyState}\n              matrix={qrMatrix}\n              onCopy={handleCopy}\n              onFocusEscape={focusSuccessor}\n              smartLink={smartLink}\n            />\n          )}\n        </div>\n\n        {footnote && <div className=\"text-xs text-muted-foreground wrap-anywhere\">{footnote}</div>}\n\n        <span aria-atomic=\"true\" className=\"sr-only\" role=\"status\">\n          {announcement}\n        </span>\n      </section>\n    )\n  },\n)\n\nAppDownload.displayName = \"AppDownload\"\n\nexport default AppDownload\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}