{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "add-to-homescreen",
  "title": "Add to Homescreen",
  "author": "Matthew Blode",
  "description": "A sheet of platform-aware instructions for installing a web app to the home screen.",
  "registryDependencies": ["button", "sheet", "@blode/copy-button", "@blode/use-install-prompt"],
  "files": [
    {
      "path": "ui/add-to-homescreen.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useInstallPrompt } from \"@/hooks/use-install-prompt\";\nimport { Button } from \"@/components/ui/button\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\n\ntype AddToHomescreenPlatform = \"android\" | \"desktop\" | \"ios\";\n\ntype AddToHomescreenBrowser =\n  | \"chrome\"\n  | \"edge\"\n  | \"facebook\"\n  | \"firefox\"\n  | \"instagram\"\n  | \"linkedin\"\n  | \"safari\"\n  | \"samsung\"\n  | \"twitter\"\n  | \"unknown\";\n\n/** One numbered instruction. `glyph` is drawn inline, mid-sentence. */\ninterface AddToHomescreenStep {\n  /** Small picture of the control the sentence names, shown inline. */\n  glyph?: React.ReactNode;\n  /** Sentence for this step. */\n  text: React.ReactNode;\n}\n\ninterface AddToHomescreenMessages {\n  /** Body of the fallback panel, when no recipe matches this browser. */\n  fallbackBody: string;\n  /** Heading of the fallback panel. */\n  fallbackTitle: string;\n  /** Accessible name of the copy-link button in the fallback panel. */\n  copyLink: string;\n  /** Label of the one-tap install button, where the browser offers one. */\n  install: string;\n  /** Sub-heading shown alongside the one-tap install button. */\n  installSubtitle: string;\n  /** Sub-heading under the title. */\n  subtitle: string;\n  /** Heading of the instruction panel. */\n  title: (appName: string) => string;\n}\n\nconst DEFAULT_MESSAGES: AddToHomescreenMessages = {\n  copyLink: \"Copy link\",\n  fallbackBody:\n    \"Open this page in Safari on iOS, or Chrome on Android, to add it to your home screen.\",\n  fallbackTitle: \"Add this app to your home screen\",\n  install: \"Install\",\n  installSubtitle: \"It opens in its own window, like an app.\",\n  subtitle: \"Two steps, and it opens like an app.\",\n  title: (appName) => `Add ${appName} to your home screen`,\n};\n\n/*\n * Glyphs are drawn rather than imported so the item installs as one file. The\n * registry has no mechanism for copying assets, so a consumer of an <img>-based\n * version would get broken links.\n */\n\nconst ShareGlyph = () => (\n  <svg aria-hidden=\"true\" fill=\"none\" viewBox=\"0 0 24 24\">\n    <title>Share</title>\n    <path\n      d=\"M12 3v12M12 3 8.5 6.5M12 3l3.5 3.5\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.6\"\n    />\n    <path\n      d=\"M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"1.6\"\n    />\n  </svg>\n);\n\nconst MenuGlyph = () => (\n  <svg aria-hidden=\"true\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n    <title>Menu</title>\n    <circle cx=\"12\" cy=\"5\" r=\"1.7\" />\n    <circle cx=\"12\" cy=\"12\" r=\"1.7\" />\n    <circle cx=\"12\" cy=\"19\" r=\"1.7\" />\n  </svg>\n);\n\nconst AddSquareGlyph = () => (\n  <svg aria-hidden=\"true\" fill=\"none\" viewBox=\"0 0 24 24\">\n    <title>Add to Home Screen</title>\n    <rect height=\"16\" rx=\"4\" stroke=\"currentColor\" strokeWidth=\"1.6\" width=\"16\" x=\"4\" y=\"4\" />\n    <path d=\"M12 8.5v7M8.5 12h7\" stroke=\"currentColor\" strokeLinecap=\"round\" strokeWidth=\"1.6\" />\n  </svg>\n);\n\n/** The bouncing pointer. Rotated by its wrapper, so it always draws upward. */\nconst ArrowGlyph = () => (\n  <svg aria-hidden=\"true\" fill=\"none\" viewBox=\"0 0 24 32\">\n    <title>Arrow</title>\n    <path\n      d=\"M12 30V4M12 4 5 11M12 4l7 7\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2.4\"\n    />\n  </svg>\n);\n\nconst testUserAgent = (pattern: RegExp): boolean => {\n  if (typeof window === \"undefined\" || !window.navigator) {\n    return false;\n  }\n\n  const brands = (\n    window.navigator as Navigator & {\n      userAgentData?: { brands: { brand: string }[] };\n    }\n  ).userAgentData?.brands;\n\n  return (\n    Boolean(brands?.some((entry) => pattern.test(entry.brand))) ||\n    pattern.test(window.navigator.userAgent)\n  );\n};\n\nconst detectIsIos = (): boolean => {\n  if (typeof window === \"undefined\") {\n    return false;\n  }\n\n  // iPadOS 13+ reports itself as a Mac, and only the touch count gives it away.\n  const isIpad = window.navigator.platform === \"MacIntel\" && window.navigator.maxTouchPoints > 1;\n  return testUserAgent(/iPad|iPhone|iPod/u) || isIpad;\n};\n\nconst referrerMatches = (fragment: string): boolean =>\n  typeof document !== \"undefined\" && document.referrer.includes(fragment);\n\n/**\n * Instagram and Threads render their in-app browser shorter than the screen and\n * do not brand the user agent, so the height gap is the only tell. It is a\n * heuristic: a browser with a persistent toolbar can produce the same gap.\n */\nconst looksLikeMetaInAppBrowser = (): boolean => {\n  if (typeof window === \"undefined\") {\n    return false;\n  }\n\n  if (referrerMatches(\"//l.instagram.com/\")) {\n    return true;\n  }\n\n  return (\n    testUserAgent(/iPhone/u) &&\n    Boolean(window.screen.height) &&\n    Boolean(window.outerHeight) &&\n    window.outerHeight < window.screen.height\n  );\n};\n\nconst detectBrowser = (platform: AddToHomescreenPlatform): AddToHomescreenBrowser => {\n  // In-app webviews impersonate the system browser, so they are tested first.\n  if (testUserAgent(/FBAN|FBAV/u)) {\n    return \"facebook\";\n  }\n  if (testUserAgent(/LinkedInApp/u)) {\n    return \"linkedin\";\n  }\n\n  if (platform === \"ios\") {\n    if (referrerMatches(\"//t.co/\")) {\n      return \"twitter\";\n    }\n    if (testUserAgent(/CriOS/u)) {\n      return \"chrome\";\n    }\n    if (testUserAgent(/FxiOS/u)) {\n      return \"firefox\";\n    }\n    if (testUserAgent(/EdgiOS/u)) {\n      return \"edge\";\n    }\n    if (looksLikeMetaInAppBrowser()) {\n      return \"instagram\";\n    }\n    return testUserAgent(/Safari/u) ? \"safari\" : \"unknown\";\n  }\n\n  if (platform === \"android\") {\n    if (testUserAgent(/SamsungBrowser/u)) {\n      return \"samsung\";\n    }\n    if (testUserAgent(/EdgA/u)) {\n      return \"edge\";\n    }\n    if (testUserAgent(/Firefox/u)) {\n      return \"firefox\";\n    }\n    return testUserAgent(/Chrome/u) ? \"chrome\" : \"unknown\";\n  }\n\n  // Desktop. Chrome, Edge and Safari can all install a page as an app.\n  if (testUserAgent(/EdgA?\\b|Edg\\//u)) {\n    return \"edge\";\n  }\n  if (testUserAgent(/Firefox/u)) {\n    return \"firefox\";\n  }\n  if (testUserAgent(/Chrome|Chromium/u)) {\n    return \"chrome\";\n  }\n  if (testUserAgent(/Safari/u)) {\n    return \"safari\";\n  }\n  return \"unknown\";\n};\n\nconst detectPlatform = (): AddToHomescreenPlatform => {\n  if (detectIsIos()) {\n    return \"ios\";\n  }\n  return testUserAgent(/Android/u) ? \"android\" : \"desktop\";\n};\n\nconst detectInstalled = (): boolean => {\n  if (typeof window === \"undefined\") {\n    return false;\n  }\n\n  return (\n    (window.navigator as Navigator & { standalone?: boolean }).standalone === true ||\n    window.matchMedia(\"(display-mode: standalone)\").matches\n  );\n};\n\ninterface Recipe {\n  /**\n   * Where the arrow points, or null when the control's position is a user\n   * setting we cannot read. A wrong arrow is worse than none: the reader\n   * trusts it, looks at the wrong edge, and stops trusting the steps too.\n   */\n  arrow: \"down\" | \"up\" | null;\n  /** Which edge of the screen the sheet hugs. */\n  edge: \"bottom\" | \"top\";\n  steps: AddToHomescreenStep[];\n}\n\nconst MENU_HINT = \"You may need to scroll the menu to find it.\";\n\n/**\n * Recipes are keyed on where the browser actually keeps its install control,\n * researched per browser rather than assumed:\n *\n * - iOS Safari keeps Share in the bottom toolbar in all three layouts\n *   (Bottom, Top and Compact); Compact only hides it behind a `...` button.\n * - iOS Chrome/Edge/Firefox put Share beside the address bar, which the user\n *   can move to the top or the bottom. Undetectable, so no arrow.\n * - Android and desktop Chromium menus are pinned to the top right.\n * - Desktop Chrome and Edge show an install pill at the end of the address bar.\n * - macOS Safari installs through File > Add to Dock (Sonoma 14 and later).\n */\nconst getRecipe = (\n  platform: AddToHomescreenPlatform,\n  browser: AddToHomescreenBrowser,\n): Recipe | null => {\n  const openInBrowser: Recipe = {\n    // The in-app \"...\" moves between apps and versions, so it is described\n    // rather than pointed at.\n    arrow: null,\n    edge: \"top\",\n    steps: [\n      { glyph: <MenuGlyph />, text: \"Tap the menu in this app's browser bar.\" },\n      { text: \"Choose Open in browser, then install it from there.\" },\n    ],\n  };\n\n  if (browser === \"facebook\" || browser === \"linkedin\" || browser === \"instagram\") {\n    return openInBrowser;\n  }\n\n  if (platform === \"ios\") {\n    if (browser === \"twitter\") {\n      return openInBrowser;\n    }\n    if (browser === \"safari\") {\n      return {\n        arrow: \"down\",\n        edge: \"bottom\",\n        steps: [\n          {\n            glyph: <ShareGlyph />,\n            text: \"Tap Share in the toolbar below. In the compact layout, tap the ... button first.\",\n          },\n          { glyph: <AddSquareGlyph />, text: `Choose Add to Home Screen. ${MENU_HINT}` },\n        ],\n      };\n    }\n    if (browser === \"chrome\" || browser === \"edge\" || browser === \"firefox\") {\n      return {\n        arrow: null,\n        edge: \"bottom\",\n        steps: [\n          // \"Beside the address bar\" survives the user moving the address bar;\n          // \"below\" would not.\n          { glyph: <ShareGlyph />, text: \"Tap Share, beside the address bar.\" },\n          { glyph: <AddSquareGlyph />, text: `Choose Add to Home Screen. ${MENU_HINT}` },\n        ],\n      };\n    }\n    return null;\n  }\n\n  if (platform === \"android\") {\n    if (browser === \"chrome\" || browser === \"edge\" || browser === \"samsung\") {\n      return {\n        arrow: \"up\",\n        edge: \"top\",\n        steps: [\n          { glyph: <MenuGlyph />, text: \"Tap the menu at the top right.\" },\n          // Chromium reserves \"Add to Home screen\" for plain shortcuts and\n          // says \"Install app\" for a page that meets the install criteria.\n          { glyph: <AddSquareGlyph />, text: `Choose Install app. ${MENU_HINT}` },\n        ],\n      };\n    }\n    if (browser === \"firefox\") {\n      return {\n        arrow: \"up\",\n        edge: \"top\",\n        steps: [\n          { glyph: <MenuGlyph />, text: \"Tap the menu at the top right.\" },\n          { glyph: <AddSquareGlyph />, text: \"Choose Install, then Add to home screen.\" },\n        ],\n      };\n    }\n    return null;\n  }\n\n  if (browser === \"chrome\" || browser === \"edge\") {\n    return {\n      arrow: \"up\",\n      edge: \"top\",\n      steps: [\n        {\n          glyph: <AddSquareGlyph />,\n          text: \"Click the install icon at the right of the address bar.\",\n        },\n        {\n          glyph: <MenuGlyph />,\n          text: \"If it is not there, open the menu and look for Install page as an app.\",\n        },\n      ],\n    };\n  }\n\n  if (browser === \"safari\") {\n    return {\n      arrow: \"up\",\n      edge: \"top\",\n      steps: [\n        { glyph: <ShareGlyph />, text: \"Open the File menu at the top of the screen.\" },\n        { glyph: <AddSquareGlyph />, text: \"Choose Add to Dock, then click Add.\" },\n      ],\n    };\n  }\n\n  // Desktop Firefox has no install path, and neither does anything unplaced.\n  return null;\n};\n\ninterface Detection {\n  browser: AddToHomescreenBrowser;\n  href: string;\n  installed: boolean;\n  platform: AddToHomescreenPlatform;\n}\n\n/** The bouncing pointer, placed just inside the edge the sheet hugs. */\nconst InstallArrow = ({ direction }: { direction: \"down\" | \"up\" }) => (\n  <div\n    aria-hidden=\"true\"\n    className={cn(\n      \"flex text-foreground/70\",\n      // Safari's share control is centred in the bottom toolbar; the Chromium\n      // menus and the desktop install pill sit at the top right, where pr-12\n      // also clears the sheet's own close button.\n      direction === \"down\" ? \"justify-center pb-1\" : \"justify-end pr-12\",\n    )}\n    data-slot=\"add-to-homescreen-arrow\"\n  >\n    <span\n      className={cn(\n        \"block animate-bounce [&_svg]:h-7 [&_svg]:w-5\",\n        direction === \"down\" && \"rotate-180\",\n      )}\n    >\n      <ArrowGlyph />\n    </span>\n  </div>\n);\n\n/**\n * Sniffing runs in an effect, never during render, so the server and the first\n * client render agree on \"nothing\" instead of flashing the wrong platform.\n */\nconst useDetection = (\n  platformOverride: AddToHomescreenPlatform | undefined,\n  browserOverride: AddToHomescreenBrowser | undefined,\n): Detection | null => {\n  const [detection, setDetection] = React.useState<Detection | null>(null);\n\n  React.useEffect(() => {\n    const platform = platformOverride ?? detectPlatform();\n    // oxlint-disable-next-line react/react-compiler -- one-shot UA detection must run after mount so SSR and the first client render agree\n    setDetection({\n      browser: browserOverride ?? detectBrowser(platform),\n      href: window.location.href,\n      // An override means a demo is driving this, so the real install state\n      // must not hide it.\n      installed: platformOverride === undefined && detectInstalled(),\n      platform,\n    });\n  }, [browserOverride, platformOverride]);\n\n  return detection;\n};\n\n/** The numbered recipe. Three columns, so a wrapped line keeps its indent. */\nconst InstallSteps = ({ steps }: { steps: AddToHomescreenStep[] }) => (\n  <ol className=\"flex flex-col gap-4 px-4\" data-slot=\"add-to-homescreen-steps\">\n    {steps.map((step, index) => (\n      <li\n        className=\"flex items-start gap-3 text-sm leading-relaxed\"\n        data-slot=\"add-to-homescreen-step\"\n        // Steps are positional and their text may repeat between recipes.\n        // biome-ignore lint/suspicious/noArrayIndexKey: positional steps\n        key={index}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"mt-px flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs tabular-figures\"\n        >\n          {index + 1}\n        </span>\n        {step.glyph && (\n          <span\n            aria-hidden=\"true\"\n            className=\"flex size-6 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-foreground [&_svg]:size-4\"\n            data-slot=\"add-to-homescreen-step-glyph\"\n          >\n            {step.glyph}\n          </span>\n        )}\n        <span className=\"min-w-0 flex-1\">{step.text}</span>\n      </li>\n    ))}\n  </ol>\n);\n\ninterface TierInput {\n  browser: AddToHomescreenBrowser;\n  canInstall: boolean;\n  platform: AddToHomescreenPlatform;\n  platformOverride?: AddToHomescreenPlatform;\n  showArrow: boolean;\n  stepsOverride?: AddToHomescreenStep[];\n}\n\n/**\n * Picks the tier and the geometry that goes with it. Tier 0 is the browser's\n * own prompt: where Chromium has offered one we can replay, a single tap beats\n * any written instruction, so no steps and no arrow are drawn.\n */\nconst resolveTier = ({\n  browser,\n  canInstall,\n  platform,\n  platformOverride,\n  showArrow,\n  stepsOverride,\n}: TierInput) => {\n  // An override means a demo is driving this, so the real prompt must not\n  // pre-empt the recipe being demonstrated.\n  const nativeInstall = canInstall && platformOverride === undefined;\n  const recipe = getRecipe(platform, browser);\n  const steps = stepsOverride ?? recipe?.steps;\n  const direction = recipe?.arrow ?? null;\n  // Caller-supplied steps describe an unknown control, so nothing is pointed at.\n  const pointable = direction !== null && stepsOverride === undefined;\n\n  return {\n    direction,\n    nativeInstall,\n    side: recipe?.edge ?? \"top\",\n    steps,\n    withArrow: showArrow && pointable && !nativeInstall,\n  };\n};\n\ninterface InstallPanelProps {\n  appIconUrl?: string;\n  detection: Detection;\n  heading: string;\n  messages: AddToHomescreenMessages;\n  nativeInstall: boolean;\n  onInstall: () => void;\n  steps?: AddToHomescreenStep[];\n  subtitle: string;\n}\n\n/** Header plus exactly one of the three tiers: install, steps, or copy-link. */\nconst InstallPanel = ({\n  appIconUrl,\n  detection,\n  heading,\n  messages,\n  nativeInstall,\n  onInstall,\n  steps,\n  subtitle,\n}: InstallPanelProps) => (\n  <>\n    <SheetHeader className=\"flex-row items-center gap-3 space-y-0 pb-0\">\n      {appIconUrl && (\n        // biome-ignore lint/performance/noImgElement: registry items must run outside Next\n        // eslint-disable-next-line next/no-img-element -- caller-supplied URL, and this file ships without a framework\n        <img\n          alt=\"\"\n          className=\"size-11 shrink-0 rounded-xl object-cover ring-1 ring-foreground/10\"\n          src={appIconUrl}\n        />\n      )}\n      <div className=\"min-w-0 flex-1\">\n        <SheetTitle className=\"text-base leading-snug\">{heading}</SheetTitle>\n        <SheetDescription className=\"text-sm\">{subtitle}</SheetDescription>\n      </div>\n    </SheetHeader>\n\n    {nativeInstall && (\n      <div className=\"px-4\" data-slot=\"add-to-homescreen-install\">\n        <Button className=\"w-full\" onClick={onInstall} type=\"button\">\n          {messages.install}\n        </Button>\n      </div>\n    )}\n\n    {!nativeInstall && steps ? <InstallSteps steps={steps} /> : null}\n\n    {!(nativeInstall || steps) && (\n      <div className=\"flex items-center gap-2 px-4\" data-slot=\"add-to-homescreen-fallback\">\n        <span className=\"min-w-0 flex-1 truncate rounded-lg border border-border bg-background px-3 py-1.5 font-mono text-muted-foreground text-xs\">\n          {detection.href}\n        </span>\n        <CopyButton label={messages.copyLink} size=\"icon-sm\" value={detection.href} />\n      </div>\n    )}\n  </>\n);\n\ninterface AddToHomescreenProps {\n  /** Square icon for the app, shown beside the title. Any URL the page can load. */\n  appIconUrl?: string;\n  /** Name of the app, used in the heading. */\n  appName: string;\n  /** Forces a browser instead of sniffing it. Meant for docs and tests. */\n  browser?: AddToHomescreenBrowser;\n  /** Class names merged onto the sheet panel. */\n  className?: string;\n  /** Open state when uncontrolled. */\n  defaultOpen?: boolean;\n  /** Overrides for the built-in English strings. */\n  messages?: Partial<AddToHomescreenMessages>;\n  /** Called when the sheet opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** Controlled open state. */\n  open?: boolean;\n  /**\n   * Forces a platform instead of sniffing it. Setting either override also\n   * skips the already-installed check, so a demo can render deterministically.\n   */\n  platform?: AddToHomescreenPlatform;\n  /**\n   * Suppresses the arrow. It can only ever remove one: a recipe whose control\n   * position is unknown never draws an arrow, whatever this is set to.\n   */\n  showArrow?: boolean;\n  /** Replaces the detected instructions entirely. */\n  steps?: AddToHomescreenStep[];\n}\n\nconst AddToHomescreen = ({\n  appIconUrl,\n  appName,\n  browser: browserOverride,\n  className,\n  defaultOpen,\n  messages: messageOverrides,\n  onOpenChange,\n  open,\n  platform: platformOverride,\n  showArrow = true,\n  steps: stepsOverride,\n}: AddToHomescreenProps) => {\n  const { canInstall, promptInstall } = useInstallPrompt();\n  const detection = useDetection(platformOverride, browserOverride);\n\n  const messages = React.useMemo<AddToHomescreenMessages>(\n    () => ({ ...DEFAULT_MESSAGES, ...messageOverrides }),\n    [messageOverrides],\n  );\n\n  if (!detection || detection.installed) {\n    return null;\n  }\n\n  const { direction, nativeInstall, side, steps, withArrow } = resolveTier({\n    browser: detection.browser,\n    canInstall,\n    platform: detection.platform,\n    platformOverride,\n    showArrow,\n    stepsOverride,\n  });\n\n  const known = Boolean(steps) || nativeInstall;\n  const heading = known ? messages.title(appName) : messages.fallbackTitle;\n  const stepSubtitle = steps ? messages.subtitle : messages.fallbackBody;\n  const subtitle = nativeInstall ? messages.installSubtitle : stepSubtitle;\n\n  const arrow = withArrow && direction ? <InstallArrow direction={direction} /> : null;\n\n  return (\n    <Sheet defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>\n      <SheetContent\n        className={cn(\n          \"gap-4 pb-5 sm:mx-auto sm:max-w-md\",\n          // Round only the edge facing the page; the edge against the screen\n          // stays square, the way drawer.tsx handles its two directions.\n          side === \"top\" ? \"rounded-b-lg\" : \"rounded-t-lg\",\n          className,\n        )}\n        data-browser={detection.browser}\n        data-platform={detection.platform}\n        data-slot=\"add-to-homescreen\"\n        side={side}\n      >\n        {side === \"top\" && arrow}\n\n        <InstallPanel\n          appIconUrl={appIconUrl}\n          detection={detection}\n          heading={heading}\n          messages={messages}\n          onInstall={promptInstall}\n          nativeInstall={nativeInstall}\n          steps={steps}\n          subtitle={subtitle}\n        />\n\n        {side === \"bottom\" && arrow}\n      </SheetContent>\n    </Sheet>\n  );\n};\n\nexport { AddToHomescreen };\nexport type {\n  AddToHomescreenBrowser,\n  AddToHomescreenMessages,\n  AddToHomescreenPlatform,\n  AddToHomescreenProps,\n  AddToHomescreenStep,\n};\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
