임동욱 임동욱 08-27
fix: 토스트가 화면에 보이도록 FoxToast로 바꾼다
components/ui/toast.tsx와 FeedbackHost가 bg-surface·shadow-toast·fixed inset-x-0
같은 Tailwind 클래스로 그려져 있었는데 이 프로젝트에는 Tailwind가 없다. 정의된 곳이
없어 토스트가 문서 맨 아래에 12px짜리 투명한 줄로 붙었다 — showToast를 부르는 39곳이
전부 아무것도 보이지 않는 상태였다.

- @fox의 FoxToast로 그린다(사용자 확정). 계열 색은 시안에 없어 모양이 하나다. 대신
  danger만 urgent로 올려 보조기술이 읽던 것을 끊고 바로 읽게 한다.
- 위치는 호출부 책임이라(@fox 규칙) feedback-host.module.scss가 상단 가운데로 잡는다.
- FoxModal은 .showModal()이라 top layer에 있어 보통 요소로는 그 위에 못 그린다.
  모달을 띄운 채 저장에 실패하면 토스트가 모달 뒤에 가려지므로 popover로 같은 top layer에
  얹는다.

Co-Authored-By: Claude Opus 5 
@ad7f0e88c199fd5da8fbbe6865cd556d308250b7
 
app/_components/feedback-host.module.scss (added)
+++ app/_components/feedback-host.module.scss
@@ -0,0 +1,58 @@
+// 전역 피드백(토스트·스피너)이 화면에서 차지하는 자리.
+//
+// FoxToast·FoxSpinner는 모양만 갖고 어디에 뜰지는 호출부가 정한다(@fox 규칙) — 그 배치를
+// 여기서 준다.
+//
+// 이슈: FoxModal은 `<dialog>.showModal()`이라 브라우저 top layer에 올라간다. 보통 요소는
+//   z-index를 아무리 올려도 그 위에 그릴 수 없어, 모달을 띄운 채 저장에 실패하면 토스트가
+//   모달 뒤에 가려진다. 그래서 토스트도 popover로 같은 top layer에 얹는다 — popover의 기본
+//   스타일(가운데 정렬·테두리·배경)을 아래에서 전부 지우는 이유다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.toastLayer {
+  position: fixed;
+  inset-block-start: fox.padding(7);
+  inset-block-end: auto;
+  inset-inline: 0;
+  inline-size: auto;
+  max-inline-size: none;
+  block-size: auto;
+  max-block-size: none;
+  margin: 0;
+  padding-inline: fox.padding(6);
+  border: 0;
+  background: none;
+  color: inherit;
+  overflow: visible;
+  // 읽고 지나가는 자리라 아래 화면 조작을 막지 않는다.
+  pointer-events: none;
+}
+
+.toastLayer:popover-open {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: fox.gap(3);
+}
+
+// popover가 없는 브라우저 — top layer는 못 쓰지만 자리는 잡는다.
+@supports not selector(:popover-open) {
+  .toastLayer {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: fox.gap(3);
+    z-index: 50;
+  }
+}
+
+.spinnerOverlay {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: fox.color(background-dim);
+}
app/_components/feedback-host.tsx
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
@@ -1,29 +1,52 @@
 'use client';
 
+import { useEffect, useRef } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxToast } from '@fox/core/components/fox-toast';
 import { Spinner } from '@/components/ui/spinner';
-import { Toast } from '@/components/ui/toast';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import styles from './feedback-host.module.scss';
 
 /**
- * FeedbackProvider의 상태를 design 레인의 Toast·Alert·Spinner 비주얼에 연결만 하는 역할 —
+ * FeedbackProvider의 상태를 @fox 비주얼(Toast·Modal·Spinner)에 연결만 하는 역할 —
  * 위치·스택·표시 타이머 등 정책은 FeedbackProvider가 가지고, 이 컴포넌트는 마운트만 담당한다.
+ *
+ * 계열(success/danger/…)은 색으로 드러나지 않는다 — @fox 시안의 토스트가 하나뿐이라
+ * (`_fox-toast.scss`) 모양이 갈리지 않는다. 대신 `danger`만 `urgent`로 올려 보조기술이
+ * 읽던 것을 끊고 바로 읽게 한다.
  */
 export function FeedbackHost() {
   const { toasts, alert, spinnerCount, hideAlert } = useFeedback();
+  const toastLayerRef = useRef<HTMLDivElement>(null);
+  const hasToasts = toasts.length > 0;
+
+  // 모달 위에서도 보이도록 top layer에 얹는다(feedback-host.module.scss의 이슈 주석 참고).
+  useEffect(() => {
+    const layer = toastLayerRef.current;
+    if (!layer || typeof layer.showPopover !== 'function') {
+      return;
+    }
+
+    const isOpen = layer.matches(':popover-open');
+    if (hasToasts && !isOpen) {
+      layer.showPopover();
+    } else if (!hasToasts && isOpen) {
+      layer.hidePopover();
+    }
+  }, [hasToasts]);
 
   return (
     <>
-      {toasts.length > 0 && (
-        <div className="fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-2 px-4">
-          {toasts.map((toast) => (
-            <Toast key={toast.id} variant={toast.variant}>
-              {toast.message}
-            </Toast>
-          ))}
-        </div>
-      )}
+      <div ref={toastLayerRef} popover="manual" className={styles.toastLayer}>
+        {toasts.map((toast) => (
+          <FoxToast
+            key={toast.id}
+            message={toast.message}
+            urgent={toast.variant === 'danger'}
+          />
+        ))}
+      </div>
 
       {alert && (
         <FoxModal
@@ -47,7 +70,7 @@
       )}
 
       {spinnerCount > 0 && (
-        <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim">
+        <div className={styles.spinnerOverlay}>
           <Spinner size="lg" tone="inverse" />
         </div>
       )}
 
components/ui/toast.tsx (deleted)
--- components/ui/toast.tsx
@@ -1,30 +0,0 @@
-import type { ReactNode } from "react";
-
-type ToastVariant = "info" | "success" | "danger" | "warning";
-
-const ACCENT_CLASSES: Record<ToastVariant, string> = {
-  info: "border-l-primary",
-  success: "border-l-success",
-  danger: "border-l-danger",
-  warning: "border-l-warning",
-};
-
-interface ToastProps {
-  variant?: ToastVariant;
-  children: ReactNode;
-}
-
-/**
- * 알림 비주얼(토스트 1개). 위치·스택·표시 타이머는 호출부(feature) 책임 —
- * 이 컴포넌트는 상태를 가지지 않는다.
- */
-export function Toast({ variant = "info", children }: ToastProps) {
-  return (
-    <div
-      role="status"
-      className={`animate-toast-in rounded-lg border-l-4 bg-surface px-4 py-3 text-body-md text-foreground shadow-toast ${ACCENT_CLASSES[variant]}`}
-    >
-      {children}
-    </div>
-  );
-}
Add a comment
List