임동욱 임동욱 08-18
feat: 로그인 화면을 시안대로 구현
시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) login 5435:14598.

시안의 입력·라벨·버튼이 디자인시스템 컴포넌트 인스턴스라 @fox로 거의 그대로 덮인다 — 치수가
우연이 아니라 정확히 같다. FoxInput lg가 h56·px20·radius12·17px·아이콘 24, FoxButton lg가
h56·px20·radius12·17px다. `label`을 주면 FoxInput이 FoxFormLabel까지 함께 그린다.

- login.module.scss 신설. 남는 건 화면 셸(배경·중앙 정렬)과 카드(440·padding 40·radius 24·
  2겹 그림자), 비밀번호 토글 자리뿐이라 @fox에 넣지 않고 화면 전용으로 뒀다(사용자 확정).
- 비밀번호 보기 토글은 로그인 전용이다(사용자 확정). FoxInput에 그 기능이 없고, `icon` 슬롯은
  `aria-hidden="true"`라 버튼을 넣으면 보조기술에서 사라진다. 그래서 슬롯 대신 같은 자리에
  버튼을 겹쳐 두고 aria-pressed·aria-controls로 상태와 대상을 알린다.
- 오류 문구는 FoxInput의 `message`가 아니라 시안의 안내문 자리에 함께 둔다. 어느 칸이 틀렸는지
  알리지 않는 편이 맞고(계정 존재 여부가 새지 않는다), 입력칸 아래 메시지가 끼면 토글의 세로
  위치도 어긋난다 — 토글을 아래에서 띄우므로 입력칸이 래퍼의 마지막 요소여야 한다.
- components/ui/auth-card를 더 이상 쓰지 않는다. 시안에 로고가 없고 그쪽은 Tailwind 제거 이후
  무스타일이다(헤더·풋터와 같은 처리).

검증 — 실측이 시안과 일치한다. 배경 #f4f5f6, 카드 440/40/24/gap16 + 그림자 2겹
(1·1·4 / 4·4·8, alpha-black10), 제목 32px 800 #1e2124, 라벨 15px 600, 입력 56/12/20/#58616a,
버튼 56/12/#256ef4/17px, 토글 24×24가 입력칸 세로 정중앙(차이 0px)에 오른쪽 여백 20px.
토글은 password↔text 전환과 aria-pressed·aria-label 갱신까지 확인했다.

Co-Authored-By: Claude Opus 5 
@0edf3e1fb70b86323c5d555f2475779fec8c08d2
app/(public)/login/_components/login-form.tsx
--- app/(public)/login/_components/login-form.tsx
+++ app/(public)/login/_components/login-form.tsx
@@ -1,56 +1,89 @@
 'use client';
 
-import { useActionState } from 'react';
+import { useActionState, useId, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxEyeIcon, FoxEyeSlashIcon } from '@fox/core/icons';
 import { login, type LoginFormState } from '../_actions';
+import styles from '../login.module.scss';
 
 const initialState: LoginFormState = {};
 
+/** 시안의 안내 문구. 오류가 없을 때 이 자리에 보인다. */
+const HELPER_TEXT = '비밀번호 5회 불일치 시 계정 로그인이 차단됩니다.';
+
+/**
+ * 로그인 폼 — 시안 login-area(5432:16137)의 제목 아래 부분이다. 입력·라벨·버튼은 시안이
+ * 디자인시스템 컴포넌트를 그대로 쓰고 있어 FoxInput/FoxButton으로 덮인다(치수·서체가 시안과
+ * 같다). `label`을 주면 FoxInput이 FoxFormLabel까지 함께 그린다.
+ *
+ * 비밀번호 보기 토글은 로그인 화면 전용이다 — FoxInput에 그 기능이 없고, `icon` 슬롯은
+ * `aria-hidden="true"`라 버튼을 넣으면 보조기술에서 사라진다(사용자 확정). 그래서 슬롯 대신
+ * 같은 자리에 버튼을 겹쳐 둔다.
+ *
+ * 오류 문구는 FoxInput의 `message`가 아니라 시안의 안내문 자리에 함께 둔다 — 어느 칸이
+ * 틀렸는지 알려주지 않는 것이 보안상 맞고(계정 존재 여부가 새지 않는다), 입력칸 아래에
+ * 메시지가 끼면 보기 토글의 세로 위치도 어긋난다.
+ */
 export function LoginForm() {
   const [state, formAction, isPending] = useActionState(login, initialState);
+  const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+  const passwordId = useId();
 
   return (
-    <form action={formAction} className="flex w-full flex-col gap-4">
-      <h1 className="text-xl font-semibold text-foreground">
-        에듀페이 관리자 로그인
-      </h1>
+    <form action={formAction} className={styles.fields}>
+      <FoxInput
+        size="lg"
+        label="ID"
+        name="loginId"
+        type="text"
+        autoComplete="username"
+        placeholder="아이디를 입력하세요."
+        required
+        state={state.error ? 'error' : 'default'}
+      />
 
-      <div className="flex flex-col gap-1">
-        <label htmlFor="loginId" className="text-sm text-foreground/70">
-          아이디
-        </label>
-        <input
-          id="loginId"
-          name="loginId"
-          type="text"
-          autoComplete="username"
-          required
-          className="border border-foreground/25 bg-background px-3 py-2 text-foreground"
-        />
-      </div>
-
-      <div className="flex flex-col gap-1">
-        <label htmlFor="password" className="text-sm text-foreground/70">
-          비밀번호
-        </label>
-        <input
-          id="password"
+      <div className={styles.passwordField}>
+        <FoxInput
+          id={passwordId}
+          size="lg"
+          label="PW"
           name="password"
-          type="password"
+          type={isPasswordVisible ? 'text' : 'password'}
           autoComplete="current-password"
+          placeholder="비밀번호를 입력하세요."
           required
-          className="border border-foreground/25 bg-background px-3 py-2 text-foreground"
+          state={state.error ? 'error' : 'default'}
         />
+        <button
+          type="button"
+          className={styles.passwordToggle}
+          aria-controls={passwordId}
+          aria-pressed={isPasswordVisible}
+          aria-label={isPasswordVisible ? '비밀번호 숨기기' : '비밀번호 보기'}
+          onClick={() => setIsPasswordVisible((visible) => !visible)}
+        >
+          {isPasswordVisible ? (
+            <FoxEyeSlashIcon weight="bold" />
+          ) : (
+            <FoxEyeIcon weight="bold" />
+          )}
+        </button>
       </div>
 
-      {state.error && <p className="text-sm text-danger">{state.error}</p>}
+      <p className={`${styles.helper} ${state.error ? styles.error : ''}`}>
+        {state.error ?? HELPER_TEXT}
+      </p>
 
-      <button
-        type="submit"
+      <FoxButton
+        type="primary"
+        size="lg"
+        htmlType="submit"
+        fullWidth
+        label={isPending ? '로그인 중...' : '로그인'}
         disabled={isPending}
-        className="bg-foreground px-4 py-2 text-background disabled:opacity-50"
-      >
-        {isPending ? '로그인 중...' : '로그인'}
-      </button>
+        className={styles.submit}
+      />
     </form>
   );
 }
 
app/(public)/login/login.module.scss (added)
+++ app/(public)/login/login.module.scss
@@ -0,0 +1,106 @@
+// 로그인 화면 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) login 5435:14598
+//
+// 입력·라벨·버튼은 시안이 디자인시스템 컴포넌트를 그대로 쓰고 있어 FoxInput/FoxButton으로
+// 덮인다. 여기 남는 건 화면 셸(배경·중앙 정렬)과 카드, 그리고 비밀번호 보기 토글의 자리뿐이라
+// @fox에 넣지 않고 화면 전용으로 둔다(사용자 확정). 값은 전부 @fox 토큰을 거친다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.page {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  padding: fox.padding(8);
+  background: fox.color(background-neutral);
+}
+
+.card {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: fox.gap(5);
+  // 시안 440px. Figma가 변수로 export하지 않는다.
+  inline-size: 44rem;
+  max-inline-size: 100%;
+  padding: fox.padding(10);
+  border-radius: fox.radius(6);
+  background: fox.color(surface-neutral-default);
+  // 시안의 neutral/lv2 — 가까운 그림자와 먼 그림자 두 겹이다.
+  box-shadow:
+    fox.shadow(location1) fox.shadow(location1) fox.shadow(blur2) 0
+      fox.primitive(alpha-black10),
+    fox.shadow(location3) fox.shadow(location3) fox.shadow(blur3) 0
+      fox.primitive(alpha-black10);
+}
+
+.title {
+  inline-size: 100%;
+  margin: 0;
+  color: fox.color(font-neutral-strong);
+  font-family: fox.font-family(title);
+  font-size: fox.font-size(display-md);
+  font-weight: fox.font-weight(bold);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+}
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(5);
+}
+
+// 비밀번호 칸 — 보기 토글을 입력 위에 얹기 위한 위치 기준이다.
+.passwordField {
+  position: relative;
+}
+
+// FoxInput의 `icon` 슬롯은 `aria-hidden="true"`라 그 안에 버튼을 넣으면 보조기술에서
+// 사라진다. 그래서 슬롯을 쓰지 않고 같은 자리에 버튼을 겹쳐 놓는다 — 시안의 아이콘 위치
+// (오른쪽 여백 form/padding/lg 안쪽)와 크기(24)를 그대로 맞춘다.
+//
+// 아래에서 띄우는 방식이라 **입력칸이 이 래퍼의 마지막 요소여야** 가운데에 온다. 오류 문구를
+// FoxInput의 `message`가 아니라 카드의 helper 자리에 모아 두는 이유가 이것이다.
+.passwordToggle {
+  position: absolute;
+  inset-block-end: calc(
+    (#{fox.form(height-lg)} - #{fox.form(icon-lg)}) / 2
+  );
+  inset-inline-end: fox.form(padding-lg);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  inline-size: fox.form(icon-lg);
+  block-size: fox.form(icon-lg);
+  padding: 0;
+  border: none;
+  background: none;
+  color: fox.color(icon-neutral-default);
+  cursor: pointer;
+}
+
+.passwordToggle > svg {
+  inline-size: 100%;
+  block-size: 100%;
+}
+
+.helper {
+  inline-size: 100%;
+  margin: 0;
+  color: fox.color(font-neutral-subtle);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-sm);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+}
+
+.error {
+  color: fox.color(font-system-danger);
+}
+
+.submit {
+  inline-size: 100%;
+}
app/(public)/login/page.tsx
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
@@ -1,7 +1,7 @@
-import { AuthCard } from '@/components/ui/auth-card';
 import { getSessionAdmin } from '@/lib/auth/dal';
 import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
 import { redirect } from 'next/navigation';
+import styles from './login.module.scss';
 import { LoginForm } from './_components/login-form';
 import { LogoutNotice } from './_components/logout-notice';
 
@@ -24,9 +24,12 @@
   const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0;
 
   return (
-    <AuthCard>
+    <div className={styles.page}>
       <LogoutNotice showError={showLogoutError} />
-      <LoginForm />
-    </AuthCard>
+      <div className={styles.card}>
+        <h1 className={styles.title}>관리자 로그인</h1>
+        <LoginForm />
+      </div>
+    </div>
   );
 }
Add a comment
List