{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "type": "registry:ui",
  "title": "Toast",
  "description": "작업 결과를 잠깐 알려주고 스스로 사라지는 알림입니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/icons",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-context",
    "@atom/lib-slot"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/toast.tsx",
      "content": "\"use client\";\n\nimport {\n  type HTMLAttributes,\n  type ReactNode,\n  type Ref,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { createStrictContext } from \"../lib/context\";\nimport { Slot } from \"../lib/slot\";\nimport { CloseIcon } from \"./icons\";\n\n/* -------------------------------------------------------------------------- */\n/*  Toast (components/toast.md, styles/atom/elements/toast.css)               */\n/*    // 앱 루트에 한 번 — live region(그룹)이 토스트보다 먼저 존재합니다.        */\n/*    <Toast.Provider placement=\"bottom\">{app}</Toast.Provider>               */\n/*                                                                            */\n/*    const toast = useToast();                                               */\n/*    toast.show({ message: \"항목을 삭제했어요\",                                 */\n/*                 action: { label: \"되돌리기\", onClick: undo } });            */\n/*                                                                            */\n/*    // 정적 표시 (문서 · 영역 안)                                             */\n/*    <Toast.Group><Toast message=\"저장했어요\" duration={null} dismissible={false} /></Toast.Group> */\n/* -------------------------------------------------------------------------- */\n\nexport type ToastTone = \"neutral\" | \"positive\" | \"critical\";\nexport type ToastPlacement = \"bottom\" | \"top\";\nexport type ToastAnchor = \"screen\" | \"parent\";\nexport type ToastAction = { label: string; onClick: () => void };\n\nconst B = \"atom-toast\";\nconst G = \"atom-toast-group\";\n/** CSS 가 4개째부터 가리므로, 스크립트도 3개까지만 내보내고 나머지는 대기시킵니다. */\nconst MAX_VISIBLE = 3;\n/** 퇴장 애니메이션(--atom-duration-fast 120ms)이 끝나지 않는 환경(CSS 미적용 등)을 위한 안전장치 */\nconst EXIT_FALLBACK_MS = 200;\n\nexport type ToastProps = Omit<HTMLAttributes<HTMLDivElement>, \"children\"> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 알림 문구입니다. 두 줄까지 보이고 그 뒤로는 잘립니다. */\n  message: ReactNode;\n  /** 아이콘 색을 결정합니다. 배경은 바뀌지 않습니다. */\n  tone?: ToastTone;\n  /** 선행 아이콘입니다. `tone` 을 `neutral` 이 아닌 값으로 주면 필수입니다. */\n  icon?: ReactNode;\n  /** 액션 버튼입니다. 하나만. 주면 `duration` 이 자동으로 `null` 이 됩니다. */\n  action?: ToastAction;\n  /** 자동 소멸까지의 ms 입니다. `null` 이면 사라지지 않습니다. */\n  duration?: number | null;\n  /** 닫기 버튼입니다. `duration: null` 이면 필수입니다. 생략하면 자동(사라지지 않을 때만 표시). */\n  dismissible?: boolean;\n  /** `--lines_1` 을 붙여 한 줄 + 말줄임으로 고정합니다. */\n  singleLine?: boolean;\n  /** 닫기 · 자동 소멸 · 액션 실행으로 퇴장이 끝난 뒤 호출됩니다. */\n  onDismiss?: () => void;\n};\n\n/**\n * Provider 가 그리는 Toast 에만 진입 모션(data-state=\"entering\")을 겁니다. 정적 마크업은 재생하지 않습니다.\n * closeRequested 는 useToast().dismiss(id) 가 퇴장 모션을 거쳐 닫히게 하는 신호입니다.\n */\nconst ManagedContext = createContext<{ closeRequested: boolean } | null>(null);\n\nfunction ToastRoot({\n  ref,\n  message,\n  tone = \"neutral\",\n  icon,\n  action,\n  duration = 4000,\n  dismissible,\n  singleLine = false,\n  onDismiss,\n  className,\n  onMouseEnter,\n  onMouseLeave,\n  onFocus,\n  onBlur,\n  onAnimationEnd,\n  ...rest\n}: ToastProps) {\n  const managed = useContext(ManagedContext);\n  const animate = managed !== null;\n  const effectiveDuration = action ? null : duration;\n  const showClose = dismissible ?? effectiveDuration === null;\n  const [phase, setPhase] = useState<\"entering\" | \"idle\" | \"exiting\" | \"gone\">(animate ? \"entering\" : \"idle\");\n\n  const onDismissRef = useRef(onDismiss);\n  useLayoutEffect(() => {\n    onDismissRef.current = onDismiss;\n  });\n\n  const dismiss = useCallback(() => setPhase((p) => (p === \"exiting\" || p === \"gone\" ? p : \"exiting\")), []);\n  const closeRequested = managed?.closeRequested ?? false;\n  useEffect(() => {\n    if (closeRequested) dismiss();\n  }, [closeRequested, dismiss]);\n\n  // 자동 소멸 타이머 — 마우스를 올리거나 포커스가 들어오면 멈추고, 떠나면 남은 시간부터 다시 셉니다.\n  const [paused, setPaused] = useState({ hover: false, focus: false });\n  const remaining = useRef(effectiveDuration);\n  useEffect(() => {\n    remaining.current = effectiveDuration;\n  }, [effectiveDuration]);\n  const isPaused = paused.hover || paused.focus;\n  useEffect(() => {\n    if (remaining.current === null || isPaused || phase === \"exiting\" || phase === \"gone\") return;\n    const started = Date.now();\n    const timer = setTimeout(dismiss, remaining.current);\n    return () => {\n      clearTimeout(timer);\n      if (remaining.current !== null) remaining.current = Math.max(0, remaining.current - (Date.now() - started));\n    };\n  }, [isPaused, phase, dismiss, effectiveDuration]);\n\n  // 퇴장: animationend 로 정리. 애니메이션이 없으면 안전장치 타이머로.\n  const finish = useCallback(() => {\n    setPhase(\"gone\");\n    onDismissRef.current?.();\n  }, []);\n  useEffect(() => {\n    if (phase !== \"exiting\") return;\n    const timer = setTimeout(finish, EXIT_FALLBACK_MS);\n    return () => clearTimeout(timer);\n  }, [phase, finish]);\n\n  if (phase === \"gone\") return null;\n\n  return (\n    <div\n      {...rest}\n      ref={ref}\n      className={cn(B, mod(B, \"tone\", tone, \"neutral\"), singleLine && `${B}--lines_1`, className)}\n      data-state={phase === \"entering\" || phase === \"exiting\" ? phase : undefined}\n      onAnimationEnd={(e) => {\n        onAnimationEnd?.(e);\n        if (e.target !== e.currentTarget) return;\n        if (phase === \"entering\") setPhase(\"idle\");\n        else if (phase === \"exiting\") finish();\n      }}\n      onMouseEnter={(e) => {\n        onMouseEnter?.(e);\n        setPaused((p) => ({ ...p, hover: true }));\n      }}\n      onMouseLeave={(e) => {\n        onMouseLeave?.(e);\n        setPaused((p) => ({ ...p, hover: false }));\n      }}\n      onFocus={(e) => {\n        onFocus?.(e);\n        setPaused((p) => ({ ...p, focus: true }));\n      }}\n      onBlur={(e) => {\n        onBlur?.(e);\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setPaused((p) => ({ ...p, focus: false }));\n      }}\n    >\n      {icon !== undefined && (\n        <Slot aria-hidden=\"true\" className={`${B}__icon`}>\n          {icon}\n        </Slot>\n      )}\n      <span className={`${B}__message`}>{message}</span>\n      {action && (\n        <button\n          className={`${B}__action`}\n          type=\"button\"\n          onClick={() => {\n            action.onClick();\n            dismiss();\n          }}\n        >\n          {action.label}\n        </button>\n      )}\n      {showClose && (\n        <button className={`${B}__close`} type=\"button\" aria-label=\"닫기\" onClick={dismiss}>\n          <CloseIcon />\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport type ToastGroupProps = HTMLAttributes<HTMLDivElement> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 그룹의 속성입니다. 개별 Toast 가 아니라 컨테이너가 위치를 정합니다. */\n  placement?: ToastPlacement;\n  /** `screen` 은 화면(fixed), `parent` 는 가장 가까운 positioned 조상(absolute). 생략하면 흐름 안(문서용). */\n  anchor?: ToastAnchor;\n  /** 하단 내비게이션이나 FAB 위로 올립니다(`--offset_raised`, 아래에서 56px). */\n  raised?: boolean;\n};\n\n/** live region 컨테이너(role=\"status\" aria-live=\"polite\"). 새 Toast 는 항상 마지막에 붙습니다. */\nfunction ToastGroup({\n  ref,\n  placement = \"bottom\",\n  anchor,\n  raised = false,\n  className,\n  ...rest\n}: ToastGroupProps) {\n  return (\n    <div\n      role=\"status\"\n      aria-live=\"polite\"\n      {...rest}\n      ref={ref}\n      className={cn(\n        G,\n        mod(G, \"anchor\", anchor),\n        mod(G, \"placement\", placement, \"bottom\"),\n        raised && `${G}--offset_raised`,\n        className,\n      )}\n    />\n  );\n}\n\nexport type ToastOptions = Omit<ToastProps, \"ref\" | \"onDismiss\">;\ntype QueuedToast = ToastOptions & { id: string };\ntype ToastApi = {\n  /** Toast 를 띄우고 id 를 돌려줍니다. */\n  show: (options: ToastOptions) => string;\n  /** id 의 Toast 를 닫습니다(대기 중이면 대기열에서 뺍니다). */\n  dismiss: (id: string) => void;\n};\n\nconst [ToastApiContext, useToastApi] = createStrictContext<ToastApi>(\"Toast.Provider\");\n\nexport type ToastProviderProps = Omit<ToastGroupProps, \"children\"> & {\n  children?: ReactNode;\n};\n\n/**\n * 그룹(live region)을 미리 렌더하고 useToast() 로 Toast 를 띄웁니다.\n * 한 번에 3개까지만 보이고, 나머지는 앞의 것이 사라질 때까지 기다립니다.\n */\nfunction ToastProvider({ placement = \"bottom\", anchor = \"screen\", raised = false, children, ...groupProps }: ToastProviderProps) {\n  const [queue, setQueue] = useState<QueuedToast[]>([]);\n  const [closing, setClosing] = useState<Set<string>>(() => new Set());\n  const seq = useRef(0);\n\n  const remove = useCallback((id: string) => {\n    setQueue((q) => q.filter((t) => t.id !== id));\n    setClosing((s) => {\n      const next = new Set(s);\n      next.delete(id);\n      return next;\n    });\n  }, []);\n\n  const api = useMemo<ToastApi>(\n    () => ({\n      show: (options) => {\n        seq.current += 1;\n        const id = `toast-${seq.current}`;\n        setQueue((q) => [...q, { ...options, id }]);\n        return id;\n      },\n      dismiss: (id) => {\n        // 대기 중(아직 안 보임)이면 바로 빼고, 보이는 중이면 퇴장 모션을 거쳐 닫습니다.\n        setQueue((q) => (q.findIndex((t) => t.id === id) >= MAX_VISIBLE ? q.filter((t) => t.id !== id) : q));\n        setClosing((s) => new Set(s).add(id));\n      },\n    }),\n    [],\n  );\n\n  const visible = queue.slice(0, MAX_VISIBLE);\n\n  return (\n    <ToastApiContext value={api}>\n      {children}\n      <ToastGroup {...groupProps} placement={placement} anchor={anchor} raised={raised}>\n        {visible.map(({ id, ...options }) => (\n          <ManagedContext key={id} value={{ closeRequested: closing.has(id) }}>\n            <ToastRoot {...options} onDismiss={() => remove(id)} />\n          </ManagedContext>\n        ))}\n      </ToastGroup>\n    </ToastApiContext>\n  );\n}\n\n/** Toast.Provider 안에서 Toast 를 띄우는 훅입니다. */\nexport function useToast(): ToastApi {\n  return useToastApi(\"useToast\");\n}\n\nexport const Toast = Object.assign(ToastRoot, { Provider: ToastProvider, Group: ToastGroup });\n",
      "type": "registry:ui",
      "target": "components/ui/toast.tsx"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/toast.css",
      "content": "/* ============================================================================\n   Element — Toast  (catalogue slug: toast)\n   ----------------------------------------------------------------------------\n   A dark chip that announces what just happened and then leaves. The chip stays\n   dark in both themes: a toast can land on any surface in the app, and one\n   guaranteed-contrast chip is cheaper to keep correct than six tone-by-theme\n   background pairs. So `--tone_*` drives the icon, not the fill — the shape\n   carries the meaning and the colour only reinforces it.\n\n   atom-toast\n     --tone_{neutral|positive|critical}      neutral is the default\n     --lines_1                               single line + ellipsis\n     [data-state=\"entering|exiting\"]         entrance / exit motion\n     __icon      optional. required as soon as a tone is set\n     __message\n     __action    optional text button. one at most\n     __close     optional icon button. required when there is no auto-dismiss\n\n   atom-toast-group\n     --placement_{bottom|top}                bottom is the default\n     --anchor_{screen|parent}                no anchor = normal flow (docs)\n     --offset_raised                         clears a bottom nav or a FAB\n\n   The group is the live region and it exists before any toast does — a live\n   region inserted together with its first message is not reliably announced.\n   New toasts are always appended last; `bottom` grows upward, `top` downward.\n   ========================================================================== */\n\n.atom-toast {\n  display: flex;\n  align-items: center;\n  /* 8px, not the 16px the message-to-action pair wants: at 16px on both sides\n     the icon reads as a third item rather than as part of the message. The\n     action buys its 16px back with a margin below. */\n  gap: var(--atom-x2);\n  width: 340px;\n  max-width: 100%;\n  min-height: 44px;\n  padding: 13px var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-tooltip);\n  color: var(--atom-fg-tooltip);\n  font-size: 13px;\n  line-height: 1.45;\n  box-shadow: var(--atom-shadow-s2);\n}\n\n.atom-toast__icon {\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n  color: var(--atom-fg-tooltip);\n}\n\n/* Two lines is the ceiling. A toast that wraps further stops being glanceable\n   and starts covering the screen it is reporting on, so the text is clamped\n   rather than allowed to grow — an over-long message is an authoring bug the\n   clamp makes visible instead of hiding. */\n.atom-toast__message {\n  flex: 1;\n  min-width: 0;\n  display: -webkit-box;\n  -webkit-box-orient: vertical;\n  -webkit-line-clamp: 2;\n  overflow: hidden;\n  word-break: keep-all;\n}\n\n.atom-toast--lines_1 .atom-toast__message {\n  display: block;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n/* --- action --------------------------------------------------------------\n   No brand tint. `--atom-primary-500` on `--atom-bg-tooltip` is 3.4:1 in light\n   and 4.2:1 in dark, both short of the 4.5:1 that 13px text needs, and\n   `--atom-primary-200` — bright enough in light at 10.3:1 — inverts to #202564\n   in dark and collapses to 1.25:1. bg-tooltip is dark in *both* themes, so any\n   token that flips brightness with the theme is unusable here; fg-tooltip is\n   the only one that stays bright in both. The action therefore reads as a\n   control through weight, underline and position instead of hue. */\n.atom-toast__action {\n  flex-shrink: 0;\n  /* Stretch plus a negative block margin makes the text button as tall as the\n     chip, so the hit area is the full 44px and not the 19px of the glyphs. */\n  align-self: stretch;\n  margin-block: -13px;\n  margin-inline-start: var(--atom-x2);\n  padding-block: 13px;\n  border-radius: var(--atom-radius-r1);\n  color: var(--atom-fg-tooltip);\n  font-size: 13px;\n  font-weight: 700;\n  text-decoration: underline;\n  text-underline-offset: 2px;\n  white-space: nowrap;\n  cursor: pointer;\n}\n\n.atom-toast__close {\n  flex-shrink: 0;\n  align-self: stretch;\n  margin-block: -13px;\n  margin-inline-end: calc(var(--atom-x2) * -1);\n  padding-inline: var(--atom-x2);\n  border-radius: var(--atom-radius-r1);\n  color: var(--atom-fg-tooltip);\n  opacity: 0.65;\n  cursor: pointer;\n  transition: opacity var(--atom-duration-fast) ease;\n}\n\n.atom-toast__close > svg {\n  width: 16px;\n  height: 16px;\n}\n\n/* --- tones — icon colour, never fill ------------------------------------- */\n.atom-toast--tone_positive .atom-toast__icon {\n  color: var(--atom-fg-positive);\n}\n\n.atom-toast--tone_critical .atom-toast__icon {\n  color: var(--atom-fg-critical);\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-toast__action:hover,\n.atom-toast__close:hover {\n  opacity: 1;\n  text-decoration-thickness: 2px;\n}\n\n/* The brand focus ring the rest of the system uses is primary-600 — invisible\n   on this chip. The ring sits inside the chip because the action already\n   reaches its edges. */\n.atom-toast__action:focus-visible,\n.atom-toast__close:focus-visible {\n  outline: 2px solid var(--atom-fg-tooltip);\n  outline-offset: -2px;\n}\n\n.atom-toast__action[disabled],\n.atom-toast__close[disabled] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n/* --- motion --------------------------------------------------------------\n   Gated on data-state so the chip does not replay its entrance every time a\n   catalogue card or a doc stage renders one statically. */\n@keyframes atom-toast-enter-bottom {\n  from {\n    opacity: 0;\n    transform: translateY(8px);\n  }\n}\n\n@keyframes atom-toast-enter-top {\n  from {\n    opacity: 0;\n    transform: translateY(-8px);\n  }\n}\n\n@keyframes atom-toast-exit {\n  to {\n    opacity: 0;\n  }\n}\n\n.atom-toast[data-state=\"entering\"] {\n  animation: atom-toast-enter-bottom var(--atom-duration-base)\n    var(--atom-ease-out);\n}\n\n.atom-toast-group--placement_top .atom-toast[data-state=\"entering\"] {\n  animation-name: atom-toast-enter-top;\n}\n\n.atom-toast[data-state=\"exiting\"] {\n  animation: atom-toast-exit var(--atom-duration-fast) ease forwards;\n}\n\n/* base.css clamps every animation to 0.01ms under reduced motion, so\n   `animationend` still fires and the teardown the script hangs off it keeps\n   working. What has to go is the 8px slide — at 0.01ms it lands as a jump,\n   which is the one thing the setting asks us not to do. Reusing the exit\n   keyframes in reverse keeps the hook and drops the movement. */\n@media (prefers-reduced-motion: reduce) {\n  .atom-toast[data-state=\"entering\"],\n  .atom-toast-group--placement_top .atom-toast[data-state=\"entering\"] {\n    animation-name: atom-toast-exit;\n    animation-direction: reverse;\n  }\n}\n\n/* --- group --------------------------------------------------------------- */\n/* Newest is appended last and has to land nearest the edge the group is\n   anchored to, which is what picks the direction: anchored at the bottom the\n   last child must be the bottom one (`column`), anchored at the top it must be\n   the top one (`column-reverse`). Either way the stack grows away from the edge\n   and the oldest chip is the one that ends up furthest from it. */\n.atom-toast-group {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: var(--atom-x2);\n  width: 100%;\n}\n\n.atom-toast-group--placement_top {\n  flex-direction: column-reverse;\n}\n\n/* A guardrail, not a queue. Three chips already cover a third of a phone\n   screen; the script is expected to hold the rest back, and this only stops a\n   runaway queue from burying the screen it is reporting on. */\n.atom-toast-group > .atom-toast:nth-last-child(n + 4) {\n  display: none;\n}\n\n.atom-toast-group--anchor_screen,\n.atom-toast-group--anchor_parent {\n  inset-inline: 0;\n  bottom: var(--atom-x4);\n  /* Above the shell (chrome.css tops out at 60) and above a Dialog: a toast\n     reporting on what happened inside a dialog has to stay readable. */\n  z-index: 80;\n  padding-inline: var(--atom-x4);\n  /* The group spans the full width, so without this it would swallow clicks\n     across the whole bottom of the screen. */\n  pointer-events: none;\n}\n\n.atom-toast-group--anchor_screen {\n  position: fixed;\n}\n\n/* For a phone frame or any other bounded surface — same geometry, scoped to\n   the nearest positioned ancestor. */\n.atom-toast-group--anchor_parent {\n  position: absolute;\n}\n\n.atom-toast-group--anchor_screen.atom-toast-group--placement_top,\n.atom-toast-group--anchor_parent.atom-toast-group--placement_top {\n  top: var(--atom-x4);\n  bottom: auto;\n}\n\n/* 56px clears a bottom navigation bar or a floating action button. */\n.atom-toast-group--offset_raised {\n  bottom: var(--atom-x14);\n}\n\n.atom-toast-group--anchor_screen > .atom-toast,\n.atom-toast-group--anchor_parent > .atom-toast {\n  pointer-events: auto;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/toast.css"
    }
  ],
  "categories": [
    "overlay"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "Toast",
      "Toast.Provider",
      "Toast.Group"
    ],
    "docs": "poc/design-system/components/toast.md"
  }
}
