# Image Studio

3 on-device blocks in the Image Studio category — each installs and runs on its own, entirely in the browser.

## Background Remover

Remove the background from a photo and get a clean, transparent PNG you can download. It automatically finds the main subject, cuts it out, and shows the result next to the original on a checkerboard. Everything runs in your browser, and nothing downloads until you drop in an image.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/image-studio/background-remover
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/image-studio/background-remover.json

```tsx
'use client';

/**
 * @file background-remover.tsx
 * @description Background Remover block (`/blocks/image-studio/background-remover`) — on-device SegFormer segmentation composited to a transparent-background PNG.
 */
import { useState } from 'react';
import { Download, RotateCcw, Scissors, X } from 'lucide-react';
import { useSegmentImage, toAppError, readFileAsDataUrl } from '@localmode/react';
import { transformers } from '@localmode/transformers';

import { MediaDropzone } from '@/components/media-dropzone';
import { BeforeAfterImageViewer } from '@/components/before-after-image-viewer';
import { ImageProcessingOverlay } from '@/components/image-processing-overlay';
import { ConfidenceScoreBadge } from '@/components/confidence-score-badge';

import { applyMaskToImage, downloadDataUrl } from '@/lib/browser-utils';

const SEGMENTER_MODEL_ID = 'Xenova/segformer-b0-finetuned-ade-512-512';

let segmenter: ReturnType<typeof transformers.segmenter> | null = null;
const getSegmenterModel = () => (segmenter ??= transformers.segmenter(SEGMENTER_MODEL_ID));

const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];

export function BackgroundRemoverBlock() {
  const [original, setOriginal] = useState<string | null>(null);
  const [processed, setProcessed] = useState<string | null>(null);
  const [bestScore, setBestScore] = useState<number | null>(null);
  const [localError, setLocalError] = useState<string | null>(null);

  const seg = useSegmentImage({ model: getSegmenterModel() });

  const processImage = async (file: File) => {
    setLocalError(null);
    try {
      const dataUrl = await readFileAsDataUrl(file);
      setOriginal(dataUrl);
      setProcessed(null);
      setBestScore(null);
      const result = await seg.execute(dataUrl);
      if (result && result.masks.length > 0) {
        const best = result.masks.reduce((a, b) => (b.score > a.score ? b : a));
        setProcessed(await applyMaskToImage(dataUrl, best.mask));
        setBestScore(best.score);
      } else if (result) {
        setLocalError('No segments were found in this image. Try another photo.');
      }
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') return;
      setLocalError(err instanceof Error ? err.message : String(err));
    }
  };

  const reset = () => {
    setOriginal(null);
    setProcessed(null);
    setBestScore(null);
    setLocalError(null);
    seg.reset();
  };

  const download = () => {
    if (processed) void downloadDataUrl(processed, `background-removed-${Date.now()}.png`);
  };

  const error = localError ?? toAppError(seg.error)?.message ?? null;
  const state = seg.isLoading ? 'processing' : error ? 'error' : processed ? 'done' : 'idle';

  return (
    <div className="flex flex-col gap-4 p-4">
      {}
      <div className="flex flex-wrap items-center justify-between gap-2">
        <p role="status" aria-live="polite" className="text-xs text-muted-foreground">
          {state === 'processing'
            ? 'Removing background…'
            : state === 'done'
              ? 'Background removed: transparent PNG ready'
              : state === 'error'
                ? 'Error'
                : 'On-device background removal - nothing leaves your browser'}
        </p>
        <div className="flex items-center gap-2">
          {processed && !seg.isLoading && (
            <button
              type="button"
              onClick={download}
              className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <Download className="size-3.5" aria-hidden />
              Download PNG
            </button>
          )}
          {original && (
            <button
              type="button"
              onClick={reset}
              disabled={seg.isLoading}
              className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <X className="size-3.5" aria-hidden />
              Clear
            </button>
          )}
        </div>
      </div>

      {error && (
        <div
          role="alert"
          className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2"
        >
          <p className="text-xs text-destructive">{error}</p>
          <button
            type="button"
            onClick={reset}
            className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <RotateCcw className="size-3" aria-hidden />
            Dismiss &amp; retry
          </button>
        </div>
      )}

      {!original && (
        <div className="max-w-xl">
          <MediaDropzone
            accept={ACCEPTED}
            multiple={false}
            processing={seg.isLoading}
            processingLabel="Removing background…"
            title="Drop an image to remove its background"
            subtitle="or click to browse"
            onFiles={(files) => files[0] && void processImage(files[0])}
            onReject={(r) => setLocalError(r[0]?.reason ?? 'That file type is not supported.')}
          />
        </div>
      )}

      {original && !processed && (
        <div className="relative max-w-xl overflow-hidden rounded-lg border border-border">
          {}
          <img
            src={original}
            alt="Uploaded image"
            className={seg.isLoading ? 'w-full opacity-50' : 'w-full'}
          />
          <ImageProcessingOverlay
            processing={seg.isLoading}
            variant="scan"
            icon={<Scissors className="size-5" aria-hidden />}
            status="Removing background…"
            detail={`${SEGMENTER_MODEL_ID} · first run downloads ~15MB`}
            onCancel={seg.cancel}
          />
        </div>
      )}

      {original && processed && (
        <div className="flex flex-col gap-3">
          <div role="group" aria-label="Background removal result">
            <BeforeAfterImageViewer
              originalSrc={original}
              processedSrc={processed}
              mode="grid"
              originalLabel="Original"
              processedLabel="Result"
              checkerboard
              originalAlt="Original image"
              resultAlt="Background removed: transparent PNG"
            />
          </div>
          {}
          {bestScore != null && bestScore > 0 && (
            <div className="flex items-center gap-2">
              <span className="text-xs text-muted-foreground">Best mask confidence</span>
              <span data-score={bestScore.toFixed(4)}>
                <ConfidenceScoreBadge score={bestScore} label="mask" />
              </span>
            </div>
          )}
        </div>
      )}
    </div>
  );
}
```

## Image Enhancer

Upscale and sharpen photos to make them larger and clearer. Choose a fast 2x mode, a higher-quality 4x mode, or a restore mode for real-world low-quality images, then compare before and after and download the result. Everything runs in your browser, and nothing downloads until you drop in an image.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/image-studio/image-enhancer
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/image-studio/image-enhancer.json

```tsx
'use client';

/**
 * @file image-enhancer.tsx
 * @description Image Enhancer block (`/blocks/image-studio/image-enhancer`) — Swin2SR super-resolution with a 2x / 4x / real-world Restore mode picker.
 */
import { useState } from 'react';
import { Download, RotateCcw, Sparkles, Upload, Wand2 } from 'lucide-react';
import { useImageToImage, toAppError, readFileAsDataUrl, validateFile } from '@localmode/react';
import { transformers } from '@localmode/transformers';

import { MediaDropzone } from '@/components/media-dropzone';
import { BeforeAfterImageViewer } from '@/components/before-after-image-viewer';
import { ImageProcessingOverlay } from '@/components/image-processing-overlay';
import { cn } from '@/lib/utils';
import { imageResultToDataUrl, getImageDimensions, downloadDataUrl } from '@/lib/browser-utils';

interface EnhanceMode {
  id: 'upscale-2x' | 'upscale-4x' | 'restore';
  label: string;
  modelId: string;
  scale: number;
  badge: string;
  hint: string;
  size: string;
}

const ENHANCE_MODES: readonly EnhanceMode[] = [
  {
    id: 'upscale-2x',
    label: '2x Upscale',
    modelId: 'Xenova/swin2SR-lightweight-x2-64',
    scale: 2,
    badge: 'Swin2SR 2x',
    hint: 'Fast lightweight 2x super-resolution (default).',
    size: '~50MB',
  },
  {
    id: 'upscale-4x',
    label: '4x Upscale',
    modelId: 'Xenova/swin2SR-classical-sr-x4-64',
    scale: 4,
    badge: 'Swin2SR 4x',
    hint: 'Classical 4x super-resolution for a larger enlargement.',
    size: '~45MB',
  },
  {
    id: 'restore',
    label: 'Restore',
    modelId: 'Xenova/swin2SR-realworld-sr-x4-64-bsrgan-psnr',
    scale: 4,
    badge: 'Swin2SR Restore',
    hint: 'Real-world restoration for old or degraded photos (4x).',
    size: '~50MB',
  },
] as const;

const enhancers = new Map<string, ReturnType<typeof transformers.imageToImage>>();
function getEnhanceModel(modelId: string) {
  let model = enhancers.get(modelId);
  if (!model) {
    model = transformers.imageToImage(modelId);
    enhancers.set(modelId, model);
  }
  return model;
}

const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];

interface EnhancedResult {
  url: string;
  width: number;
  height: number;
}

export function ImageEnhancerBlock() {
  const hooks: Record<EnhanceMode['id'], ReturnType<typeof useImageToImage>> = {
    'upscale-2x': useImageToImage({
      model: getEnhanceModel(ENHANCE_MODES[0].modelId),
      scale: ENHANCE_MODES[0].scale,
    }),
    'upscale-4x': useImageToImage({
      model: getEnhanceModel(ENHANCE_MODES[1].modelId),
      scale: ENHANCE_MODES[1].scale,
    }),
    restore: useImageToImage({
      model: getEnhanceModel(ENHANCE_MODES[2].modelId),
      scale: ENHANCE_MODES[2].scale,
    }),
  };

  const [modeId, setModeId] = useState<EnhanceMode['id']>('upscale-2x');
  const [original, setOriginal] = useState<string | null>(null);
  const [results, setResults] = useState<Partial<Record<EnhanceMode['id'], EnhancedResult>>>({});
  const [validationError, setValidationError] = useState<string | null>(null);

  const mode = ENHANCE_MODES.find((m) => m.id === modeId)!;
  const active = hooks[modeId];
  const result = results[modeId] ?? null;

  const enhance = async (id: EnhanceMode['id'], src: string) => {
    setValidationError(null);
    try {
      const out = await hooks[id].execute(src);
      if (out) {
        const url = await imageResultToDataUrl(out.image);
        const dims = await getImageDimensions(url);
        setResults((prev) => ({ ...prev, [id]: { url, width: dims.width, height: dims.height } }));
      }
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') return;
    }
  };

  const processImage = async (file: File) => {
    const err = validateFile({ file, accept: ACCEPTED });
    if (err) {
      setValidationError(err.message);
      return;
    }
    const dataUrl = await readFileAsDataUrl(file);
    setOriginal(dataUrl);
    setResults({});
    await enhance(modeId, dataUrl);
  };

  const changeMode = (id: EnhanceMode['id']) => {
    setModeId(id);
    if (original && !results[id] && !hooks[id].isLoading) void enhance(id, original);
  };

  const reset = () => {
    setOriginal(null);
    setResults({});
    setValidationError(null);
    active.reset();
  };

  const download = () => {
    if (result) void downloadDataUrl(result.url, `enhanced-${mode.scale}x-${Date.now()}.png`);
  };

  const error = validationError ?? toAppError(active.error)?.message ?? null;
  const state = active.isLoading ? 'processing' : error ? 'error' : result ? 'done' : 'idle';

  return (
    <div className="flex flex-col gap-4 p-4">
      {}
      <div className="flex flex-wrap items-center justify-between gap-2">
        <p role="status" aria-live="polite" className="text-xs text-muted-foreground">
          {state === 'processing'
            ? `Enhancing (${mode.label})…`
            : state === 'done'
              ? `Enhanced ${mode.scale}x`
              : state === 'error'
                ? 'Error'
                : 'On-device super-resolution - nothing leaves your browser'}
        </p>
        <div className="flex items-center gap-2">
          {result && !active.isLoading && (
            <span className="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
              <Sparkles className="size-3" aria-hidden />
              Enhanced {mode.scale}x
            </span>
          )}
          {original && (
            <button
              type="button"
              onClick={reset}
              disabled={active.isLoading}
              className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <Upload className="size-3.5" aria-hidden />
              Upload new
            </button>
          )}
        </div>
      </div>

      {}
      <div className="flex flex-col gap-1.5">
        <div
          role="group"
          aria-label="Enhancement mode"
          className="inline-flex w-fit rounded-lg border border-border bg-muted p-1"
        >
          {ENHANCE_MODES.map((m) => (
            <button
              key={m.id}
              type="button"
              aria-pressed={m.id === modeId}
              onClick={() => changeMode(m.id)}
              className={cn(
                'inline-flex min-h-11 items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:min-h-9',
                m.id === modeId
                  ? 'bg-background text-foreground shadow-sm'
                  : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {m.label}
            </button>
          ))}
        </div>
        <p className="text-xs text-muted-foreground">{mode.hint}</p>
      </div>

      {error && (
        <div
          role="alert"
          className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2"
        >
          <p className="text-xs text-destructive">{error}</p>
          <button
            type="button"
            onClick={() => {
              setValidationError(null);
              active.reset();
            }}
            className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <RotateCcw className="size-3" aria-hidden />
            Dismiss &amp; retry
          </button>
        </div>
      )}

      {!original && (
        <div className="max-w-xl">
          <MediaDropzone
            accept={ACCEPTED}
            multiple={false}
            processing={active.isLoading}
            processingLabel="Enhancing…"
            title="Drop an image to enhance"
            subtitle="or click to browse"
            onFiles={(files) => files[0] && void processImage(files[0])}
            onReject={(r) => setValidationError(r[0]?.reason ?? 'That file type is not supported.')}
          />
        </div>
      )}

      {original && !result && (
        <div className="relative max-w-xl overflow-hidden rounded-lg border border-border">
          {}
          <img
            src={original}
            alt="Uploaded image"
            className={active.isLoading ? 'w-full opacity-50' : 'w-full'}
          />
          <ImageProcessingOverlay
            processing={active.isLoading}
            variant="scan"
            icon={<Wand2 className="size-5" aria-hidden />}
            status={`Enhancing (${mode.label})…`}
            detail={`${mode.modelId} · first run downloads ${mode.size} - this may take a moment`}
            onCancel={active.cancel}
          />
        </div>
      )}

      {original && result && (
        <div className="grid gap-4 lg:grid-cols-[1fr_260px]">
          <div
            role="group"
            aria-label="Enhanced result"
            data-width={result.width}
            data-height={result.height}
            data-scale={mode.scale}
          >
            <BeforeAfterImageViewer
              originalSrc={original}
              processedSrc={result.url}
              mode="toggle"
              originalLabel="Original"
              processedLabel="Enhanced"
              checkerboard={false}
              originalAlt="Original image"
              resultAlt={`Enhanced ${mode.scale}x`}
            />
          </div>

          {}
          <div className="flex flex-col gap-3 rounded-lg border border-border p-4">
            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Info</p>
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Model</span>
              <span className="font-mono text-xs">{mode.badge}</span>
            </div>
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Scale</span>
              <span data-scale={mode.scale}>{mode.scale}x</span>
            </div>
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Output</span>
              <span className="tabular-nums">
                {result.width}×{result.height}
              </span>
            </div>
            <div className="flex items-center justify-between text-sm">
              <span className="text-muted-foreground">Status</span>
              <span className="flex items-center gap-1.5 text-primary">
                <span className="size-1.5 rounded-full bg-primary" aria-hidden />
                Complete
              </span>
            </div>
            <button
              type="button"
              onClick={download}
              className="mt-1 inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <Download className="size-4" aria-hidden />
              Download Enhanced PNG
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
```

## Image Captioner

Automatically write a short description (alt-text) for any image. Drop in JPEG, PNG, WebP, or GIF files up to 10MB and your captions build up in a gallery you can copy from, remove single items, or clear all at once. Everything runs in your browser, and nothing downloads until you add your first image.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/image-studio/image-captioner
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/image-studio/image-captioner.json

```tsx
'use client';

/**
 * @file image-captioner.tsx
 * @description Image Captioner block (`/blocks/image-studio/image-captioner`) — ViT-GPT2 alt-text over an accumulating gallery with copy-to-clipboard.
 */
import { useState } from 'react';
import { Check, Copy, Trash2 } from 'lucide-react';
import {
  useOperationList,
  toAppError,
  readFileAsDataUrl,
  validateFile,
} from '@localmode/react';
import type { CaptionImageResult } from '@localmode/core';
import { transformers } from '@localmode/transformers';

import { MediaDropzone } from '@/components/media-dropzone';

const CAPTIONER_MODEL_ID = 'Xenova/vit-gpt2-image-captioning';

let captioner: ReturnType<typeof transformers.captioner> | null = null;
const getCaptionerModel = () => (captioner ??= transformers.captioner(CAPTIONER_MODEL_ID));

const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024;

interface CaptionCard {
  id: string;
  src: string;
  caption: string;
  fileName: string;
}

export function ImageCaptionerBlock() {
  const [validationError, setValidationError] = useState<string | null>(null);
  const [pending, setPending] = useState<string | null>(null);
  const [copiedId, setCopiedId] = useState<string | null>(null);

  const { items, isLoading, error: hookError, execute, cancel, reset, clearItems, removeItem } =
    useOperationList<[{ image: string; fileName: string }], CaptionImageResult, CaptionCard>({
      fn: async (input, signal) => {
        const { captionImage } = await import('@localmode/core');
        return captionImage({ model: getCaptionerModel(), image: input.image, abortSignal: signal });
      },
      transform: (result, input) => ({
        id: crypto.randomUUID(),
        src: input.image,
        caption: result.caption,
        fileName: input.fileName,
      }),
    });

  const captionFile = async (file: File) => {
    const err = validateFile({ file, accept: ACCEPTED, maxSize: MAX_SIZE });
    if (err) {
      setValidationError(err.message);
      return;
    }
    setValidationError(null);
    const dataUrl = await readFileAsDataUrl(file);
    setPending(dataUrl);
    try {
      await execute({ image: dataUrl, fileName: file.name });
    } finally {
      setPending(null);
    }
  };

  const copy = async (id: string, caption: string) => {
    await navigator.clipboard.writeText(caption);
    setCopiedId(id);
    setTimeout(() => setCopiedId((cur) => (cur === id ? null : cur)), 2000);
  };

  const error = validationError ?? toAppError(hookError)?.message ?? null;
  const state = isLoading ? 'processing' : error ? 'error' : items.length > 0 ? 'done' : 'idle';

  return (
    <div className="flex flex-col gap-4 p-4">
      {}
      <div className="flex flex-wrap items-center justify-between gap-2">
        <p role="status" aria-live="polite" className="text-xs text-muted-foreground">
          {state === 'processing'
            ? 'Generating caption… (first run downloads ~230MB)'
            : items.length > 0
              ? 'Captioned'
              : 'On-device image captioning - nothing leaves your browser'}
        </p>
        <div className="flex items-center gap-2">
          {items.length > 0 && (
            <span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
              {items.length} {items.length === 1 ? 'image' : 'images'} captioned
            </span>
          )}
          {items.length > 0 && (
            <button
              type="button"
              onClick={() => clearItems()}
              className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <Trash2 className="size-3.5" aria-hidden />
              Clear All
            </button>
          )}
        </div>
      </div>

      {error && (
        <div
          role="alert"
          className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2"
        >
          <p className="text-xs text-destructive">{error}</p>
          <button
            type="button"
            onClick={() => {
              setValidationError(null);
              if (hookError) reset();
            }}
            className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Dismiss
          </button>
        </div>
      )}

      {}
      {!isLoading && (
        <div className={items.length === 0 ? 'max-w-xl' : ''}>
          <MediaDropzone
            accept={ACCEPTED}
            maxSize={MAX_SIZE}
            multiple={false}
            addAnother={items.length > 0}
            title="Drop images to caption"
            subtitle="or click to browse"
            onFiles={(files) => files[0] && void captionFile(files[0])}
            onReject={(r) => setValidationError(r[0]?.reason ?? 'That file is not supported.')}
          />
        </div>
      )}

      {}
      {isLoading && (
        <div className="flex items-center gap-3 rounded-lg border border-border p-3">
          {pending ? (
            <img src={pending} alt="Image being captioned" className="size-16 rounded-md object-cover opacity-70" />
          ) : (
            <div className="size-16 rounded-md bg-muted" />
          )}
          <div className="flex flex-col gap-1">
            <p className="text-sm font-medium">Generating caption…</p>
            <p className="text-xs text-muted-foreground">ViT-GPT2 is analyzing your image</p>
          </div>
          <button
            type="button"
            onClick={cancel}
            className="ml-auto inline-flex h-7 items-center rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Cancel
          </button>
        </div>
      )}

      {}
      {items.length > 0 && (
        <ul aria-label="Captions" className="flex flex-col gap-2">
          {items.map((item) => (
            <li
              key={item.id}
              className="flex items-start gap-3 rounded-lg border border-border p-3"
            >
              {}
              <img src={item.src} alt="" className="size-12 shrink-0 rounded-md object-cover" />
              <div className="min-w-0 flex-1">
                <p className="text-sm text-foreground">{item.caption}</p>
                <p className="truncate text-[11px] text-muted-foreground">{item.fileName}</p>
              </div>
              <div className="flex shrink-0 items-center gap-1.5">
                <button
                  type="button"
                  onClick={() => void copy(item.id, item.caption)}
                  aria-label={copiedId === item.id ? 'Caption copied' : `Copy caption for ${item.fileName}`}
                  className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  {copiedId === item.id ? (
                    <>
                      <Check className="size-3.5 text-emerald-500" aria-hidden />
                      Copied
                    </>
                  ) : (
                    <>
                      <Copy className="size-3.5" aria-hidden />
                      Copy
                    </>
                  )}
                </button>
                <button
                  type="button"
                  onClick={() => removeItem((i) => i.id === item.id)}
                  aria-label="Delete image"
                  className="inline-flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Trash2 className="size-3.5" aria-hidden />
                </button>
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```
