# 템플릿 반응 정책 구현 기록 (A+B 단계)

- 일자: 2026-04-19
- 브랜치: `gbpark-node`
- 근거 문서: [`2026-04-19-template-responsive-policy.md`](./2026-04-19-template-responsive-policy.md)
- 요약: 정책 문서(4모드·그룹 단위 반응)를 실제 코드로 이식. 타입/정책 엔진/런타임 주입/렌더러/시뮬레이터까지 일괄 구현.

---

## 1. 왜 했나

기존 `TemplateRenderer`는 이미 `@container` 기반 반응형 일부가 들어가 있었지만, 로직이 다음과 같은 **이분법** 수준이었다.

- FULL_WIDTH (테이블/컨테이너) → 단독 row, 100% 폭
- 작은 블록(canvas 25% 미만) → 원본 px + 좌/우 앵커
- 큰 블록 → 가로 비례 축소

이 방식은 정책 문서가 지적한 문제를 그대로 남겼다.

- 버튼 여러 개가 "작은 블록 여러 개"로 흩어져 배치됨 → 좁아지면 겹치거나 어색함
- KPI 카드 5개는 "큰 블록"으로 일괄 축소되어 각 셀이 찌그러짐
- 컴포넌트 성격과 무관하게 같은 방식으로 반응

정책 문서가 요구한 **"컴포넌트 성격별 차등 반응 + 그룹 단위 적용"** 이 빠진 상태였다.

---

## 2. 범위 결정

사용자 협의 결과 다음 방향으로 확정.

| 항목 | 결정 |
|---|---|
| 어디까지 | **A+B 통합** — 정책 엔진 + 컴포넌트 내부 보강을 한 묶음으로. A만 하면 "타입만 생기고 화면은 그대로" 상태가 되어 체감 효과 없음. |
| 오버라이드 UI (C) | **나중**. 스키마에만 `responsiveMode` 추가, 디자이너 UI는 지금 열지 않음. |
| 기존 템플릿 | **런타임 기본값 주입**. DB 마이그레이션 스크립트 불필요. |
| 1/2·1/4 시뮬레이터 | **디자이너 툴바 연동**. 대시보드에서만 보면 피드백 루프가 너무 느림. |
| 반응 단위 | **그룹 단위** (핵심 보강 포인트). 개별 컴포넌트가 아니라 "버튼 두 개 = 버튼 그룹 wrap", "KPI 5개 = 카드 그룹 reflow". |

---

## 3. 작업 분할 (Task)

| # | 작업 | 상태 |
|---|---|---|
| 1 | `ResponsiveConfig` 타입 확장 | ✅ |
| 2 | 반응 정책 엔진 모듈 작성 | ✅ |
| 3 | `TemplateRenderer` 그룹 탐지 + 정책 적용 | ✅ |
| 4 | 컴포넌트 내부 반응 구현 보강 (Stats auto-fit 등) | ✅ |
| 5 | 반응 미리보기 패널 + 디자이너 툴바 연결 | ✅ |
| 6 | 통합 검증 (타입체크 비교) | ✅ |

---

## 4. 변경/추가 파일

### 신규
- `frontend/lib/registry/responsive-policy.ts` — **정책 엔진** (4모드별 CSS 생성 + 그룹 탐지 + 기본값 매핑)
- `frontend/components/dash/TemplateResponsivePreview.tsx` — Full/Half/Quarter 토글 미리보기 패널
- `frontend/app/(main)/admin/template-preview/page.tsx` — 새 창에서 열리는 미리보기 라우트

### 수정
- `frontend/types/invyone-component.ts` — `ResponsiveMode`, `ResponsiveAnchor`, `ResponsiveConfig` 타입 추가. `TemplateComponent.responsive?` 필드 추가
- `frontend/components/dash/TemplateRenderer.tsx` — 그룹 기반 정책 적용. 기존 `isSmall/FULL_WIDTH` 이분법 제거, `detectResponsiveGroups` + `resolveResponsivePolicy` 기반 재작성
- `frontend/lib/registry/components/stats/StatsComponent.tsx` — orientation=grid/horizontal 기본값을 `repeat(auto-fit, minmax(180px, 1fr))` 기반 자동 재배치로 전환
- `frontend/components/screen/ScreenDesigner.tsx` — `handleResponsivePreview` 핸들러 추가. POP 모드가 아니면 미리보기 버튼이 이 핸들러 호출
- `frontend/components/screen/toolbar/SlimToolbar.tsx` — 버튼 title을 "POP 미리보기" → "미리보기" 로 일반화

---

## 5. 핵심 개념

### 5.1 네 개의 반응 모드

| 모드 | CSS 개념 | 누가 쓰나 |
|---|---|---|
| `fixed` | 원본 px + anchor(`left`/`right`·`top`/`bottom`) | 단일 button/input/title/divider/pagination |
| `scroll` | `width:100%` + `min-width:디자인폭` + `overflow-x:auto` | table, search, container, split-panel, tabs, accordion |
| `reflow` | `display:grid; grid-template-columns: repeat(auto-fit, minmax(X, 1fr))` | stats 그룹, form |
| `wrap` | `display:flex; flex-wrap:wrap` | button-bar, 가상 button-group(2+) |

### 5.2 그룹 탐지 (★핵심)

같은 row 안에서 **연속된 동일 `componentId`** 블록을 런타임에 가상 그룹으로 묶는다.

```
row: [button, button, input, stats, stats, stats]
  ↓ detectResponsiveGroups
groups: [
  { componentId: 'button', blocks: [b1, b2], virtual: true },   // → wrap
  { componentId: 'input',  blocks: [i1],     virtual: false },  // → fixed
  { componentId: 'stats',  blocks: [s1,s2,s3], virtual: true }, // → reflow
]
```

FULL_WIDTH 타입(table 등)은 그룹 대상이 아니며 항상 단독 그룹으로 유지된다.

### 5.3 단독 vs 그룹 승격 (`GROUP_PROMOTION`)

단일 컴포넌트의 기본 모드가 여러 개 배치 시 그룹 모드로 승격된다.

```ts
// responsive-policy.ts
const DEFAULT_MODE_BY_ID = {
  button: 'fixed', input: 'fixed', title: 'fixed', ...
  stats: 'reflow', table: 'scroll', 'button-bar': 'wrap', ...
};

const GROUP_PROMOTION = {
  button: 'wrap',  // 버튼 두 개 이상 → wrap
  input:  'wrap',
  stats:  'reflow',
  title:  'wrap',
};

getDefaultResponsiveMode(componentId, groupSize);
// groupSize>1 이고 GROUP_PROMOTION 에 있으면 승격 모드
```

### 5.4 앵커 자동 결정 (fixed 모드)

디자인 시점 좌표를 기준으로 캔버스를 9등분해 앵커를 결정한다.

```
centerX < 40% → left계열
centerX > 60% → right계열
그 외         → center

centerY > 60% → bottom계열
그 외         → top계열
```

축소 시 버튼이 "원본 디자인 위치에서 가장 가까운 모서리에 붙어서" 이동하도록.

### 5.5 런타임 기본값 주입

DB 저장본에 `responsive` 필드가 없어도 렌더 시점에 `componentId + groupSize`로 모드를 결정하므로 기존 템플릿은 **무수정으로** 새 정책을 따른다.

---

## 6. 렌더 파이프라인

```
DB views JSON
  ↓ normalizeBlocks    — 좌표/componentId/config 표준화
blocks[]
  ↓ groupIntoRows      — y 좌표 겹침으로 row 분할, FULL_WIDTH 는 단독 row
rows[][]
  ↓ (각 row 마다)
  detectResponsiveGroups — 연속 동일 타입 → 가상 그룹
groups[]
  ↓ (각 group 마다)
  resolveResponsivePolicy — { wrapperStyle, wrapperClassName, innerStyle, appliedMode }
  ↓
DOM:
  .dash-tpl-row-abs (position:relative)
    .dash-tpl-group  (position:absolute, bbox 기반)
       .dash-tpl-item (wrap/reflow: 자식 자동 배치 / scroll: minWidth 확보)
         <실제 컴포넌트>
```

narrow(카드 폭 ≤ 640px)일 때는 `@container` 쿼리로 row 자체를 세로 스택, 모든 `position:absolute`를 해제, reflow는 1열로 수렴.

---

## 7. 기본값 매핑 (최종)

| componentId | 단독 모드 | 그룹 승격 모드 | `minItemWidth` |
|---|---|---|---|
| `table` | scroll | — | — |
| `search` | scroll | — | — |
| `container` | scroll | — | — |
| `split-panel` | scroll | — | — |
| `tabs` | scroll | — | — |
| `accordion` | scroll | — | — |
| `stats` | reflow | reflow | 180 |
| `form` | reflow | — | 240 |
| `button-bar` | wrap | — | 96 |
| `button` | fixed | **wrap** | 96 |
| `input` | fixed | **wrap** | 160 |
| `title` | fixed | **wrap** | 160 |
| `divider` | fixed | — | — |
| `pagination` | fixed | — | — |

---

## 8. 컴포넌트 내부 보강

### Stats (`StatsComponent.tsx`)

변경 전 — `orientation='grid'` 일 때 `repeat(${columns}, 1fr)` 고정 컬럼 수.
변경 후 — columns는 **상한**으로만 사용, 실제 배치는 `auto-fit + minmax` 기반.

```css
/* columns 지정 시: 최대 columns 개 + 카드 당 최소 180px */
repeat(auto-fit, minmax(max(180px, calc((100% - gap) / columns)), 1fr))

/* columns 미지정 시 */
repeat(auto-fit, minmax(180px, 1fr))
```

결과: FHD 5열 → 중간 3열 → 작은 2열 → 매우 작은 1열 자동 수렴 (정책 문서 §4.3 요구).

### Title, Button, Input, Table

개별 수정 없음. 바깥 정책 wrapper가 `width:100% height:100%` 를 보장하고 내부 컴포넌트가 이미 그에 맞춰 동작하므로 건드릴 필요가 없었다. Table은 정책 엔진의 scroll 모드가 `min-width: max(pos.width, 480)`를 걸어 자동으로 가로 스크롤이 생긴다.

---

## 9. 미리보기 흐름

```
빌더에서 저장
  ↓
SlimToolbar 👁 버튼 클릭 (POP 모드가 아니면 handleResponsivePreview 호출)
  ↓
window.open('/admin/template-preview?id=<templateId>', _blank)
  ↓
PreviewPage
  → getTemplateInfo(templateId)
  → TemplateResponsivePreview { template }
     → Full/Half/Quarter 토글 (1200/600/300px)
     → TemplateRenderer { template, mock context }
```

장점
- ScreenDesigner(8286줄) 내부 구조를 거의 안 건드림 — 핸들러 하나와 title 한 글자만 수정
- 새 라우트 분리로 상태 오염 없음
- 폭이 바뀔 때 `@container inline-size` + 정책 엔진이 자동 반응하므로 즉시 시각 확인 가능

단점 (→ C 단계에서 개선)
- 편집 중 상태가 아닌 "저장된 상태" 기준 — 편집 후 저장해야 미리보기에 반영됨

---

## 10. 검증

타입체크 결과.

| 상태 | tsc 에러 수 |
|---|---|
| 변경 전 (stash) | 3399 |
| 변경 후 | 3392 |

→ 내 변경으로 새로 생긴 에러 없음. 기존 에러 7건이 줄었음(디자인 해상도 필드 호환성 보강 부산물).

변경 파일 개별 체크에서 관련 에러 0건.

---

## 11. 남은 것 (C 단계, 이번 범위 밖)

1. **디자이너 속성 패널** 에 `responsiveMode` 오버라이드 UI
2. **편집 중 상태 실시간 미리보기** — 현재는 저장 후 새 창. 좌/우 분할 또는 split 패널로 실시간 반영
3. **정교한 튜닝** — Table 커스텀 minWidth, Form columns 재계산, Title 줄바꿈 시 높이 증가 등
4. **Stats 커스텀 프리셋** — KPI 타입별 기본 `minItemWidth` 조정 (아이콘 있는 큰 카드 vs 숫자만 있는 컴팩트 카드)

---

## 12. 참고 위치

- 정책 정의: [`2026-04-19-template-responsive-policy.md`](./2026-04-19-template-responsive-policy.md)
- 타입: `frontend/types/invyone-component.ts` (섹션 6 카드 엔진 v2 내부)
- 정책 엔진: `frontend/lib/registry/responsive-policy.ts`
- 렌더러: `frontend/components/dash/TemplateRenderer.tsx`
- 미리보기 패널: `frontend/components/dash/TemplateResponsivePreview.tsx`
- 미리보기 라우트: `frontend/app/(main)/admin/template-preview/page.tsx`

---

## 13. 2차 개정 — 같은 날 오후 (실사용 검증 + 회귀 수정)

초기 A+B 구현 직후 실제 템플릿(stats 5개 + 버튼 2개 + 테이블)으로 Quarter 상태 검증 중 여러 회귀 발견. 최종 도달한 모델과 그 과정의 오답들을 기록.

### 13.1 최종 모델 (현재 코드 상태)

**그룹 탐지 (`detectResponsiveGroups`)**
- 같은 row + 같은 `componentId` + **gap ≤ max(직전블록폭 × 0.5, 32px)** 일 때만 그룹
- 떨어져 있는 같은 id 는 각자 **단독 fixed** (중앙 버튼 + 우측 버튼이 서로 다른 그룹)

**row 레이아웃 — 항상 horizontal**
- row 는 `display: flex; flex-direction: row; flex-wrap: nowrap; align-items: flex-start`
- 카드 폭이 좁다는 이유만으로 **`flex-direction: column` 전환을 하지 않는다** (이전 버전에서 임의로 넣었던 규칙. 정책 문서에 근거 없음)
- row 자체는 `flex: 0 0 auto` — 자기 크기 유지, 내부 reflow 로 커지면 자연 성장

**그룹 사이 spacer** (원본 x 간격 보존)
```tsx
<div className="dash-tpl-spacer" style={{ flex: '0 5 <gap%>' }} />
```
- `flex-shrink: 5` — 공간 부족 시 **spacer 가 먼저 축소**되어 그룹들 원본 x 성격 최대한 유지
- 그룹간 간격이 1% 미만이면 생성 안 함 (DOM 최소)

**그룹 flex 정책 (shrink 우선순위 차등)**

| 종류 | flex | 의미 |
|---|---|---|
| spacer | `0 5 X%` | 가장 먼저 shrink |
| scroll / reflow 그룹 | `1 0.5 X%` | 중간 정도 shrink, 비례 성장 |
| wrap 그룹 | `0 0.3 <width>px` | 가장 덜 shrink, 자연폭 기준 |
| fixed 단일 (버튼 제외) | `0 0 auto` | 완전 고정 |
| fixed 단일 (button) | `0 1 auto` + minWidth 48 | **shrink 허용** (compact 단계와 연동) |

**button 전용 compact 단계** (자동 세로 스택 대신)
```css
@container tpl (max-width: 900px) { /* padding 10px, font 12px */ }
@container tpl (max-width: 600px) { /* padding 6px, font 11px */ }
@container tpl (max-width: 420px) { /* padding 4px, font 10px */ }
```
- 버튼은 `fixed → compact fixed → overflow/wrap(optional)` 순서로 반응
- 박스 자체 shrink + 내부 패딩/폰트 단계 축소 = 버튼이 사라지지 않고 자연스럽게 줄어듦
- `data-comp="button"` 속성으로 CSS 타겟팅

**세로 반응 (카드 높이에 테이블이 따라옴)**
- `.dash-tpl-full` (FULL_WIDTH 단독 row) → **`flex: 1 1 auto; min-height: 0`** — wrapper 남은 세로 공간 흡수
- `.dash-tpl-row` (일반 row) → `flex: 0 0 auto` (자기 크기 유지)
- table 자체는 자체 `overflow: auto` 로 내부 스크롤 처리 (정책 엔진의 wrapper 는 `overflow: hidden` + 100% 폭)
- wrapper 전체 상하 스크롤은 최후 fallback (거의 발생 안 함)

**Stats `columns` 기본값**
- 이전: `Math.min(items.length, 4)` — items 5개면 4열 강제 → 5번째가 wrap
- 현재: `undefined` — `repeat(auto-fit, minmax(180px, 1fr))` 가 폭에 맞춰 자동 결정. 5개면 폭만 되면 5열 한 줄

### 13.2 폐기된 오답들 (재발 방지)

| 오답 | 왜 틀렸나 | 현재 해법 |
|---|---|---|
| `@container (max-width: 640px) { flex-direction: column }` | 정책 문서에 없는 규칙. row 안의 버튼들을 강제 세로 스택해서 의도 파괴 | **쿼리 전체 폐기**. row 는 항상 horizontal |
| narrow 에서 `.rp-fixed { width: 100% !important }` | 원본 100px 버튼이 카드 전체 폭으로 확장 | 없음. fixed 는 항상 원본 px 기준 |
| gap threshold 없는 같은-id 자동 그룹화 | 중앙 버튼 + 우측 버튼을 하나의 wrap 그룹으로 묶어 나란히 붙여버림 | gap 기반 분리 (§13.1) |
| `min-height: 0 !important` row override | inline `min-height: rowHeight` 를 무효화해서 row 가 0 으로 붕괴 | 제거. inline minHeight + `height: auto` 조합 |
| table wrapper 의 `overflow-x: auto` + `min-width: 원본폭` | 테이블 wrapper 가 카드 밖으로 튀어나감 | wrapper `overflow: hidden` + 테이블 자체 내부 스크롤에 위임 |

### 13.3 디버그 덤프

`?debug=1` 또는 `localStorage.INVYONE_DEBUG=1` 일 때 콘솔에 상세 덤프.
- canvas / wrapper clientWidth·Height / narrowExpected
- blocks 테이블 (rawCid, unifiedId, x/y/w/h)
- groupIntoRows 결과
- detectResponsiveGroups + appliedMode
- row/그룹 DOM computed (flex-direction, flex, width, margin-left)
- ResizeObserver 로 wrapper 크기 변경 시 자동 재덤프

### 13.4 같은 세션에서 수정한 다른 버그

**대시보드 카드 삭제 무효 (`DashboardCanvas.handleRemove`)**
- 원인: zustand `removeCard` 만 호출하고 서버 API `deleteDashboardCard` 미호출 → 새로고침 시 DB 에서 재로드되어 카드 부활
- 수정: `activeDashboardId` 조회 후 `deleteDashboardCard(dashboardId, cardId)` 호출 추가 (낙관적 UI)

### 13.5 현재 남은 것

- **디자이너 편집 중 실시간 반영 미리보기** (현재는 저장 후 새 창)
- **responsiveMode 오버라이드 UI** (C 단계)
- 매우 좁은 카드에서 table 내부 self-scroll 외 추가 compact 단계 (pagination, header 등)
