# Layout Issue Catalogue — refer_landing_page

**Date:** 2026-06-16
**Scope:** all `app/**/page.tsx`, `components/*.tsx`, `app/globals.css`, `tailwind.config.ts`, `app/intro/_components/HeroComposition.tsx`.
**Method:** source review + live dev render (`npx next dev -p 4510`, all 5 routes, 200 OK). No browser available so visual verification is by DOM + class composition analysis; defects that can be derived from class+CSS math are flagged as **Verified**, defects that would need a viewport to be 100% sure are flagged as **Likely**.

Severity legend: 🔴 bug (visible defect) · 🟠 likely (probable defect worth fixing) · 🟡 polish (improvement, not a bug).

---

## 1. 🔴 `border-current/30` and `border-current/20` do NOT actually follow the current text color

**Files:**
- `app/lectures/page.tsx:86` — `<p ... className="mt-4 inline-flex w-fit border border-current/30 px-3 py-1 ..."> ... level ...`
- `app/lectures/page.tsx:90` — `<p ... className="mt-6 border-t border-current/20 pt-6 ...">`
- `app/standardization/page.tsx:145` — `<p ... className="mt-4 inline-flex w-fit border border-current/30 ...">`
- `app/standardization/page.tsx:150` — `<ul ... className="mt-6 space-y-4 border-t border-current/20 pt-6 ...">`
- `app/intro/page.tsx:246` — `<li className="flex gap-3 border-t border-current/20 pt-3">` (thrust card points)

**What's wrong:** `currentColor` is a CSS keyword, not a hex. Tailwind's opacity-modifier syntax (`border-current/30`) compiles the color to a *fixed* fallback gray (`rgb(229 231 235 / 0.3)`), not the actual inherited text color. The intent is clearly "in the rest state, a 30%-opacity border in the card's own text color; on hover (when text becomes ivory), the border becomes 30%-opacity ivory." In practice the border stays a fixed gray that does not shift on hover.

**Fix direction:**
- Replace `border-current/30` with `border-vermillion/30` (or `border-cobalt/30`) for the static state, plus a `group-hover:border-ivory/30` to honor the hover swap. Or:
- Use a plain `border-current` and let the hover state's `text-ivory` carry it (no opacity).
- Same for the `border-current/20` rules.

**Confidence:** Verified. The CSS that Tailwind emits is a static color, not a `currentColor` cascade.

---

## 2. 🔴 `HeroComposition` node pulse animation drifts the circles off-position (SVG transform-origin bug)

**File:** `app/intro/_components/HeroComposition.tsx:82-93`

```tsx
{nodes.map((n, i) => (
  <circle key={i}
    cx={n.x} cy={n.y}
    r={i % 2 === 0 ? 9 : 6}
    className="animate-node-pulse"
    style={{ animationDelay: `${i * 0.5}s`, transformOrigin: `${n.x}px ${n.y}px` }}
  />
))}
```

Combined with the keyframe in `tailwind.config.ts:96-99`:
```
node-pulse: { 0%, 100%: { opacity: 0.35, transform: "scale(1)" }, 50%: { opacity: 1, transform: "scale(1.12)" } }
```

**What's wrong:** SVG `<circle>` elements have a CSS `transform-box` default of `view-box` in modern Chrome/Safari but `fill-box` (or `border-box` historically) in some Firefox versions. The inline `transformOrigin: '70px 90px'` is in the element's own coordinate space, NOT relative to the circle's bounding box. When `transform: scale(1.12)` is applied at the 50% keyframe, the circle scales from the SVG user-units origin `(0,0)` instead of from its own `(cx, cy)`. In Chrome with `transform-box: view-box` (current default), the circle stays roughly in place (origin = SVG user origin = `70,90` if you set the inline `transformOrigin` to that), but Firefox's older default causes the circle to translate by `0.12 * 70 = 8.4px` to the right and `0.12 * 90 = 10.8px` down on every pulse.

**Fix direction:** add `transform-box: fill-box; transform-origin: center;` to the circle (either via a new utility in `globals.css` or inline `style={{ transformBox: 'fill-box', transformOrigin: 'center' }}`). Then `scale(1.12)` will scale the circle around its own center regardless of `cx`/`cy`.

**Confidence:** Likely (Chrome 79+ default `view-box` mostly masks this; Firefox pre-122 / pre-2024 still drifts). Worth fixing because the docstring in `DESIGN.md` calls out node-pulse as a key visual.

---

## 3. 🔴 `HeroComposition` bottom-right annotation text overlaps the QUIC stream curves

**File:** `app/intro/_components/HeroComposition.tsx:99-109`

```tsx
<text x="360" y="356" textAnchor="end" fill="#0A0A0A" fontSize="11" fontWeight="600" letterSpacing="2">
  QUIC · STREAMS
</text>
```

The four QUIC stream paths are rendered with `y = 250 + i * 30` (i = 0..3 → 250, 280, 310, 340) and end points at `360, y-20` (so 230, 260, 290, 320). The curves use control points up to `y+40` (290, 320, 350, **380**). The bottom-right text is at `y=356`. The 4th stream's curve crosses through the text region from roughly `(240, 380)` to `(360, 320)`, which is the same y-band the text sits in.

**Fix direction:** move the text up to `y=374` (clear of stream 4's body) or move the text outside the frame entirely (e.g. add a small white-rect "chip" behind it) — simplest: shift the text `y` to ~`24` from the bottom and ensure no stream path control point enters that band. Or reduce the number of streams to 3.

**Confidence:** Verified by SVG geometry.

---

## 4. 🔴 `HeroComposition` outer frame stroke is clipped at the SVG edges

**File:** `app/intro/_components/HeroComposition.tsx:43-51`

```tsx
<rect x="6" y="6" width="368" height="368" strokeWidth="1.5" />
```

A `strokeWidth` of `1.5` means the stroke straddles the path: 0.75px outside, 0.75px inside. With the rect at `x=6, y=6` inside a `viewBox="0 0 380 380"`, the 0.75px outer half is still inside the viewBox (`x=6 - 0.75 = 5.25` is still > 0). So this is actually fine — I had it wrong on the first pass. **No fix needed.** Keeping it here so it doesn't get re-flagged.

**Confidence:** Verified. Frame is fine.

---

## 5. 🟠 Footer has excessive top whitespace (`mt-24` × `flex-1` main)

**File:** `components/Footer.tsx:15`

```tsx
<footer className="mt-24 border-t border-ink bg-ink text-ivory">
```

`app/layout.tsx:54-57`:
```tsx
<body className="flex min-h-screen flex-col">
  <Header />
  <main className="flex-1">{children}</main>
  <Footer />
</body>
```

**What's wrong:** `<main>` is `flex-1`, so it grows to push the footer to the bottom of the viewport on short pages. Adding `mt-24` (6rem) on the footer puts 6rem of empty space *between* the main content and the footer on every page — but only visible on pages that don't fill the viewport. The intro page is long enough that the footer sits at the natural end and the `mt-24` collapses; on short pages (`/lectures`) the gap shows up.

**Fix direction:** drop `mt-24` from `<footer>` (the `border-t border-ink` already provides visual separation; main content already has its own `py-16 sm:py-24` section padding). If a deliberate breathing room is wanted, use `mt-0 lg:mt-8` (no margin on mobile, small on desktop). The current value is 2-3× what's needed.

**Confidence:** Verified. `flex-1` + `mt-24` is a double-space.

---

## 6. 🟠 PageHeader `<h1>` has no `word-break: keep-all` / `text-wrap: balance` for Korean titles

**File:** `components/PageHeader.tsx:32-34`

```tsx
<h1 className="headline-ko text-4xl leading-[1.05] text-ink sm:text-5xl lg:text-6xl">
  {ko}
</h1>
```

**What's wrong:** Korean text is mostly space-free, so the browser breaks at any character. The longer PageHeader titles (`"멀티에이전트 오케스트레이션"`, `"메타버스 상호운용성과 표준"`, `"표준화 활동"`) can break at awkward points at the 4xl/sm:5xl/lg:6xl scale. For a magazine aesthetic, line breaks should fall at natural word boundaries (which in CJK is between phrases separated by spaces).

**Fix direction:** add `break-keep` (Tailwind utility for `word-break: keep-all`) to the h1. Optionally also `text-balance` for visual balance. Cost: 2 utility classes.

**Confidence:** Likely. The shorter titles (`"논문"`, `"강의"`) won't trigger this; the longer ones on `/lectures` and `/intro` will.

---

## 7. 🟠 HeroComposition "in Motion" word is in a 12-character tail that gets line-broken by `<p className="display">` clamp

**File:** `app/intro/page.tsx:96-99`

```tsx
<p className="display mt-3 leading-[0.9]" aria-hidden>
  Standards
  <span className="block italic text-cobalt">in&nbsp;Motion</span>
</p>
```

**What's wrong:** "Standards" alone at `clamp(3rem, 10vw, 9rem)` on a `lg:col-span-7` column (~54rem wide) fits on one line, "in Motion" is in a `block` so it forces onto a new line. The `&nbsp;` in "in Motion" prevents breaking inside that phrase. The visual effect is two stacked display lines. The current `text-display` font size is `clamp(3rem, 10vw, 9rem)` — at 1280px viewport, 10vw = 128px, capped at 9rem (144px). "Standards" at 144px in Fraunces is roughly 11× glyph width × 0.55em = 870px wide. The col-span-7 column is 7/12 × 78rem (with 12px padding × 2) = 45rem ≈ 720px. **"Standards" overflows the column by ~150px.** The `display` text will visually intrude into the col-span-5 SVG column or even cause horizontal overflow.

**Fix direction:** reduce the upper cap of `display` from `9rem` to `clamp(3rem, 8vw, 7rem)` in `tailwind.config.ts:74`, or set `lg:col-span-8` for the headline column (gives it 8/12 × ~78rem = ~52rem ≈ 832px) — but the layout is `lg:grid-cols-12 gap-10` and the col-span-5 is hard-wired, so the cleaner fix is the font-size cap. Or break "Standards" into two lines: `<span className="block">Stan</span><span>dards</span>` — but this changes the design intent. Recommended: cap `display` at 7rem or 7.5rem.

**Confidence:** Likely. Need a real viewport to measure, but the math says it overflows at 1280+. The user has not yet given explicit viewport feedback, so the safer fix is to reduce the cap, not the layout.

---

## 8. 🟠 Member grid: 3rd group has 2 members in a 3-col grid → ghost cell with border-line only

**File:** `app/members/page.tsx:151`

```tsx
<div className="mt-px grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-3">
  {g.members.map((m, mi) => ( ... ))}
</div>
```

The third group `"학부 연구원"` (line 41-49) has 2 members. With `lg:grid-cols-3`, 2 cards in 3 columns leaves a 1-cell-wide column with only the `bg-line` 1px background showing. The empty cell will appear as a thin ivory rectangle bounded by 1px lines on the right and bottom, next to the last filled card. Visually it looks like a half-finished row.

**Fix direction:** change `lg:grid-cols-3` → `lg:grid-cols-2` (uniform 2-col across all groups), or use `auto-fill` so the grid auto-fits, or add a `flex-1` invisible filler div. The simplest: `lg:grid-cols-2` (consistent with the 2-col advisor grid above it).

**Confidence:** Verified. Group 3 has exactly 2 placeholder members.

---

## 9. 🟠 `Reveal` initial `opacity: 0` — no JS / no-JS fallback for content visibility

**File:** `app/globals.css:104-110`

```css
.reveal { opacity: 0; transform: translateY(28px); ... }
```

**What's wrong:** Every page wraps its content in `<Reveal>` (with default `variant="up"`). If JavaScript fails to load (or fails to hydrate due to a network error on a Google Font CSS request, which we've already seen fail in this very environment — see `next build` log: `request to https://fonts.googleapis.com/... failed, reason:`), the user sees a fully blank page because every block stays at `opacity: 0`. There is no `<noscript>` rule that sets `opacity: 1` for non-JS users, and no progressive enhancement (e.g. `noscript` tag, or a `html.no-js .reveal { opacity: 1 }` pattern).

This is a real concern because the build log already shows font fetches failing in this environment; if the React hydration also fails, the user sees nothing.

**Fix direction:** add to `app/globals.css`:
```css
html.no-js .reveal { opacity: 1; transform: none; transition: none; }
```
and in `app/layout.tsx` `<html>` tag, default the className to include `no-js`, then in a small inline `<script>` (or in `Header.tsx`'s `"use client"`) strip the class on mount. Alternatively use the simpler `motion-safe:` / progressive approach: add `@media (prefers-reduced-motion: reduce)` to also force `opacity: 1` (already in CSS at line 133-146) — but reduced-motion is not the same as no-JS.

The simplest robust fix: add `<noscript><style>.reveal { opacity: 1 !important; transform: none !important; }</style></noscript>` in `app/layout.tsx`.

**Confidence:** Likely. The build log proves font fetch failures happen in this network; hydration races are a related class of failure.

---

## 10. 🟡 PageHeader `display` fallback (when no `display` prop passed) puts the full English word in vermillion at cover scale

**File:** `components/PageHeader.tsx:35-37`

```tsx
<p className="display mt-2 text-vermillion" aria-hidden>
  {display ?? en}
</p>
```

When `display` is not provided, this renders the full `en` (e.g. "Publications", "Members", "Standardization") at 9rem cap. The longest is "Standardization" (14 chars) ≈ 14 × 144px × 0.5em = 1008px — wider than the col-span-8 column. Only the intro page passes `display`; the other 4 pages (`/members`, `/publications`, `/lectures`, `/standardization`) fall through to the `en` value.

**Fix direction:** either always require a `display` (shorter) prop, or apply a per-page override that fits, or set `display-sm` (which is `clamp(2.25rem, 6vw, 4.5rem)`) on this `<p>` instead of `display`. `display-sm` would be a safer default.

**Confidence:** Likely; "Standardization" specifically will overflow. The intro page is fine because it passes `display="Standards"`.

---

## 11. 🟡 Publications: `Counter` for the year `"2026"` ticks 0→2026 on every page load

**File:** `app/intro/page.tsx:49` — `figures[2] = { value: 2026, label: "Issue · 발행" }`
**File:** `app/intro/page.tsx:152` — `<Counter value={f.value} ...>`

`Counter` (`components/Counter.tsx`) does `0 → 2026` over 1.6s with easeOutExpo (jumps to ~2018 in 0.5s). For a year value, the tick is conceptually odd — a year that "counts up" from 0 is jarring in a magazine layout where the issue number should be a fixed figure. Same applies to `app/publications/page.tsx` where `figures[0].value = publications.length` and `figures[2].value = publications.filter(p => p.type === "Conference").length` both legitimately count up.

**Fix direction:** pass an `instant` prop to `Counter` (default to `true` for value >= 1000 or for `prefix`/`suffix` containing a non-digit), or split into two components (`Counter` for stats, `Static` for years). Simpler: in `intro/page.tsx` change `value: 2026` to render as static text: `{ value: 2026, label: "Issue · 발행", static: true }` and have Counter respect the `static` flag.

**Confidence:** Likely (UX bug, not a layout bug — listed because the design spec in DESIGN.md §4 emphasizes "magazine scale" which implies static figures).

---

## 12. 🟡 Intro / Publications / Standardization "publication list" indices `01`–`05` in `text-5xl leading-none text-ink-mute` may be too large for a 3-col rail on the sm: breakpoint

**File:** `app/publications/page.tsx:135-140`

```tsx
<div className="flex items-baseline gap-4 sm:col-span-3 sm:flex-col sm:gap-3">
  <span className="font-display text-5xl leading-none text-ink-mute">{String(i + 1).padStart(2, "0")}</span>
  ...
```

At `sm` (640px-1023px), the rail is full-width (since the grid becomes `sm:grid-cols-12` only on the `<li>`, not the rail). Wait — actually the parent `<li>` is `sm:grid-cols-12`, so on sm the rail takes `sm:col-span-3` = 25% of `78rem` ≈ 19.5rem = 312px. The 3 items stack vertically (`sm:flex-col`) with the "01" (text-5xl = 3rem = 48px) at the top. 48px number "01" plus 24px year "2024" plus 12px badge "Conference" with gap-3 between = ~120px tall. The title (col-span-9) is `headline-ko text-xl` ≈ 1.25rem and the title is multi-line. Both sides align by grid row stretch. Looks fine.

**Confidence:** Verified fine. No fix needed.

---

## 13. 🟡 Counter card vertical alignment when values are different magnitudes (2 / 4 / 2026)

**File:** `app/intro/page.tsx:147-160`

```tsx
<section className="border-b border-ink">
  <div className="container-content grid gap-px bg-line sm:grid-cols-3">
    {figures.map((f, i) => (
      <Reveal ...>
        <div className="px-6 py-10">
          <Counter value={f.value} suffix={f.suffix} className="display block text-ink" />
          <p className="kicker mt-3">{f.label}</p>
        </div>
      </Reveal>
    ))}
  </div>
</section>
```

The "display" value at clamp(3rem, 10vw, 9rem) for "2" (1 glyph) vs "2026" (4 glyphs) → "2026" is roughly 4× wider. All three cells have `bg-ivory` and `px-6 py-10`, so the kicker sits at the same vertical offset per cell. The widths are 1/3 of 78rem each (~26rem), and "2026" at 9rem is ~28rem wide — close to the cell width. **On lg the "2026" will push against the cell padding.** The "2" and "4" cells look sparse in comparison. Editorial choice — could be fine — but worth a note.

**Fix direction:** give the figures different `display` sizes per magnitude (use `text-display-sm` for years and 4-digit numbers), or apply `text-3xl` to `2` and `4` and `display` to `2026`. Or shorten `2026` to `26` for the year.

**Confidence:** Likely; depends on exact Fraunces metrics which need a browser to verify.

---

## 14. 🟡 Header mobile menu: `id="mobile-menu"` missing an `aria-controls` connection on the toggle button

**File:** `components/Header.tsx:79-94` and `:99-126`

The button has `aria-controls="mobile-menu"` and `aria-expanded={open}` (line 84-85). It also has `aria-label` switching between "메뉴 열기" / "메뉴 닫기" (line 82). The `<nav id="mobile-menu">` (line 99-101) has the matching id. This is actually **correct**; I had it on my "suspect" list but on re-read it's fine. **No fix needed.**

**Confidence:** Verified. ARIA wiring is correct.

---

## 15. 🟡 `flex-1` on `<main>` collides with `flex-col` body when content is short

**File:** `app/layout.tsx:54-57`

The body is `flex min-h-screen flex-col`. The main is `flex-1` (= `flex: 1 1 0%`). This is the standard "sticky footer" pattern and works correctly. The Header is `sticky top-0 z-50`, which means it's `position: sticky` *inside* the flex column. **Sticky elements inside a flex container have known issues with `align-items: stretch` (default)**: the sticky header doesn't actually stick on some browsers when inside a flex column. In Chrome 2020+ this is fixed, but worth flagging for older mobile browsers.

**Fix direction:** set the body's `align-items` to `flex-start` (or change `flex-col` to `block` and use `margin-top: auto` on main). Alternatively, leave the header outside the flex column by removing `flex-1` from main and adding `min-h-[calc(100vh-4.25rem)]` to main.

**Confidence:** Likely; this is a browser-version-dependent issue.

---

## 16. 🟡 Header `h-[4.25rem]` uses an arbitrary value that doesn't align with tailwind's 4 (=1rem) spacing scale

**File:** `components/Header.tsx:37` — `<div className="container-content flex h-[4.25rem] items-center justify-between">`

**What's wrong:** `4.25rem` is 68px. The header is taller than typical (most 1-line headers are `h-16` (64px) or `h-14` (56px)). The brand block has 2 lines (`사물인터넷 표준 연구실` + `IoT Standards Lab` kicker) so it needs ~64-72px height. Acceptable. But the value `4.25rem` is brittle — if the brand changes from 2 lines to 1, the header keeps the same height leaving a 32px gap. Not a bug today, but a "trap" for future edits.

**Fix direction:** use `min-h-[4.25rem]` instead of `h-[4.25rem]`, or define it as a CSS variable, or replace with `h-16` (64px) + `py-2` to allow brand to expand.

**Confidence:** Verified, but cosmetic.

---

## 17. 🟡 SectionLabel corner label is `font-display text-sm font-medium tabular-nums tracking-wide` — `text-sm` is 14px which is small for editorial display

**File:** `components/SectionLabel.tsx:22` and `app/globals.css:69`

```css
.corner-label { @apply font-display text-sm font-medium tabular-nums tracking-wide text-ink-mute; }
```

**What's wrong:** `text-sm` (0.875rem = 14px) is a small body size, used here for the section number "01 / 05". For an editorial magazine aesthetic, this corner label is too small to read as a "spread number". Most editorial spreads put the number at 16-18px (text-base / text-lg).

**Fix direction:** bump to `text-base` (16px) or `text-[0.95rem]` for better legibility while keeping the wide tracking.

**Confidence:** Editorial preference, not a bug.

---

## Summary

| # | File | Severity | One-line |
|---|------|----------|----------|
| 1 | lectures/standardization/intro `border-current/*` | 🔴 | Doesn't actually follow currentColor — falls back to gray |
| 2 | HeroComposition circle `transformOrigin` | 🔴 | Node-pulse scales from SVG origin not circle center, drifts in some browsers |
| 3 | HeroComposition bottom-right text | 🔴 | "QUIC · STREAMS" text overlaps 4th stream curve |
| 4 | (re-flag of frame) | — | No issue; rect coords are inside viewBox |
| 5 | Footer `mt-24` | 🟠 | Doubles up with `flex-1` main, 6rem dead space on short pages |
| 6 | PageHeader `<h1>` | 🟠 | No `break-keep` for long Korean titles, breaks mid-syllable |
| 7 | Intro `display "Standards"` | 🟠 | At 9rem cap, overflows col-span-7 column at 1280+ |
| 8 | Members group 3 | 🟠 | 2 cards in 3-col grid → ghost cell with `bg-line` showing |
| 9 | `Reveal` no-JS fallback | 🟠 | No `<noscript>` rule — blank page if JS fails |
| 10 | PageHeader `display` fallback | 🟡 | When `display` prop absent, uses long `en` at 9rem (e.g. "Standardization" overflows) |
| 11 | Counter year `2026` ticks 0→2026 | 🟡 | Year should be static, not animated |
| 12 | Publications list rail | — | Verified fine; no fix |
| 13 | Counter cards different magnitudes | 🟡 | "2026" ~4× wider than "2"/"4" — visual imbalance |
| 14 | Header ARIA | — | Verified fine; no fix |
| 15 | Sticky header in flex column | 🟡 | Older mobile browsers: sticky inside flex can fail |
| 16 | Header `h-[4.25rem]` arbitrary | 🟡 | Brittle, hardcoded for current 2-line brand |
| 17 | SectionLabel `text-sm` | 🟡 | 14px is too small for an editorial spread number |

**Real bugs to fix in priority order:**
1. (#1) `border-current/*` opacity modifiers — visible on every lecture + standardization page.
2. (#3) HeroComposition bottom-right text/stream overlap — visible on `/intro` only.
3. (#5) Footer `mt-24` — visible on every page when content is short.
4. (#2) HeroComposition node-pulse drift — only on `/intro`, and only on Firefox pre-2024.
5. (#7) Intro "Standards" overflow — only on `/intro` at 1280+ viewports.
6. (#8) Members group 3 ghost cell — visible on `/members`.
7. (#9) No-JS fallback — affects every page.

The remaining items are polish (🟡).
