# 코드 리뷰 — refer_landing_page ("Issue 01" 디자인 시스템 적용)

> 리뷰 대상: `main` 브랜치, `893dd91` (Initial commit) 이후의 working-tree 변경분
> (`git status` 기준 12 modified + 9 untracked files, +1,092 / -262 lines, 5개 라우트 + 5개 신규 컴포넌트 + 디자인 문서 1건)
> 검증 방법: `git diff` 정독, `npx tsc --noEmit` (clean), `npm run build` (clean, 8/8 정적 페이지), 디자인 정합성/HTML 유효성/접근성 정적 분석
> 작성일: 2026-06-16 (1차) · **재검증: 2026-06-17 (2차)**

> ### ⚠️ 2차 재검증 노트 (2026-06-17)
> 1차 리뷰(2026-06-16) **이후 코드가 일부 수정**되었습니다. 1차의 P0/P1 중 여러 건이 이미 working tree에 반영되어, 아래 본문의 일부 항목은 **stale(이미 해결)** 상태입니다. 2차 재검증 결과 상태를 갱신하고, 1차가 놓친 **신규 버그 1건(`display-sm` 클래스 미정의 — §3 신규 A)** 을 추가했습니다.
>
> | 항목 | 1차 상태 | 2차 현재 상태 |
> |---|---|---|
> | §3 #1 publications `<ol>`>`<div>`>`<li>` | 🟠 미해결 | ✅ **해결됨** (`as="li"` 적용, `publications/page.tsx:133`) |
> | §4 P1 #4 Marquee `w-full max-w-full` | 미반영 | ✅ **해결됨** (`Marquee.tsx:22`) |
> | §3 medium·P1 #3 HeroComposition 텍스트 겹침 | 🟡 미해결 | ✅ **해소됨** (주석을 상단 모서리 y=24로 이동) |
> | §4 P0 #2 Next.js 14.2.5 보안 | 🟠 미해결 | 🟠 **여전히 미해결** — 잔존 P0 |
> | **신규 A** `display-sm` 클래스 미정의 | — | 🔴 **신규 High (1차 누락)** |
> | **신규 B** `Counter` rAF unmount 미취소 | — | 🟡 **신규 Medium (1차 누락)** |

---

## 1. 개요 (Overview)

이번 변경은 "한 권의 인쇄 매거지(Issue 01)" 컨셉의 **에디토리얼 디자인 시스템**을 처음부터 적용한 대규모 시각·구조 리뉴얼입니다. 단순한 클래스 리네임이 아니라 **레이아웃 언어·타이포그래피·모션·테마 토큰 전체**를 재설계했습니다.

핵심 변화:

- **테마 토큰 재설계**: `brand`(KNU 블루) / `accent` 단일 톤 → `ink` / `ivory` / `paper` / `line` + 두 개의 에디토리얼 강조색 `vermillion`(MCM) · `cobalt`(QUIC) 시스템으로 교체. `tailwind.config.ts` 와 `app/globals.css` `:root` 양쪽에 미러링되어 있어 토큰 일관성 보장.
- **타이포그래피 교체**: 시스템 sans → Fraunces(영문 디스플레이 세리프) + Noto Serif KR(한글 세리프 헤드라인) + Inter(본문) 3-페어. `next/font`로 CSS 변수 주입.
- **신규 모션·UI 프리미티브 4종**: `Reveal`(IntersectionObserver 기반 스크롤 등장), `Counter`(rAF 카운트업, `prefers-reduced-motion` 존중), `Marquee`(CSS 키프레임 러닝 티커), `SectionLabel`("01 / 05" 스프레드 넘버링).
- **페이지 단위 전면 재구성**: 5개 라우트 모두 cover spread → figures → 메인 콘텐츠 → 보조 섹션의 잡지식 레이아웃으로 재작성. 카운터, 마키, 섹션 라벨이 페이지마다 다른 의미로 재사용됨.
- **디자인 문서 정비**: `docs/DESIGN.md` 정본 보강, `docs/LAYOUT_AUDIT.md` 17개 이슈 카탈로그(이번 변경으로 일부 해결), `docs/TYPOGRAPHY_FIXES.md` 작업 노트 추가.
- **문서**: `README.md`에 디자인 시스템 섹션·커스터마이징 훅 5종(색·폰트·모션·콘텐츠·공유 컴포넌트) 추가.

기술적으로 **빌드/lint/타입 체크 모두 통과**합니다 (`npm run build` → 8/8 정적 페이지 생성, 87.1 KB shared first-load JS, no warnings).

### 이전에 카탈로그화된 이슈 해결 현황

`docs/LAYOUT_AUDIT.md` 17개 이슈 중 이번 diff에서 **다수가 해결**되었습니다 (해결 항목 8, 잔존 5, 미해결 4, 검증 후 OK 3):

| # | 이슈 | 상태 |
|---|------|------|
| 1 | `border-current/*` opacity modifier → fixed gray | ✅ 해결 (`border-ink/20` 또는 `border-vermillion/30`로 교체) |
| 2 | HeroComposition `transformOrigin` 박스 미스매치 | ✅ 해결 (인라인 `transformBox: "fill-box"` 추가) |
| 3 | HeroComposition 우하단 텍스트와 4번 스트림 충돌 | ✅ 해결 (text `y` 356 → 334로 이동) |
| 5 | Footer `mt-24` × `<main flex-1>` 이중 여백 | ✅ 해결 (`mt-0 lg:mt-8`) |
| 6 | PageHeader `<h1>` 한국어 단어 분리 | ✅ 해결 (`break-keep` + `text-balance` 추가) |
| 7 | "Standards" 9rem 캡 오버플로 | ✅ 해결 (`display` 캡 9rem → 7rem) |
| 8 | Members 3그룹 2명 → 3-col ghost cell | ✅ 해결 (`lg:grid-cols-2`로 통일) |
| 9 | `.reveal` no-JS 폴백 부재 | ✅ 해결 (`<noscript><style>` 추가) |
| 10 | PageHeader `display` 미지정 시 `en` 풀사이즈 | ⚠️ **부분 무효** — `display ?? en` 분기는 맞으나 폴백이 의존하는 `display-sm` 클래스가 **미정의**라 의도된 3.75rem이 아닌 ~16px로 렌더됨. **신규 A 참조** |
| 11 | Counter "2026" 0→2026 카운트업 | ⚠️ **부분 무효** — `static` prop은 정상이나 `display-sm` 스케일이 **미적용**(~16px). **신규 A 참조** |
| 17 | `corner-label` 14px → 16px | ✅ 해결 (`text-sm` → `text-base`) |

---

## 2. 변경된 파일 (Files changed)

| 파일 | 변경 종류 | 설명 |
|---|---|---|
| `tailwind.config.ts` | modified | 컬러 토큰 전체 교체(ink/ivory/paper/line/vermillion/cobalt, brand는 back-compat 별칭), 폰트 패밀리 3종, `display`/`display-sm` 매거진 스케일, marquee/stream-dash/node-pulse 5종 keyframes·animation 추가. |
| `app/globals.css` | modified | `:root` CSS 변수 미러, 종이 그레인 배경, `::selection` 강조, `.display`/`.headline-ko`/`.kicker`/`.corner-label`/`.pull-quote`/`.rule`/`.spread-card`/`.draw-underline` 에디토리얼 프리미티브, `.reveal` 4종 variant, `.marquee-track`, `prefers-reduced-motion` 폴백 추가. 기존 `.card`/`.section-title`/`.section-subtitle` 제거(어디서도 미사용). |
| `app/layout.tsx` | modified | `next/font`로 3개 폰트 로드 + CSS 변수 주입, `<html className>`에 변수 부착, `<noscript>` 폴백. |
| `components/Header.tsx` | modified | 데스크탑 nav에 01–05 섹션 번호 + 영문 라벨 + 활성 상태 애니언더라인, 모바일 햄버거 아이콘 6×6→5×5 + 테두리 토글, 이슈 스트립 ("Issue 01 — 2026"), sticky ivory 배경. |
| `components/Footer.tsx` | modified | 3-col 단순 링크 → 5-col 잡지 콜로폰(colophon/contact/index), `<Marquee>` 콜로폰 티커, 라벨별 인덱스 01–05, `draw-underline` 호버 효과, 세리프 산세리프if credit. |
| `components/PageHeader.tsx` | modified | 그라데이션 히어로 → ivory 잡지 마스트헤드, `<SectionLabel>`+`<Reveal>` 조합, 한국어 serif `headline-ko` + 영어 `display` 워드 + 우측 보더라인 데스크립션, `display` 폴백 안전장치. |
| `app/intro/page.tsx` | modified | Cover spread(헤드라인 + HeroComposition SVG) + 카운터 행(연구 축/표준화 기구/Issue) + 미션 풀쿼트 + 두 추력 비교 카드(vermillion × cobalt 연결 라인) + Focus Areas 4-col 그리드. `thrusts` 데이터에 `no`/`tag`/`accent` 필드 추가, 키워드/figures 데이터 추가. |
| `app/members/page.tsx` | modified | 카운터 3종(지도교수/대학원/학부) + advisor 7+5 콜룸 + research groups 3그룹(vermillion/cobalt/ink 액센트). 3번째 그룹(학부) 2명 → `sm:grid-cols-2`로 통일. |
| `app/publications/page.tsx` | modified | Venue `<Marquee>` + 카운터 3종(전체/저널/학회) + 잡지식 출판물 리스트(번호·연도·타입 좌측 rail + 제목·저자·venue 우측 본문), `Journal` → vermillion, `Conference` → cobalt. |
| `app/standardization/page.tsx` | modified | Org `<Marquee>` + 카운터 3종(표준화 기구/기여 항목/기술 영역) + 4개 표준화 기구 카드(oneM2M/W3C WoT/IETF QUIC/OMA LwM2M), 기구별 accent(vermillion/cobalt 교차) + 적응형 보더. |
| `app/lectures/page.tsx` | modified | 강의 3종을 3-col 카운터-스타일 카드로 재구성, 강의별 accent(vermillion/cobalt/ink), 우상단 01–03 spread 번호, 호버 시 accent 배경 + ivory 텍스트. |
| `README.md` | modified | 디자인 시스템 섹션, 컴포넌트 트리 보강(SectionLabel/Reveal/Counter/Marquee 추가), 커스터마이징 훅 5종(색·폰트·모션·콘텐츠·공유 컴포넌트) 상세화. |
| **신규** `components/Reveal.tsx` | added (60 lines) | `IntersectionObserver` 기반 스크롤 등장, `variant: up|left|right|scale`, `delay` ms, `as` prop으로 태그 오버라이드 가능. cleanup에서 `io.disconnect()`. |
| **신규** `components/Counter.tsx` | added (78 lines) | rAF 카운트업 (`easeOutExpo`), `prefers-reduced-motion` 존중 시 즉시 렌더, `static` prop으로 years/고정값 처리. |
| **신규** `components/Marquee.tsx` | added (41 lines) | 콘텐츠를 2번 복제해 `-50%` 트랜슬레이트 루프, `reverse` prop으로 방향 반전, ✦ 세퍼레이터. |
| **신규** `components/SectionLabel.tsx` | added (28 lines) | "01 / 05" 스프레드 넘버 + hairline + kicker. |
| **신규** `app/intro/_components/HeroComposition.tsx` | added (116 lines) | 인라인 SVG 에디토리얼 일러스트, MCM 노드 메시(vermillion) + QUIC 멀티스트림(cobalt), `node-pulse`·`stream-dash` CSS 애니메이션. |
| **신규** `docs/DESIGN.md` (17,872 B) | added | 디자인 시스템 정본(토큰·타이포·모션·컴포넌트 4종). |
| **신규** `docs/LAYOUT_AUDIT.md` (22,936 B) | added | 17개 이슈 카탈로그(이번 리뷰 시점 기준 11개 해결). |
| **신규** `docs/TYPOGRAPHY_FIXES.md` (4,322 B) | added | 타이포그래피 작업 노트. |
| **신규** `.antigravity-session.md`, `.kanban-*.md` (4 files) | added | Antigravity CLI 세션 / 카반 작업 산출물 (리뷰 대상 아님, 무시). |
| **신규** `package-lock.json` | added | 의존성 잠금 파일 (기존 `package.json`은 변경 없음 — dev install로 추정, 커밋 대상 검토 필요, §3 참조). |

---

## 3. 발견된 이슈 (Issues)

### 🔴 신규 A (High) — `display-sm` 클래스가 정의되어 있지 않아 매거진 디스플레이 텍스트가 ~16px로 축소 렌더 *(1차 누락)*

**위치:** `components/PageHeader.tsx:43`, `app/intro/page.tsx:53` (+ `Counter` 경유)

`display-sm`은 `tailwind.config.ts:77`에서 **`fontSize` 키**로 정의되어 있습니다. Tailwind의 `fontSize` 키는 `text-<key>` 유틸리티만 생성하므로 실제로 만들어지는 클래스는 `text-display-sm`이고, **맨(bare) `display-sm` 클래스는 생성되지 않습니다.** 한편 `app/globals.css`에는 `.display` 컴포넌트 클래스만 있고 `.display-sm` 컴포넌트 클래스는 없습니다.

검증 (컴파일된 CSS 기준):

```bash
$ grep -o "display-sm" .next/static/css/app/layout.css | wc -l
0          # ← display-sm 규칙이 전혀 없음
$ grep -o "\.display"  .next/static/css/app/layout.css | wc -l
1          # ← .display(컴포넌트 클래스)는 존재
```

따라서 `className="display-sm ..."`은 **아무 폰트 크기도 적용하지 않는 no-op 클래스**이고, 해당 텍스트는 브라우저 기본 크기(~16px)로 렌더됩니다.

**영향:**

- `PageHeader`에 `display` prop을 넘기지 않으면 fallback `en` 단어가 `display-sm`으로 렌더됩니다. **현재 `PageHeader`를 사용하는 4개 페이지(members/publications/lectures/standardization) 모두 `display`를 넘기지 않으므로**, "Members" · "Publications" · "Lectures" · "Standardization" 영문 디스플레이 단어가 의도된 `clamp(2rem, 5.5vw, 3.75rem)`이 아니라 **~16px 작은 vermillion 세리프**로 표시됩니다 — 마스트헤드의 큰 영문 단어가 사실상 사라집니다.
- `app/intro/page.tsx:53`의 연도 "2026" 피겨는 `size: "display-sm"`이라 `Counter`가 `display-sm`으로 렌더 → **~16px**. 같은 행의 "2" · "4"는 `display`(최대 7rem)라 **극단적인 크기 불일치**로 피겨 행이 깨져 보입니다.
- 이 버그는 본 문서 §1 LAYOUT_AUDIT 표의 #10/#11(및 #6/#7 일부) "해결" 주장을 **무효화**합니다.

**왜 1차에서 못 잡았나 / build가 clean한 이유:** Tailwind/PostCSS는 **미정의 유틸리티 클래스를 에러 없이 조용히 무시**합니다. 따라서 `npx tsc --noEmit`도 `npm run build`도 통과하며, "build clean"이 **시각적 정합성을 보장하지 않습니다.**

**권장 수정 (택1):**

```css
/* (A안, 권장) app/globals.css 컴포넌트 레이어에 .display-sm 추가 */
.display-sm { @apply font-display text-display-sm font-light; }
/* → 추가 시 PageHeader의 중복 `font-display font-light`는 제거 가능 */
```

또는 사용처를 `display-sm` → `text-display-sm`으로 바꾸되, 폰트 패밀리/굵기 클래스(`font-display font-light`)를 함께 명시 (Counter 사용처는 현재 폰트 클래스가 없으므로 누락 주의).

### 🟡 신규 B (Medium) — `Counter`의 `requestAnimationFrame` 루프가 unmount 시 취소되지 않음 *(1차 누락)*

**위치:** `components/Counter.tsx:52-69`

```tsx
const io = new IntersectionObserver(([entry]) => {
  ...
  let raf = 0;
  const tick = (now) => { ...; if (t < 1) raf = requestAnimationFrame(tick); };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);   // ← IO 콜백의 반환값 → 아무 데도 안 쓰임
}, { threshold: 0.4 });
io.observe(el);
return () => io.disconnect();                // ← useEffect cleanup: 옵저버만 끊고 rAF는 안 멈춤
```

`return () => cancelAnimationFrame(raf)`는 **IntersectionObserver 콜백의 반환값**이라 호출되지 않습니다. `useEffect`의 cleanup은 `io.disconnect()`만 수행하므로, **진행 중인 rAF 카운트업 루프는 중단되지 않습니다.** 카운트업 도중 컴포넌트가 언마운트되면(예: 빠른 라우트 전환) rAF가 언마운트된 컴포넌트에 `setDisplay`를 계속 호출합니다. React 18은 이 setState를 경고 없이 no-op 처리하므로 **실 피해는 경미(낭비 프레임 수 개)**하지만, 명백한 누수입니다.

**권장:** `raf` 변수를 `useEffect` 스코프로 끌어올려 cleanup에서 `cancelAnimationFrame(raf)`을 호출. 또는 `isStatic`/완료 플래그로 보호.

---

### ✅ ~~🟠 High — 잘못된 HTML 구조: `<div>`가 `<ol>`/`<ul>` 안에서 `<li>`를 감쌈~~ — **[2026-06-17 해결됨]**

> **2차 재검증:** 현재 코드는 `app/publications/page.tsx:133`에서 `<Reveal as="li" ...>`로 렌더되며 주석에 본 항목(`REVIEW.md §3 #1`)을 인용해 둠. `<ol>`의 직접 자식 계약(`<li>`만 허용)을 준수하므로 **해결**. 아래 설명은 이력 보존용.

**위치:** `app/publications/page.tsx:131-160`

```tsx
<ol className="mt-12 border-t border-ink">
  {publications.map((p, i) => (
    <Reveal key={p.title} delay={(i % 3) * 80}>     {/* default as="div" */}
      <li className="group grid gap-6 border-b border-ink py-8 ...">
        ...
      </li>
    </Reveal>
  ))}
</ol>
```

`Reveal`은 기본 `as: "div"`로 렌더링됩니다. 결과 DOM은 `<ol><div class="reveal"><li>...</li></div></ol>`이 되어 HTML 명세 위반입니다([spec](https://html.spec.whatwg.org/multipage/grouping-content.html#the-ol-element): `<ol>`의 자식은 0개 이상의 `<li>`/`<script>`/`<template>`만 허용). React는 콘솔 경고를 띄우지 않지만, 자동복구로 DOM이 재정렬될 수 있고(`<li>`가 `<ol>` 밖으로 이동), `<ol>`의 `start`/`type` 카운터 의미가 깨질 수 있습니다.

나머지 페이지(lectures/standardization/members/intro)는 모두 `<Reveal>` 안에서 `<article>`을 감싸므로 안전합니다. 오직 publications만 `<li>`를 직접 감쌉니다.

**권장 수정:**

```tsx
<Reveal as="li" key={p.title} delay={(i % 3) * 80}
        className="group grid gap-6 border-b border-ink py-8 ...">
  ...
</Reveal>
```

### 🟠 High — `npm audit` 1 critical + 6 high 보안 권고 미반영

**위치:** `package.json` / `package-lock.json` (Next.js `14.2.5`)

`.kanban-final-report.md` §2에 따르면 의존성에 8개 권고(1 critical, 6 high, 1 moderate)가 있습니다. 핵심은 `next@14.2.5` → `14.2.35` 패치 한 줄로 21개가 해결됩니다(서버 컴포넌트 DoS, 캐시 포이즈닝, 미들웨어 SSRF, PostCSS XSS 등). 호환성: 같은 14.x 라인, React 18 락도 그대로.

**권장 수정:**

```bash
npm install next@14.2.35
npm audit
```

### 🟡 Medium — `package-lock.json`이 untracked 상태로 추가됨

**위치:** working tree 루트

`package.json`은 diff에 없는데 `package-lock.json`이 untracked로 들어왔습니다. 이전 커밋(`893dd91`)에도 lockfile이 없었다면 이번에 처음 도입된 것인데, 그 경우:

- **의도된 커밋**이면 그대로 두고, 향후 `package.json` 변경 시 함께 업데이트되는지 CI 또는 가이드로 명시.
- **무심코 추가된 것**이면(예: 로컬 `npm install` 부산물) `.gitignore`로 옮기는 것을 권장. 단, 215 KB인 lockfile을 추적하면 재현 가능한 빌드가 보장되므로 보통은 추적하는 편이 낫습니다.

**권장 결정:** lockfile 추적 유지(재현 가능한 빌드의 정석). 단, 의도가 불분명하면 `git log --diff-filter=A -- package-lock.json`으로 첫 등장 시점 확인.

### 🟡 Medium — `Reveal`의 IntersectionObserver cleanup 시점: `disconnect`이 unmount 시점에만 실행

**위치:** `components/Reveal.tsx:47`

```tsx
useEffect(() => {
  ...
  io.observe(el);
  return () => io.disconnect();
}, []);
```

`unobserve`가 첫 intersection 시점에 호출되어(entry isIntersecting → unobserve, line 39) 이후 콜백이 다시 안 불리지만, dependency array가 `[]`라서 `variant`/`delay` prop이 바뀌어도 옵저버가 재생성되지 않습니다. 현재 `variant`는 CSS attribute로 적용되므로 prop 변경이 옵저버 동작에는 영향이 없어 실 문제는 없지만, **마운트 이후 prop만 바뀌는 경우 stale observer**가 됩니다(미세).

**권장:** `useEffect` deps에 `[variant, delay]`를 추가하거나, `entry.target`의 `data-variant` 속성을 read해서 비교. 다만 현재 사용 패턴(컴포넌트가 한 번 마운트되면 prop이 안 바뀌는 정적 리스트)에서는 영향 없음 — **minor**.

### 🟡 Medium — `Reveal as` prop 시그니처가 런타임 안전성을 보장하지 않음

**위치:** `components/Reveal.tsx:19`, `26`

`as?: ElementType`이라 `<Reveal as="ol">`처럼 잘못된 부모(예: `ol` 안의 `ol` 금지)나, `<Reveal as="li">`를 잊고 wrapper를 list 안에 넣는 사용을 막을 수 없습니다. #1의 문제가 발생한 이유도 이 가드 부재입니다. 호출 컨벤션으로 잡혀 있지만(모든 페이지가 `<Reveal>` 안에 article/div를 둠), 실수 방지 차원에서 **사용 시 `li`/`ol`/`ul` 컨텍스트일 땐 `as`를 강제하는 lint 룰(예: eslint-plugin-jsx-a11y custom rule) 또는 README에 "list 내부에서 쓸 땐 `as=\"li\"`" 규칙**을 명시하면 좋겠습니다.

### 🟡 Medium — `Marquee`의 트랙이 `flex` + 콘텐츠가 `inline-flex`인데, width가 콘텐츠에 맞춰져 viewBox/parent overflow 계산이 viewport에 의존

**위치:** `components/Marquee.tsx:22-38`

`<div className="marquee-track">` 안의 시퀀스가 `[0, 1].map(dup)`로 2번 복제됩니다. CSS는 `translateX(0 → -50%)`로 50% 이동 후 같은 위치로 와서 seamless loop. **하지만 `marquee-track`이 `inline-flex`이고 콘텐츠가 `flex`인 점이 데스크탑에서만 동작**합니다 — 부모에 `overflow-hidden`은 있으니 화면이 줄어들면 콘텐츠가 부모 너비에 의해 클립되지만, **부모 width가 명시되지 않은 경우(예: `<Reveal>`의 wrapper)** `inline-flex`는 콘텐츠 폭만큼 자라서 페이지를 가로로 스크롤 가능하게 만들 수 있습니다. 현재는 모두 `container-content` 안에 있어 안전하지만, 다른 곳에서 단독 사용 시 가로 스크롤이 생길 수 있습니다.

**권장:** `Marquee` 최상위에 `max-w-full` 또는 `w-full`을 추가하고, 부모 wrapper에서도 가로 클립이 보장되는지 명시.

### ✅ ~~🟡 Medium — HeroComposition 4번 스트림이 우측 모서리 가까이 통과, 우하단 텍스트와의 간격이 6–10px~~ — **[2026-06-17 해소됨]**

> **2차 재검증:** 현재 코드는 주석(annotation) 텍스트를 **상단 두 모서리(y=24)** 로 이동했습니다(`HeroComposition.tsx:104,107`). 스트림(y≥250)·노드 메시(y=60~240)와 수직으로 분리되어 겹침이 구조적으로 제거되었습니다. 1차의 `y=334` 우하단 배치 가정은 더 이상 유효하지 않습니다. 아래 설명은 이력 보존용.

**위치:** `app/intro/_components/HeroComposition.tsx:99-113`

LAYOUT_AUDIT #3은 `y=356 → 334`로 이동하여 해결되었지만, 스트림 곡선의 마지막 control point는 `360, 320` (i=3) 입니다. 텍스트 baseline은 `y=334`이고 폰트 size 11 + letterSpacing 2이므로 텍스트 상단 ~y=323, 하단 ~y=336. 텍스트의 우측 끝(`x=360, textAnchor="end"`)은 x=360에 anchor되므로 가로 폭이 좁지는 않지만, **스트림 4번은 x=240~360 / y=300~340 대역**을 지나가며 텍스트의 시작점(좌측)에 근접합니다. 텍스트 내용("QUIC · STREAMS")이 ~100px 폭이라면 우측 x≈260~360에 그려지는데, 4번 스트림의 끝점 (360, 320)이 텍스트의 (260, 334) 근처를 지나갑니다.

해결된 것으로 보이지만 실제로는 텍스트가 스트림 곡선과 **여전히 시각적으로 겹칠 수 있는** 좁은 마진입니다. `y=320` 정도 또는 `y=348` 정도로 더 멀리 두는 편이 안전합니다.

### 🟡 Medium — `text-balance` + `break-keep`이 한국어에 대해 100% 안정적이지 않음

**위치:** `components/PageHeader.tsx:32`

```tsx
<h1 className="headline-ko break-keep text-balance text-3xl leading-[1.05] text-ink sm:text-4xl lg:text-5xl">
```

- `text-balance` (CSS `text-wrap: balance`)는 2024년 기준 Chrome 114+ / Safari 17.5+ / Firefox 121+에서 지원. **지원하지 않는 브라우저에서는 무시되며 fallback 없음** — 큰 문제 아님.
- `break-keep` (`word-break: keep-all`)은 한국어/일본어/중국어 텍스트에서 단어 경계로 줄바꿈을 강제. 한국어는 공백이 거의 없어서 효과가 제한적입니다. `"멀티에이전트 오케스트레이션"` 같이 공백이 있는 제목은 효과가 있고, `"사물인터넷표준연구실"` 같이 공백이 없는 경우 여전히 글자 단위로 깨질 수 있습니다.

**권장:** 한 줄이 너무 길어질 가능성이 있는 한국어 헤드라인은 강제로 `<br />`을 박거나 `whitespace-pre-line` + 줄바꿈 문자(`\n`)를 사용.

### 🟡 Medium — `Reveal`로 감싼 `<div>`/`<article>` 안에 있는 `<a>` 링크가 reveal 애니메이션과 충돌할 가능성

`Reveal`은 `opacity: 0` → 1로 페이드 인하는데, `prefers-reduced-motion`이 설정되지 않은 환경에서 사용자가 링크를 빠르게 따라가려 할 때(약 0.8s transition + delay) **클릭 타깃이 시각적으로 보이지만 실제 클릭 가능 시점이 늦어지는** 사각지대가 생깁니다. IntersectionObserver의 `threshold: 0.12`는 12%가 뷰포트에 들어와야 트리거되므로 화면 상단에서 스크롤 시 약 100ms 정도만 보입니다.

**권장:** `prefers-reduced-motion` 사용자에게만 즉시 표시 + 일반 사용자에게는 0.4s로 duration 단축 검토(현재 820ms는 약간 길음). 또는 threshold를 더 낮춰 0.05 정도로.

### 🟢 Low — `key={p.title}`가 title 충돌 시 깨짐

**위치:** `app/publications/page.tsx:131`, `app/standardization/page.tsx` 등

publication title이 unique하지 않은 경우(같은 제목이 여러 venue에 출판될 수 있음) React key 충돌이 일어납니다. 현재 데이터는 unique이지만, 향후 같은 title이 들어오면 오류. `key={\`${p.title}-${p.year}-${p.venue}\`}` 등 composite key가 더 안전.

### 🟢 Low — `accent` 식별자가 색상 이름과 충돌

**위치:** `app/lectures/page.tsx:18,26,34`, `app/members/page.tsx:25,34,44`, `app/standardization/page.tsx:20,30,40,50`

각 페이지가 data field 이름으로 `accent`를 쓰고(`accent: "vermillion" as const`), `accentText`/`accentDot` 맵의 key로도 씁니다. 가독성은 양호하지만 `accent`는 Tailwind 유틸리티 클래스 이름과 충돌할 수 있고, 추후 CSS `accent-color` 속성과 헷갈릴 여지가 있습니다. 의미상 `theme` 또는 `tint`가 더 명확합니다.

### 🟢 Low — `<Reveal>`의 SSR/CSR hydration mismatch 가능성 (이론적)

`Reveal`은 `"use client"`이고 첫 렌더에서 `is-visible` 클래스를 아직 안 가진 상태로 마운트됩니다. HTML은 SSR 시에도 `.reveal` + `data-variant` + `opacity: 0`이 적용되어 전송되고, JS hydrate 후 IntersectionObserver가 `is-visible`을 토글합니다. **서버에서는 항상 `opacity: 0` → 클라이언트에서는 항상 `opacity: 0`이라 hydration mismatch는 없음**. 다만 **no-JS 환경**에서는 noscript 폴백(`<noscript><style>.reveal { opacity: 1 !important; ... }</style></noscript>`)이 의도대로 동작하므로 해결됨. ✅

### 🟢 Low — `Header` 모바일 메뉴의 z-index와 `<Marquee>` band의 sticky 충돌 가능성

`Header`는 `sticky top-0 z-50`. `<Marquee>`는 absolute/sticky 없이 일반 flow. 모바일에서 햄버거 메뉴가 열렸을 때(`<nav id="mobile-menu">` 펼쳐진 상태) `<Marquee>`가 위에 있으면 클릭이 가려질 수 있으나, `<Marquee>`는 `aria-hidden`이라 인터랙션 요소는 아닙니다. **실 문제 아님**.

### 🟢 Low — `app/intro/page.tsx:90-105`에 중첩된 `<Reveal>` 4연타

```tsx
<Reveal delay={60}>...</Reveal>
<Reveal delay={120}>...</Reveal>
<Reveal delay={200}>...</Reveal>
<Reveal delay={260}>...</Reveal>
```

같은 부모 영역에 4개 sibling Reveal이 60ms 간격으로 등장. `Reveal` 각각이 별도 IntersectionObserver 인스턴스를 생성하므로 **브라우저 메모리에 observer 4개가 동시** 만들어집니다(가벼우나 관용적이지 않음). 같은 영역이면 하나의 Reveal로 묶고 내부 stagger를 CSS `transition-delay`로 처리하는 편이 깔끔합니다.

### 🟢 Low — 접근성: `Marquee`에 `prefers-reduced-motion` 폴백은 있으나 키보드/SR 사용자용 일시정지 토글 없음

`prefers-reduced-motion` 환경에서는 `.marquee-track { animation: none }`이 적용되어 멈춥니다. 그러나 키보드 사용자나 스크린리더 사용자에게 명시적인 정지 토글은 없습니다. `aria-hidden`이라 SR은 무시하므로 큰 문제는 아니나, 인지적으로 천천히 움직이는 콘텐츠가 본문 읽기를 방해할 수 있습니다(학습장애 사용자 등). WCAG 2.2.2 (Pause, Stop, Hide) 권고. 현재 컨텐츠는 "decorative ticker"로 분류 가능하므로 **선택적 개선사항**.

### 🟢 Low — `HeroComposition` SVG의 `aria-label`이 한글/영문 혼합

`aria-label="MCM 상호운용 노드와 QUIC 멀티스트림을 형상화한 추상 일러스트레이션"` — 영문+한글 혼합. SR 호환성을 고려하면 영문만 또는 한/영 둘 다 `<title>` + `<desc>`로 분리하는 게 표준. 현재 SR 환경에서 한글이 잘 읽히므로 **minor**.

---

## 4. 개선 제안 (Concrete Suggestions)

> **2차 갱신:** 1차 P0 #1과 P1 #3/#4는 이미 해결되어 목록에서 제거(취소선)했고, **신규 A(`display-sm` 미정의)** 를 P0로 승격했습니다.

### P0 (반드시 머지 전 처리)

1. **신규 A — `display-sm` 클래스 정의** — `app/globals.css`에 `.display-sm { @apply font-display text-display-sm font-light; }` 추가. 미적용 시 4개 PageHeader 페이지의 영문 디스플레이 단어와 intro "2026" 피겨가 ~16px로 깨짐. **§3 신규 A 참조.** 1분 작업.
2. **`#2` npm audit 해결** — `npm install next@14.2.35` 후 `npm audit` clean 확인. `package.json`은 여전히 `14.2.5` (미반영). 1분 작업.
3. ~~**`#1` HTML invalidity 수정**~~ — ✅ **이미 해결됨** (`publications/page.tsx:133` `<Reveal as="li">`).

### P1 (다음 PR에서)

4. ~~**HeroComposition 우하단 텍스트 마진**~~ — ✅ **해소됨** (주석을 상단 y=24로 이동).
5. ~~**Marquee `w-full max-w-full` 명시**~~ — ✅ **이미 적용됨** (`Marquee.tsx:22`).
6. **신규 B — `Counter` rAF cleanup** — `raf`를 useEffect 스코프로 올려 cleanup에서 `cancelAnimationFrame`. **§3 신규 B 참조.** 5분 작업.
7. **`Reveal` 내부 stagger를 `transition-delay`로 통합** — intro 페이지 4연타는 단일 wrapper로 묶고 자식은 `style={{ transitionDelay: ... }}`로 처리. (미해결, minor) 10분 작업.
8. **`Reveal`의 `transition-duration`을 820ms → 600ms로 단축** — 모션 선호 off 환경에서 페이지 응답성 개선. (`globals.css:20` 아직 820ms) 1분 작업.

### P2 (정리)

7. **README에 "List 안에서 `<Reveal>` 사용 시 `as=\"li\"` 필수" 규칙 추가** — `docs/DESIGN.md` 4절 보강.
8. **데이터 필드명 `accent` → `tint` 리네임** — lecture/member/standardization 페이지 3곳 + intro. 15분 작업.
9. **`publications` 키를 composite으로** — `key={\`${p.title}-${p.year}\`}`. 1분 작업.
10. **package.json에 `"engines": { "node": ">=20" }` 명시** — Node 22 환경 명시. 1분.
11. **마키에 명시적 정지 토글** (선택) — `prefers-reduced-motion` 외에 키보드/터치 사용자가 토글 가능하도록 `<button aria-label="티커 일시정지">` 추가. 30분 작업.
12. **`Counter`에 `aria-live="polite"` 추가** — 카운트업이 끝났을 때 SR이 "전체 논문 4건" 식으로 알려줄 수 있음. 5분 작업.

---

## 5. 결론 (Verdict) — **2026-06-17 재검증 기준**

### **Needs changes (잔존 P0 2건 머지 전 처리 필요)**

매우 잘 정리된 변경입니다. 1차 리뷰 이후 코드가 갱신되어 **1차 P0 #1(publications HTML)·P1 #3(HeroComposition)·P1 #4(Marquee) 3건이 이미 해결**되었습니다. 다만 2차 재검증에서 **1차가 놓친 시각 버그 1건**을 발견했고, 1차의 보안 권고는 아직 미반영입니다.

**머지 전 반드시 처리할 2건 (P0):**

1. **`display-sm` 클래스 미정의** (`globals.css`에 `.display-sm` 추가) — 4개 PageHeader 페이지의 영문 디스플레이 단어와 intro "2026" 피겨가 의도된 ~3.75rem이 아니라 ~16px로 렌더되어 마스트헤드/피겨 행이 깨짐. **build/tsc가 잡지 못하는 조용한 버그.** §3 신규 A.
2. **Next.js 14.2.5 보안 권고 1 critical + 6 high** — 동일 라인 14.2.35로 한 줄 업그레이드로 해결. (아직 `package.json` 미반영)

이 두 건을 처리하면 **LGTM**입니다. 그 후 P1(신규 B Counter cleanup, Reveal stagger/duration)은 다음 PR에서 정리하면 됩니다.

### 핵심 강점 (정리)

- **토큰 일관성**: `tailwind.config.ts` ↔ `app/globals.css` `:root` 양쪽 미러, `tailwind.config.ts` 한 줄 변경이 사이트 전체에 전파.
- **모션 일관성**: 4개 페이지에서 같은 카운터/마키/리빌이 다른 의미로 재사용되어 통일된 리듬.
- **접근성 기본기**: `aria-hidden` (Marquee), `aria-expanded` (Header), `aria-label` (HeroComposition SVG), `noscript` 폴백, `prefers-reduced-motion` 존중.
- **타입 안전성**: 모든 prop이 optional/required 명시, `as const` 리터럴 타입으로 액센트 매핑, TypeScript 5.9 / strict 모드 clean.
- **문서화**: `docs/DESIGN.md`(정본), `docs/LAYOUT_AUDIT.md`(자체 점검), `docs/TYPOGRAPHY_FIXES.md`(작업 노트) 3종이 변경을 추적 가능하게 만듦.

### 결론 요약

| 항목 | 평가 |
|---|---|
| 빌드/타입/lint | ✅ Clean (단, 미정의 클래스 `display-sm`는 빌드가 못 잡음 — §3 A) |
| 디자인 일관성 | ⚠️ 5개 라우트 잡지 미학은 일관되나, `display-sm` 미정의로 영문 마스트헤드/연도 피겨가 실제로는 깨짐 |
| 모션/타이포/테마 | ✅ 토큰 시스템 작동 (타이포 한 군데 `display-sm` 누락 제외) |
| 1차 이슈 해결 (2차 확인) | ✅ P0 #1·P1 #3·P1 #4 추가 해결됨 |
| 신규 발견 이슈 (2차) | 🔴 1건(`display-sm` 미정의, High), 🟡 1건(Counter rAF cleanup) |
| 잔존 P0 | 🟠 2건 — (a) `display-sm` 정의, (b) Next.js 14.2.35 업그레이드 |
| 머지 가능 여부 | **잔존 P0 2건 처리 후 가능** |

---

*리뷰어 메모: 변경량 대비 코드 품질이 매우 높습니다. 특히 LAYOUT_AUDIT에 카탈로그된 이슈 중 핵심 8건을 한 번에 해결한 점, 그리고 `<noscript>` 폴백을 layout 단계에 추가한 점은 마이너 변경에서 놓치기 쉬운 디테일을 잘 챙긴 흔적입니다. 위 2건(P0)만 정리되면 본 변경은 그대로 main에 머지해도 좋을 수준입니다.*
