{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-drop",
  "type": "registry:ui",
  "title": "File drop",
  "description": "An area to drop files on or choose them from, that turns away the wrong type or size and says why, and rows that show each file uploading on a Progress rail, with Retry for one that failed.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/progress.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/file-drop.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/file-drop 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * File drop: where someone adds files, by dropping them or choosing them, and where they watch them upload\n * (components.md, \"File drop\").\n *\n * - FileDrop is the area. This file is the contract; the look is a body per material (below).\n * - On a touch screen nothing is dragged in, so the words say \"Add files\" and the button does the work.\n * - Choosing: Choose files opens the file picker; so does a click anywhere on the area. Only the button is a tab stop.\n * - Checking: files whose type is not in accept or whose size is over maxSize are turned away, each with a sentence\n *   under the area that says which file and why. The rest go to onFiles. The sentences go when the next files come.\n * - FileList and FileRow show what was added: one row per file, its name, its size, and while it uploads a\n *   progress rail under the name. A failed file says why in --litt-danger and offers Retry. Remove is a button named for\n *   its file, and says Cancel while the file uploads.\n * - Dragging is counted, because enter and leave fire for children too; the words say what a drop will do. A body shows\n *   it on the instant tier and under forced colors keeps an outline.\n * - Disabled lies flat: no drop and no tab stop. The words stay --litt-fg-subtle, because they still say something (when\n *   it opens again), as a disabled Slide to confirm's do.\n * - One contract, three bodies (proposals/worlds.md, Bodies). This file is the contract: the props, the checking, the\n *   drag count, the picker and its hidden input, the alert, the list and the row's actions. It resolves them once into\n *   FileDropBodyProps and FileRowBodyProps and renders the body the material picks (byWorld).\n *   - PressBody and PressRowBody, below: a plate that stands (--litt-block, --litt-plate-lift) with a deep tray cut into it\n *     (--litt-tray, --litt-tray-shade) that holds a raised arrow block (--litt-block, --litt-block-lift), the words and Choose files. While\n *     files are over it the tray goes deeper (--litt-slot, --litt-slot-shade) and its lower wall catches the light. The list is\n *     a tray of slats: each file a raised block with its name, size and a Progress rail cut into it.\n *   - bodies/file-drop.flat.tsx: a grey sheet with one sentence, \"Drop files or choose\", ringed in ink while a file is\n *     over it. Each file is a line of words with Retry and Remove as words.\n *   - bodies/file-drop.device.tsx: a slot with an engraved legend over LCD glass and a Choose key; each file a line of\n *     glass whose upload lights segments in steps.\n */\nimport { useId, useRef, useState, type ComponentProps, type DragEvent, type ReactNode } from \"react\";\nimport { Button } from \"./button\";\nimport { ArrowUpIcon, FileIcon, RetryIcon, XIcon } from \"./icons\";\nimport { Progress } from \"./progress\";\nimport { byWorld, useDevice, useMaterial } from \"../../lib/litt/motion\";\nimport { FileDropDeviceBody, FileRowDeviceBody } from \"./bodies/file-drop.device\";\nimport { FileDropFlatBody, FileRowFlatBody } from \"./bodies/file-drop.flat\";\n\n/** 2048 to \"2.0 KB\": the size in the units people read. */\nexport function formatSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${bytes < 10 * 1024 ? (bytes / 1024).toFixed(1) : Math.round(bytes / 1024)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/** Does a file match one entry of an accept list: \".pdf\", \"image/*\" or \"image/png\"? */\nfunction matches(file: File, rule: string): boolean {\n  const r = rule.trim().toLowerCase();\n  if (r.startsWith(\".\")) return file.name.toLowerCase().endsWith(r);\n  if (r.endsWith(\"/*\")) return file.type.toLowerCase().startsWith(r.slice(0, -1));\n  return file.type.toLowerCase() === r;\n}\n\nexport type FileRejection = { file: File; reason: string };\n\n/* ------------------------------------------------------------------ FileDrop */\n\nexport type FileDropProps = {\n  /** What the files are for, as the area's name: \"Project files\". Screen readers hear it with the button. */\n  label: string;\n  /** The types it takes, as a file input's accept: [\".pdf\", \"image/*\"]. Leave it out to take any file. */\n  accept?: string[];\n  /** The largest file it takes, in bytes. */\n  maxSize?: number;\n  /** Takes several files at once. Default true. */\n  multiple?: boolean;\n  /** Runs with the files that passed the checks, in the order they came. */\n  onFiles: (files: File[]) => void;\n  /** Runs with the files that were turned away and why. The area also says it under itself. */\n  onReject?: (rejected: FileRejection[]) => void;\n  /** The line under \"Drop files here\": the types and the limit, \"PDF or images, up to 10 MB\". */\n  hint?: ReactNode;\n  disabled?: boolean;\n  className?: string;\n};\n\n/** An area to drop files on or choose them from. It checks type and size, says what it turned away, and hands over the rest. */\nexport function FileDrop({ label, accept, maxSize, multiple = true, onFiles, onReject, hint, disabled = false, className = \"\" }: FileDropProps) {\n  const { coarse } = useDevice();\n  const input = useRef<HTMLInputElement>(null);\n  const hintId = useId();\n  const [over, setOver] = useState(0); // how many dragged items are over the area: enter and leave fire for children too\n  const [turnedAway, setTurnedAway] = useState<FileRejection[]>([]);\n\n  const take = (list: FileList | null) => {\n    if (!list || disabled) return;\n    const files = [...list].slice(0, multiple ? undefined : 1);\n    const ok: File[] = [];\n    const no: FileRejection[] = [];\n    for (const file of files) {\n      if (accept?.length && !accept.some((r) => matches(file, r))) no.push({ file, reason: `${file.name} is not a type this takes.` });\n      else if (maxSize !== undefined && file.size > maxSize) no.push({ file, reason: `${file.name} is ${formatSize(file.size)}, over the ${formatSize(maxSize)} limit.` });\n      else ok.push(file);\n    }\n    setTurnedAway(no);\n    if (no.length) onReject?.(no);\n    if (ok.length) onFiles(ok);\n  };\n\n  const dragging = over > 0 && !disabled;\n  const drag = (e: DragEvent<HTMLDivElement>, step: number) => {\n    if (disabled || !e.dataTransfer.types.includes(\"Files\")) return;\n    e.preventDefault();\n    setOver((n) => Math.max(0, n + step));\n  };\n\n  const area: ComponentProps<\"div\"> = {\n    role: \"group\",\n    \"aria-label\": label,\n    onDragEnter: (e) => drag(e, 1),\n    onDragLeave: (e) => drag(e, -1),\n    onDragOver: (e) => {\n      if (disabled || !e.dataTransfer.types.includes(\"Files\")) return;\n      e.preventDefault();\n      e.dataTransfer.dropEffect = \"copy\";\n    },\n    onDrop: (e) => {\n      if (disabled) return;\n      e.preventDefault();\n      setOver(0);\n      take(e.dataTransfer.files);\n    },\n    /* A click anywhere chooses files too; the button is the keyboard's way in, so the area is no tab stop. */\n    onClick: (e) => {\n      if (!disabled && !(e.target as Element).closest(\"button\")) input.current?.click();\n    },\n  };\n  const Body = byWorld(useMaterial(), { press: PressBody, flat: FileDropFlatBody, device: FileDropDeviceBody });\n\n  return (\n    <div className={\"grid grid-cols-[minmax(0,1fr)] gap-3 \" + className}>\n      <Body\n        area={area}\n        words={dragging ? (multiple ? \"Drop to add these files\" : \"Drop to add this file\") : coarse ? \"Add files\" : \"Drop files here\"}\n        hint={hint}\n        hintId={hintId}\n        dragging={dragging}\n        disabled={disabled}\n        chooseLabel={multiple ? \"Choose files\" : \"Choose a file\"}\n        onChoose={() => input.current?.click()}\n        picker={\n          <input\n            ref={input}\n            type=\"file\"\n            tabIndex={-1}\n            aria-hidden=\"true\"\n            className=\"sr-only\"\n            multiple={multiple}\n            accept={accept?.join(\",\")}\n            disabled={disabled}\n            onChange={(e) => {\n              take(e.target.files);\n              e.target.value = \"\"; // so choosing the same file again still counts\n            }}\n          />\n        }\n      />\n      {/* What was turned away, and why. An alert, because it answers what the person just did. */}\n      <div role=\"alert\" className=\"grid gap-1 empty:hidden\">\n        {turnedAway.map((r) => (\n          <p key={r.file.name + r.file.size} className=\"text-litt-small text-litt-danger\">\n            {r.reason}\n          </p>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/**\n * What the contract hands the area's body: the drag, the picker and the words, resolved once. A body draws the group\n * (`area`: its role, name, drag and drop handlers and the click that chooses, kept whole, plus its own className and\n * data-body), the words, the hint, a Choose button that calls onChoose and is described by the hint, and the hidden\n * `picker` input, which it renders anywhere inside the group. It says dragging and disabled without color alone: the\n * words change, and a disabled area has no tab stop.\n */\nexport type FileDropBodyProps = {\n  area: ComponentProps<\"div\">;\n  /** \"Drop files here\", \"Drop to add these files\" while dragging, or \"Add files\" on a touch screen. */\n  words: string;\n  hint?: ReactNode;\n  /** The hint's id, which the Choose button names with aria-describedby. */\n  hintId: string;\n  dragging: boolean;\n  disabled: boolean;\n  /** \"Choose files\", or \"Choose a file\" when it takes one. */\n  chooseLabel: string;\n  onChoose: () => void;\n  /** The hidden file input. */\n  picker: ReactNode;\n};\n\n/**\n * Press, the instrument: a plate that stands on the page (--litt-block, --litt-plate-lift) with a deep tray cut into it\n * (--litt-tray, --litt-tray-shade, 4px in, its corners the plate's less those 4px). In the tray stands the arrow on a raised\n * block (--litt-block, --litt-block-lift), then the words, and Choose files at the end. While files are over it the tray goes\n * deeper: its floor is the slot's (--litt-slot, a step toward --litt-fg) and its walls are the slot's (--litt-slot-shade), whose lower wall catches\n * the light. The words change too, so it is never said by depth alone. Disabled lies flat: --litt-surface-2 with the\n * hairline, and a tray that is only a --litt-surface-3 floor.\n */\nfunction PressBody({ area, words, hint, hintId, dragging, disabled, chooseLabel, onChoose, picker }: FileDropBodyProps) {\n  return (\n    <div\n      {...area}\n      data-body=\"press\"\n      className={\n        \"rounded-litt-surface p-1 forced-colors:border \" +\n        (disabled ? \"cursor-not-allowed bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\" : \"cursor-pointer bg-litt-block shadow-(--litt-plate-lift)\")\n      }\n    >\n      <div\n        className={\n          \"flex flex-wrap items-center gap-4 rounded-[calc(var(--litt-radius-surface)-4px)] p-4 transition-[background-color,box-shadow] duration-(--litt-dur-instant) ease-litt forced-colors:border sm:flex-nowrap sm:p-5 \" +\n          (disabled\n            ? \"bg-litt-surface-3\"\n            : dragging\n              ? \"bg-[color-mix(in_oklab,var(--litt-slot),var(--litt-fg)_6%)] shadow-(--litt-slot-shade) forced-colors:outline-2 forced-colors:outline-solid forced-colors:outline-[Highlight]\"\n              : \"bg-litt-tray shadow-(--litt-tray-shade)\")\n        }\n      >\n        <span\n          aria-hidden=\"true\"\n          className={\n            \"grid size-8 shrink-0 place-items-center rounded-litt-control forced-colors:border \" +\n            (disabled ? \"bg-litt-surface-2 text-litt-fg-disabled shadow-[inset_0_0_0_1px_var(--litt-line)]\" : \"bg-litt-block text-litt-fg shadow-(--litt-block-lift)\")\n          }\n        >\n          <ArrowUpIcon />\n        </span>\n        <div className=\"grid min-w-0 flex-1 basis-40 gap-1\">\n          <p className={\"text-litt-body font-litt-title \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg\")}>{words}</p>\n          {/* The hint is on the tray's floor, a step darker than the plate, so it takes the full ink to keep 4.5:1 there. */}\n          {hint && (\n            <p id={hintId} className={\"text-litt-small \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg\")}>\n              {hint}\n            </p>\n          )}\n        </div>\n        <Button size=\"sm\" variant=\"secondary\" disabled={disabled} aria-describedby={hint ? hintId : undefined} onClick={onChoose}>\n          {chooseLabel}\n        </Button>\n        {picker}\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ FileList and FileRow */\n\n/* Press: the list is a tray, and each file stands in it as a slat. Flat: rows parted by space. Device: lines of glass. */\nconst listLook = {\n  press: \"gap-1 rounded-litt-surface bg-litt-tray p-1 shadow-(--litt-tray-shade) forced-colors:border \",\n  flat: \"gap-1 \",\n  device: \"gap-2 \",\n};\n\n/** The files that were added, one row each. Name it, so a screen reader knows whose files these are. */\nexport function FileList({ label, children, className = \"\" }: { label: string; children: ReactNode; className?: string }) {\n  const material = useMaterial();\n  return (\n    <ul aria-label={label} className={\"grid grid-cols-[minmax(0,1fr)] \" + listLook[material] + className}>\n      {children}\n    </ul>\n  );\n}\n\nexport type FileRowProps = {\n  name: string;\n  /** In bytes. */\n  size?: number;\n  /** How far the upload is, 0 to 100. Leave it out once the file is in. */\n  progress?: number;\n  /** Why the upload failed. Shown in --litt-danger under the name, with Retry. */\n  error?: ReactNode;\n  onRetry?: () => void;\n  onRemove?: () => void;\n};\n\n/** What the contract hands a row's body: the row's props and whether it is uploading (a rail shows, and Remove says Cancel). */\nexport type FileRowBodyProps = FileRowProps & { uploading: boolean };\n\n/** One file in a FileList: its name and size, a Progress rail while it uploads, why it failed, Retry and Remove. */\nexport function FileRow(props: FileRowProps) {\n  const uploading = props.progress !== undefined && props.progress < 100 && !props.error;\n  const Body = byWorld(useMaterial(), { press: PressRowBody, flat: FileRowFlatBody, device: FileRowDeviceBody });\n  return <Body {...props} uploading={uploading} />;\n}\n\n/**\n * Press, a slat: a raised block (--litt-block, --litt-block-lift) standing in the list's tray, 4px in, its corners the tray's less\n * those 4px. Its name and size are on its face, a Progress rail is cut into it under them, and Retry and Remove are\n * ghost icon buttons at its end.\n */\nfunction PressRowBody({ name, size, progress, error, uploading, onRetry, onRemove }: FileRowBodyProps) {\n  return (\n    <li\n      data-body=\"press\"\n      className=\"flex items-center gap-3 rounded-[calc(var(--litt-radius-surface)-4px)] bg-litt-block py-3 pr-2 pl-4 shadow-(--litt-block-lift) forced-colors:border\"\n    >\n      <FileIcon className=\"shrink-0 text-litt-fg-muted\" />\n      <div className=\"grid min-w-0 flex-1 gap-2\">\n        <p className=\"flex items-baseline justify-between gap-4\">\n          <span className=\"min-w-0 truncate text-litt-caption font-normal text-litt-fg\">{name}</span>\n          {size !== undefined && <span className=\"shrink-0 text-litt-caption tabular-nums font-normal text-litt-fg-subtle\">{formatSize(size)}</span>}\n        </p>\n        {(uploading || error) && <Progress label={`Uploading ${name}`} hideLabel value={progress ?? 0} status={error ? \"failed\" : \"active\"} message={error} />}\n      </div>\n      <div className=\"flex shrink-0 items-center gap-1\">\n        {error && onRetry && (\n          <Button size=\"sm\" variant=\"ghost\" iconOnly aria-label={`Retry ${name}`} onClick={onRetry}>\n            <RetryIcon />\n          </Button>\n        )}\n        {onRemove && (\n          <Button size=\"sm\" variant=\"ghost\" iconOnly aria-label={uploading ? `Cancel ${name}` : `Remove ${name}`} onClick={onRemove}>\n            <XIcon />\n          </Button>\n        )}\n      </div>\n    </li>\n  );\n}\n",
      "target": "components/litt/file-drop.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/file-drop.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/file-drop 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * File drop, the Device body (proposals/worlds.md, Bodies; material.md, 8): a slot in the body, with a key to choose.\n *\n * - The area is an engraved legend (litt-legend: capitals, tracked, --litt-engrave) over a slot: LCD glass (--litt-field,\n *   --litt-field-shade, the mono) set in a bezel well (--litt-tray, --litt-tray-shade) with the hint on it, and a Choose key (the\n *   Button's, --litt-block on its 2px skirt) beside it. Nothing is framed round them: the body is the page.\n * - Dragging: the legend changes to say what a drop will do, the bezel takes the focus ring, the glass brightens to\n *   its ink and a lamp at its end lights (--litt-field-ink with a little glow, never the accent, R6). The lamp never says\n *   it alone: the words do. They switch in three steps on the instant tier, never a fade.\n * - Disabled lies flat: a surface-2 bezel with a hairline, dimmed glass, the legend in --litt-fg-subtle because it still\n *   says something, and no drop and no tab stop.\n * - A file row is a line of LCD glass: the file icon, its name in the mono and, at its end, its size; while it uploads\n *   the size gives way to a row of segments that light in steps and the percent. Retry and Remove are real keys on the\n *   body beside it (a ghost button would not read on dark glass). A failure keeps its lit segments where they\n *   stopped, in --litt-danger, and says why on a second line of glass under it, beside a lamp in --litt-danger.\n * - The segments are decoration: the row's role=\"progressbar\" is named \"Uploading <file>\", carries the percent as its\n *   value text, and its end is said once in a polite status, as Progress does.\n * - Forced colors drops shadows and fills, so the bezel and the glass get real borders and a lit lamp or segment is\n *   a system highlight.\n * - The contract (the drop, the checking, the picker, the alert) is file-drop.tsx's.\n */\nimport { useId, type CSSProperties } from \"react\";\nimport { Button } from \"../button\";\nimport { FileIcon, RetryIcon, XIcon } from \"../icons\";\nimport { formatSize, type FileDropBodyProps, type FileRowBodyProps } from \"../file-drop\";\n\n/* A change switches in place in three even steps on the instant tier, as a display's segments light. */\nconst STEPS = \"steps(3, end)\";\nconst stepped: CSSProperties = {\n  transitionProperty: \"background-color, box-shadow, color\",\n  transitionDuration: \"var(--litt-dur-instant)\",\n  transitionTimingFunction: `${STEPS}`,\n};\n\n/** Device, the slot: an engraved legend over LCD glass in a bezel, and a Choose key. */\nexport function FileDropDeviceBody({ area, words, hint, hintId, dragging, disabled, chooseLabel, onChoose, picker }: FileDropBodyProps) {\n  return (\n    <div\n      {...area}\n      data-body=\"device\"\n      className={\"grid grid-cols-[minmax(0,1fr)] gap-2 \" + (disabled ? \"cursor-not-allowed\" : \"cursor-pointer\")}\n    >\n      <p className={\"litt-legend text-litt-caption font-litt-title \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg\")} style={stepped}>\n        {words}\n      </p>\n      <div className=\"flex flex-wrap items-center gap-2 sm:flex-nowrap\">\n        <div\n          className={\n            \"flex min-h-litt-control-md min-w-48 flex-1 basis-48 rounded-litt-control p-0.5 forced-colors:border \" +\n            (disabled\n              ? \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\"\n              : dragging\n                ? \"bg-litt-tray shadow-[var(--litt-tray-shade),var(--litt-focus-ring)] forced-colors:outline-2 forced-colors:outline-solid forced-colors:outline-[Highlight]\"\n                : \"bg-litt-tray shadow-(--litt-tray-shade)\")\n          }\n          style={stepped}\n        >\n          <div\n            className={\n              \"relative flex min-w-0 flex-1 items-center rounded-[calc(var(--litt-radius-control)-2px)] py-1 pr-6 pl-3 font-litt-field text-litt-caption forced-colors:border \" +\n              (disabled ? \"bg-transparent text-litt-fg-subtle\" : \"bg-litt-field shadow-(--litt-field-shade) \" + (dragging ? \"text-litt-field-ink\" : \"text-litt-field-ink-subtle\"))\n            }\n            style={stepped}\n          >\n            {hint && (\n              <p id={hintId} className=\"min-w-0\">\n                {hint}\n              </p>\n            )}\n            <span\n              aria-hidden=\"true\"\n              className={\n                \"absolute top-1/2 right-2 size-1.5 -translate-y-1/2 rounded-litt-pill forced-colors:border \" +\n                (dragging && !disabled ? \"bg-litt-field-ink shadow-[0_0_6px_var(--litt-field-ink)] forced-colors:bg-[Highlight]\" : \"bg-litt-field-line forced-colors:bg-transparent\")\n              }\n              style={stepped}\n            />\n          </div>\n        </div>\n        <Button size=\"md\" variant=\"secondary\" disabled={disabled} aria-describedby={hint ? hintId : undefined} onClick={onChoose}>\n          {chooseLabel}\n        </Button>\n      </div>\n      {picker}\n    </div>\n  );\n}\n\n/* Twenty segments; the share lights that many of them. */\nconst SEGMENTS = 20;\n\n/** Device, a file row: a line of LCD glass, its upload as segments lighting in steps, and Retry and Remove as keys. */\nexport function FileRowDeviceBody({ name, size, progress, error, uploading, onRetry, onRemove }: FileRowBodyProps) {\n  const labelId = useId();\n  const messageId = useId();\n  const showing = uploading || !!error;\n  const share = Math.max(0, Math.min(1, (progress ?? 0) / 100));\n  const lit = Math.round(share * SEGMENTS);\n  const reading = error ? \"Failed\" : `${Math.round(share * 100)}%`;\n  return (\n    <li data-body=\"device\" className=\"grid grid-cols-[minmax(0,1fr)] gap-1.5\">\n      <div className=\"flex items-start gap-2\">\n        <div className=\"flex min-h-8 min-w-0 flex-1 items-center gap-3 rounded-litt-control bg-litt-field px-4 font-litt-field text-litt-caption text-litt-field-ink shadow-(--litt-field-shade) forced-colors:border pointer-coarse:min-h-11\">\n          <FileIcon className=\"hidden shrink-0 text-litt-field-ink-subtle sm:block\" />\n          <span className=\"min-w-0 flex-1 truncate\">{name}</span>\n          {showing && (\n            <>\n              <span id={labelId} className=\"sr-only\">{`Uploading ${name}`}</span>\n              <div\n                role=\"progressbar\"\n                aria-labelledby={labelId}\n                aria-describedby={error ? messageId : undefined}\n                aria-valuemin={0}\n                aria-valuemax={100}\n                aria-valuenow={Math.round(share * 100)}\n                aria-valuetext={reading}\n                className=\"grid h-2 w-14 shrink-0 grid-flow-col auto-cols-fr gap-px sm:w-24\"\n              >\n                {Array.from({ length: SEGMENTS }, (_, i) => (\n                  <span\n                    key={i}\n                    aria-hidden=\"true\"\n                    className={\n                      \"h-full forced-colors:border \" +\n                      (i < lit ? (error ? \"bg-litt-danger forced-colors:bg-[Highlight]\" : \"bg-litt-field-ink forced-colors:bg-[Highlight]\") : \"bg-litt-field-line forced-colors:bg-transparent\")\n                    }\n                    style={stepped}\n                  />\n                ))}\n              </div>\n            </>\n          )}\n          <span className=\"shrink-0 text-right tabular-nums text-litt-field-ink-subtle\">{showing ? error ? <span className=\"hidden sm:inline\">Failed</span> : reading : size !== undefined ? formatSize(size) : \"\"}</span>\n        </div>\n        <div className=\"flex shrink-0 items-center gap-2\">\n          {error && onRetry && (\n            <Button size=\"sm\" variant=\"secondary\" iconOnly aria-label={`Retry ${name}`} onClick={onRetry}>\n              <RetryIcon />\n            </Button>\n          )}\n          {onRemove && (\n            <Button size=\"sm\" variant=\"secondary\" iconOnly aria-label={uploading ? `Cancel ${name}` : `Remove ${name}`} onClick={onRemove}>\n              <XIcon />\n            </Button>\n          )}\n        </div>\n      </div>\n      {error && (\n        <p id={messageId} className=\"flex items-center gap-3 rounded-litt-control bg-litt-field py-1.5 pr-4 pl-4 font-litt-field text-litt-caption text-litt-field-ink shadow-(--litt-field-shade) forced-colors:border\">\n          <span aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-litt-pill bg-litt-danger forced-colors:border\" />\n          <span>{error}</span>\n        </p>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {error ? `Uploading ${name}: failed.` : \"\"}\n      </span>\n    </li>\n  );\n}\n",
      "target": "components/litt/bodies/file-drop.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/file-drop.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/file-drop 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * File drop, the Flat body (proposals/worlds.md, Bodies; material.md, 7): ink on paper.\n *\n * - The area is a grey sheet (--litt-cut is --litt-surface-3) with one sentence on it, \"Drop files or choose\", the last word a\n *   pill (the one shape for what a hand touches) that opens the picker. No arrow, no block, no line, no shade. Under\n *   it the hint in grey words. On a touch screen the sentence is the pill alone, \"Add files\".\n * - While files are over it the sheet is ringed in ink (a 2px outline drawn inside the sheet, the only line Flat\n *   makes) and the sentence says what a drop will do: \"Drop to add these files\". The pill steps aside (clipped away,\n *   not removed) so the sheet has one sentence, not two. Forced colors keeps the outline in Highlight.\n * - Choose is a real button named by the contract (\"Choose files\", with the hint as its description): the visible\n *   word is inside the name, so a voice user who says \"choose\" reaches it.\n * - A file is a line of words: its name in ink and its size in grey, Retry and Remove as words in pills at the\n *   end, and under them the Progress rail while it uploads (an ink fill on grey, nothing else) and, if it failed,\n *   why in --litt-danger. No icon, no rule between rows; space parts them.\n * - Disabled lies flat: a --litt-surface-2 sheet, the sentence in --litt-fg-subtle, no drop and no tab stop.\n */\nimport { Button } from \"../button\";\nimport { Progress } from \"../progress\";\nimport { useDevice } from \"../../../lib/litt/motion\";\nimport { formatSize, type FileDropBodyProps, type FileRowBodyProps } from \"../file-drop\";\n\n/** Flat, a grey sheet with one sentence: \"Drop files or choose\", ringed in ink while a file is over it. */\nexport function FileDropFlatBody({ area, words, hint, hintId, dragging, disabled, chooseLabel, onChoose, picker }: FileDropBodyProps) {\n  const { coarse } = useDevice();\n  /* While files are over it the pill is clipped away (sr-only), not removed, so it is still the button that was there. */\n  const pill = (\n    <span className={dragging ? \"sr-only\" : \"contents\"}>\n      <Button size=\"sm\" variant=\"secondary\" className=\"enabled:bg-litt-surface-1\" disabled={disabled} aria-label={chooseLabel} aria-describedby={hint ? hintId : undefined} onClick={onChoose}>\n        {coarse ? \"Add files\" : \"choose\"}\n      </Button>\n    </span>\n  );\n  return (\n    <div\n      {...area}\n      data-body=\"flat\"\n      className={\n        \"grid justify-items-center gap-2 rounded-litt-surface p-6 text-center forced-colors:border sm:p-8 \" +\n        (disabled ? \"cursor-not-allowed bg-litt-surface-2\" : \"cursor-pointer bg-litt-cut\") +\n        (dragging ? \" outline-2 -outline-offset-2 outline-solid outline-litt-fg forced-colors:outline-[Highlight]\" : \"\")\n      }\n    >\n      <p className={\"flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-litt-body font-litt-title \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg\")}>\n        {dragging ? (\n          words\n        ) : coarse ? null : (\n          \"Drop files or\"\n        )}\n        {pill}\n      </p>\n      {hint && (\n        <p id={hintId} className=\"text-litt-small text-litt-fg-subtle\">\n          {hint}\n        </p>\n      )}\n      {picker}\n    </div>\n  );\n}\n\n/** Flat, a file as a line of words: name in ink, size in grey, Retry and Remove as words, the rail and the reason under. */\nexport function FileRowFlatBody({ name, size, progress, error, uploading, onRetry, onRemove }: FileRowBodyProps) {\n  return (\n    <li data-body=\"flat\" className=\"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 py-2\">\n      <p className=\"flex min-w-0 items-baseline gap-3\">\n        <span className=\"min-w-0 truncate text-litt-small font-normal text-litt-fg\">{name}</span>\n        {size !== undefined && <span className=\"shrink-0 text-litt-caption tabular-nums font-normal text-litt-fg-subtle\">{formatSize(size)}</span>}\n      </p>\n      <div className=\"flex shrink-0 items-center gap-1\">\n        {error && onRetry && (\n          <Button size=\"sm\" variant=\"ghost\" aria-label={`Retry ${name}`} onClick={onRetry}>\n            Retry\n          </Button>\n        )}\n        {onRemove && (\n          <Button size=\"sm\" variant=\"ghost\" aria-label={uploading ? `Cancel ${name}` : `Remove ${name}`} onClick={onRemove}>\n            {uploading ? \"Cancel\" : \"Remove\"}\n          </Button>\n        )}\n      </div>\n      {(uploading || error) && (\n        <Progress\n          className=\"col-span-2\"\n          label={`Uploading ${name}`}\n          hideLabel\n          value={progress ?? 0}\n          status={error ? \"failed\" : \"active\"}\n          message={error}\n        />\n      )}\n    </li>\n  );\n}\n",
      "target": "components/litt/bodies/file-drop.flat.tsx"
    }
  ],
  "docs": "Import FileDrop, FileList and FileRow from components/litt/file-drop. FileDrop hands the files that passed to onFiles; you upload them and render a FileRow for each.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
