# Chrome AI Download Gate

Chrome AI Download Gate [#chrome-ai-download-gate]

Chrome's built-in AI APIs (`Summarizer`, `Translator`, `LanguageModel`) can exist in a browser while their on-device model has not been fetched yet. In that state `availability()` reports `'downloadable'`, and **Chrome refuses to start the download outside a user activation**. A button is therefore not a nicety here — it is the only way to trigger the download.

The **Chrome AI Download Gate** renders that button, the in-flight progress bar, the failed-attempt message, and the two terminal states (the API is absent, or this device cannot run it). It renders `null` once the model is `'available'`, because a ready capability needs no gate. **Chrome AI Ready Badge** is a compact confirmation you can show in its place.

It is presentational and hook-driven: bind `availability`, `progress`, and `isDownloading` to `useProviderFallback`'s Chrome state and pass its download action as `onDownload`. It owns no orchestration and starts nothing itself.

<Callout type="warn">
  The download is Chrome's, not your app's: it is shared across every site and happens once. Gemini Nano (the Prompt API) is roughly 1.5 GB; the Translator downloads a smaller pack **per language pair**, so availability must be re-probed whenever the pair changes.
</Callout>

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';

import {
  ChromeAIDownloadGate,
  ChromeAIReadyBadge,
  type ChromeAvailabilityState,
} from '@/components/chrome-ai-download-gate';

/**
 * Demo for ChromeAIDownloadGate. Simulates the full lifecycle a user sees —
 * downloadable → downloading (with progress) → available — plus the two terminal
 * states. No real model is downloaded and no browser API is called.
 */
export default function ChromeAIDownloadGateDemo() {
  const [availability, setAvailability] = useState<ChromeAvailabilityState>('downloadable');
  const [progress, setProgress] = useState<number | undefined>(undefined);
  const [isDownloading, setIsDownloading] = useState(false);

  function simulateDownload() {
    setIsDownloading(true);
    setProgress(0);
    let p = 0;
    const id = setInterval(() => {
      p = Math.min(1, p + 0.08);
      setProgress(p);
      if (p >= 1) {
        clearInterval(id);
        setIsDownloading(false);
        setProgress(undefined);
        setAvailability('available');
      }
    }, 150);
  }

  function reset() {
    setAvailability('downloadable');
    setProgress(undefined);
    setIsDownloading(false);
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <ChromeAIDownloadGate
        availability={availability}
        label="Chrome Summarizer"
        size="~1.5 GB"
        isDownloading={isDownloading}
        progress={progress}
        onDownload={simulateDownload}
        fallbackLabel="Transformers.js"
      />

      {availability === 'available' && !isDownloading ? (
        <div className="flex items-center gap-3">
          <ChromeAIReadyBadge label="Chrome Summarizer" />
          <button
            type="button"
            onClick={reset}
            className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
          >
            Reset demo
          </button>
        </div>
      ) : null}

      <div className="flex flex-col gap-2 border-t border-border pt-4">
        <p className="text-xs font-medium text-muted-foreground">Terminal states</p>
        <ChromeAIDownloadGate
          availability="unsupported"
          label="Chrome Prompt API"
          onDownload={() => {}}
          fallbackLabel="Transformers.js"
        />
        <ChromeAIDownloadGate
          availability="unavailable"
          label="Chrome Summarizer"
          onDownload={() => {}}
          fallbackLabel="Transformers.js"
        />
      </div>
    </div>
  );
}
```

Installation [#installation]

```bash
npx shadcn@latest add @localmode/ui/local-first/chrome-ai-download-gate
```

Data source & dependencies [#data-source--dependencies]

**Data source:** renders the `availability` you pass — works with any backend. Recommended producer: `useProviderFallback` from `@localmode/react` (on-device, optional), whose `chromeAvailability`, `chromeDownloadProgress`, `downloadingCapability`, and `requestChromeDownload` map one-to-one onto these props.

* `lucide-react` — icons
* `clsx` + `tailwind-merge` — via the shared `cn()` util

Files installed [#files-installed]

* `chrome-ai-download-gate.tsx` — `ChromeAIDownloadGate` + `ChromeAIReadyBadge`
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**ChromeAIDownloadGate**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `availability` | `ChromeAvailabilityState` | — | **Required.** Chrome's reported state for the capability this gate guards. |
| `label` | `string` | — | **Required.** Human-readable capability name (e.g. "Summarizer", "Gemini Nano"). |
| `size` | `string` | — | Approximate download size (e.g. "~1.5 GB"). Shown on the prompt. |
| `onDownload` | `function` | — | **Required.** Called when the user clicks Download. Chrome only starts the download from a user activation, so this MUST be wired to a real click — it cannot be triggered on mount. |
| `isDownloading` | `boolean` | — | True while the download is in flight. |
| `progress` | `number` | — | Download completion as a 0–1 fraction. Renders an indeterminate bar when omitted. |
| `error` | `string \| null` | — | Message shown when the last download attempt failed. |
| `fallbackLabel` | `string` | — | What the app will use instead while the model is missing (e.g. "Transformers.js"). |

**ChromeAIReadyBadge**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | — | **Required.** Capability name shown in the badge. |

Examples [#examples]

Wired to `useProviderFallback` [#wired-to-useproviderfallback]

`requestChromeDownload` must be called from the click handler — Chrome will reject a download started from an effect or on mount.

```tsx
import { useEffect } from 'react';
import { useProviderFallback } from '@localmode/react';
import { ChromeAIDownloadGate } from '@/components/chrome-ai-download-gate';

export function SummarizePanel() {
  const {
    chromeAvailability,
    refreshChromeAvailability,
    requestChromeDownload,
    chromeDownloadProgress,
    downloadingCapability,
    error,
  } = useProviderFallback({
    loadChromeAI: () => import('@localmode/chrome-ai'),
    loadTransformers: () => import('@localmode/transformers'),
  });

  // Reading availability() downloads nothing.
  useEffect(() => {
    void refreshChromeAvailability('summarize', { chromeStyle: 'tldr', length: 'medium' });
  }, [refreshChromeAvailability]);

  return (
    <ChromeAIDownloadGate
      availability={chromeAvailability.summarize ?? 'unsupported'}
      label="Chrome Summarizer"
      size="~1.5 GB, shared across every site"
      isDownloading={downloadingCapability === 'summarize'}
      progress={chromeDownloadProgress?.progress}
      error={error?.message ?? null}
      fallbackLabel="Transformers.js (DistilBART)"
      onDownload={() => {
        void requestChromeDownload('summarize', { chromeStyle: 'tldr', length: 'medium' });
      }}
    />
  );
}
```

Per-language-pair gating [#per-language-pair-gating]

The Translator downloads one pack per directed pair, so re-probe on every pair change and name the pair in the label.

```tsx
useEffect(() => {
  void refreshChromeAvailability('translate', { source, target });
}, [source, target, refreshChromeAvailability]);

<ChromeAIDownloadGate
  availability={chromeAvailability.translate ?? 'unsupported'}
  label={`Chrome Translator (${source}→${target})`}
  size="one language pack"
  isDownloading={downloadingCapability === 'translate'}
  progress={chromeDownloadProgress?.progress}
  onDownload={() => void requestChromeDownload('translate', { source, target })}
  fallbackLabel="Transformers.js (Opus-MT)"
/>;
```

Showing a ready state [#showing-a-ready-state]

The gate renders `null` when the model is `'available'`, so pair it with the badge:

```tsx
{chromeAvailability.summarize === 'available' ? (
  <ChromeAIReadyBadge label="Chrome Summarizer" />
) : (
  <ChromeAIDownloadGate {...gateProps} />
)}
```

States [#states]

| `availability` | What renders                                                               |
| -------------- | -------------------------------------------------------------------------- |
| `available`    | Nothing (`null`) — pair with `ChromeAIReadyBadge`                          |
| `downloadable` | Heading, size copy, and the **Download model** button                      |
| `downloading`  | Heading and a live progress bar (indeterminate when `progress` is omitted) |
| `unavailable`  | A terminal message: this device cannot run the model                       |
| `unsupported`  | A terminal message: this browser has no such API                           |

Customization [#customization]

The button's accessible name is deliberately the generic &#x2A;*"Download model"** rather than a name built from the capability label. A name like "Download Chrome Summarizer" contains the substring "Summarize", which would collide with a host block's own **Summarize** run button under role+name lookups and resolve two controls. The capability is named in the heading above the button instead. If you rename the button, keep it distinct from the run controls around it.

The progress bar is a plain `role="progressbar"` with `aria-valuenow`, and the status line is a `role="status"` live region, so screen readers announce the download without extra wiring.