임동욱 임동욱 08-11
Merge branch 'claude/design-system-setup-46d8aa' into hub
디자인 시스템 기반을 Tailwind에서 @fox(SCSS + CSS Modules)로 교체한다.

⚠️ 기존 화면(꾸미기 아이템 포함)의 Tailwind 클래스는 이 시점부터 무효다 —
라우팅·로직은 정상이나 시각 스타일이 빠진 상태이며, 화면을 @fox 컴포넌트로
재작성하며 복구한다. components/ui/의 기존 22개는 소비처가 남아 있어 함께
교체될 때까지 유지한다.

Co-Authored-By: Claude Opus 5 
@13bf742269bfb3344496b4143863f5067a45cfdc
 
@fox/README.md (added)
+++ @fox/README.md
@@ -0,0 +1,123 @@
+# @fox — 포터블 디자인 시스템
+
+프로젝트에 종속되지 않는 디자인 시스템 패키지. **이 폴더를 통째로 복사하고 설정 2줄을
+추가하면** 다른 프로젝트에서 그대로 동작합니다.
+
+```
+@fox/
+  styles/                SCSS 토큰 · 함수 · 믹스인
+    index.scss           글로벌 진입점 (앱이 딱 한 번 import — CSS 출력)
+    abstracts.scss       저작 진입점 (컴포넌트가 @use — CSS 출력 0)
+    tokens/              토큰 map = 단일 진실 공급원(SSOT)
+    _root.scss           map → --fox-* 커스텀 프로퍼티 생성
+    _reset.scss          리셋 & 베이스
+    _functions.scss      fox.color() 등 토큰 접근 + 검증
+    _mixins.scss         fox.typo() · fox.media() 등 조합 패턴
+  core/
+    components/          공용 컴포넌트 (규약은 components/README.md)
+    utils/               cx() 등 의존성 없는 유틸
+```
+
+## 다른 프로젝트로 이식하기
+
+1. `@fox/` 폴더를 대상 프로젝트 루트에 복사합니다.
+
+2. `sass`를 설치합니다.
+
+   ```bash
+   npm install --save-dev sass
+   ```
+
+3. **TypeScript 경로 별칭** — `tsconfig.json`:
+
+   ```json
+   { "compilerOptions": { "paths": { "@fox/*": ["./@fox/*"] } } }
+   ```
+
+4. **SCSS 로드 경로** — `next.config.ts`:
+
+   ```ts
+   sassOptions: { loadPaths: [path.join(process.cwd())] }
+   ```
+
+   TypeScript의 `paths`는 번들러 전용이라 Sass의 `@use` 해석에는 관여하지 않습니다.
+   그래서 같은 경로를 양쪽에 각각 선언해야 TS와 SCSS의 import 표기가 일치합니다.
+   이게 없으면 컴포넌트마다 `../../../` 상대 경로를 써야 해서 폴더 복사가 불가능해집니다.
+
+5. 앱의 글로벌 스타일에서 진입점을 한 번 import 합니다.
+
+   ```scss
+   /* app/globals.scss */
+   @use "@fox/styles";
+   ```
+
+6. (선택) 폰트를 주입합니다 — 아래 "호스트 앱과의 계약" 참고.
+
+Next.js가 아닌 번들러라면 4번만 그 도구의 Sass 옵션(`includePaths` / `loadPaths`)으로
+바꿔주면 됩니다.
+
+## 핵심 설계
+
+**토큰의 SSOT는 `tokens/`의 SCSS map 하나뿐입니다.** 여기서 두 가지가 함께 파생됩니다 —
+`_root.scss`가 만드는 `--fox-*` CSS 커스텀 프로퍼티와, `_functions.scss`가 검증에 쓰는
+유효 이름 목록. 값과 이름이 한 곳에만 있으므로 둘이 어긋날 수 없습니다.
+
+**규율을 컴파일러가 강제합니다.** 화면 코드는 `color: #4f46e5`를 쓸 수 없고
+`fox.color(primary)`로 토큰을 지목해야 합니다. 없는 이름을 쓰면 빌드가 실패하며, 에러
+메시지가 사용 가능한 토큰 목록을 함께 출력합니다.
+
+```
+Error: [@fox] 알 수 없는 color 토큰: `primry` — 사용 가능한 값: primary, primary-hover, ...
+```
+
+**1rem = 10px입니다.** `_reset.scss`의 `html { font-size: 62.5% }`가 근거입니다. px → rem
+환산이 10으로 나누기가 되어 토큰 값이 읽기 쉽고, px 고정과 달리 사용자가 브라우저 글자
+크기를 키우면 앱 전체가 비례해 확대됩니다.
+
+> ⚠️ **미디어 쿼리 안의 `rem`은 예외입니다.** 미디어 쿼리는 `html`의 `font-size` 영향을
+> 받지 않고 항상 브라우저 기본 크기(보통 16px) 기준으로 계산합니다. 그래서
+> `tokens/_layout.scss`의 브레이크포인트 `48rem`은 480px가 아니라 **768px**입니다.
+> 각 값에 px 환산을 주석으로 달아 두었습니다.
+
+**라이트/다크는 값이 한 곳에만 존재합니다.** `light-dark(라이트, 다크)`로 한 줄에 두 값을
+함께 쓰고, 전환은 `color-scheme`이 담당합니다. 다크 전용 오버라이드 블록이 없으므로 한쪽만
+고쳐서 생기는 테마 불일치가 구조적으로 불가능합니다.
+`<html data-theme="light"|"dark">`로 수동 선택을 덮어쓸 수 있고, 속성이 없으면 OS 설정을
+따릅니다.
+
+> **브라우저 지원** — `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이
+> 필요하지만, Next.js 16(Turbopack)의 Lightning CSS가 현재 설정에서 이를
+> `var(--lightningcss-light, 라이트) var(--lightningcss-dark, 다크)` 조합으로 폴리필해
+> 출력하므로 구형 브라우저에서도 동작합니다. browserslist 타깃을 좁히면 폴리필이 빠지고
+> 원본 `light-dark()`가 그대로 나가므로, 타깃을 바꿀 때 이 전제를 다시 확인하세요.
+> Lightning CSS를 쓰지 않는 번들러로 옮길 때도 마찬가지입니다.
+
+## 호스트 앱과의 계약
+
+`@fox`가 소유하지 않고 앱에서 받는 값입니다.
+
+| CSS 변수 | 용도 | 미주입 시 |
+| --- | --- | --- |
+| `--app-font-sans` | 기본 서체 | `system-ui`로 폴백 |
+| `--app-font-mono` | 고정폭 서체 | `ui-monospace`로 폴백 |
+
+폰트 파일은 앱이 소유합니다(Next.js에서는 `next/font`가 최적화·자체 호스팅을 담당). `@fox`는
+변수를 참조만 하므로, 폴더를 복사해 간 프로젝트는 그 프로젝트의 폰트를 그대로 씁니다.
+
+```tsx
+// app/layout.tsx
+const sans = localFont({ src: [...], variable: "--app-font-sans" });
+<html className={sans.variable}>
+```
+
+테마 토글을 붙이려면 `<html>`의 `data-theme` 속성을 `"light" | "dark"`로 설정하고, 전환
+애니메이션이 필요하면 같은 tick에 `data-theme-transition` 속성을 부여한 뒤 200ms 후
+제거합니다(`_root.scss`가 그 동안만 색상 트랜지션을 겁니다).
+
+## 토큰 추가하기
+
+1. `tokens/`의 해당 map에 항목을 추가합니다. 그것으로 끝입니다 — 커스텀 프로퍼티 생성과
+   함수 검증 목록이 자동으로 따라옵니다.
+2. 값의 출처(Figma file/node 등)를 주석으로 남깁니다.
+3. **새 토큰 그룹**(map 자체를 신설)이 필요하면 `tokens/_index.scss`에 `@forward`를 추가하고,
+   `_functions.scss`에 접근 함수를, `_root.scss`에 `@each` 블록을 각각 추가합니다.
 
@fox/core/components/README.md (added)
+++ @fox/core/components/README.md
@@ -0,0 +1,87 @@
+# 컴포넌트 작성 규약
+
+아직 컴포넌트가 없습니다. 디자인 확정에 따라 하나씩 추가합니다.
+
+## 파일 배치
+
+컴포넌트 하나당 폴더 하나. 스타일을 같은 폴더에 두어야 폴더째 복사·삭제가 가능합니다.
+
+```
+components/
+  button/
+    button.tsx          컴포넌트
+    button.module.scss  스타일 (반드시 .module.scss — 전역 오염 방지)
+    index.ts            export { Button } from "./button";
+  index.ts              배럴 — export * from "./button";
+```
+
+## 작성 규칙
+
+**1. 스타일은 `abstracts`만 `@use` 한다.**
+
+```scss
+@use "@fox/styles/abstracts" as fox;
+
+.button {
+  padding: fox.space(2) fox.space(4);
+  border-radius: fox.radius(md);
+  background: fox.color(primary);
+  color: fox.color(on-primary);
+
+  @include fox.typo(label-lg);
+  @include fox.focus-ring;
+  @include fox.transition((background-color, color));
+}
+```
+
+`@fox/styles`(진입점)를 `@use` 하면 안 됩니다 — 그쪽은 CSS를 출력하므로 컴포넌트마다
+토큰 선언 전체가 복제됩니다. `abstracts`는 출력이 0입니다.
+
+**2. 원시 디자인 값을 쓰지 않는다.**
+
+`color: #4f46e5`, `padding: 13px`, `border-radius: 12px` 금지. 전부 `fox.*()` 함수로
+토큰을 지목합니다. 없는 토큰 이름을 쓰면 빌드가 실패하므로 오타는 배포까지 갈 수
+없습니다. **맞는 토큰이 없다고 해서 원시 값을 쓰지 말고, 토큰을 먼저 추가**합니다
+(`@fox/styles/tokens/`).
+
+예외는 의미가 고정되어 토큰화가 무의미한 값뿐입니다 — `0`, `100%`, `1px`(구분선),
+`auto`, `inherit`.
+
+**3. 기본은 Server Component로 둔다.**
+
+`'use client'`는 상호작용(상태·이벤트 핸들러·브라우저 API)이 실제로 필요한 컴포넌트에만
+붙입니다. CSS Module은 빌드타임에 클래스명 문자열로 바뀔 뿐이라 런타임 JS가 없으므로,
+스타일링만 하는 컴포넌트는 서버에 남을 수 있습니다. 여기에 불필요하게 `'use client'`를
+붙이면 이 컴포넌트를 쓰는 화면 전체가 클라이언트로 내려갑니다.
+
+**4. variant는 클래스 맵으로 고정한다.**
+
+```tsx
+import styles from "./button.module.scss";
+import { cx } from "@fox/core/utils";
+
+type ButtonVariant = "primary" | "secondary" | "ghost";
+
+const VARIANT: Record<ButtonVariant, string> = {
+  primary: styles.primary,
+  secondary: styles.secondary,
+  ghost: styles.ghost,
+};
+
+export function Button({ variant = "primary", className, ...props }: ButtonProps) {
+  return <button className={cx(styles.button, VARIANT[variant], className)} {...props} />;
+}
+```
+
+`Record<Variant, string>`이라 variant를 추가하면 맵에 항목을 빠뜨릴 수 없습니다(타입 에러).
+
+**5. `className` prop은 배치용으로만 열어 둔다.**
+
+호출부가 `className`으로 넘기는 것은 margin·grid 배치 같은 **위치** 조정이지, 색·크기 같은
+**디자인 값**이 아닙니다. 디자인이 달라져야 하면 variant를 추가합니다.
+
+**6. 호스트 앱에 의존하지 않는다.**
+
+`@fox/` 안에서는 `@/lib/...`, `@/app/...` 같은 앱 경로를 import 하지 않습니다. 이 폴더는
+다른 프로젝트로 통째로 복사되어야 하므로, 앱이 주는 값은 반드시 prop이나 CSS 변수로
+받습니다(폰트가 `--app-font-sans`를 받는 것과 같은 방식).
 
@fox/core/utils/cx.ts (added)
+++ @fox/core/utils/cx.ts
@@ -0,0 +1,15 @@
+export type ClassValue = string | false | null | undefined;
+
+/**
+ * 조건부 클래스명을 합친다. falsy 값은 버린다.
+ *
+ *   cx(styles.button, isActive && styles.active, className)
+ *
+ * 유틸리티 CSS 프레임워크에서 쓰는 클래스 병합 라이브러리(tailwind-merge 등)가
+ * 필요 없는 이유: CSS Module은 클래스명이 파일 단위로 스코프되므로 서로 다른
+ * 컴포넌트의 클래스가 같은 속성을 두고 충돌하지 않는다. 한 컴포넌트 안에서의
+ * 충돌은 선언 순서(뒤에 온 규칙이 이김)로 결정되며, 이는 CSS의 정상 동작이다.
+ */
+export function cx(...values: ClassValue[]): string {
+  return values.filter(Boolean).join(" ");
+}
 
@fox/core/utils/index.ts (added)
+++ @fox/core/utils/index.ts
@@ -0,0 +1,1 @@
+export { cx, type ClassValue } from "./cx";
 
@fox/styles/_functions.scss (added)
+++ @fox/styles/_functions.scss
@@ -0,0 +1,75 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 토큰 접근 함수 — 디자인 시스템 규율을 **컴파일러가 강제**하는 지점.
+//
+// 화면 코드는 `color: #4f46e5`나 `padding: 13px`를 쓸 수 없고, 반드시
+// `color: fox.color(primary)` / `padding: fox.space(3)`로 토큰을 지목해야 한다.
+// 존재하지 않는 이름을 쓰면 `@error`로 **빌드가 실패한다** — 오타나 임의 값이
+// 리뷰를 통과해 배포까지 흘러가는 경로가 원천 차단된다.
+//
+// 반환값은 CSS 커스텀 프로퍼티 참조(`var(--fox-*)`)다. 값 자체가 아니라 참조를
+// 돌려주므로 (1) 라이트/다크가 런타임에 전환되고 (2) 특정 영역만 토큰을 덮어쓰는
+// 스코프 오버라이드가 가능하며 (3) devtools에서 어떤 토큰인지 그대로 보인다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@use "sass:map";
+@use "tokens";
+
+/// 토큰 이름을 검증하고 대응하는 CSS 커스텀 프로퍼티 참조를 반환한다.
+/// @param {String} $group - 토큰 그룹(커스텀 프로퍼티 접두사에 그대로 쓰인다)
+/// @param {String|Number} $name - 토큰 이름
+/// @param {Map} $registry - 검증 기준이 되는 토큰 map
+@function _token($group, $name, $registry) {
+  @if not map.has-key($registry, $name) {
+    @error "[@fox] 알 수 없는 #{$group} 토큰: `#{$name}` — 사용 가능한 값: #{map.keys($registry)}";
+  }
+
+  @return var(--fox-#{$group}-#{$name});
+}
+
+@function color($name) {
+  @return _token("color", $name, tokens.$color);
+}
+
+@function space($name) {
+  @return _token("space", $name, tokens.$space);
+}
+
+@function radius($name) {
+  @return _token("radius", $name, tokens.$radius);
+}
+
+@function shadow($name) {
+  @return _token("shadow", $name, tokens.$shadow);
+}
+
+@function duration($name) {
+  @return _token("duration", $name, tokens.$duration);
+}
+
+@function easing($name) {
+  @return _token("easing", $name, tokens.$easing);
+}
+
+@function font($name) {
+  @return _token("font", $name, tokens.$font-family);
+}
+
+@function weight($name) {
+  @return _token("weight", $name, tokens.$font-weight);
+}
+
+@function z($name) {
+  @return _token("z", $name, tokens.$z);
+}
+
+/// 브레이크포인트 원값(rem 리터럴)을 반환한다 — **유일하게 `var()`가 아닌 실제 값을
+/// 돌려주는 함수**다. 미디어 쿼리가 `var()`를 해석하지 못하기 때문이다(`tokens/_layout.scss`).
+/// 미디어 쿼리 자체는 `media()` 믹스인을 쓰고, 이 함수는 컨테이너 `max-width`처럼
+/// 브레이크포인트와 같은 값을 선언에 직접 써야 할 때만 쓴다.
+@function bp($name) {
+  @if not map.has-key(tokens.$breakpoint, $name) {
+    @error "[@fox] 알 수 없는 breakpoint 토큰: `#{$name}` — 사용 가능한 값: #{map.keys(tokens.$breakpoint)}";
+  }
+
+  @return map.get(tokens.$breakpoint, $name);
+}
 
@fox/styles/_mixins.scss (added)
+++ @fox/styles/_mixins.scss
@@ -0,0 +1,109 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 믹스인 — 여러 선언이 항상 함께 가야 하는 패턴을 한 줄로 묶는다.
+//
+// 함수(`_functions.scss`)가 "값 하나"를 강제한다면, 믹스인은 "값들의 조합"을
+// 강제한다. 타이포 4종(크기·행간·굵기·자간)이나 감소 모션 분기처럼 하나만 빠져도
+// 어긋나는 것들을 화면마다 되풀이해 적지 않게 만드는 것이 목적이다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@use "sass:map";
+@use "tokens";
+@use "functions" as fn;
+
+/// 타이포 프리셋 적용 — 크기·행간·굵기·자간 4종을 한 번에.
+/// 개별 스칼라를 따로 고르지 못하게 해서 타이포 조합이 무한히 갈라지는 것을 막는다.
+@mixin typo($name) {
+  @if not map.has-key(tokens.$typo, $name) {
+    @error "[@fox] 알 수 없는 typo 프리셋: `#{$name}` — 사용 가능한 값: #{map.keys(tokens.$typo)}";
+  }
+
+  font-size: var(--fox-typo-#{$name}-size);
+  font-weight: var(--fox-typo-#{$name}-weight);
+  line-height: var(--fox-typo-#{$name}-line-height);
+  letter-spacing: var(--fox-typo-#{$name}-letter-spacing);
+}
+
+/// 지정 브레이크포인트 **이상**에서 적용 (모바일 우선).
+@mixin media($name) {
+  @media (min-width: fn.bp($name)) {
+    @content;
+  }
+}
+
+/// 지정 브레이크포인트 **미만**에서 적용.
+/// `media()`와 경계가 겹치지 않도록 0.01rem을 뺀다 — 같은 뷰포트 폭에서 두 블록이
+/// 동시에 적용되면 나중 선언이 이기는 미묘한 버그가 생긴다.
+@mixin media-below($name) {
+  @media (max-width: fn.bp($name) - 0.01rem) {
+    @content;
+  }
+}
+
+/// 트랜지션 — `prefers-reduced-motion: no-preference`로 감싸 감소 모션 환경에서는
+/// 아예 선언되지 않게 한다(값을 0으로 만드는 것보다 확실하다).
+/// @param {List} $properties - 전환할 속성. 여러 개면 괄호로 묶어 전달한다.
+@mixin transition($properties, $duration: base, $easing: out) {
+  @media (prefers-reduced-motion: no-preference) {
+    transition-property: $properties;
+    transition-duration: fn.duration($duration);
+    transition-timing-function: fn.easing($easing);
+  }
+}
+
+/// 키프레임 애니메이션 — 감소 모션 분기는 `transition()`과 동일하게 내장.
+@mixin animate($name, $duration: base, $easing: out, $fill: both) {
+  @media (prefers-reduced-motion: no-preference) {
+    animation: $name fn.duration($duration) fn.easing($easing) $fill;
+  }
+}
+
+/// 키보드 포커스 링 — `:focus-visible`만 대상으로 하므로 마우스 클릭 시에는
+/// 나타나지 않는다. 모든 상호작용 요소에 반드시 넣는다(접근성).
+@mixin focus-ring($color: null, $width: 0.2rem, $offset: 0.2rem) {
+  $ring-color: fn.color(primary);
+
+  @if $color != null {
+    $ring-color: $color;
+  }
+
+  &:focus-visible {
+    outline: $width solid $ring-color;
+    outline-offset: $offset;
+  }
+}
+
+/// 화면에는 보이지 않지만 스크린리더는 읽는 텍스트.
+/// `display: none`이나 `visibility: hidden`은 보조기기에서도 사라지므로 쓰지 않는다.
+@mixin visually-hidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  margin: -1px;
+  padding: 0;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+  border: 0;
+}
+
+/// 말줄임 — 1줄이면 `text-overflow`, 여러 줄이면 line-clamp.
+@mixin truncate($lines: 1) {
+  overflow: hidden;
+
+  @if $lines == 1 {
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  } @else {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: $lines;
+  }
+}
+
+/// 페이지 콘텐츠 가로 폭 제한 + 좌우 여백.
+@mixin container($max: xl, $gutter: 4) {
+  width: 100%;
+  max-width: fn.bp($max);
+  margin-inline: auto;
+  padding-inline: fn.space($gutter);
+}
 
@fox/styles/_reset.scss (added)
+++ @fox/styles/_reset.scss
@@ -0,0 +1,121 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 리셋 & 베이스
+//
+// 브라우저 기본 스타일 중 디자인 시스템과 충돌하는 것만 걷어낸다. 전면 리셋(모든
+// 요소를 무스타일로)이 아니라, 시맨틱 태그의 기본 동작은 살리고 시각 표현만
+// 토큰으로 되돌리는 방식이다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@use "abstracts" as fox;
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+// ⚠️ **1rem = 10px 규약의 근거.** 브라우저 기본 글자 크기(16px)의 62.5%가 10px다.
+// px → rem 환산이 10으로 나누기가 되어 토큰 값이 읽기 쉬워진다.
+// px 고정과 달리 사용자가 브라우저 글자 크기를 키우면 앱 전체가 비례해 확대된다
+// (접근성 설정 존중). 그 대신 `body`에서 실제 본문 크기를 되돌려 놓아야 한다 —
+// 아래 `body`의 `typo(body-md)`가 그 역할이다.
+//
+// ⚠️ 미디어 쿼리 안의 `rem`은 이 값의 영향을 받지 않는다(항상 브라우저 기본 크기
+// 기준). `tokens/_layout.scss` 참고.
+html {
+  font-size: 62.5%;
+  -webkit-text-size-adjust: 100%;
+}
+
+body {
+  margin: 0;
+  background-color: fox.color(background);
+  color: fox.color(foreground);
+  font-family: fox.font(sans);
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+
+  @include fox.typo(body-md);
+}
+
+// 제목·문단의 기본 여백을 제거한다 — 간격은 레이아웃(gap/margin 토큰)이 책임지고,
+// 태그가 스스로 여백을 갖지 않게 해야 조립 결과를 예측할 수 있다.
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+figure,
+blockquote,
+dl,
+dd {
+  margin: 0;
+}
+
+// 제목 태그의 기본 크기/굵기도 제거한다 — 시각적 위계는 `typo()` 프리셋으로만
+// 정하고, 태그 선택은 문서 구조(접근성) 기준으로 하게 만든다.
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-size: inherit;
+  font-weight: inherit;
+}
+
+ul,
+ol {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+// 폼 요소는 폰트를 상속하지 않는 것이 브라우저 기본값이라 명시적으로 되돌린다.
+button,
+input,
+select,
+textarea {
+  margin: 0;
+  font: inherit;
+  color: inherit;
+  letter-spacing: inherit;
+}
+
+button {
+  padding: 0;
+  border: 0;
+  background: none;
+  cursor: pointer;
+}
+
+button:disabled {
+  cursor: default;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+img,
+svg,
+video,
+canvas {
+  display: block;
+  max-width: 100%;
+}
+
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+
+// 브라우저 기본 포커스 링을 지우지 않는다 — 컴포넌트가 `focus-ring()` 믹스인으로
+// 자기 링을 정의하면 그것이 우선하고, 정의하지 않은 요소는 기본 링이 남아 키보드
+// 사용자가 길을 잃지 않는다.
+:where(:focus-visible) {
+  outline-offset: 0.2rem;
+}
 
@fox/styles/_root.scss (added)
+++ @fox/styles/_root.scss
@@ -0,0 +1,104 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 토큰 CSS 출력 — `tokens/` 의 SCSS map을 `--fox-*` 커스텀 프로퍼티로 선언한다.
+//
+// **이 파일은 앱 전체에서 딱 한 번만 로드되어야 한다** (`index.scss` 경유).
+// 컴포넌트의 `.module.scss`에서는 절대 `@use` 하지 않는다 — CSS Module은 파일마다
+// 별도 컴파일 단위여서, 컴포넌트마다 `:root` 블록 전체가 복제된다.
+//
+// map을 순회해 생성하므로 토큰을 추가할 때 이 파일은 손대지 않는다. 값의 이름·개수는
+// 언제나 `tokens/` 쪽 map 하나에만 존재한다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@use "sass:map";
+@use "tokens";
+
+:root {
+  // 기본은 OS 설정 추종. 아래 `[data-theme]` 규칙이 이 값만 덮어써 수동 선택을
+  // 처리하므로, 색상 값 자체는 `tokens/_color.scss` 한 곳에만 존재한다.
+  color-scheme: light dark;
+
+  @each $name, $value in tokens.$color {
+    --fox-color-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$space {
+    --fox-space-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$radius {
+    --fox-radius-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$shadow {
+    --fox-shadow-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$duration {
+    --fox-duration-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$easing {
+    --fox-easing-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$font-family {
+    --fox-font-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$font-weight {
+    --fox-weight-#{$name}: #{$value};
+  }
+
+  @each $name, $value in tokens.$z {
+    --fox-z-#{$name}: #{$value};
+  }
+
+  // 브레이크포인트는 **미디어 쿼리에서 쓸 수 없다** — 조건부는 `var()`를 해석하지
+  // 않는다. 그럼에도 내보내는 이유는 컨테이너 `max-width`처럼 선언부에서 같은 값을
+  // 참조할 일이 있고, 카탈로그가 토큰 목록을 CSSOM에서 읽기 때문이다.
+  // 미디어 쿼리는 언제나 `fox.media()` 믹스인을 쓴다.
+  @each $name, $value in tokens.$breakpoint {
+    --fox-breakpoint-#{$name}: #{$value};
+  }
+
+  // 타이포 프리셋은 한 이름당 4개의 프로퍼티로 펼쳐진다 — `fox.typo()` 믹스인이
+  // 이 네 개를 함께 참조한다.
+  @each $name, $preset in tokens.$typo {
+    --fox-typo-#{$name}-size: #{map.get($preset, size)};
+    --fox-typo-#{$name}-weight: #{map.get($preset, weight)};
+    --fox-typo-#{$name}-line-height: #{map.get($preset, line-height)};
+    --fox-typo-#{$name}-letter-spacing: #{map.get($preset, letter-spacing)};
+  }
+}
+
+// 수동 테마 선택 — `color-scheme`만 고정하면 `light-dark()`가 해당 분기로 확정된다.
+// 색상 값을 여기서 재정의하지 않으므로 라이트/다크 값이 어긋날 수 없다.
+// 속성이 없거나 `"system"`이면 위 `:root`의 `light dark`가 유지되어 OS를 따른다.
+:root[data-theme="light"] {
+  color-scheme: light;
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
+}
+
+// 테마 전환 애니메이션 — 호스트 앱이 토글 직전 `<html>`에 `[data-theme-transition]`을
+// 부여하고 ~200ms 후 제거하는 **시간 한정 opt-in**이다. 상시 트랜지션이 아니므로
+// 다른 hover 트랜지션과 경쟁하지 않는다.
+// `prefers-reduced-motion` 게이팅으로 감소 모션 환경에서는 즉시 반영된다.
+// `!important`는 이 200ms 동안만 존재하는 규칙이 컴포넌트의 transition 선언에
+// 밀리지 않게 하기 위한 것이다.
+@media (prefers-reduced-motion: no-preference) {
+  html[data-theme-transition],
+  html[data-theme-transition] *,
+  html[data-theme-transition] *::before,
+  html[data-theme-transition] *::after {
+    transition:
+      background-color var(--fox-duration-base) var(--fox-easing-out),
+      border-color var(--fox-duration-base) var(--fox-easing-out),
+      color var(--fox-duration-base) var(--fox-easing-out),
+      fill var(--fox-duration-base) var(--fox-easing-out),
+      stroke var(--fox-duration-base) var(--fox-easing-out),
+      outline-color var(--fox-duration-base) var(--fox-easing-out) !important;
+  }
+}
 
@fox/styles/abstracts.scss (added)
+++ @fox/styles/abstracts.scss
@@ -0,0 +1,22 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 저작(authoring) 진입점 — 컴포넌트의 `.module.scss`가 `@use`하는 파일.
+//
+//   @use "@fox/styles/abstracts" as fox;
+//
+//   .button {
+//     padding: fox.space(2) fox.space(4);
+//     background: fox.color(primary);
+//     border-radius: fox.radius(md);
+//     @include fox.typo(label-lg);
+//     @include fox.focus-ring;
+//   }
+//
+// ⚠️ **이 파일은 CSS를 한 줄도 출력하지 않는다.** 함수·믹스인·map 정의만 있다.
+// CSS Module은 파일마다 별도 컴파일 단위라, CSS를 출력하는 모듈을 여기서 forward하면
+// 컴포넌트 수만큼 같은 CSS가 복제된다. 실제 CSS 출력(토큰 선언·리셋)은 앱이 딱 한 번
+// import하는 `index.scss`가 전담한다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@forward "tokens";
+@forward "functions";
+@forward "mixins";
 
@fox/styles/index.scss (added)
+++ @fox/styles/index.scss
@@ -0,0 +1,13 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 글로벌 스타일 진입점 — **호스트 앱이 딱 한 번 import한다.**
+//
+//   // app/globals.scss
+//   @use "@fox/styles";
+//
+// 여기서만 CSS가 출력된다(토큰 선언 + 리셋). 컴포넌트의 `.module.scss`는 이 파일이
+// 아니라 `abstracts.scss`를 `@use`해야 한다 — 그쪽은 CSS 출력이 0이라 몇 번을
+// 참조해도 중복이 생기지 않는다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+@use "root";
+@use "reset";
 
@fox/styles/tokens/_color.scss (added)
+++ @fox/styles/tokens/_color.scss
@@ -0,0 +1,61 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 색상 토큰
+//
+// 이 map이 단일 진실 공급원(SSOT)이다. 여기서 두 가지가 함께 파생된다:
+//   1. `_root.scss` → `--fox-color-*` CSS 커스텀 프로퍼티 선언
+//   2. `_functions.scss`의 `color()` → 토큰 이름 유효성 컴파일타임 검증
+// 값과 이름 목록이 한 파일에만 존재하므로 둘 사이의 드리프트가 구조적으로 불가능하다.
+//
+// 라이트/다크는 `light-dark(라이트값, 다크값)` 한 줄로 함께 보유한다. 테마 전환은
+// `_root.scss`의 `color-scheme`이 담당하므로 다크 전용 블록을 따로 두지 않는다 —
+// 한쪽만 고쳐서 생기는 테마 불일치가 애초에 발생할 수 없다.
+//
+// 브라우저 지원: `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이
+// 필요하지만, Next.js 16(Turbopack)의 Lightning CSS가 현재 설정에서 이를
+// `var(--lightningcss-light, 라이트) var(--lightningcss-dark, 다크)` 조합으로 **폴리필**해
+// 출력하므로 구형 브라우저에서도 동작한다(2026-08-11 빌드 산출물에서 확인).
+// browserslist 타깃을 최신 브라우저로 좁히면 폴리필이 빠지고 원본 `light-dark()`가
+// 그대로 나가므로, 타깃을 바꿀 때 이 전제를 다시 확인한다.
+//
+// ⚠️ 아래 값은 전부 **임시 플레이스홀더**다. 파이프라인 검증과 /design 카탈로그
+// 렌더를 위한 중립 팔레트이며, 실제 브랜드 값은 사용자 제공 Figma/파일로 교체한다.
+// 교체 시 이 파일의 값만 바꾸면 앱 전체에 반영된다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+$color: (
+  // 브랜드
+  primary: light-dark(#4f46e5, #818cf8),
+  primary-hover: light-dark(#4338ca, #a5b4fc),
+  primary-subtle: light-dark(#eef2ff, #312e81),
+  on-primary: light-dark(#ffffff, #1e1b34),
+
+  // 표면 — background(페이지 바닥) < surface(카드) < surface-raised(팝오버 등)
+  background: light-dark(#ffffff, #14161b),
+  surface: light-dark(#ffffff, #1e2128),
+  surface-muted: light-dark(#f5f7fa, #262a33),
+  surface-raised: light-dark(#ffffff, #2c3038),
+
+  // 전경(텍스트·아이콘)
+  foreground: light-dark(#1f2430, #f5f7fa),
+  foreground-muted: light-dark(#565d6b, #bbc4d8),
+  foreground-subtle: light-dark(#8790a3, #8790a3),
+  foreground-inverse: light-dark(#ffffff, #14161b),
+
+  // 경계선
+  border: light-dark(#dae0ed, #424752),
+  border-subtle: light-dark(#eaeef6, #2f343d),
+  border-strong: light-dark(#b6bfd2, #5a616e),
+
+  // 상태 — 각 상태색 위에 얹는 텍스트색을 on-* 로 함께 정의한다.
+  danger: light-dark(#dc2626, #f87171),
+  on-danger: light-dark(#ffffff, #1a0d0d),
+  success: light-dark(#16a34a, #4ade80),
+  on-success: light-dark(#ffffff, #0a1a10),
+  warning: light-dark(#d97706, #fbbf24),
+  on-warning: light-dark(#ffffff, #1a1205),
+  info: light-dark(#0284c7, #38bdf8),
+  on-info: light-dark(#ffffff, #05161f),
+
+  // 오버레이
+  scrim: light-dark(rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.65))
+);
 
@fox/styles/tokens/_index.scss (added)
+++ @fox/styles/tokens/_index.scss
@@ -0,0 +1,13 @@
+// 토큰 map 모음 진입점 — **CSS를 한 줄도 출력하지 않는다.**
+// 값 선언(이 폴더)과 CSS 출력(`../_root.scss`)을 분리해 두었기 때문에, 컴포넌트의
+// `.module.scss`가 몇 개든 `abstracts`를 `@use`해도 `:root` 블록이 중복 출력되지
+// 않는다(각 CSS Module은 별도 컴파일 단위라 CSS를 내보내는 모듈을 공유하면
+// 파일마다 같은 CSS가 복제된다).
+
+@forward "color";
+@forward "space";
+@forward "typography";
+@forward "radius";
+@forward "shadow";
+@forward "motion";
+@forward "layout";
 
@fox/styles/tokens/_layout.scss (added)
+++ @fox/styles/tokens/_layout.scss
@@ -0,0 +1,41 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 레이아웃 토큰 — 브레이크포인트와 z-index 층.
+// ─────────────────────────────────────────────────────────────────────────────
+
+// 브레이크포인트는 이 파일에서 **유일하게 CSS 커스텀 프로퍼티로 내보내지 않는
+// 토큰**이다. 미디어 쿼리 조건부는 `var()`를 해석하지 않기 때문에
+// (`@media (min-width: var(--x))`는 무효), 컴파일타임 SCSS 값이어야만 한다.
+// 그래서 브레이크포인트 사용은 `fox.media()` 믹스인 경유가 강제된다.
+//
+// ⚠️ **미디어 쿼리 안의 `rem`은 브라우저 기본 글자 크기(보통 16px) 기준이다.**
+// `html { font-size: 62.5% }`의 영향을 받지 않으므로, 다른 토큰의 "1rem = 10px"
+// 규칙이 여기서는 적용되지 않는다. 아래 px 환산을 반드시 함께 본다.
+// rem으로 두는 이유: 사용자가 브라우저 글자 크기를 키우면 브레이크포인트도 함께
+// 커져 확대 환경에서 레이아웃이 더 일찍 접힌다(px 고정은 이 대응이 불가능하다).
+$breakpoint: (
+  sm: 40rem,
+  // 640px
+  md: 48rem,
+  // 768px
+  lg: 64rem,
+  // 1024px
+  xl: 80rem,
+  // 1280px
+  2xl: 96rem
+  // 1536px
+);
+
+// z-index 층 — 숫자를 화면에서 직접 고르지 않게 하려는 토큰이다. 값 사이를 100씩
+// 띄워 둔 것은 나중에 층 사이에 새 층을 끼워 넣을 여지를 남기기 위함이다.
+$z: (
+  base: 0,
+  raised: 100,
+  sticky: 200,
+  dropdown: 300,
+  scrim: 400,
+  modal: 500,
+  popover: 600,
+  toast: 700,
+  tooltip: 800,
+  splash: 900
+);
 
@fox/styles/tokens/_motion.scss (added)
+++ @fox/styles/tokens/_motion.scss
@@ -0,0 +1,26 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 모션 토큰 — 지속시간과 이징.
+// 이징은 "어느 방향으로 움직이는가"로 이름 붙인다: 화면에 들어올 때는 감속(out),
+// 나갈 때는 가속(in), 제자리에서 상태만 바뀔 때는 in-out.
+//
+// 실제 트랜지션·애니메이션은 `_mixins.scss`의 `transition()` / `animate()`를 쓴다 —
+// 두 믹스인 모두 `prefers-reduced-motion` 분기를 내장하고 있어 감소 모션 환경
+// 대응을 화면마다 되풀이할 필요가 없다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+$duration: (
+  instant: 0ms,
+  fast: 120ms,
+  base: 200ms,
+  slow: 300ms,
+  slower: 500ms
+);
+
+$easing: (
+  linear: linear,
+  in: cubic-bezier(0.4, 0, 1, 1),
+  out: cubic-bezier(0, 0, 0.2, 1),
+  in-out: cubic-bezier(0.4, 0, 0.2, 1),
+  // 살짝 오버슈트 — 토글·체크 같은 작은 확인 동작에.
+  spring: cubic-bezier(0.34, 1.56, 0.64, 1)
+);
 
@fox/styles/tokens/_radius.scss (added)
+++ @fox/styles/tokens/_radius.scss
@@ -0,0 +1,17 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 모서리 반경 토큰 — 1rem = 10px 기준.
+// `full`은 pill 형태(캡슐 버튼·뱃지)를 위한 값으로, 높이보다 크기만 하면 되므로
+// 임의의 큰 값을 쓴다.
+// ⚠️ 값은 임시 플레이스홀더 — Figma 확정 시 교체.
+// ─────────────────────────────────────────────────────────────────────────────
+
+$radius: (
+  none: 0,
+  xs: 0.4rem,
+  sm: 0.6rem,
+  md: 0.8rem,
+  lg: 1.2rem,
+  xl: 1.6rem,
+  2xl: 2.4rem,
+  full: 999rem
+);
 
@fox/styles/tokens/_shadow.scss (added)
+++ @fox/styles/tokens/_shadow.scss
@@ -0,0 +1,18 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 그림자 토큰 — 고도(elevation)를 역할 이름으로 표현한다.
+// 크기(sm/md/lg)가 아니라 쓰임(card/dialog/…)으로 이름 붙이면 "이 요소는 어느
+// 층에 뜨는가"가 코드에 드러나고, 나중에 값이 바뀌어도 의미가 유지된다.
+//
+// 그림자는 라이트/다크에서 같은 값을 쓰지 않는 편이 자연스럽다(어두운 배경에서는
+// 그림자가 거의 보이지 않는다). 필요해지면 여기서 `light-dark()`로 분기한다.
+// ⚠️ 값은 임시 플레이스홀더 — Figma 확정 시 교체.
+// ─────────────────────────────────────────────────────────────────────────────
+
+$shadow: (
+  none: none,
+  card: light-dark(0 0.1rem 0.3rem rgb(16 24 40 / 0.08), 0 0.1rem 0.3rem rgb(0 0 0 / 0.4)),
+  raised: light-dark(0 0.4rem 0.8rem rgb(16 24 40 / 0.1), 0 0.4rem 0.8rem rgb(0 0 0 / 0.45)),
+  popup: light-dark(0 0.8rem 1.6rem rgb(16 24 40 / 0.12), 0 0.8rem 1.6rem rgb(0 0 0 / 0.5)),
+  dialog: light-dark(0 1.6rem 3.2rem rgb(16 24 40 / 0.16), 0 1.6rem 3.2rem rgb(0 0 0 / 0.55)),
+  toast: light-dark(0 1rem 2rem rgb(16 24 40 / 0.18), 0 1rem 2rem rgb(0 0 0 / 0.6))
+);
 
@fox/styles/tokens/_space.scss (added)
+++ @fox/styles/tokens/_space.scss
@@ -0,0 +1,29 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 간격 토큰 (4/8 grid)
+//
+// **모든 치수는 rem 기준이며 `1rem = 10px`이다** (`_reset.scss`의
+// `html { font-size: 62.5% }`). px → rem 환산이 10으로 나누기라 암산이 되고,
+// 사용자가 브라우저 기본 글자 크기를 키우면 레이아웃 전체가 함께 확대된다
+// (px 고정과 달리 접근성 설정을 존중한다).
+//
+// 키는 4px 배수 단위다 — `space(4)` = 16px. 임의 값이 필요해 보이면 스케일에
+// 단계를 추가할 일인지 먼저 검토한다(§10.4).
+// ─────────────────────────────────────────────────────────────────────────────
+
+$space: (
+  0: 0,
+  1: 0.4rem,
+  2: 0.8rem,
+  3: 1.2rem,
+  4: 1.6rem,
+  5: 2rem,
+  6: 2.4rem,
+  8: 3.2rem,
+  10: 4rem,
+  12: 4.8rem,
+  16: 6.4rem,
+  20: 8rem,
+  24: 9.6rem
+);
+// 키 → px 환산: 1=4 · 2=8 · 3=12 · 4=16 · 5=20 · 6=24 · 8=32 · 10=40 · 12=48 ·
+// 16=64 · 20=80 · 24=96
 
@fox/styles/tokens/_typography.scss (added)
+++ @fox/styles/tokens/_typography.scss
@@ -0,0 +1,52 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 타이포그래피 토큰
+//
+// 폰트 패밀리는 **호스트 앱이 주입하는 계약**이다 — `@fox`는 `--app-font-sans` /
+// `--app-font-mono`를 참조만 하고 폰트 파일을 소유하지 않는다. Next.js는 `next/font`가
+// 앱의 layout에서 CSS 변수를 만들어 주므로(폰트 최적화·자체 호스팅이 앱 책임),
+// `@fox` 폴더를 다른 프로젝트로 복사해도 그 프로젝트의 폰트를 그대로 따른다.
+// 미주입 시에도 깨지지 않도록 `var()` 폴백을 반드시 둔다 — 폴백 없는 `var()`가
+// undefined면 font-family 선언 전체가 무효가 된다.
+//
+// 개별 스칼라(size/weight/…)보다 **프리셋(`$typo`)을 우선 사용**한다. 크기·행간·
+// 자간·굵기는 함께 움직여야 의도한 타이포가 되므로, 화면에서 넷을 따로 고르게 두면
+// 조합이 무한히 갈라진다. `@include fox.typo(title-md)` 한 줄로 넷이 함께 적용된다.
+//
+// ⚠️ 아래 값·이름은 임시 플레이스홀더다. 실제 타입 스케일은 Figma 확정 시 교체한다.
+// ─────────────────────────────────────────────────────────────────────────────
+
+$font-family: (
+  sans: (var(--app-font-sans, system-ui), "Apple SD Gothic Neo", sans-serif),
+  mono: (var(--app-font-mono, ui-monospace), monospace)
+);
+
+$font-weight: (
+  regular: 400,
+  medium: 500,
+  semibold: 600,
+  bold: 700
+);
+
+// 타이포 프리셋 — size / line-height / weight / letter-spacing 4종을 한 묶음으로.
+// 1rem = 10px 기준이므로 1.6rem = 16px.
+$typo: (
+  display-lg: (size: 4.8rem, line-height: 1.2, weight: 700, letter-spacing: -0.02em),
+  display-md: (size: 4rem, line-height: 1.2, weight: 700, letter-spacing: -0.02em),
+  display-sm: (size: 3.2rem, line-height: 1.25, weight: 700, letter-spacing: -0.02em),
+
+  headline-lg: (size: 2.8rem, line-height: 1.3, weight: 700, letter-spacing: -0.015em),
+  headline-md: (size: 2.4rem, line-height: 1.35, weight: 700, letter-spacing: -0.015em),
+  headline-sm: (size: 2rem, line-height: 1.4, weight: 700, letter-spacing: -0.01em),
+
+  title-lg: (size: 1.8rem, line-height: 1.45, weight: 600, letter-spacing: -0.01em),
+  title-md: (size: 1.6rem, line-height: 1.5, weight: 600, letter-spacing: 0),
+  title-sm: (size: 1.4rem, line-height: 1.5, weight: 600, letter-spacing: 0),
+
+  body-lg: (size: 1.6rem, line-height: 1.6, weight: 400, letter-spacing: 0),
+  body-md: (size: 1.4rem, line-height: 1.6, weight: 400, letter-spacing: 0),
+  body-sm: (size: 1.2rem, line-height: 1.55, weight: 400, letter-spacing: 0),
+
+  label-lg: (size: 1.4rem, line-height: 1.4, weight: 600, letter-spacing: 0),
+  label-md: (size: 1.3rem, line-height: 1.4, weight: 500, letter-spacing: 0),
+  label-sm: (size: 1.2rem, line-height: 1.35, weight: 500, letter-spacing: 0.01em)
+);
 
app/design/_components/theme-switch.tsx (added)
+++ app/design/_components/theme-switch.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import {
+  DEFAULT_THEME_PREFERENCE,
+  THEME_ATTRIBUTE_NAME,
+  THEME_PREFERENCE_CYCLE,
+  THEME_STORAGE_KEY,
+  type ThemePreference,
+} from "@/lib/constants/theme";
+import styles from "../design.module.scss";
+
+const LABELS: Record<ThemePreference, string> = {
+  system: "시스템",
+  light: "라이트",
+  dark: "다크",
+};
+
+/**
+ * `<html data-theme>` 자체가 SSOT이므로 React state를 두지 않고 DOM을 직접 구독한다
+ * (앱 헤더의 테마 토글과 동일한 원칙 — 서버/클라이언트 출력이 갈리지 않는다).
+ * 서버 스냅샷은 항상 기본값(system)이라 첫 렌더가 결정적이다.
+ */
+function subscribe(onChange: () => void): () => void {
+  const observer = new MutationObserver(onChange);
+  observer.observe(document.documentElement, {
+    attributes: true,
+    attributeFilter: [THEME_ATTRIBUTE_NAME],
+  });
+  return () => observer.disconnect();
+}
+
+function getSnapshot(): ThemePreference {
+  const value = document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME);
+  return value === "light" || value === "dark" ? value : DEFAULT_THEME_PREFERENCE;
+}
+
+function getServerSnapshot(): ThemePreference {
+  return DEFAULT_THEME_PREFERENCE;
+}
+
+function applyTheme(next: ThemePreference): void {
+  const root = document.documentElement;
+
+  // 전환 트랜지션은 200ms 동안만 켜지는 opt-in이다(@fox/styles/_root.scss).
+  root.setAttribute("data-theme-transition", "");
+
+  if (next === "system") {
+    root.removeAttribute(THEME_ATTRIBUTE_NAME);
+    localStorage.removeItem(THEME_STORAGE_KEY);
+  } else {
+    root.setAttribute(THEME_ATTRIBUTE_NAME, next);
+    localStorage.setItem(THEME_STORAGE_KEY, next);
+  }
+
+  window.setTimeout(() => {
+    root.removeAttribute("data-theme-transition");
+  }, 200);
+}
+
+/** 카탈로그에서 라이트/다크 토큰을 눈으로 대조하기 위한 개발용 전환기. */
+export function ThemeSwitch() {
+  const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+  return (
+    <div className={styles.themeSwitch} role="group" aria-label="테마 전환">
+      {THEME_PREFERENCE_CYCLE.map((preference) => (
+        <button
+          key={preference}
+          type="button"
+          className={styles.themeButton}
+          aria-pressed={current === preference}
+          onClick={() => applyTheme(preference)}
+        >
+          {LABELS[preference]}
+        </button>
+      ))}
+    </div>
+  );
+}
 
app/design/_components/token-catalog.tsx (added)
+++ app/design/_components/token-catalog.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import type { CSSProperties } from "react";
+import { useTokenRegistry } from "../_hooks/use-token-registry";
+import { typoPresetNames, type TokenEntry } from "../_lib/read-tokens";
+import { ThemeSwitch } from "./theme-switch";
+import styles from "../design.module.scss";
+
+/**
+ * ⚠️ 이 파일은 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 카탈로그는 토큰
+ * 레지스트리를 런타임에 순회하며 개수를 미리 알 수 없는 토큰을 렌더해야 하는데, CSS
+ * 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서 인라인으로 넣는 값은 디자인
+ * 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 토큰이 소유한다.
+ * 일반 화면·컴포넌트에서는 이 패턴을 쓰지 않는다.
+ */
+function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties {
+  return { [property]: `var(${token.property})` } as CSSProperties;
+}
+
+function typoRef(preset: string): CSSProperties {
+  return {
+    fontSize: `var(--fox-typo-${preset}-size)`,
+    fontWeight: `var(--fox-typo-${preset}-weight)`,
+    lineHeight: `var(--fox-typo-${preset}-line-height)`,
+    letterSpacing: `var(--fox-typo-${preset}-letter-spacing)`,
+  };
+}
+
+function ValueTable({ rows }: { rows: { label: string; value: string }[] }) {
+  return (
+    <table className={styles.table}>
+      <thead>
+        <tr>
+          <th scope="col">토큰</th>
+          <th scope="col">값</th>
+        </tr>
+      </thead>
+      <tbody>
+        {rows.map((row) => (
+          <tr key={row.label}>
+            <td>{row.label}</td>
+            <td>{row.value}</td>
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  );
+}
+
+function rows(group: TokenEntry[] | undefined, fn: string) {
+  return (group ?? []).map((token) => ({
+    label: `fox.${fn}(${token.name})`,
+    value: token.value,
+  }));
+}
+
+export function TokenCatalog() {
+  // CSSOM은 브라우저에만 있으므로 하이드레이션 이후에 채워진다. 서버 렌더 결과가
+  // 비어 있어도 개발 전용 페이지라 문제되지 않는다.
+  const registry = useTokenRegistry();
+
+  if (!registry) {
+    return (
+      <main className={styles.page}>
+        <p className={styles.lede}>토큰을 읽는 중…</p>
+      </main>
+    );
+  }
+
+  const colors = registry.color ?? [];
+  const spaces = registry.space ?? [];
+  const radii = registry.radius ?? [];
+  const shadows = registry.shadow ?? [];
+  const presets = typoPresetNames(registry);
+
+  return (
+    <main className={styles.page}>
+      <header className={styles.header}>
+        <div>
+          <h1 className={styles.title}>디자인 시스템 카탈로그</h1>
+          <p className={styles.lede}>
+            @fox/styles/tokens 전량을 CSSOM에서 읽어 그립니다. 개발 환경에서만 열립니다.
+          </p>
+        </div>
+        <ThemeSwitch />
+      </header>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>색상 · {colors.length}</h2>
+        <p className={styles.sectionNote}>
+          라이트/다크 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 위 전환기로 두 테마를
+          대조해 보세요.
+        </p>
+        <div className={styles.grid}>
+          {colors.map((token) => (
+            <div key={token.property} className={styles.swatch}>
+              <div className={styles.swatchChip} style={ref("background", token)} />
+              <div className={styles.swatchMeta}>
+                <span className={styles.name}>fox.color({token.name})</span>
+                <span className={styles.ref}>{token.value}</span>
+              </div>
+            </div>
+          ))}
+        </div>
+      </section>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>타이포 프리셋 · {presets.length}</h2>
+        <p className={styles.sectionNote}>
+          크기·굵기·행간·자간 4종이 한 묶음입니다. @include fox.typo(이름)으로 적용합니다.
+        </p>
+        {presets.map((preset) => (
+          <div key={preset} className={styles.typoRow}>
+            <span className={styles.typoName}>{preset}</span>
+            <span className={styles.typoSample} style={typoRef(preset)}>
+              다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123
+            </span>
+          </div>
+        ))}
+      </section>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>간격 · {spaces.length}</h2>
+        <p className={styles.sectionNote}>
+          4px 배수 스케일. 1rem = 10px이므로 fox.space(4) = 1.6rem = 16px입니다.
+        </p>
+        {spaces.map((token) => (
+          <div key={token.property} className={styles.spaceRow}>
+            <span className={styles.typoName}>
+              space({token.name}) · {token.value}
+            </span>
+            <span className={styles.spaceBar} style={ref("inlineSize", token)} />
+          </div>
+        ))}
+      </section>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>모서리 반경 · {radii.length}</h2>
+        <div className={styles.grid}>
+          {radii.map((token) => (
+            <div key={token.property} className={styles.tile} style={ref("borderRadius", token)}>
+              {token.name} · {token.value}
+            </div>
+          ))}
+        </div>
+      </section>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>그림자 · {shadows.length}</h2>
+        <p className={styles.sectionNote}>크기가 아니라 쓰임(고도)으로 이름 붙였습니다.</p>
+        <div className={styles.grid}>
+          {shadows.map((token) => (
+            <div key={token.property} className={styles.shadowCell}>
+              <div className={styles.shadowTile} style={ref("boxShadow", token)}>
+                {token.name}
+              </div>
+            </div>
+          ))}
+        </div>
+      </section>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>모션</h2>
+        <ValueTable
+          rows={[...rows(registry.duration, "duration"), ...rows(registry.easing, "easing")]}
+        />
+      </section>
+
+      <section className={styles.section}>
+        <h2 className={styles.sectionTitle}>서체 · 굵기 · z 층 · 브레이크포인트</h2>
+        <p className={styles.sectionNote}>
+          브레이크포인트는 미디어 쿼리가 var()를 해석하지 못하므로 실제 사용은 fox.media()
+          믹스인 경유입니다. 또한 미디어 쿼리 안의 rem은 html font-size의 영향을 받지 않아
+          48rem은 480px가 아니라 768px입니다.
+        </p>
+        <ValueTable
+          rows={[
+            ...rows(registry.font, "font"),
+            ...rows(registry.weight, "weight"),
+            ...rows(registry.z, "z"),
+            ...rows(registry.breakpoint, "media"),
+          ]}
+        />
+      </section>
+    </main>
+  );
+}
 
app/design/_hooks/use-token-registry.ts (added)
+++ app/design/_hooks/use-token-registry.ts
@@ -0,0 +1,45 @@
+import { useSyncExternalStore } from "react";
+import { readTokenRegistry, type TokenRegistry } from "../_lib/read-tokens";
+import { THEME_ATTRIBUTE_NAME } from "@/lib/constants/theme";
+
+// CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다
+// (effect에서 setState 하면 렌더가 연쇄된다).
+//
+// 토큰 **값**은 현재 테마에 따라 달라지므로 `data-theme`이 바뀌면 다시 읽어야 한다.
+// 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를
+// 돌려주면 무한 루프가 된다 — 그래서 테마 키로 캐시해 같은 테마 동안에는 동일한
+// 참조를 반환한다.
+let cached: { theme: string; registry: TokenRegistry } | null = null;
+
+function currentTheme(): string {
+  return document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME) ?? "system";
+}
+
+function subscribe(onChange: () => void): () => void {
+  const observer = new MutationObserver(onChange);
+  observer.observe(document.documentElement, {
+    attributes: true,
+    attributeFilter: [THEME_ATTRIBUTE_NAME],
+  });
+  return () => observer.disconnect();
+}
+
+function getSnapshot(): TokenRegistry | null {
+  const theme = currentTheme();
+  if (!cached || cached.theme !== theme) {
+    cached = { theme, registry: readTokenRegistry() };
+  }
+  return cached.registry;
+}
+
+function getServerSnapshot(): TokenRegistry | null {
+  return null;
+}
+
+/**
+ * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다.
+ * 테마를 바꾸면 그 테마에서 해석된 값으로 갱신된다.
+ */
+export function useTokenRegistry(): TokenRegistry | null {
+  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
 
app/design/_lib/read-tokens.ts (added)
+++ app/design/_lib/read-tokens.ts
@@ -0,0 +1,99 @@
+export interface TokenEntry {
+  /** 토큰 이름 (`--fox-color-primary` → `primary`) */
+  name: string;
+  /**
+   * 현재 테마에서 실제로 해석된 값 (`#818cf8`).
+   *
+   * 선언 원문이 아니라 계산값을 쓴다 — Lightning CSS가 `light-dark()`를
+   * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로
+   * 원문은 읽기 어렵고, 계산값은 지금 테마에서 눈에 보이는 색과 정확히 일치한다.
+   */
+  value: string;
+  /** 스타일에서 참조할 커스텀 프로퍼티 전체 이름 */
+  property: string;
+}
+
+export type TokenRegistry = Record<string, TokenEntry[]>;
+
+const PREFIX = "--fox-";
+const TYPO_FIELDS = ["-size", "-weight", "-line-height", "-letter-spacing"];
+
+/**
+ * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다.
+ *
+ * 토큰 목록을 카탈로그가 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에 값을
+ * 추가하면 카탈로그에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다.
+ * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다.
+ *
+ * 브라우저에서만 동작한다(문서가 있어야 한다).
+ */
+export function readTokenRegistry(): TokenRegistry {
+  const registry: TokenRegistry = {};
+  const seen = new Set<string>();
+  // 이름·순서는 CSSOM 규칙에서, 값은 계산 스타일에서 가져온다.
+  const computed = getComputedStyle(document.documentElement);
+
+  for (const sheet of Array.from(document.styleSheets)) {
+    let rules: CSSRuleList;
+    try {
+      rules = sheet.cssRules;
+    } catch {
+      // 교차 출처 스타일시트는 규칙을 읽을 수 없다 — 우리 토큰은 동일 출처이므로 건너뛴다.
+      continue;
+    }
+
+    for (const rule of Array.from(rules)) {
+      if (!(rule instanceof CSSStyleRule) || rule.selectorText !== ":root") {
+        continue;
+      }
+
+      for (const property of Array.from(rule.style)) {
+        if (!property.startsWith(PREFIX) || seen.has(property)) {
+          continue;
+        }
+        seen.add(property);
+
+        const rest = property.slice(PREFIX.length);
+        const separator = rest.indexOf("-");
+        if (separator <= 0) {
+          continue;
+        }
+
+        const group = rest.slice(0, separator);
+        const name = rest.slice(separator + 1);
+
+        (registry[group] ??= []).push({
+          name,
+          value:
+            computed.getPropertyValue(property).trim() ||
+            rule.style.getPropertyValue(property).trim(),
+          property,
+        });
+      }
+    }
+  }
+
+  return registry;
+}
+
+/**
+ * 타이포 프리셋은 이름 하나가 4개의 커스텀 프로퍼티로 펼쳐져 있으므로
+ * (`--fox-typo-title-md-size` 등) 접미사를 떼어 프리셋 이름만 순서대로 복원한다.
+ */
+export function typoPresetNames(registry: TokenRegistry): string[] {
+  const names: string[] = [];
+
+  for (const entry of registry.typo ?? []) {
+    const field = TYPO_FIELDS.find((suffix) => entry.name.endsWith(suffix));
+    if (!field) {
+      continue;
+    }
+
+    const preset = entry.name.slice(0, -field.length);
+    if (!names.includes(preset)) {
+      names.push(preset);
+    }
+  }
+
+  return names;
+}
 
app/design/design.module.scss (added)
+++ app/design/design.module.scss
@@ -0,0 +1,211 @@
+// 디자인 시스템 카탈로그 스타일.
+//
+// 토큰 목록은 이 파일이 아니라 **CSSOM에서 런타임에 읽는다**(`_components/token-catalog.tsx`).
+// SCSS 값을 JS로 내보내는 CSS Modules의 `:export`를 처음 썼으나, Next.js 16의 기본
+// 번들러인 Turbopack은 Lightning CSS로 CSS를 파싱해 `:export`(webpack css-loader 시절의
+// ICSS 확장)를 인식하지 못한다 — 빌드는 통과하지만 규칙이 통째로 버려져 값이 `undefined`가
+// 된다. `--fox-*` 커스텀 프로퍼티를 CSSOM에서 직접 열거하면 같은 "목록을 두 곳에 두지
+// 않는다"는 목적을 번들러 의존 없이 달성한다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.page {
+  flex: 1;
+  padding: fox.space(8) fox.space(6);
+
+  @include fox.container(xl, 6);
+}
+
+.header {
+  display: flex;
+  flex-wrap: wrap;
+  gap: fox.space(4);
+  align-items: baseline;
+  justify-content: space-between;
+  padding-block-end: fox.space(6);
+  border-block-end: 1px solid fox.color(border);
+}
+
+.title {
+  @include fox.typo(headline-lg);
+}
+
+.lede {
+  margin-block-start: fox.space(2);
+  color: fox.color(foreground-muted);
+
+  @include fox.typo(body-md);
+}
+
+// ── 테마 전환 ───────────────────────────────────────────────────────────────
+.themeSwitch {
+  display: flex;
+  gap: fox.space(1);
+  padding: fox.space(1);
+  border: 1px solid fox.color(border);
+  border-radius: fox.radius(full);
+  background: fox.color(surface-muted);
+}
+
+.themeButton {
+  padding: fox.space(2) fox.space(4);
+  border-radius: fox.radius(full);
+  color: fox.color(foreground-muted);
+
+  @include fox.typo(label-md);
+  @include fox.focus-ring;
+  @include fox.transition((background-color, color));
+
+  &[aria-pressed="true"] {
+    background: fox.color(primary);
+    color: fox.color(on-primary);
+  }
+}
+
+.section {
+  padding-block: fox.space(8);
+  border-block-end: 1px solid fox.color(border-subtle);
+}
+
+.sectionTitle {
+  margin-block-end: fox.space(1);
+
+  @include fox.typo(title-lg);
+}
+
+.sectionNote {
+  margin-block-end: fox.space(5);
+  color: fox.color(foreground-muted);
+
+  @include fox.typo(body-sm);
+}
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
+  gap: fox.space(4);
+}
+
+// ── 색상 ────────────────────────────────────────────────────────────────────
+.swatch {
+  overflow: hidden;
+  border: 1px solid fox.color(border);
+  border-radius: fox.radius(md);
+  background: fox.color(surface);
+}
+
+.swatchChip {
+  block-size: 6.4rem;
+}
+
+.swatchMeta {
+  padding: fox.space(3);
+}
+
+.name {
+  display: block;
+  color: fox.color(foreground);
+  word-break: break-all;
+
+  @include fox.typo(label-lg);
+}
+
+.ref {
+  display: block;
+  margin-block-start: fox.space(1);
+  color: fox.color(foreground-subtle);
+  font-family: fox.font(mono);
+  word-break: break-all;
+
+  @include fox.typo(body-sm);
+}
+
+// ── 타이포 ──────────────────────────────────────────────────────────────────
+.typoRow {
+  display: flex;
+  flex-wrap: wrap;
+  gap: fox.space(2) fox.space(6);
+  align-items: baseline;
+  padding-block: fox.space(3);
+  border-block-end: 1px solid fox.color(border-subtle);
+}
+
+.typoName {
+  flex: 0 0 14rem;
+  color: fox.color(foreground-subtle);
+  font-family: fox.font(mono);
+
+  @include fox.typo(body-sm);
+}
+
+.typoSample {
+  flex: 1;
+  min-inline-size: 20rem;
+}
+
+// ── 간격 ────────────────────────────────────────────────────────────────────
+.spaceRow {
+  display: flex;
+  gap: fox.space(4);
+  align-items: center;
+  padding-block: fox.space(2);
+}
+
+.spaceBar {
+  block-size: 1.6rem;
+  border-radius: fox.radius(xs);
+  background: fox.color(primary);
+}
+
+// ── radius · shadow ─────────────────────────────────────────────────────────
+.tile {
+  display: flex;
+  block-size: 9.6rem;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid fox.color(border);
+  background: fox.color(surface);
+  color: fox.color(foreground-muted);
+
+  @include fox.typo(label-md);
+}
+
+.shadowTile {
+  display: flex;
+  block-size: 9.6rem;
+  align-items: center;
+  justify-content: center;
+  border-radius: fox.radius(lg);
+  background: fox.color(surface);
+  color: fox.color(foreground-muted);
+
+  @include fox.typo(label-md);
+}
+
+.shadowCell {
+  padding: fox.space(4);
+}
+
+// ── 값 테이블 ───────────────────────────────────────────────────────────────
+.table {
+  inline-size: 100%;
+  text-align: start;
+}
+
+.table th,
+.table td {
+  padding: fox.space(2) fox.space(3);
+  border-block-end: 1px solid fox.color(border-subtle);
+}
+
+.table th {
+  color: fox.color(foreground-muted);
+
+  @include fox.typo(label-md);
+}
+
+.table td {
+  font-family: fox.font(mono);
+
+  @include fox.typo(body-sm);
+}
 
app/design/page.tsx (added)
+++ app/design/page.tsx
@@ -0,0 +1,20 @@
+import { notFound } from "next/navigation";
+import { TokenCatalog } from "./_components/token-catalog";
+
+export const metadata = {
+  title: "디자인 시스템 카탈로그",
+};
+
+/**
+ * 디자인 시스템 토큰 카탈로그 — 개발 전용.
+ *
+ * 라우트 자체는 서버에 남기고(프로덕션 차단을 서버에서 판정), 실제 렌더는 CSSOM을 읽어야
+ * 하는 클라이언트 컴포넌트가 맡는다.
+ */
+export default function DesignCatalogPage() {
+  if (process.env.NODE_ENV === "production") {
+    notFound();
+  }
+
+  return <TokenCatalog />;
+}
 
app/globals.css (deleted)
--- app/globals.css
@@ -1,273 +0,0 @@
-@import "tailwindcss";
-
-/* 색상 토큰 출처: edupay-app lib/ui/core/tokens/app_colors.dart
-   (Figma: FOX PAY 1차 제작시안, node 91:10205). 다크 값은 edupay-app에 대응
-   다크 시안이 없어 "임의 도출 — 정식 다크 시안 확정 시 교체".
-   메커니즘: `color-scheme` + `light-dark()`(Chrome 123+/Safari 17.5+/Firefox 120+
-   지원, 미지원 브라우저는 값이 무효화되므로 사내 어드민 한정 트레이드오프로 채택
-   — design 계획 §4.1/§5.3, 2026-07-28 게이트 승인). 각 시맨틱 토큰이
-   `light-dark(라이트값, 다크값)`으로 라이트/다크 값을 한 선언에 함께 보유해
-   드리프트를 구조적으로 차단한다. 수동 전환은 아래 `:root[data-theme]`가
-   `color-scheme`만 덮어써 처리하므로 값 자체는 이 블록 한 곳에만 존재한다
-   (Next.js 16 번들 문서 preventing-flash-before-hydration.md §Themes 패턴). */
-:root {
-  color-scheme: light dark;
-
-  /* Brand */
-  --primary: light-dark(#502ef5, #877aec);
-  --primary-container: light-dark(#6d5ce7, #502ef5);
-  /* on-primary 다크값 대비 보정: 원래 #ffffff였으나 다크 --primary(#877aec) 위에서
-     3.49:1로 WCAG AA(4.5:1) 텍스트 기준 미달 확인 → #14161b로 교체해 5.18:1 통과.
-     Material 3 다크 규약(밝은 primary 표면 + 어두운 onPrimary 텍스트)과 일치하며
-     text-primary on surface(4.61:1)에는 영향 없음 — design 계획 §5.1(i),
-     2026-07-28 게이트 승인. */
-  --on-primary: light-dark(#ffffff, #14161b);
-  --on-primary-container: light-dark(#e2e0fb, #e2e0fb);
-  --secondary: light-dark(#877aec, #9b92ff);
-
-  /* Surface / background (원 토큰: surface / gray0) */
-  --background: light-dark(#ffffff, #14161b);
-  --surface: light-dark(#ffffff, #1e2128);
-  --surface-muted: light-dark(#f5f7fa, #2c3038);
-
-  /* Foreground / text (원 토큰: onSurface 계열) */
-  --foreground: light-dark(#2c3038, #f5f7fa);
-  --foreground-muted: light-dark(#565d6b, #bbc4d8);
-  --foreground-subtle: light-dark(#6b7486, #8790a3);
-
-  /* Border (원 토큰: outline 계열) */
-  --border: light-dark(#dae0ed, #424752);
-  --border-subtle: light-dark(#e1e6f1, #343943);
-
-  /* Status */
-  --danger: light-dark(#ec2e2f, #ff5c5d);
-  --success: light-dark(#21b770, #3acb93);
-  --warning: light-dark(#ffb020, #ffc24d);
-
-  /* Overlay */
-  --scrim: light-dark(rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.7));
-}
-
-/* 수동 테마 선택 — <html data-theme="light"|"dark">가 `color-scheme`만 덮어써
-   위 light-dark() 값이 해당 분기로 고정된다(값 자체는 여기서 재정의하지 않음).
-   data-theme 속성이 없거나 "system"이면 :root의 `color-scheme: light dark`가
-   유지되어 OS `prefers-color-scheme`를 그대로 따른다(feature 전달 사양). */
-:root[data-theme="light"] {
-  color-scheme: light;
-}
-
-:root[data-theme="dark"] {
-  color-scheme: dark;
-}
-
-@theme inline {
-  --color-primary: var(--primary);
-  --color-primary-container: var(--primary-container);
-  --color-on-primary: var(--on-primary);
-  --color-on-primary-container: var(--on-primary-container);
-  --color-secondary: var(--secondary);
-
-  --color-background: var(--background);
-  --color-surface: var(--surface);
-  --color-surface-muted: var(--surface-muted);
-
-  --color-foreground: var(--foreground);
-  --color-foreground-muted: var(--foreground-muted);
-  --color-foreground-subtle: var(--foreground-subtle);
-
-  --color-border: var(--border);
-  --color-border-subtle: var(--border-subtle);
-
-  --color-danger: var(--danger);
-  --color-success: var(--success);
-  --color-warning: var(--warning);
-
-  --color-scrim: var(--scrim);
-
-  /* WantedSans 연결 전에도 안전한 폴백(feature 레인이 app/layout.tsx에서
-     next/font/local로 --font-wanted-sans를 정의). */
-  --font-sans: var(--font-wanted-sans, system-ui), system-ui,
-    "Apple SD Gothic Neo", sans-serif;
-  --font-mono: var(--font-geist-mono);
-
-  /* 타이포 토큰 출처: edupay-app lib/ui/core/tokens/app_typography.dart
-     (Figma: FOX PAY 1차 제작시안, node 112:11439). voucher/numeric 등 앱 전용
-     스타일은 어드민 무관으로 미이식. */
-  --text-display-lg: 3rem;
-  --text-display-lg--line-height: 1.5;
-  --text-display-lg--font-weight: 700;
-
-  --text-display-md: 2.25rem;
-  --text-display-md--line-height: 1.5;
-  --text-display-md--letter-spacing: -0.72px;
-  --text-display-md--font-weight: 700;
-
-  --text-display-sm: 2rem;
-  --text-display-sm--line-height: 1.5;
-  --text-display-sm--letter-spacing: -0.64px;
-  --text-display-sm--font-weight: 700;
-
-  --text-headline-lg: 1.5rem;
-  --text-headline-lg--line-height: 1.5;
-  --text-headline-lg--letter-spacing: -0.72px;
-  --text-headline-lg--font-weight: 700;
-
-  --text-headline-md: 1.25rem;
-  --text-headline-md--line-height: 1.5;
-  --text-headline-md--letter-spacing: -0.6px;
-  --text-headline-md--font-weight: 700;
-
-  --text-headline-sm: 1.125rem;
-  --text-headline-sm--line-height: 1.3;
-  --text-headline-sm--letter-spacing: -0.54px;
-  --text-headline-sm--font-weight: 700;
-
-  --text-title-lg: 1.125rem;
-  --text-title-lg--line-height: 1.5;
-  --text-title-lg--font-weight: 700;
-
-  --text-title-md: 1rem;
-  --text-title-md--line-height: 1.5;
-  --text-title-md--font-weight: 700;
-
-  --text-title-sm: 0.875rem;
-  --text-title-sm--line-height: 1.5;
-  --text-title-sm--letter-spacing: -0.42px;
-  --text-title-sm--font-weight: 700;
-
-  --text-body-lg: 1rem;
-  --text-body-lg--line-height: 1.5;
-  --text-body-lg--letter-spacing: -0.48px;
-  --text-body-lg--font-weight: 400;
-
-  --text-body-md: 0.875rem;
-  --text-body-md--line-height: 1.5;
-  --text-body-md--font-weight: 400;
-
-  --text-body-sm: 0.75rem;
-  --text-body-sm--line-height: 1.5;
-  --text-body-sm--font-weight: 400;
-
-  --text-label-lg: 1rem;
-  --text-label-lg--line-height: 1.4;
-  --text-label-lg--letter-spacing: -0.48px;
-  --text-label-lg--font-weight: 700;
-
-  --text-label-md: 0.875rem;
-  --text-label-md--letter-spacing: -0.28px;
-  --text-label-md--font-weight: 400;
-
-  --text-label-sm: 0.75rem;
-  --text-label-sm--font-weight: 400;
-
-  /* radius 토큰 출처: edupay-app lib/ui/core/tokens/app_radius.dart.
-     Tailwind 기본 스케일과 다른 값으로 의도적 재정의(앱 일체화, §8-4 승인).
-     pill(999)은 기본 rounded-full로 충족, card(50)은 앱 전용 미이식. */
-  --radius-xs: 0.25rem;
-  --radius-sm: 0.5rem;
-  --radius-md: 0.75rem;
-  --radius-lg: 1rem;
-  --radius-xl: 1.25rem;
-  --radius-2xl: 1.5rem;
-
-  /* 그림자 토큰 출처: edupay-app lib/ui/core/tokens/app_shadows.dart —
-     어드민 유효분만 이식(voucher inner·stacked 등 미이식). */
-  --shadow-card: 0 5.16px 5.16px rgb(93 100 119 / 0.12);
-  --shadow-list-card: 0 8px 16px rgb(93 100 119 / 0.12);
-  --shadow-dialog: 0 8px 16px rgb(0 0 0 / 0.1);
-  --shadow-popup: 2.16px 6.32px 6px rgb(177 178 180 / 0.24);
-  --shadow-toast: 0 10px 12px rgb(0 0 0 / 0.2);
-  --shadow-subtle: 0 2px 4px rgb(0 0 0 / 0.08);
-
-  /* 애니메이션 토큰(신규 네임스페이스 — §4.2 승인). 오버레이·다이얼로그·토스트·
-     스플래시 전환에 사용. --ease-*는 Tailwind 기본값 사용, duration은 값에
-     내장(트랜지션 duration 표준은 bare 유틸 duration-200 사용). */
-  --animate-fade-in: fade-in 200ms var(--ease-out);
-  --animate-fade-out: fade-out 200ms var(--ease-in);
-  --animate-dialog-in: dialog-in 200ms var(--ease-out);
-  --animate-toast-in: toast-in 300ms var(--ease-out);
-}
-
-@keyframes fade-in {
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-}
-
-/* 스플래시 등 풀스크린 오버레이 퇴장용 — fade-in의 역방향 키프레임. */
-@keyframes fade-out {
-  from {
-    opacity: 1;
-  }
-  to {
-    opacity: 0;
-  }
-}
-
-@keyframes dialog-in {
-  from {
-    opacity: 0;
-    transform: scale(0.95);
-  }
-  to {
-    opacity: 1;
-    transform: scale(1);
-  }
-}
-
-@keyframes toast-in {
-  from {
-    opacity: 0;
-    transform: translateY(0.5rem);
-  }
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-/* 간격 토큰 매핑 (변경 없음) — edupay-app lib/ui/core/tokens/app_spacing.dart의
-   4/8 grid(4·8·12·16·20·24·32·40)는 Tailwind 기본 --spacing: 0.25rem 스케일과
-   정확히 일치한다: xs→p-1, sm→p-2, md→p-3, lg→p-4, xl→p-5, xxl→p-6,
-   xxxl→p-8, xxxxl→p-10. */
-
-/* 테마 수동 전환 — feature 소비처(예: 헤더 테마 토글)를 위한 CSS 구동 변형자.
-   design 계획 §9 게이트 교차 검증에서 feature 안(아이콘 3개를 모두 DOM에 렌더하고
-   CSS만으로 하나를 표시)을 채택했다 — 서버/클라 출력이 항상 동일해 hydration
-   불일치 표면적이 0이다. `theme-light`/`theme-dark`는 명시적 선택에만 매칭되고,
-   `theme-system`은 data-theme 부재 **및** 명시적 data-theme="system" 값 둘 다
-   커버해야 하므로(둘은 동일하게 동작 — feature 전달 사양) 부정 선택자 두 개로
-   "light도 dark도 아님"을 표현한다(속성 부재 시에도 :not()은 매칭된다). */
-@custom-variant theme-system (&:where(html:not([data-theme="light"]):not([data-theme="dark"]) *));
-@custom-variant theme-light (&:where(html[data-theme="light"] *));
-@custom-variant theme-dark (&:where(html[data-theme="dark"] *));
-
-/* 테마 전환 색상 트랜지션 — feature가 토글 직전 <html>에 [data-theme-transition]을
-   부여하고 ~200ms 후 제거하는 시간 한정 opt-in이다(상시 트랜지션이 아니므로 다른
-   hover/트랜지션 유틸과 경쟁하지 않는다). prefers-reduced-motion: no-preference로
-   게이팅해 감소 모션 환경에서는 아무 트랜지션도 걸리지 않는다(전환 없이 즉시 반영).
-   !important는 유틸리티의 transition 계열 클래스(transition-none 포함)가 이 200ms
-   동안만 존재하는 규칙을 덮어쓰지 않도록 한다. box-shadow는 제외 — --shadow 토큰은
-   테마별로 값이 바뀌지 않아 전환할 대상이 없다. */
-@media (prefers-reduced-motion: no-preference) {
-  html[data-theme-transition],
-  html[data-theme-transition] *,
-  html[data-theme-transition] *::before,
-  html[data-theme-transition] *::after {
-    transition:
-      background-color 200ms var(--ease-out),
-      border-color 200ms var(--ease-out),
-      color 200ms var(--ease-out),
-      fill 200ms var(--ease-out),
-      stroke 200ms var(--ease-out),
-      outline-color 200ms var(--ease-out) !important;
-  }
-}
-
-body {
-  background: var(--background);
-  color: var(--foreground);
-}
 
app/globals.scss (added)
+++ app/globals.scss
@@ -0,0 +1,20 @@
+// 앱 글로벌 스타일 — 디자인 시스템 로드 + **이 앱에만 해당하는** 최소 베이스.
+//
+// 색·타이포·간격 같은 디자인 값은 여기에 두지 않는다. 전부 `@fox/styles/tokens/`가
+// 소유하며, 이 파일은 "@fox를 켜고, 이 앱의 셸 구조를 잡는다"까지만 한다.
+// 화면·컴포넌트 스타일은 각자의 `.module.scss`로 간다.
+
+@use "@fox/styles";
+
+// 앱 셸 — 루트 레이아웃이 뷰포트 높이를 채우고 세로로 쌓이는 구조.
+// 푸터를 바닥에 붙이거나 본문 영역만 스크롤시키는 화면들이 이 전제에 기댄다.
+html,
+body {
+  block-size: 100%;
+}
+
+body {
+  display: flex;
+  flex-direction: column;
+  min-block-size: 100%;
+}
app/layout.tsx
--- app/layout.tsx
+++ app/layout.tsx
@@ -9,7 +9,7 @@
   THEME_ATTRIBUTE_NAME,
   THEME_STORAGE_KEY,
 } from "@/lib/constants/theme";
-import "./globals.css";
+import "./globals.scss";
 
 // 첫 페인트 전 테마 적용 — <head>는 <body>보다 먼저 파싱되므로 <html>만 조작 가능하다
 // (preventing-flash-before-hydration.md의 Themes 패턴). localStorage에 명시 선택("light"
@@ -41,9 +41,11 @@
   SPLASH_SHOWN_STORAGE_KEY
 )}))document.documentElement.setAttribute("data-splash-shown","")}catch(e){}})()`;
 
-// 어드민 전체 기본 서체(edupay-app 일체화, design 전달 사양) — Geist는 대체되어 제거했다.
-// globals.css `--font-sans`가 이 변수를 우선 참조하고, 로드 전에도 system-ui로 안전하게
-// 폴백하도록 이미 구성돼 있다.
+// 어드민 전체 기본 서체. 폰트 파일은 **앱이 소유**하고 디자인 시스템은 참조만 한다 —
+// `@fox`는 `--app-font-sans`를 읽을 뿐이라(@fox/README.md "호스트 앱과의 계약") 폴더를
+// 다른 프로젝트로 복사해도 그 프로젝트의 서체를 그대로 따른다. 변수명이 `--app-*`인 것도
+// 그 계약의 이름이기 때문이며, 미주입 시에도 `system-ui`로 폴백하도록 토큰 쪽에 폴백이
+// 들어 있다.
 const wantedSans = localFont({
   src: [
     {
@@ -67,14 +69,13 @@
       style: "normal",
     },
   ],
-  variable: "--font-wanted-sans",
+  variable: "--app-font-sans",
   display: "swap",
 });
 
-// 코드/숫자 표기용 고정폭 서체 — globals.css `--font-mono`가 여전히 이 변수를 직접 참조하므로
-// (폴백 체인 없음) 유지한다.
+// 코드/숫자 표기용 고정폭 서체 — sans와 동일하게 `--app-font-mono` 계약으로 주입한다.
 const geistMono = Geist_Mono({
-  variable: "--font-geist-mono",
+  variable: "--app-font-mono",
   subsets: ["latin"],
 });
 
@@ -107,14 +108,14 @@
     <html
       lang="ko"
       suppressHydrationWarning
-      className={`${wantedSans.variable} ${geistMono.variable} h-full antialiased`}
+      className={`${wantedSans.variable} ${geistMono.variable}`}
     >
       <head>
         <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
         <style dangerouslySetInnerHTML={{ __html: SPLASH_HIDE_STYLE }} />
         <script dangerouslySetInnerHTML={{ __html: SPLASH_GATE_SCRIPT }} />
       </head>
-      <body className="min-h-full flex flex-col">
+      <body>
         <SplashGate />
         <FeedbackProvider>
           {children}
next.config.ts
--- next.config.ts
+++ next.config.ts
@@ -1,3 +1,4 @@
+import path from "node:path";
 import type { NextConfig } from "next";
 
 // 다층 캐시·SEO 차단(§4)의 응답 헤더 계층. immutable 정적 자산(_next/static, _next/image)은
@@ -19,6 +20,14 @@
 ];
 
 const nextConfig: NextConfig = {
+  // SCSS 로드 경로 — 프로젝트 루트를 Sass의 loadPaths에 넣어 `@use "@fox/styles/abstracts"`
+  // 같은 루트 기준 절대 경로가 해석되게 한다. 이게 없으면 컴포넌트마다 `../../../` 상대
+  // 경로를 써야 해서 @fox 폴더를 다른 프로젝트로 통째로 복사할 수 없다(포터블 요건).
+  // tsconfig의 `@fox/*` alias는 TS/번들러 전용이라 Sass 해석에는 관여하지 않으므로,
+  // 같은 경로를 여기서 한 번 더 선언해야 TS와 SCSS의 import 표기가 일치한다.
+  sassOptions: {
+    loadPaths: [path.join(process.cwd())],
+  },
   async headers() {
     return [
       {
package-lock.json
--- package-lock.json
+++ package-lock.json
@@ -13,27 +13,13 @@
         "react-dom": "19.2.4"
       },
       "devDependencies": {
-        "@tailwindcss/postcss": "^4",
         "@types/node": "^20",
         "@types/react": "^19",
         "@types/react-dom": "^19",
         "eslint": "^9",
         "eslint-config-next": "16.2.12",
-        "tailwindcss": "^4",
+        "sass": "^1.102.0",
         "typescript": "^5"
-      }
-    },
-    "node_modules/@alloc/quick-lru": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
-      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
     "node_modules/@babel/code-frame": {
@@ -1246,6 +1232,308 @@
         "node": ">=12.4.0"
       }
     },
+    "node_modules/@parcel/watcher": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
+      "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^2.0.3",
+        "is-glob": "^4.0.3",
+        "node-addon-api": "^7.0.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher-android-arm64": "2.6.0",
+        "@parcel/watcher-darwin-arm64": "2.6.0",
+        "@parcel/watcher-darwin-x64": "2.6.0",
+        "@parcel/watcher-freebsd-x64": "2.6.0",
+        "@parcel/watcher-linux-arm-glibc": "2.6.0",
+        "@parcel/watcher-linux-arm-musl": "2.6.0",
+        "@parcel/watcher-linux-arm64-glibc": "2.6.0",
+        "@parcel/watcher-linux-arm64-musl": "2.6.0",
+        "@parcel/watcher-linux-x64-glibc": "2.6.0",
+        "@parcel/watcher-linux-x64-musl": "2.6.0",
+        "@parcel/watcher-win32-arm64": "2.6.0",
+        "@parcel/watcher-win32-x64": "2.6.0"
+      }
+    },
+    "node_modules/@parcel/watcher-android-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
+      "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
+      "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
+      "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-freebsd-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
+      "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
+      "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
+      "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
+      "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
+      "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
+      "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
+      "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
+      "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
+      "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher/node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
     "node_modules/@rtsao/scc": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1260,277 +1548,6 @@
       "license": "Apache-2.0",
       "dependencies": {
         "tslib": "^2.8.0"
-      }
-    },
-    "node_modules/@tailwindcss/node": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
-      "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/remapping": "^2.3.5",
-        "enhanced-resolve": "^5.24.1",
-        "jiti": "^2.7.0",
-        "lightningcss": "1.32.0",
-        "magic-string": "^0.30.21",
-        "source-map-js": "^1.2.1",
-        "tailwindcss": "4.3.3"
-      }
-    },
-    "node_modules/@tailwindcss/oxide": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
-      "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 20"
-      },
-      "optionalDependencies": {
-        "@tailwindcss/oxide-android-arm64": "4.3.3",
-        "@tailwindcss/oxide-darwin-arm64": "4.3.3",
-        "@tailwindcss/oxide-darwin-x64": "4.3.3",
-        "@tailwindcss/oxide-freebsd-x64": "4.3.3",
-        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
-        "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
-        "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
-        "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
-        "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
-        "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
-        "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
-        "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-android-arm64": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
-      "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-darwin-arm64": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
-      "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-darwin-x64": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
-      "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-freebsd-x64": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
-      "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
-      "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
-      "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
-      "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
-      "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
-      "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
-      "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
-      "bundleDependencies": [
-        "@napi-rs/wasm-runtime",
-        "@emnapi/core",
-        "@emnapi/runtime",
-        "@tybys/wasm-util",
-        "@emnapi/wasi-threads",
-        "tslib"
-      ],
-      "cpu": [
-        "wasm32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/core": "^1.11.1",
-        "@emnapi/runtime": "^1.11.1",
-        "@emnapi/wasi-threads": "^1.2.2",
-        "@napi-rs/wasm-runtime": "^1.1.4",
-        "@tybys/wasm-util": "^0.10.2",
-        "tslib": "^2.8.1"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
-      "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
-      "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/postcss": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz",
-      "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@alloc/quick-lru": "^5.2.0",
-        "@tailwindcss/node": "4.3.3",
-        "@tailwindcss/oxide": "4.3.3",
-        "postcss": "^8.5.16",
-        "tailwindcss": "4.3.3"
       }
     },
     "node_modules/@tybys/wasm-util": {
@@ -2674,6 +2691,22 @@
         "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
+    "node_modules/chokidar": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+      "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "readdirp": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/client-only": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2862,8 +2895,8 @@
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
       "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-      "devOptional": true,
       "license": "Apache-2.0",
+      "optional": true,
       "engines": {
         "node": ">=8"
       }
@@ -2909,20 +2942,6 @@
       "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
       "dev": true,
       "license": "MIT"
-    },
-    "node_modules/enhanced-resolve": {
-      "version": "5.24.3",
-      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
-      "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "graceful-fs": "^4.2.4",
-        "tapable": "^2.3.3"
-      },
-      "engines": {
-        "node": ">=10.13.0"
-      }
     },
     "node_modules/es-abstract": {
       "version": "1.24.2",
@@ -3883,13 +3902,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/graceful-fs": {
-      "version": "4.2.11",
-      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
-      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
-      "dev": true,
-      "license": "ISC"
-    },
     "node_modules/has-bigints": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4010,6 +4022,13 @@
       "engines": {
         "node": ">= 4"
       }
+    },
+    "node_modules/immutable": {
+      "version": "5.1.9",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
+      "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
+      "devOptional": true,
+      "license": "MIT"
     },
     "node_modules/import-fresh": {
       "version": "3.3.1",
@@ -4522,6 +4541,8 @@
       "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
       "dev": true,
       "license": "MIT",
+      "optional": true,
+      "peer": true,
       "bin": {
         "jiti": "lib/jiti-cli.mjs"
       }
@@ -4663,267 +4684,6 @@
         "node": ">= 0.8.0"
       }
     },
-    "node_modules/lightningcss": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
-      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
-      "dev": true,
-      "license": "MPL-2.0",
-      "dependencies": {
-        "detect-libc": "^2.0.3"
-      },
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      },
-      "optionalDependencies": {
-        "lightningcss-android-arm64": "1.32.0",
-        "lightningcss-darwin-arm64": "1.32.0",
-        "lightningcss-darwin-x64": "1.32.0",
-        "lightningcss-freebsd-x64": "1.32.0",
-        "lightningcss-linux-arm-gnueabihf": "1.32.0",
-        "lightningcss-linux-arm64-gnu": "1.32.0",
-        "lightningcss-linux-arm64-musl": "1.32.0",
-        "lightningcss-linux-x64-gnu": "1.32.0",
-        "lightningcss-linux-x64-musl": "1.32.0",
-        "lightningcss-win32-arm64-msvc": "1.32.0",
-        "lightningcss-win32-x64-msvc": "1.32.0"
-      }
-    },
-    "node_modules/lightningcss-android-arm64": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
-      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-darwin-arm64": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
-      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-darwin-x64": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
-      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-freebsd-x64": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
-      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-arm-gnueabihf": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
-      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-arm64-gnu": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
-      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-arm64-musl": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
-      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-x64-gnu": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
-      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-linux-x64-musl": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
-      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-win32-arm64-msvc": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
-      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
-    "node_modules/lightningcss-win32-x64-msvc": {
-      "version": "1.32.0",
-      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
-      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MPL-2.0",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">= 12.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/parcel"
-      }
-    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4968,16 +4728,6 @@
       "license": "ISC",
       "dependencies": {
         "yallist": "^3.0.2"
-      }
-    },
-    "node_modules/magic-string": {
-      "version": "0.30.21",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
-      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
     "node_modules/math-intrinsics": {
@@ -5165,6 +4915,14 @@
       "engines": {
         "node": "^10 || ^12 || >=14"
       }
+    },
+    "node_modules/node-addon-api": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
     },
     "node_modules/node-exports-info": {
       "version": "1.6.2",
@@ -5456,35 +5214,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/postcss": {
-      "version": "8.5.23",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
-      "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/postcss"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "nanoid": "^3.3.16",
-        "picocolors": "^1.1.1",
-        "source-map-js": "^1.2.1"
-      },
-      "engines": {
-        "node": "^10 || ^12 || >=14"
-      }
-    },
     "node_modules/prelude-ls": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5565,6 +5294,20 @@
       "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
       "dev": true,
       "license": "MIT"
+    },
+    "node_modules/readdirp": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
+      "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
+      "devOptional": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
+      }
     },
     "node_modules/reflect.getprototypeof": {
       "version": "1.0.10",
@@ -5742,6 +5485,27 @@
       },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/sass": {
+      "version": "1.102.0",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.102.0.tgz",
+      "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "chokidar": "^5.0.0",
+        "immutable": "^5.1.5",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher": "^2.4.1"
       }
     },
     "node_modules/scheduler": {
@@ -6180,27 +5944,6 @@
       },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/tailwindcss": {
-      "version": "4.3.3",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
-      "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/tapable": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
-      "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/webpack"
       }
     },
     "node_modules/tinyglobby": {
package.json
--- package.json
+++ package.json
@@ -14,13 +14,12 @@
     "react-dom": "19.2.4"
   },
   "devDependencies": {
-    "@tailwindcss/postcss": "^4",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "eslint": "^9",
     "eslint-config-next": "16.2.12",
-    "tailwindcss": "^4",
+    "sass": "^1.102.0",
     "typescript": "^5"
   }
 }
 
postcss.config.mjs (deleted)
--- postcss.config.mjs
@@ -1,7 +0,0 @@
-const config = {
-  plugins: {
-    "@tailwindcss/postcss": {},
-  },
-};
-
-export default config;
proxy.ts
--- proxy.ts
+++ proxy.ts
@@ -16,12 +16,32 @@
  */
 const PUBLIC_PATHS = ['/login'];
 
+/**
+ * 개발 환경에서만 인증을 면제하는 경로. 디자인 시스템 카탈로그(`/design`)는 개발 전용
+ * 라우트이며 프로덕션에서는 페이지 자체가 `notFound()`로 404를 낸다 — 즉 여기서 열어도
+ * 프로덕션 노출 경로가 생기지 않는다. 백엔드나 세션 없이도 토큰을 확인할 수 있게 한다.
+ *
+ * fail-closed 원칙은 유지된다: 이 목록은 개발 빌드에서만 평가되고, 프로덕션 빌드에서는
+ * `PUBLIC_PATHS`만 남는다.
+ */
+const DEV_ONLY_PUBLIC_PATHS = ['/design'];
+
+function isPublicPath(pathname: string): boolean {
+  if (PUBLIC_PATHS.includes(pathname)) {
+    return true;
+  }
+
+  return (
+    process.env.NODE_ENV !== 'production' &&
+    DEV_ONLY_PUBLIC_PATHS.includes(pathname)
+  );
+}
+
 export function proxy(request: NextRequest) {
   const { pathname } = request.nextUrl;
-  const isPublicPath = PUBLIC_PATHS.includes(pathname);
   const hasSessionCookie = request.cookies.has(SESSION_COOKIE_NAME);
 
-  if (!isPublicPath && !hasSessionCookie) {
+  if (!isPublicPath(pathname) && !hasSessionCookie) {
     return NextResponse.redirect(new URL('/login', request.url));
   }
 
tsconfig.json
--- tsconfig.json
+++ tsconfig.json
@@ -19,7 +19,8 @@
       }
     ],
     "paths": {
-      "@/*": ["./*"]
+      "@/*": ["./*"],
+      "@fox/*": ["./@fox/*"]
     }
   },
   "include": [
Add a comment
List