# Plan: Chat Virtual-Scroll Bottom Fix + Scroll-to-Bottom FAB (v2)

## Part 1: Summary

채팅 메인 뷰에서 iframe(diagram-html) 리사이즈 시 하단 콘텐츠가 보이지 않는 버그를 수정하고,
사용자가 위로 스크롤했을 때 "최하단으로" FAB 버튼을 표시합니다.

**버그 원인**: `throttledResize()`가 iframe 높이 변경 후 scroll reconciliation을 호출하지 않음.
TanStack Virtual의 ResizeObserver가 deferred frame으로 동작해 타이밍 gap 발생.

**해결**: resize 전 near-bottom 상태 캡처 → resize 후 해당 상태로 `reconcileChatBottomAfterLayout(wasNearBottom)` 호출.
FAB 버튼으로 수동 복구 경로도 제공.

---

## Part 2: Diff-Level Plan

### File 1: `public/js/diagram/iframe-renderer.ts` — MODIFY

**Import 추가** (line 6 이후):
```typescript
import { isChatNearBottom, reconcileChatBottomAfterLayout } from '../features/chat-scroll.js';
```

**throttledResize 수정** (line 568-576):

**Before**:
```typescript
function throttledResize(source: Window, height: number): void {
  if (resizeTimers.has(source)) return;
  resizeTimers.set(source, window.setTimeout(() => resizeTimers.delete(source), 100));

  document.querySelectorAll('iframe').forEach(iframe => {
    if (iframe.contentWindow === source) {
      iframe.style.height = `${Math.min(Math.max(height, 60), 2000)}px`;
    }
  });
}
```

**After**:
```typescript
function throttledResize(source: Window, height: number): void {
  if (resizeTimers.has(source)) return;
  resizeTimers.set(source, window.setTimeout(() => resizeTimers.delete(source), 100));

  const wasNearBottom = isChatNearBottom();
  let resized = false;
  document.querySelectorAll('iframe').forEach(iframe => {
    if (iframe.contentWindow === source) {
      iframe.style.height = `${Math.min(Math.max(height, 60), 2000)}px`;
      resized = true;
    }
  });
  if (resized && wasNearBottom) {
    requestAnimationFrame(() => reconcileChatBottomAfterLayout(true));
  }
}
```

**Why**: height 변경 전에 near-bottom 상태 캡처. 변경 후 scrollHeight 증가로 인해
`isChatNearBottom()`이 false로 바뀌는 문제 방지. `true`를 명시적으로 전달하여 무조건 follow.

---

### File 2: `public/js/features/chat-scroll.ts` — MODIFY

**수정 1**: `ensureScrollTracking` 내부에서 FAB 초기화 (line 66-73)

**Before** (line 66-73):
```typescript
    if (scrollTrackingBound) return;
    const c = document.getElementById('chatMessages');
    if (!c) return;
    scrollTrackingBound = true;
    c.addEventListener('scroll', () => {
        const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
        updateScrollIntentFromDistance(dist);
    }, { passive: true });
}
```

**After**:
```typescript
    if (scrollTrackingBound) return;
    const c = document.getElementById('chatMessages');
    if (!c) return;
    scrollTrackingBound = true;
    c.addEventListener('scroll', () => {
        const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
        updateScrollIntentFromDistance(dist);
    }, { passive: true });
    initScrollFab();
}
```

**수정 2**: `updateScrollIntentFromDistance` 함수 (line 23-27)

**Before**:
```typescript
function updateScrollIntentFromDistance(dist: number): void {
    userNearBottom = dist < SCROLL_BOTTOM_THRESHOLD;
    scrollIntent = userNearBottom ? 'following' : 'pinnedAway';
    if (scrollIntent === 'pinnedAway') cancelPendingChatRestorePasses();
}
```

**After**:
```typescript
function updateScrollIntentFromDistance(dist: number): void {
    userNearBottom = dist < SCROLL_BOTTOM_THRESHOLD;
    scrollIntent = userNearBottom ? 'following' : 'pinnedAway';
    if (scrollIntent === 'pinnedAway') cancelPendingChatRestorePasses();
    updateScrollFabVisibility();
}
```

**수정 3**: `markFollowingBottom` 함수 (line 18-20)

**Before**:
```typescript
export function markFollowingBottom(): void {
    userNearBottom = true;
    scrollIntent = 'following';
}
```

**After**:
```typescript
export function markFollowingBottom(): void {
    userNearBottom = true;
    scrollIntent = 'following';
    updateScrollFabVisibility();
}
```

**추가 4**: 파일 최하단 (line 226 이후 — EOF append)

```typescript
// ── Scroll-to-Bottom FAB ──
let fabEl: HTMLButtonElement | null = null;
let fabVisible = false;

function createScrollFab(): HTMLButtonElement {
    const btn = document.createElement('button');
    btn.className = 'scroll-to-bottom-fab';
    btn.setAttribute('aria-label', 'Scroll to bottom');
    btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 3v10M4 9l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
    btn.addEventListener('click', () => scrollToBottom(true));
    return btn;
}

function initScrollFab(): void {
    const host = document.querySelector('.chat-area') as HTMLElement | null;
    if (!host || fabEl) return;
    fabEl = createScrollFab();
    host.appendChild(fabEl);
}

function updateScrollFabVisibility(): void {
    if (!fabEl) return;
    const shouldShow = scrollIntent === 'pinnedAway';
    if (shouldShow === fabVisible) return;
    fabVisible = shouldShow;
    fabEl.classList.toggle('visible', shouldShow);
}
```

Note: `initScrollFab`과 `updateScrollFabVisibility`는 모듈 내부에서만 사용하므로 export 불필요.
`ui.ts` 수정도 불필요 — `ensureScrollTracking()` 내부에서 자동 호출.

---

### File 3: `public/css/chat.css` — MODIFY

**추가 위치**: `.chat-restore-indicator` 블록 이후 (line 165 근처 이후)

```css
/* ── Scroll-to-Bottom FAB ── */
.scroll-to-bottom-fab {
    position: absolute;
    bottom: 80px;
    right: 20px;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--surface);
    border: 1px solid var(--border);
    color: var(--text-dim);
    cursor: pointer;
    opacity: 0;
    pointer-events: none;
    transform: translateY(8px);
    transition: opacity 200ms, transform 200ms;
    z-index: 10;
    box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.scroll-to-bottom-fab.visible {
    opacity: 1;
    pointer-events: auto;
    transform: translateY(0);
}
.scroll-to-bottom-fab:hover {
    color: var(--text);
    border-color: var(--accent);
}
.scroll-to-bottom-fab:active {
    transform: scale(0.92);
}
```

---

## Audit Issue Fixes Applied (v2)

| Audit 이슈 | 수정 |
|-------------|------|
| BLOCKER: isChatNearBottom() 평가 타이밍 | height 변경 **전** 캡처 → `wasNearBottom` → `reconcile(true)` |
| FAIL: ui.ts 호출 anchor 불명확 | `ensureScrollTracking()` 내부에서 `initScrollFab()` 호출 (ui.ts 수정 불필요) |
| MINOR: line 227 anchor | EOF=226으로 정정 |

## 변경 범위 (최종)

- MODIFY: `public/js/diagram/iframe-renderer.ts` (import 1줄 + throttledResize 6줄)
- MODIFY: `public/js/features/chat-scroll.ts` (EOF append ~25줄 + 기존 함수 3곳 수정)
- MODIFY: `public/css/chat.css` (FAB 스타일 ~30줄)
- ~~MODIFY: `public/js/ui.ts`~~ — 삭제됨 (불필요)

Total: 3 files, ~65 lines added/modified.
