{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "type": "registry:component",
  "title": "Toast",
  "description": "A short message that leaves on its own, with a title that changes through dither.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/toast/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/toast/index.tsx",
      "content": "'use client';\n\n/**\n * Toast — a short message about something that just happened, which leaves on its own.\n *\n * ```tsx\n * <Toast.Provider position=\"bottom-right\">\n *   <App />\n * </Toast.Provider>\n *\n * const toast = useToast();\n * toast.add({ title: 'Invoice sent', description: 'Harbour Bakery will get it in a minute.', type: 'success' });\n * toast.promise(save(), { loading: 'Saving draft', success: 'Draft saved', error: 'Could not save' });\n * ```\n *\n * ## The title changes through dither\n *\n * A toast's title is not swapped. When it arrives, it resolves out of a row of\n * dither cells from the start, with a caret riding the edge, the way a line\n * types itself onto a terminal. When it changes — a save going on to say\n * \"Draft saved\" where it said \"Saving draft\" — the old words dim, turn\n * monospace, are eaten by an ordered dither and the new ones resolve through\n * the same cells, so a reader\n * sees that this is the same toast with news, not a second one. When it\n * leaves, it dissolves back into the cells before the card fades.\n *\n * The cells and caret are drawn in the toast's colour; the settled text is the\n * ordinary foreground, so it reads as text and not as a highlight. With\n * reduced motion turned on, the title changes in place.\n *\n * ## Versions\n *\n * `effect` on the provider picks one of three. `type`, the default, does the\n * above to the title alone. `materialize` brings the whole card in through an\n * ordered dither first, and only then resolves its title and description\n * through the dither cells, so the toast builds itself in two steps; leaving,\n * the words dissolve and then the card does. `timer` adds a dithered bar along\n * the bottom that runs down the toast's time and pauses while the stack is open.\n *\n * ## The mark is a dither cell too\n *\n * Beside the title, a small square of ordered dither says what kind of toast\n * it is: filled solid in the success, info, warning or destructive colour, or\n * cycling through its densities while a `loading` toast waits.\n *\n * ## Stacked, then spread\n *\n * Several toasts stack into one, with the ones behind peeking out under it.\n * Hovering or focusing the stack spreads them into a list, and pauses their\n * timers while it is open. A toast can be swiped away towards the nearest edge.\n *\n * ## Announced, not focused\n *\n * Toasts are read out by a screen reader as they arrive, politely by default\n * and at once for `priority: 'high'`, without moving focus. F6 moves focus to\n * the stack, so its buttons can be reached from the keyboard. Anything that\n * needs an answer before the reader moves on belongs in an Alert Dialog.\n */\nimport { Toast as BaseToast } from '@base-ui/react/toast';\nimport { useEffect, useState, type CSSProperties, type ReactNode } from 'react';\nimport { tv } from 'tailwind-variants';\nimport { maskStyle, prefersReducedMotion, SweepCells, useCardSweep, type SweepPhase } from './dither';\nimport { DitherText } from './dither-text';\n\nexport type ToastPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';\nexport type ToastType = 'default' | 'success' | 'info' | 'warning' | 'destructive' | 'loading';\nexport type ToastEffect = 'type' | 'materialize' | 'timer';\n\nconst toastVariants = tv({\n  slots: {\n    viewport: [\n      'fixed z-[60] flex w-[calc(100%-2*var(--toast-inset))] max-w-[22.5rem] outline-none [--toast-inset:1rem] sm:[--toast-inset:1.5rem]',\n      'data-[position^=top]:top-(--toast-inset) data-[position^=bottom]:bottom-(--toast-inset)',\n      'data-[position$=left]:left-(--toast-inset) data-[position$=right]:right-(--toast-inset)',\n      'data-[position$=center]:left-1/2 data-[position$=center]:-translate-x-1/2',\n    ],\n    // In place, for a preview: as tall as the front toast, in the flow of the page.\n    inlineViewport: 'relative w-full max-w-[22.5rem] [--toast-inset:0px] h-(--toast-frontmost-height,5rem) min-h-16',\n    root: [\n      'group/toast absolute inset-x-0 z-[calc(100-var(--toast-index))] select-none',\n      'rounded-xl border bg-popover text-popover-foreground shadow-lg shadow-black/5 dark:shadow-black/30',\n      '[--gap:0.625rem] [--peek:0.5rem] [--shrink:calc(var(--toast-index)*0.05)]',\n      // Collapsed, every toast is as tall as the front one, so the ones behind show only as edges.\n      'h-(--toast-frontmost-height,var(--toast-height)) data-expanded:h-(--toast-height)',\n      '[transition:transform_0.45s_cubic-bezier(0.22,1,0.36,1),opacity_0.3s,height_0.2s]',\n      'data-[position^=bottom]:bottom-0 data-[position^=bottom]:origin-bottom',\n      'data-[position^=top]:top-0 data-[position^=top]:origin-top',\n      // Collapsed: peeking out behind the front one, a little smaller each.\n      'data-[position^=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-var(--toast-index)*var(--peek)))_scale(calc(1-var(--shrink)))]',\n      'data-[position^=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+var(--toast-index)*var(--peek)))_scale(calc(1-var(--shrink)))]',\n      // Spread: each at its own place in the list.\n      'data-[position^=bottom]:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-var(--toast-offset-y)-var(--toast-index)*var(--gap)))]',\n      'data-[position^=top]:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+var(--toast-offset-y)+var(--toast-index)*var(--gap)))]',\n      // A bridge over the gap, so moving the pointer between spread toasts does not collapse them.\n      \"after:absolute after:inset-x-0 after:h-(--gap) after:content-[''] data-[position^=bottom]:after:top-full data-[position^=top]:after:bottom-full\",\n      // Arriving from the edge it is pinned to.\n      'data-[position^=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]',\n      'data-[position^=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]',\n      'data-starting-style:opacity-0 data-limited:opacity-0',\n      // Leaving: the title dissolves first, then the card fades. A swipe goes at once.\n      'data-ending-style:opacity-0 data-ending-style:not-data-swipe-direction:[transition-delay:0s,0.32s,0s]',\n      // Materialize holds the card until its text and then the card itself have dithered out.\n      'data-[effect=materialize]:data-ending-style:not-data-swipe-direction:[transition-delay:0s,0.9s,0s]',\n      'data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))]',\n      'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))]',\n      'data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]',\n      'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]',\n      'motion-reduce:transition-[opacity]',\n    ],\n    // A layer under or over the content, clipped to the card's corners.\n    surface: 'pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]',\n    timer: 'absolute inset-x-0 bottom-0 h-[3px] origin-left bg-(--toast-color) group-data-expanded/toast:[animation-play-state:paused] motion-reduce:[animation:none!important]',\n    content: [\n      'relative flex items-start gap-3 overflow-hidden p-4 transition-opacity duration-200',\n      // Behind the front one, collapsed, its content would show through the edge.\n      'not-data-expanded:data-behind:opacity-0',\n    ],\n    mark: 'mt-[0.3125rem] grid size-2.5 shrink-0 grid-cols-2 grid-rows-2 gap-px text-(--toast-color)',\n    body: 'flex min-w-0 flex-1 flex-col gap-1',\n    title: 'text-sm font-medium leading-5 text-foreground',\n    description: 'text-[0.8125rem] leading-5 text-muted-foreground',\n    action: [\n      'inline-flex h-7 shrink-0 cursor-pointer items-center rounded-md border bg-background px-2.5 text-xs font-medium text-foreground shadow-xs outline-none',\n      'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring dark:bg-input/30',\n    ],\n    close: [\n      'relative -me-1 -mt-1 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none',\n      'opacity-0 transition-opacity group-hover/toast:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring',\n      'hover:bg-accent hover:text-foreground pointer-coarse:opacity-100',\n    ],\n  },\n});\n\n/** The colour each type's mark and dither are drawn in. */\nconst COLOR: Record<ToastType, string> = {\n  default: 'var(--foreground)',\n  success: 'var(--success)',\n  info: 'var(--info)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  loading: 'var(--info)',\n};\n\n/** The order the mark's four cells light in while loading: a 2×2 Bayer matrix, read as thresholds. */\nconst BAYER_2 = [0, 2, 3, 1];\n\nfunction Mark({ type }: { type: ToastType }) {\n  const slots = toastVariants();\n  const loading = type === 'loading';\n  return (\n    <span data-slot=\"toast-mark\" aria-hidden=\"true\" className={slots.mark()}>\n      {BAYER_2.map((threshold, index) => (\n        <span\n          key={index}\n          className={loading ? 'rounded-[1px] bg-current motion-safe:animate-pulse' : 'rounded-[1px] bg-current'}\n          style={loading ? { animationDelay: `${threshold * 0.25}s`, animationDuration: '1s' } : undefined}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction swipeOf(position: ToastPosition): ('up' | 'down' | 'left' | 'right')[] {\n  const vertical = position.startsWith('top') ? 'up' : 'down';\n  if (position.endsWith('center')) return [vertical];\n  return [position.endsWith('left') ? 'left' : 'right', vertical];\n}\n\nfunction textOf(node: ReactNode): string | null {\n  return typeof node === 'string' || typeof node === 'number' ? String(node) : null;\n}\n\n/** What a toast can carry in `data`. */\nexport interface ToastData {\n  /** Which dither this one toast is drawn with, overriding the provider's `effect`. */\n  effect?: ToastEffect;\n}\n\nconst DEFAULT_TIMEOUT = 5000;\n\n/** The keyframes the timer bar runs on, written once per provider. */\nconst KEYFRAMES =\n  '@keyframes panelui-toast-timer{from{transform:scaleX(1)}to{transform:scaleX(0)}}' +\n  '@keyframes panelui-toast-sweep{from{transform:translateX(-100%)}to{transform:translateX(250%)}}';\n\n/** How long the card takes to dither in or out, in `materialize`. */\nconst CARD_MS = 520;\n/** How long the text takes to dissolve before the card follows it out. */\nconst TEXT_OUT_MS = 380;\n\n/**\n * A line of text that is held at its full size while it dithers in and out,\n * so the toast keeps its height and the stack does not jump as letters arrive.\n */\nfunction Dithered({ text, show, accent, caret }: { text: string; show: boolean; accent: string; caret?: boolean }) {\n  return (\n    <span className=\"grid\">\n      <span aria-hidden=\"true\" className=\"invisible col-start-1 row-start-1\">\n        {text}\n      </span>\n      <span className=\"col-start-1 row-start-1\">\n        <DitherText text={show ? text : ''} accent={accent} caret={caret} />\n      </span>\n    </span>\n  );\n}\n\nfunction ToastItem({\n  toast,\n  index,\n  position,\n  swipe,\n  effect: fallback,\n  timeout: defaultTimeout,\n}: {\n  toast: ReturnType<typeof BaseToast.useToastManager>['toasts'][number];\n  index: number;\n  position: ToastPosition;\n  swipe: ('up' | 'down' | 'left' | 'right')[];\n  effect: ToastEffect;\n  timeout: number;\n}) {\n  const slots = toastVariants();\n  const type: ToastType = toast.type && toast.type in COLOR ? (toast.type as ToastType) : 'default';\n  const effect = (toast.data as ToastData | undefined)?.effect ?? fallback;\n  const title = textOf(toast.title);\n  const leaving = toast.transitionStatus === 'ending';\n  const timeout = toast.timeout ?? defaultTimeout;\n  const description = textOf(toast.description);\n  const materialize = effect === 'materialize';\n\n  // Materialize runs in order: the card dithers in, then its text resolves\n  // through dither; leaving, the text dissolves first and the card follows.\n  const [textIn, setTextIn] = useState(!materialize || prefersReducedMotion());\n  const [cardOut, setCardOut] = useState(false);\n  useEffect(() => {\n    if (textIn) return;\n    const timer = setTimeout(() => setTextIn(true), CARD_MS);\n    return () => clearTimeout(timer);\n  }, [textIn]);\n  useEffect(() => {\n    if (!materialize || !leaving) return;\n    const timer = setTimeout(() => setCardOut(true), prefersReducedMotion() ? 0 : TEXT_OUT_MS);\n    return () => clearTimeout(timer);\n  }, [materialize, leaving]);\n\n  const phase: SweepPhase = !materialize ? 'done' : cardOut ? 'out' : textIn ? 'done' : 'in';\n  const sweep = useCardSweep(phase, CARD_MS);\n  const showText = textIn && !leaving;\n\n  return (\n    <BaseToast.Root\n      toast={toast}\n      swipeDirection={swipe}\n      data-slot=\"toast\"\n      data-type={type}\n      data-effect={effect}\n      data-position={position}\n      data-behind={index > 0 ? '' : undefined}\n      className={slots.root()}\n      style={{ '--toast-color': COLOR[type], ...sweep.mask } as CSSProperties}\n    >\n      {materialize && phase !== 'done' ? (\n        <span aria-hidden=\"true\" className={slots.surface({ className: 'z-20' })}>\n          <SweepCells phase={phase} edge={sweep.edge} color={COLOR[type]} className=\"absolute inset-0 size-full\" />\n        </span>\n      ) : null}\n      <BaseToast.Content className={slots.content()}>\n        <Mark type={type} />\n        <div className={slots.body()}>\n          <BaseToast.Title data-slot=\"toast-title\" className={slots.title()}>\n            {title !== null ? <Dithered text={title} show={showText} accent={COLOR[type]} /> : toast.title}\n          </BaseToast.Title>\n          {toast.description ? (\n            <BaseToast.Description data-slot=\"toast-description\" className={slots.description()}>\n              {materialize && description !== null ? (\n                <Dithered text={description} show={showText} accent={COLOR[type]} caret={false} />\n              ) : (\n                toast.description\n              )}\n            </BaseToast.Description>\n          ) : null}\n        </div>\n        {toast.actionProps ? <BaseToast.Action data-slot=\"toast-action\" className={slots.action()} /> : null}\n        <BaseToast.Close data-slot=\"toast-close\" aria-label=\"Dismiss\" className={slots.close()}>\n          <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" fill=\"none\" className=\"size-3.5\">\n            <path d=\"M4.5 4.5l7 7m0-7l-7 7\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" />\n          </svg>\n        </BaseToast.Close>\n      </BaseToast.Content>\n      {effect === 'timer' ? (\n        <span aria-hidden=\"true\" className={slots.surface()}>\n          {timeout > 0 ? (\n            // Restarted by every update, which also restarts the toast's own timer.\n            <span\n              key={toast.updateKey ?? 0}\n              data-slot=\"toast-timer\"\n              className={slots.timer()}\n              style={{ ...maskStyle(10, 1), animation: `panelui-toast-timer ${timeout}ms linear forwards` }}\n            />\n          ) : (\n            // No timeout: it waits, so the bar sweeps instead of running down.\n            <span\n              data-slot=\"toast-timer\"\n              className={slots.timer({ className: 'w-2/5 origin-center' })}\n              style={{ ...maskStyle(10, 1), animation: 'panelui-toast-sweep 1.4s cubic-bezier(0.45,0,0.55,1) infinite' }}\n            />\n          )}\n        </span>\n      ) : null}\n    </BaseToast.Root>\n  );\n}\n\nfunction ToastList({\n  position,\n  inline,\n  effect,\n  timeout,\n}: {\n  position: ToastPosition;\n  inline: boolean;\n  effect: ToastEffect;\n  timeout: number;\n}) {\n  const { toasts } = BaseToast.useToastManager();\n  const slots = toastVariants();\n  const swipe = swipeOf(position);\n\n  const viewport = (\n    <BaseToast.Viewport\n      data-slot=\"toast-viewport\"\n      data-position={position}\n      data-inline={inline ? '' : undefined}\n      className={inline ? slots.inlineViewport() : slots.viewport()}\n    >\n      <style>{KEYFRAMES}</style>\n      {toasts.map((toast, index) => (\n        <ToastItem\n          key={toast.id}\n          toast={toast}\n          index={index}\n          position={position}\n          swipe={swipe}\n          effect={effect}\n          timeout={timeout}\n        />\n      ))}\n    </BaseToast.Viewport>\n  );\n\n  return inline ? viewport : <BaseToast.Portal>{viewport}</BaseToast.Portal>;\n}\n\nexport interface ToastProviderProps extends Omit<BaseToast.Provider.Props, 'children'> {\n  /** Which corner or edge of the screen the toasts gather at. Default `bottom-right`. */\n  position?: ToastPosition;\n  /**\n   * Draws the stack where the provider is, in the flow of the page, instead\n   * of pinned to a corner of the screen. For a preview or a thumbnail.\n   */\n  inline?: boolean;\n  /**\n   * How the toasts are dithered. `type` writes the title through dither\n   * cells. `materialize` dithers the whole card in, then resolves its title and\n   * description through dither inside it, and runs the same backwards when it\n   * leaves. `timer` writes the title as `type` does and runs a dithered bar down\n   * the toast's time. One toast can choose its own with `data: { effect }`.\n   * Default `type`.\n   */\n  effect?: ToastEffect;\n  children?: ReactNode;\n}\n\n/**\n * Holds the toasts and draws them. Put it once near the root; `useToast()`\n * works anywhere inside it. Takes `timeout` (ms, default 5000, `0` to stay),\n * `limit` (default 3) and a `toastManager` from `createToaster()` for\n * toasts raised outside React.\n */\nfunction ToastProvider({\n  position = 'bottom-right',\n  inline = false,\n  effect = 'type',\n  children,\n  ...props\n}: ToastProviderProps) {\n  return (\n    <BaseToast.Provider {...props}>\n      {children}\n      <ToastList position={position} inline={inline} effect={effect} timeout={props.timeout ?? DEFAULT_TIMEOUT} />\n    </BaseToast.Provider>\n  );\n}\nToastProvider.displayName = 'Toast.Provider';\n\n/**\n * Raises, updates and closes toasts. `add({ title, description, type, timeout,\n * actionProps })` returns an id; `update(id, …)` changes one in place, and its\n * title dissolves into the new one; `promise(p, { loading, success, error })`\n * follows a promise through the three; `close(id)` dismisses one, or all.\n */\nexport const useToast = BaseToast.useToastManager;\n\n/** A toast manager for code outside React. Pass it to `Toast.Provider` as `toastManager`. */\nexport const createToaster = BaseToast.createToastManager;\n\nexport const Toast = { Provider: ToastProvider };\n\nexport { ToastProvider };\n"
    },
    {
      "path": "components/panelui/toast/dither-text.tsx",
      "type": "registry:component",
      "target": "@components/panelui/toast/dither-text.tsx",
      "content": "'use client';\n\nimport { useEffect, useRef, useState, type CSSProperties } from 'react';\nimport { prefersReducedMotion } from './dither';\n\n/*\n * Text that changes by dissolving through dither.\n *\n * Every character of the old text goes dim and monospace, flickers through a\n * few stand-in glyphs and is eaten by an ordered dither, densest first, until\n * it is a solid cell. The new text then resolves from the start: a head moves\n * along it, each cell behind the head thins out through the same dither and\n * settles into its letter, and a caret rides the head. Characters are drawn as\n * spans rather than on a canvas, so the type is the page's own, crisp at any\n * zoom, and the cells take `currentColor`.\n *\n * The dither cells are CSS: four Bayer-like densities built from conic and\n * radial gradients on a 2–3px tile, plus a solid block. No image, no canvas.\n */\n\n/** Ordered densities, from the sparsest dust to a solid cell. */\nconst CELL: readonly CSSProperties[] = [\n  { backgroundImage: 'radial-gradient(currentColor 0.55px, transparent 0.75px)', backgroundSize: '3px 3px' },\n  { backgroundImage: 'conic-gradient(currentColor 0 25%, transparent 0)', backgroundSize: '2px 2px' },\n  { backgroundImage: 'repeating-conic-gradient(currentColor 0 25%, transparent 0 50%)', backgroundSize: '2px 2px' },\n  { backgroundImage: 'conic-gradient(transparent 0 25%, currentColor 0)', backgroundSize: '2px 2px' },\n  { backgroundColor: 'currentColor' },\n];\n\nconst SCRAMBLE = 'abcdefghijklmnopqrstuvwxyz0123456789';\n\n/** Milliseconds between one character starting to go and the next. */\nconst OUT_STAGGER = 14;\n/** How long one character takes from letter to solid cell. */\nconst OUT_SPAN = 240;\n/** When the new text starts arriving, after the old started going. */\nconst IN_DELAY = 150;\n/** Milliseconds between one new character starting to arrive and the next, for a short line. */\nconst IN_STAGGER = 30;\n/** The longest a whole line takes to start arriving; a long line's characters come closer together. */\nconst IN_SWEEP = 560;\n\n/** The gap between characters arriving, so that a long line takes no longer than `IN_SWEEP`. */\nfunction staggerFor(length: number) {\n  return length > 1 ? Math.min(IN_STAGGER, IN_SWEEP / (length - 1)) : IN_STAGGER;\n}\n/** How long one new character takes from cell to letter. */\nconst IN_SPAN = 210;\n/** How long the caret stays once the text has arrived. */\nconst CARET_HOLD = 520;\n\ntype Glyph =\n  | { kind: 'char'; char: string }\n  | { kind: 'mono'; char: string }\n  | { kind: 'cell'; level: number }\n  | { kind: 'gap' };\n\nfunction scramble(seed: number) {\n  return SCRAMBLE[Math.abs(Math.floor(seed)) % SCRAMBLE.length]!;\n}\n\n/** What the character at `index` looks like `elapsed` ms into a change from `from` to `to`. */\nfunction glyphAt(from: string, to: string, index: number, elapsed: number): Glyph {\n  const incoming = elapsed - IN_DELAY - index * staggerFor(to.length);\n  const target = to[index];\n\n  if (target !== undefined && incoming >= 0) {\n    if (target === ' ') return { kind: 'gap' };\n    const p = incoming / IN_SPAN;\n    if (p >= 1) return { kind: 'char', char: target };\n    // Arriving: a solid cell thins out, flickers as a letter, then settles.\n    if (p < 0.72) return { kind: 'cell', level: CELL.length - 1 - Math.floor((p / 0.72) * (CELL.length - 1)) };\n    return { kind: 'mono', char: p < 0.86 ? scramble(index * 7 + elapsed / 40) : target };\n  }\n\n  const source = from[index];\n  if (source === undefined) {\n    // A position only the new text has, which the head has not reached.\n    return target === undefined ? { kind: 'gap' } : { kind: 'cell', level: 0 };\n  }\n  if (source === ' ') return { kind: 'gap' };\n  const p = (elapsed - index * Math.min(OUT_STAGGER, IN_SWEEP / Math.max(1, from.length - 1))) / OUT_SPAN;\n  if (p < 0) return { kind: 'char', char: source };\n  // Going: dim and monospace, a few stand-in letters, then dither fills it in.\n  if (p < 0.3) return { kind: 'mono', char: p < 0.12 ? source : scramble(index * 13 + elapsed / 45) };\n  const level = Math.min(CELL.length - 1, Math.floor(((p - 0.3) / 0.7) * CELL.length));\n  // A position the new text does not reach thins back to nothing once solid.\n  if (target === undefined && p >= 1) {\n    const fade = (p - 1) * CELL.length;\n    return fade >= CELL.length ? { kind: 'gap' } : { kind: 'cell', level: Math.max(0, CELL.length - 1 - Math.floor(fade)) };\n  }\n  return { kind: 'cell', level };\n}\n\nfunction durationOf(from: string, to: string) {\n  const arrive = to.length > 0 ? IN_DELAY + (to.length - 1) * staggerFor(to.length) + IN_SPAN : 0;\n  // A tail of the old text that the new one does not cover goes solid and then thins away.\n  const leave =\n    from.length > to.length ? (from.length - 1) * Math.min(OUT_STAGGER, IN_SWEEP / Math.max(1, from.length - 1)) + OUT_SPAN * 2 : 0;\n  return Math.max(arrive, leave);\n}\n\nexport interface DitherTextProps {\n  /** The text to show. A change dissolves the old text into the new. */\n  text: string;\n  /** Whether the first text arrives through dither, as a change from nothing does. Default `true`. */\n  animateOnMount?: boolean;\n  /** Draws a caret at the arriving edge while the text changes. Default `true`. */\n  caret?: boolean;\n  /** The colour the dither cells and the caret are drawn in. Settled letters keep the text colour. */\n  accent?: string;\n  className?: string;\n}\n\n/** A line of text whose changes dissolve through ordered dither. */\nexport function DitherText({ text, animateOnMount = true, caret = true, accent, className }: DitherTextProps) {\n  const [shown, setShown] = useState(animateOnMount ? '' : text);\n  const transition = useRef<{ from: string; to: string; start: number } | null>(null);\n  const [now, setNow] = useState(0);\n  const frame = useRef(0);\n\n  useEffect(() => {\n    if (text === shown && !transition.current) return;\n    if (prefersReducedMotion()) {\n      transition.current = null;\n      setShown(text);\n      return;\n    }\n    // An effect run again for the same text (as React does in development)\n    // carries on with the change already under way, rather than restarting it.\n    if (transition.current?.to !== text) {\n      // A change mid-change starts from what is on screen now, as near as text allows.\n      const from = transition.current ? transition.current.to : shown;\n      transition.current = { from, to: text, start: performance.now() };\n    }\n    const { from } = transition.current;\n    const total = durationOf(from, text) + (caret ? CARET_HOLD : 0);\n\n    const tick = (time: number) => {\n      const current = transition.current;\n      if (!current) return;\n      if (time - current.start >= total) {\n        transition.current = null;\n        setShown(current.to);\n        return;\n      }\n      setNow(time);\n      frame.current = requestAnimationFrame(tick);\n    };\n    cancelAnimationFrame(frame.current);\n    frame.current = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame.current);\n    // `shown` is read as the starting point only.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [text, caret]);\n\n  const active = transition.current;\n  const label = <span className=\"sr-only\">{text}</span>;\n\n  if (!active) {\n    return (\n      <span data-slot=\"dither-text\" className={className}>\n        {shown}\n      </span>\n    );\n  }\n\n  const elapsed = Math.max(0, now - active.start);\n  const length = Math.max(active.from.length, active.to.length);\n  const head = Math.min(\n    active.to.length,\n    Math.max(0, Math.floor((elapsed - IN_DELAY) / staggerFor(active.to.length)) + 1)\n  );\n  const arriving = elapsed < durationOf(active.from, active.to);\n  const glyphs = Array.from({ length }, (_, index) => glyphAt(active.from, active.to, index, elapsed));\n\n  return (\n    <span data-slot=\"dither-text\" data-changing=\"\" className={className}>\n      {label}\n      <span aria-hidden=\"true\" className=\"whitespace-pre\" style={accent ? ({ '--dither-accent': accent } as CSSProperties) : undefined}>\n        {glyphs.map((glyph, index) => (\n          <span key={index} className=\"relative\">\n            {glyph.kind === 'char' ? glyph.char : null}\n            {glyph.kind === 'gap' ? ' ' : null}\n            {glyph.kind === 'mono' ? (\n              <span className=\"font-mono text-[0.92em] text-(--dither-accent,currentColor) opacity-55\">{glyph.char}</span>\n            ) : null}\n            {glyph.kind === 'cell' ? (\n              <span\n                className=\"inline-block h-[0.82em] w-[0.6em] align-[-0.06em] text-(--dither-accent,currentColor)\"\n                style={CELL[glyph.level]}\n              />\n            ) : null}\n            {caret && index === head - 1 && (arriving || Math.floor(elapsed / 260) % 2 === 0) ? (\n              <span className=\"absolute inset-y-[0.08em] -end-[0.08em] w-[0.09em] bg-(--dither-accent,currentColor)\" />\n            ) : null}\n          </span>\n        ))}\n        {caret && head === 0 ? (\n          <span className=\"inline-block h-[0.84em] w-[0.09em] align-[-0.06em] bg-(--dither-accent,currentColor)\" />\n        ) : null}\n      </span>\n    </span>\n  );\n}\n"
    },
    {
      "path": "components/panelui/toast/dither.tsx",
      "type": "registry:component",
      "target": "@components/panelui/toast/dither.tsx",
      "content": "'use client';\n\nimport { useEffect, useRef, useState, type CSSProperties } from 'react';\n\n/*\n * The ordered dither the toast's versions are drawn with.\n *\n * A coverage level picks the cells of the 4×4 Bayer matrix that are on, and\n * one SVG tile of those cells, as a CSS mask, cuts an element down to an even\n * dither, as the timer bar is drawn. The card sweep thresholds a tone per\n * column against the same matrix on a canvas.\n */\n\n/** The 4×4 Bayer matrix, as thresholds 0–15. */\nconst BAYER = [\n  [0, 8, 2, 10],\n  [12, 4, 14, 6],\n  [3, 11, 1, 9],\n  [15, 7, 13, 5],\n] as const;\n\n/** Levels a mask can take: 0 hides everything, 16 shows everything. */\nexport const LEVELS = 16;\n\nconst tiles = new Map<number, string>();\n\n/** A CSS `url()` of one tile showing the cells lit at `level` (0–16). */\nfunction tile(level: number) {\n  const cached = tiles.get(level);\n  if (cached) return cached;\n  let rects = '';\n  for (let y = 0; y < 4; y++) {\n    for (let x = 0; x < 4; x++) {\n      if (BAYER[y]![x]! < level) rects += `<rect x='${x}' y='${y}' width='1' height='1'/>`;\n    }\n  }\n  const url = `url(\"data:image/svg+xml,${encodeURIComponent(\n    `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4' shape-rendering='crispEdges'>${rects}</svg>`\n  )}\")`;\n  tiles.set(level, url);\n  return url;\n}\n\n/** Masks an element to an even dither at `level` (0–16), in cells of `cell` px. Full or empty need no mask. */\nexport function maskStyle(level: number, cell = 2): CSSProperties {\n  const clamped = Math.max(0, Math.min(LEVELS, Math.round(level)));\n  if (clamped >= LEVELS) return {};\n  const image = clamped === 0 ? 'linear-gradient(transparent, transparent)' : tile(clamped);\n  const size = `${cell * 4}px ${cell * 4}px`;\n  return {\n    maskImage: image,\n    WebkitMaskImage: image,\n    maskSize: size,\n    WebkitMaskSize: size,\n    maskRepeat: 'repeat',\n    WebkitMaskRepeat: 'repeat',\n  };\n}\n\nexport function prefersReducedMotion() {\n  return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n}\n\n/** How wide the band of dither cells behind the head is, as a share of the card's width. */\nconst BAND = 0.34;\n/** The width of one column of cells, in px: about a character, so the card is built as the text is. */\nconst COLUMN = 8;\n\nexport type SweepPhase = 'in' | 'out' | 'done';\n\n/**\n * Builds a card out of dither cells the way a line of text is built: a head\n * moves across it, the columns it has just passed arrive as solid cells in\n * the accent, and they thin out through the dither to show the card. On the\n * way out the columns thicken into solid cells and go, from the start edge.\n *\n * Returns a mask for the card, which shows only what the head has passed (or\n * what the tail has not), and a canvas of the cells, to sit over the card.\n */\nexport function useCardSweep(phase: SweepPhase, duration: number) {\n  const [progress, setProgress] = useState(phase === 'done' ? 1 : 0);\n\n  useEffect(() => {\n    if (phase === 'done') return;\n    if (prefersReducedMotion()) {\n      setProgress(1);\n      return;\n    }\n    const began = performance.now();\n    let frame = 0;\n    const tick = (time: number) => {\n      const t = Math.min(1, (time - began) / duration);\n      setProgress(t);\n      if (t < 1) frame = requestAnimationFrame(tick);\n    };\n    setProgress(0);\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [phase, duration]);\n\n  // The head (arriving) or the tail (leaving), in units of the card's width.\n  const edge = progress * (1 + BAND);\n  let mask: CSSProperties = {};\n  if (phase === 'in' && progress < 1) {\n    const image = `linear-gradient(to right, #000 ${edge * 100}%, transparent ${edge * 100}%)`;\n    mask = { maskImage: image, WebkitMaskImage: image };\n  } else if (phase === 'out') {\n    const tail = Math.max(0, edge - BAND);\n    const image = `linear-gradient(to right, transparent ${tail * 100}%, #000 ${tail * 100}%)`;\n    mask = { maskImage: image, WebkitMaskImage: image };\n  }\n  const sweeping = phase !== 'done' && progress > 0 && progress < 1;\n  return { mask, progress, edge, sweeping };\n}\n\n/** The cells of a card sweep, drawn over the card in `color`. */\nexport function SweepCells({\n  phase,\n  edge,\n  color,\n  className,\n}: {\n  phase: SweepPhase;\n  edge: number;\n  color: string;\n  className?: string;\n}) {\n  const canvas = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const node = canvas.current;\n    const context = node?.getContext('2d');\n    if (!node || !context) return;\n    node.style.color = color;\n    const ink = getComputedStyle(node).color;\n    const ratio = window.devicePixelRatio || 1;\n    const width = node.clientWidth;\n    const height = node.clientHeight;\n    if (node.width !== Math.round(width * ratio)) node.width = Math.round(width * ratio);\n    if (node.height !== Math.round(height * ratio)) node.height = Math.round(height * ratio);\n    context.setTransform(ratio, 0, 0, ratio, 0, 0);\n    context.clearRect(0, 0, width, height);\n    if (phase === 'done' || width === 0) return;\n    context.fillStyle = ink;\n\n    const dot = 2;\n    const head = edge * width;\n    const band = BAND * width;\n    for (let column = 0; column * COLUMN < width; column++) {\n      const x = column * COLUMN;\n      // How far through its change this column is: 1 just behind the head, 0 at the band's far end.\n      const distance = phase === 'in' ? (head - (x + COLUMN)) / band : (x - (head - band)) / band;\n      if (distance < 0 || distance > 1) continue;\n      // Arriving, a column starts solid and thins; leaving, it thickens to solid and goes.\n      const tone = phase === 'in' ? 1 - distance : 1 - distance;\n      for (let y = 0; y < height; y += dot) {\n        for (let dx = 0; dx < COLUMN; dx += dot) {\n          const threshold = BAYER[(y / dot) % 4]![((x + dx) / dot) % 4]!;\n          if (tone * LEVELS > threshold + 0.5) context.fillRect(x + dx, y, dot, dot);\n        }\n      }\n    }\n    // The caret rides the head on the way in, as it does on a line of text.\n    if (phase === 'in' && head > 0 && head < width) {\n      context.fillRect(Math.round(head) - 1, Math.round(height * 0.18), 2, Math.round(height * 0.64));\n    }\n  }, [phase, edge, color]);\n\n  return <canvas ref={canvas} aria-hidden=\"true\" className={className} />;\n}\n"
    }
  ]
}
