# 가변 선 기반 레이아웃 시스템 설계 (v2, 2026-04-20)

> `band` 기반 TemplateRenderer 를 좌표 기반 variable line grid 로 전환하기 위
> 한 설계. 편집 모델은 자유배치(`xPct/yPct/wPct/hPct`) 유지. 렌더 시 블록
> 좌표에서 세로선/가로선을 뽑아내고, 그 사이 간격이 화면마다 다르게 형성되는
> variable track grid 를 구성한다. 편집기/미리보기/런타임이 동일
> `computeLayout()` 을 호출한다.
>
> 관련 파일 (기존):
> - `frontend/components/dash/TemplateRenderer.tsx`
> - `frontend/lib/utils/templateMigrate.ts`
> - `frontend/types/invyone-component.ts` (§7)
> - `frontend/components/dash/TemplateResponsivePreview.tsx`
> - `frontend/components/dash/DashboardCard.tsx`
>
> 신규:
> - `frontend/lib/layout/lineLayout.ts`

---

## 1. 설계 요약 (개정판)

### 1.1 원칙

1. **좌표가 진실**. role 은 semantic marker 로만 남고 레이아웃 계산에 개입하
   지 않는다. `main/companion/action` 은 grid 상 동일 취급. `overlay` 만
   absolute 강제.
2. **variable line grid**. 블록 경계선으로부터 xLines/yLines 를 추출하고, 각
   트랙 폭은 **인접 선 간 거리 비율** 에 비례한다 (`minmax(0, <ratio>fr)`).
   uniform grid (12칸/균일폭) 으로 후퇴하지 않는다.
3. **x / y tolerance 분리**. 세로축과 가로축은 서로 다른 밀도를 가질 수
   있으므로 baseXTolerance / baseYTolerance 를 각각 관리한다.
4. **0 과 1 경계 항상 포함**. wrapper 의 좌단/상단/우단/하단은 반드시 트랙
   경계로 보장한다.
5. **과분할 대응 — 점진 tolerance 증가 + thin track merge**. 기본 tol 은
   작게 두고, 선 개수가 과다하면 `tolerance *= 1.35` 로 올리며 재추출. 결과
   에서도 폭 비율이 너무 얇은 트랙은 인접 트랙으로 병합.
6. **overlay fallback 엄격화**. grid 에 안전하게 들어갈 수 없는 블록만
   overlay 로 도피. 남발 금지.
7. **edit ↔ runtime parity**. `computeLayout()` 은 순수 함수로 lib 에 분리
   되어 편집 미리보기·반응 미리보기·DashboardCard 런타임에서 동일 호출.
8. **feature flag 점진 전환**. band 와 line 을 한동안 병행, 롤백 가능.

### 1.2 파라미터 상수 (최종)

```ts
export const LAYOUT_CONSTANTS = {
  BASE_X_TOLERANCE: 0.004,        // 0.4% — 1920 캔버스에서 ~8px
  BASE_Y_TOLERANCE: 0.004,        // 0.4% — 1080 캔버스에서 ~4px
  MAX_TOLERANCE:    0.02,         // 2% — 과분할 재병합 상한
  TOLERANCE_GROWTH: 1.35,         // 1회 증가폭
  THIN_TRACK_THRESHOLD: 0.0025,   // 0.25% 미만 트랙은 병합 후보
  OVERLAP_THRESHOLD: 0.15,        // 작은 블록 면적 대비 15% 이상 겹치면 overlay
  SNAP_ERROR_MULT: 2,             // snap 오차 > tolerance * 2 면 overlay
};
```

### 1.3 현재 구조의 문제 재확인

- `TemplateRenderer#buildBands` 가 role+bandId 로 재조립 → 좌표 의미 왜곡.
- `.itpl-band-action` 이 `justify-content:flex-end` → 디자이너 좌표 무시.
- `templateMigrate#normalizeRoles` Stage 2 (button/input role 재분류) 가 매
  로드마다 좌표 기반 재분류 → **사용자가 v2 에 저장한 role 이 덮어써진다**.
  (templateMigrate.ts:359-399)
- `bandId` fallback 이 직전 main band 강제 합류 → 공간적으로 먼 action/
  companion 이 틀린 band 에 붙는다.

이 모든 것은 "좌표 있는데 좌표 버리고 semantic band 쓴다" 는 구조적 오류에서
비롯. 좌표 하나로 단일화가 필요하다.

---

## 2. lineLayout.ts 핵심 타입 / 함수 시그니처

```ts
// frontend/lib/layout/lineLayout.ts

// ── 입출력 타입 ─────────────────────────────────────────────────────────

/** 레이아웃 계산 입력 — BlockV2 의 최소 subset */
export interface LayoutBlockInput {
  id: string;
  xPct: number; yPct: number;
  wPct: number; hPct: number;
  /** 좌표 무관 absolute 강제. BlockV2.role==='overlay' 매핑용 */
  forceOverlay?: boolean;
  /** z-order hint (생략 시 입력 순서) */
  z?: number;
}

/** 레이아웃 옵션 (상수 오버라이드용, 대부분 기본값 사용) */
export interface LayoutOptions {
  baseXTolerance?:   number; // default 0.004
  baseYTolerance?:   number; // default 0.004
  maxTolerance?:     number; // default 0.02
  toleranceGrowth?:  number; // default 1.35
  thinTrackThreshold?: number; // default 0.0025
  overlapThreshold?:   number; // default 0.15
  snapErrorMult?:      number; // default 2
  /** 세로축 사이징 — 'fr'(비율) vs 'auto'(content-size) */
  rowSizing?: 'fr' | 'auto';   // default 'fr'
}

/** 트랙 하나 = 인접 두 선 사이의 간격 */
export interface GridTrack {
  index: number;        // 0-based
  startPct: number;     // 0~1
  endPct:   number;     // 0~1
  /** 전체 대비 폭 비율(= endPct - startPct). fr weight 로 쓴다 */
  weight: number;
}

/** 개별 블록의 레이아웃 결과 */
export interface BlockLayout {
  blockId: string;
  mode: 'grid' | 'overlay';
  // grid 모드 전용 (1-based span)
  colStart?: number; colEnd?: number;
  rowStart?: number; rowEnd?: number;
  // overlay 모드 전용 (원본 좌표)
  leftPct?: number; topPct?: number; wPct?: number; hPct?: number;
  // 디버그 필드
  snapError?: number;
  overlayReason?: 'forced' | 'overlap' | 'invalid-span' | 'snap-error';
}

/** 최종 레이아웃 결과 */
export interface LayoutResult {
  xLines: number[];               // 정렬된 x 경계선, 첫=0 끝=1
  yLines: number[];               // 정렬된 y 경계선, 첫=0 끝=1
  cols: GridTrack[];
  rows: GridTrack[];
  blocks: BlockLayout[];          // 입력 순서 보존
  xToleranceUsed: number;
  yToleranceUsed: number;
}

// ── 공개 함수 ────────────────────────────────────────────────────────────

/** 메인 — blocks 에서 variable line grid 를 계산 */
export function computeLayout(
  blocks: LayoutBlockInput[],
  opts?: LayoutOptions,
): LayoutResult;

/** grid-template-columns/rows 문자열 생성 헬퍼 */
export function toTrackTemplate(
  tracks: GridTrack[],
  mode: 'fr' | 'auto',
): string;

// ── 내부 헬퍼 (export 해서 디버깅/테스트 가능) ──────────────────────────

/** 값들을 tolerance 로 클러스터링해 경계선 배열 생성. 0 과 1 포함. */
export function extractLines(values: number[], tolerance: number): number[];

/** thin track(< threshold) 을 인접 트랙과 병합. 0/1 경계는 유지. */
export function mergeThinTracks(lines: number[], threshold: number): number[];

/** 블록 수 기준 target 선 개수를 넘지 않게 tolerance 를 점진 증가시켜 재추출 */
export function fitLines(
  values: number[],
  blockCount: number,
  baseTol: number,
  maxTol: number,
  growth: number,
  thinThreshold: number,
): { lines: number[]; tolerance: number };

/** 선 배열에서 value 에 가장 가까운 인덱스 + 오차 */
export function nearestLine(value: number, lines: number[]): { idx: number; err: number };
```

---

## 3. 알고리즘

### 3.1 선 추출

```ts
export function extractLines(values: number[], tol: number): number[] {
  const sorted = [...values].filter((v) => v >= 0 && v <= 1).sort((a, b) => a - b);
  const clusters: number[] = [];
  let bucket: number[] = [];
  for (const v of sorted) {
    if (bucket.length === 0 || v - bucket[bucket.length - 1] <= tol) {
      bucket.push(v);
    } else {
      clusters.push(bucket.reduce((s, x) => s + x, 0) / bucket.length);
      bucket = [v];
    }
  }
  if (bucket.length) {
    clusters.push(bucket.reduce((s, x) => s + x, 0) / bucket.length);
  }
  // 0 / 1 경계 강제 포함: tolerance 내 clusters 는 0/1 로 흡수
  const lines: number[] = [0];
  for (const c of clusters) {
    if (c <= tol) continue;           // 0 에 흡수됨
    if (c >= 1 - tol) continue;       // 1 에 흡수됨 (아래에서 push)
    lines.push(c);
  }
  lines.push(1);
  return lines;
}
```

### 3.2 Thin track 병합 (0.25%)

인접 선 사이 거리가 `thinTrackThreshold` 미만이면 내부 선 중 하나를 제거하
거나 평균으로 병합한다. 양 끝(0, 1) 은 보존.

```ts
export function mergeThinTracks(lines: number[], threshold: number): number[] {
  if (lines.length <= 2) return [0, 1];
  const cur = [...lines];
  let changed = true;
  // O(N^2) 이지만 선 개수는 수십개라 문제 없음
  while (changed && cur.length > 2) {
    changed = false;
    for (let i = 0; i < cur.length - 1; i++) {
      const gap = cur[i + 1] - cur[i];
      if (gap >= threshold) continue;
      if (i === 0) {
        // 첫 트랙이 얇음 → 두 번째 선(인덱스 1) 제거
        cur.splice(1, 1);
      } else if (i === cur.length - 2) {
        // 마지막 트랙이 얇음 → 뒤에서 두 번째 선 제거
        cur.splice(cur.length - 2, 1);
      } else {
        // 내부: 두 선을 평균으로 합치고 한 개 제거
        cur[i] = (cur[i] + cur[i + 1]) / 2;
        cur.splice(i + 1, 1);
      }
      changed = true;
      break;
    }
  }
  cur[0] = 0;
  cur[cur.length - 1] = 1;
  return cur;
}
```

### 3.3 점진 tolerance 증가 (과분할 대응)

```ts
export function fitLines(
  values: number[],
  blockCount: number,
  baseTol: number,
  maxTol: number,
  growth: number,
  thinThreshold: number,
) {
  const target = Math.max(4, blockCount * 2 + 2);
  let tol = baseTol;
  let lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
  while (lines.length > target && tol < maxTol) {
    tol = Math.min(maxTol, tol * growth);
    lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
  }
  return { lines, tolerance: tol };
}
```

### 3.4 span 매핑

```ts
export function nearestLine(v: number, lines: number[]) {
  let best = 0, bestErr = Infinity;
  for (let i = 0; i < lines.length; i++) {
    const e = Math.abs(lines[i] - v);
    if (e < bestErr) { best = i; bestErr = e; }
  }
  return { idx: best, err: bestErr };
}
```

각 블록에 대해:

```
l  = nearestLine(xPct,          xLines)
r  = nearestLine(xPct + wPct,   xLines)
t  = nearestLine(yPct,          yLines)
btm= nearestLine(yPct + hPct,   yLines)

snapError = max(l.err, r.err, t.err, btm.err)
colStart = l.idx   + 1   // grid 1-based
colEnd   = r.idx   + 1
rowStart = t.idx   + 1
rowEnd   = btm.idx + 1
```

### 3.5 Overlay 판정 (엄격)

판정 순서 (먼저 hit 하는 것을 이유로 기록):

1. `forceOverlay === true` → `overlayReason='forced'`.
2. `snapError > effectiveTolerance * 2` → `overlayReason='snap-error'`.
   - `effectiveTolerance = max(xToleranceUsed, yToleranceUsed)`
3. `colEnd <= colStart` 또는 `rowEnd <= rowStart` → `overlayReason='invalid-span'`.
4. 실제 bbox 교집합 면적이 두 블록 중 작은 블록 면적 대비 15% 이상 →
   `overlayReason='overlap'`. z 값이 크거나 뒤에 들어온 블록을 overlay 로.

4번은 이미 grid 에 배치 확정된 블록들 사이에서만 계산 (overlay 확정된 블록
간의 겹침은 z-index 로 해결되므로 제외).

### 3.6 Track template 생성

```ts
export function toTrackTemplate(
  tracks: GridTrack[],
  mode: 'fr' | 'auto',
): string {
  if (mode === 'auto') return tracks.map(() => 'auto').join(' ');
  return tracks
    .map((t) => `minmax(0, ${t.weight.toFixed(6)}fr)`)
    .join(' ');
}
```

`minmax(0, <w>fr)` 는 트랙이 content 때문에 자랄 수는 있어도 최소값 0 까지
줄어들 수 있도록 허용 — 좁은 카드에서 테이블/입력이 wrapper 를 밀어내는 문
제 차단.

### 3.7 메인 함수 (의사코드)

```ts
export function computeLayout(
  blocks: LayoutBlockInput[],
  opts: LayoutOptions = {},
): LayoutResult {
  const baseXTol  = opts.baseXTolerance   ?? 0.004;
  const baseYTol  = opts.baseYTolerance   ?? 0.004;
  const maxTol    = opts.maxTolerance     ?? 0.02;
  const growth    = opts.toleranceGrowth  ?? 1.35;
  const thinTh    = opts.thinTrackThreshold ?? 0.0025;
  const overlapTh = opts.overlapThreshold ?? 0.15;
  const snapMult  = opts.snapErrorMult    ?? 2;
  const rowSizing = opts.rowSizing        ?? 'fr';

  // 1. 명시적 overlay 분리
  const forced = blocks.filter((b) => b.forceOverlay);
  const candidates = blocks.filter((b) => !b.forceOverlay);

  // 2. 경계값 수집 (grid 후보만)
  const xs: number[] = [];
  const ys: number[] = [];
  for (const b of candidates) {
    xs.push(b.xPct, b.xPct + b.wPct);
    ys.push(b.yPct, b.yPct + b.hPct);
  }

  // 3. x/y 각각 선 추출 + thin merge + 과분할 재병합
  const fx = fitLines(xs, candidates.length, baseXTol, maxTol, growth, thinTh);
  const fy = fitLines(ys, candidates.length, baseYTol, maxTol, growth, thinTh);
  const xLines = fx.lines, yLines = fy.lines;
  const tolX = fx.tolerance, tolY = fy.tolerance;
  const cols = toTracks(xLines);
  const rows = toTracks(yLines);

  // 4. span 매핑 + snap error 판정
  const placed: BlockLayout[] = [];
  const gridOk: { b: LayoutBlockInput; layout: BlockLayout }[] = [];
  const effTol = Math.max(tolX, tolY);

  for (const b of candidates) {
    const l = nearestLine(b.xPct, xLines);
    const r = nearestLine(b.xPct + b.wPct, xLines);
    const t = nearestLine(b.yPct, yLines);
    const bt = nearestLine(b.yPct + b.hPct, yLines);
    const snapError = Math.max(l.err, r.err, t.err, bt.err);

    const colStart = l.idx + 1, colEnd = r.idx + 1;
    const rowStart = t.idx + 1, rowEnd = bt.idx + 1;

    if (snapError > effTol * snapMult) {
      placed.push(makeOverlay(b, snapError, 'snap-error'));
      continue;
    }
    if (colEnd <= colStart || rowEnd <= rowStart) {
      placed.push(makeOverlay(b, snapError, 'invalid-span'));
      continue;
    }
    const layout: BlockLayout = {
      blockId: b.id, mode: 'grid',
      colStart, colEnd, rowStart, rowEnd, snapError,
    };
    placed.push(layout);
    gridOk.push({ b, layout });
  }

  // 5. overlap 판정 — 이미 grid 확정된 블록 쌍에 한정
  for (let i = 0; i < gridOk.length; i++) {
    for (let j = i + 1; j < gridOk.length; j++) {
      const a = gridOk[i].b, c = gridOk[j].b;
      if (overlapRatio(a, c) >= overlapTh) {
        // z 큰쪽 또는 뒤에 들어온쪽을 overlay 로 강등
        const target = pickOverlayTarget(a, c, gridOk[i].layout, gridOk[j].layout);
        demoteToOverlay(target, 'overlap');
      }
    }
  }

  // 6. 명시 overlay 는 항상 absolute 로 덧붙임
  for (const b of forced) {
    placed.push(makeOverlay(b, 0, 'forced'));
  }

  return {
    xLines, yLines, cols, rows,
    blocks: placed,
    xToleranceUsed: tolX,
    yToleranceUsed: tolY,
  };
}
```

`makeOverlay`, `overlapRatio`, `toTracks` 는 §8 의 실제 구현에 정의.

---

## 4. 테스트 케이스 목록

`frontend/lib/layout/lineLayout.test.ts` 로 jest 테스트 작성. 최소 커버:

| # | 이름 | 입력 시나리오 | 기대 결과 |
|---|---|---|---|
| T1 | 2×2 균일 | 4개 블록 (0.5 폭·높이로 분할) | xLines=[0,0.5,1], yLines=[0,0.5,1], 모두 grid, snapError≈0 |
| T2 | 3열 1행 | wPct=1/3 씩 3개 | xLines=[0,0.333,0.667,1], rows=[0,1], 모두 grid |
| T3 | 세로 스택 5개 | wPct=1, hPct=0.2 씩 | xLines=[0,1], yLines=[0,0.2,0.4,0.6,0.8,1], 모두 grid |
| T4 | 0.3% 오차 병합 | 같은 열 2개 블록의 xPct 가 0.3 vs 0.303 | baseXTol=0.004 로 같은 선으로 병합, 과분할 없음 |
| T5 | 경계 흡수 | 블록 xPct=0.002 (≤ tol) | xLines 에 0.002 없음, 0 에 흡수됨 |
| T6 | 명시 overlay | role='overlay' 1개 + 일반 2개 | overlay 블록은 mode='overlay', overlayReason='forced', grid cols/rows 에 영향 없음 |
| T7 | 겹침 overlay | 50% 교차하는 두 블록 | 뒤 블록 mode='overlay', overlayReason='overlap' |
| T8 | 15% 경계 테스트 | 10% 겹침 2개 / 20% 겹침 2개 | 10% → 둘 다 grid, 20% → 뒤 블록 overlay |
| T9 | invalid span | wPct=0 블록 | overlay, overlayReason='invalid-span' |
| T10 | 얇은 트랙 병합 | 블록들이 0.1% 간격으로 거의 같은 선 | mergeThinTracks 로 병합되어 트랙 1개 |
| T11 | 과분할 재병합 | 블록 3개인데 경계 10개 (0.001 간격) | target=8 초과 → tolerance 1.35 배 증가 재추출 |
| T12 | snap error 초과 | tol 확정 후 블록 경계가 어느 선과도 멀어 err > tol*2 | mode='overlay', overlayReason='snap-error' |
| T13 | rowSizing='auto' | 일반 블록 2개 | rows template 에 `auto auto` 출력 |
| T14 | 경계 0,1 포함 | 블록 좌표가 중앙에만 있어도 | xLines[0]===0, xLines.at(-1)===1 |
| T15 | fr weight | xLines=[0,0.3,1] → cols.weight = [0.3, 0.7] | toTrackTemplate 출력이 `minmax(0, 0.3fr) minmax(0, 0.7fr)` |

---

## 5. debug=lines 시각화

활성 조건:
- URL 쿼리 `?debug=lines` 또는 `?debug=1`
- `localStorage.INVYONE_DEBUG_LINES = '1'`

표시:
- wrapper 에 `.itpl-dbg` 클래스 추가.
- `xLines.length` 개의 `<div className="itpl-line-v">` 를 wrapper 에 absolute
  렌더, `left: ${x*100}%`.
- `yLines.length` 개의 `<div className="itpl-line-h">` 를 `top: ${y*100}%`.
- 각 grid cell (블록 slot) 에 `outline: 1px dashed rgba(108,92,231,.35)`.
- 블록 wrapper 에 `data-col="${colStart}/${colEnd}"`,
  `data-row="${rowStart}/${rowEnd}"`, `data-snap="${snapError.toFixed(4)}"`.
- overlay 블록에 `data-overlay-reason="${overlayReason}"`.
- 마운트 시 `console.groupCollapsed('[layout] xLines/yLines/cols/rows/blocks')`
  로 구조화된 dump.

CSS:

```css
.itpl-dbg { outline: 1px solid rgba(108,92,231,.25); }
.itpl-dbg .itpl-line-v,
.itpl-dbg .itpl-line-h {
  position: absolute; z-index: 9999; pointer-events: none;
  background: rgba(0,206,201,.4);
}
.itpl-dbg .itpl-line-v { top: 0; bottom: 0; width: 1px; }
.itpl-dbg .itpl-line-h { left: 0; right: 0; height: 1px; }
.itpl-dbg .itpl-slot   { outline: 1px dashed rgba(108,92,231,.35); }
.itpl-dbg .itpl-overlay{ outline: 1px dashed rgba(253,121,168,.55); }
```

---

## 6. TemplateRenderer 교체 패치 초안

핵심 변경:
- `buildBands`, `.itpl-band*` CSS, `BlockSlot/OverlaySlot` 의 `leftGapPct`·
  `itpl-band-action/main` 구조 제거.
- `computeLayout()` 으로 layout 생성. grid / overlay 분리 렌더.
- feature flag (`useLineEngine`) 로 기존 band 렌더와 병행 운용.

```tsx
// frontend/components/dash/TemplateRenderer.tsx  (발췌 — line 엔진 부분)
import { computeLayout, toTrackTemplate } from '@/lib/layout/lineLayout';

function useLineEngineFlag(): boolean {
  if (typeof window === 'undefined') {
    return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
  }
  const qs = new URLSearchParams(window.location.search);
  const q = qs.get('layout');
  if (q === 'line' || q === 'band') return q === 'line';
  const ls = window.localStorage?.getItem('INVYONE_LAYOUT');
  if (ls === 'line' || ls === 'band') return ls === 'line';
  return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
}

function useDebugLinesFlag(): boolean {
  if (typeof window === 'undefined') return false;
  const qs = new URLSearchParams(window.location.search);
  if (qs.get('debug') === 'lines' || qs.has('debug')) return true;
  return window.localStorage?.getItem('INVYONE_DEBUG_LINES') === '1';
}

function LineGridView({
  blocks, context, view, aspectPolicy,
}: {
  blocks: BlockV2[];
  context: TemplateRenderContext;
  view: ViewKey;
  aspectPolicy: 'preserve' | 'free';
}) {
  const layout = useMemo(() => computeLayout(
    blocks.map((b) => ({
      id: b.id, xPct: b.xPct, yPct: b.yPct, wPct: b.wPct, hPct: b.hPct,
      forceOverlay: b.role === 'overlay',
    })),
    { rowSizing: aspectPolicy === 'preserve' ? 'fr' : 'auto' },
  ), [blocks, aspectPolicy]);

  const debug = useDebugLinesFlag();

  const gridTemplateColumns = toTrackTemplate(layout.cols, 'fr');
  const gridTemplateRows    = toTrackTemplate(layout.rows,
    aspectPolicy === 'preserve' ? 'fr' : 'auto');

  const byId = new Map(blocks.map((b) => [b.id, b] as const));

  if (debug && typeof window !== 'undefined') {
    // eslint-disable-next-line no-console
    console.groupCollapsed(
      '%c[layout/line]',
      'color:#00cec9;font-weight:bold',
      `blocks=${blocks.length} cols=${layout.cols.length} rows=${layout.rows.length}`,
    );
    console.table(layout.blocks.map((bl) => ({
      id: bl.blockId, mode: bl.mode,
      col: bl.mode === 'grid' ? `${bl.colStart}/${bl.colEnd}` : '',
      row: bl.mode === 'grid' ? `${bl.rowStart}/${bl.rowEnd}` : '',
      reason: bl.overlayReason ?? '',
      snapErr: bl.snapError?.toFixed(4) ?? '',
    })));
    console.log('xLines', layout.xLines);
    console.log('yLines', layout.yLines);
    console.log('tol x/y', layout.xToleranceUsed, layout.yToleranceUsed);
    console.groupEnd();
  }

  return (
    <div className={`itpl-wrapper itpl-line ${debug ? 'itpl-dbg' : ''}`}>
      <style>{LINE_CSS}</style>
      <div className="itpl-grid" style={{ gridTemplateColumns, gridTemplateRows }}>
        {layout.blocks.map((bl) => {
          const block = byId.get(bl.blockId);
          if (!block) return null;
          if (bl.mode === 'grid') {
            return (
              <div
                key={block.id}
                className={`itpl-slot role-${block.role} policy-${block.responsivePolicy}`}
                data-col={`${bl.colStart}/${bl.colEnd}`}
                data-row={`${bl.rowStart}/${bl.rowEnd}`}
                data-snap={bl.snapError?.toFixed(4)}
                style={{
                  gridColumn: `${bl.colStart} / ${bl.colEnd}`,
                  gridRow:    `${bl.rowStart} / ${bl.rowEnd}`,
                }}
              >
                <BlockRenderer block={block} context={context} view={view} />
              </div>
            );
          }
          return (
            <div
              key={block.id}
              className={`itpl-overlay role-${block.role} policy-${block.responsivePolicy}`}
              data-overlay-reason={bl.overlayReason}
              style={{
                left:   `${bl.leftPct! * 100}%`,
                top:    `${bl.topPct!  * 100}%`,
                width:  `${bl.wPct!    * 100}%`,
                height: `${bl.hPct!    * 100}%`,
              }}
            >
              <BlockRenderer block={block} context={context} view={view} />
            </div>
          );
        })}
        {debug && layout.xLines.map((x, i) => (
          <div key={`vx-${i}`} className="itpl-line-v" style={{ left: `${x * 100}%` }} />
        ))}
        {debug && layout.yLines.map((y, i) => (
          <div key={`hy-${i}`} className="itpl-line-h" style={{ top:  `${y * 100}%` }} />
        ))}
      </div>
    </div>
  );
}

const LINE_CSS = `
  .itpl-wrapper.itpl-line {
    position: relative; width: 100%; height: 100%;
    container-type: inline-size; container-name: tpl;
    box-sizing: border-box; overflow: hidden;
  }
  .itpl-line .itpl-grid {
    position: relative; display: grid; width: 100%; height: 100%;
    box-sizing: border-box;
  }
  .itpl-line .itpl-slot   { box-sizing: border-box; min-width: 0; min-height: 0; overflow: hidden; }
  .itpl-line .itpl-overlay{ position: absolute; z-index: 2; box-sizing: border-box; }
  .itpl-line .role-action { display: flex; align-items: center; }
  .itpl-dbg .itpl-line-v,
  .itpl-dbg .itpl-line-h  { position: absolute; z-index: 9999; pointer-events: none; background: rgba(0,206,201,.45); }
  .itpl-dbg .itpl-line-v  { top: 0; bottom: 0; width: 1px; }
  .itpl-dbg .itpl-line-h  { left: 0; right: 0; height: 1px; }
  .itpl-dbg .itpl-slot    { outline: 1px dashed rgba(108,92,231,.35); }
  .itpl-dbg .itpl-overlay { outline: 1px dashed rgba(253,121,168,.55); }
`;
```

기존 TemplateRenderer 의 최상위 return 부를 다음처럼 분기:

```tsx
const useLine = useLineEngineFlag();
// ...
if (useLine) {
  return <LineGridView
    blocks={blocks}
    context={context}
    view={view}
    aspectPolicy={(v2Views.canvas?.aspectPolicy ?? 'preserve')}
  />;
}
// 기존 band 렌더 (1차 이행 중 동일 출력 보장용)
return (<div className="itpl-wrapper">...</div>);
```

2~3주 안정화 후 else 블록과 `buildBands` 관련 코드 삭제.

---

## 7. templateMigrate.ts — normalizeRoles Stage 2 제거 패치

Stage 2 (coord 기반 button/input role 재분류) 완전 삭제. Stage 1 (legacy
componentId 통합 + policy 보정) 만 유지. `bandId` 는 읽을 때 건드리지 않고,
새로 쓸 때는 기록하지 않는 방향이지만, 1차 패치에선 제거하지 않고 그대로 둠
(구버전 데이터 호환 보전).

```ts
// frontend/lib/utils/templateMigrate.ts (normalizeRoles 교체)

// (COORD_DEPENDENT_ROLES 상수는 삭제)

function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
  const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => {
    if (!view) return view;

    // componentId / responsivePolicy 보정만 — role 은 디자이너 의도 그대로 보존.
    const next: BlockV2[] = view.blocks.map((b) => {
      const unified = LEGACY_TO_UNIFIED[b.componentId] ?? b.componentId;
      const nextPolicy = inferPolicy(unified);
      const idChanged = unified !== b.componentId;
      const policyChanged = b.responsivePolicy !== nextPolicy;
      if (!idChanged && !policyChanged) return b;
      return { ...b, componentId: unified, responsivePolicy: nextPolicy };
    });

    return { ...view, blocks: next };
  };
  return {
    ...v,
    list: patchView(v.list)!,
    ...(v.create ? { create: patchView(v.create)! } : {}),
    ...(v.edit  ? { edit:  patchView(v.edit)!  } : {}),
  };
}
```

`bandId` 는 이 함수에서 건드리지 않고 그대로 통과. computeLayout 은 BlockV2
의 `bandId` 필드를 **읽지도 않음** → 레이아웃 계산과 완전 무관.

Optional: 저장(save) 경로에서 새 포맷을 쓸 때는 `bandId` 를 기록하지 않도록
정리할 수 있으나 이번 패치에서는 호환만 유지.

---

## 8. Feature flag 기반 점진 전환안

우선순위:
1. `?layout=line` URL 쿼리 — 개발/디자인 QA 용.
2. `localStorage.INVYONE_LAYOUT === 'line'` — 고정 전환.
3. `NEXT_PUBLIC_LAYOUT_ENGINE === 'line'` — 배포 기본값.

전환 일정 (제안):

| Phase | 기간 | 기본값 | 설명 |
|---|---|---|---|
| P1 | Day 0~3 | `band` | line 은 URL 쿼리/localStorage 로만 활성. QA/디자인팀 확인 |
| P2 | Day 4~10 | `line` (env) | 배포 기본을 line 으로. `?layout=band` 로 롤백 가능 |
| P3 | Day 11~ | `line` 고정 | band 코드/CSS 삭제. `normalizeRoles` Stage 2 제거 확정. bandId 저장 중단 |

롤백:
- 문제 발생 시 `?layout=band` 또는 env 변경으로 즉시 복귀.
- line 엔진은 순수 함수라 roll-forward 패치(컴포넌트 격리 버그 등)가 쉬움.

---

## 9. 구현 순서

1. **Step 1 — lineLayout.ts + 테스트** (독립 모듈)
   - `frontend/lib/layout/lineLayout.ts` 신규.
   - `frontend/lib/layout/lineLayout.test.ts` jest 테스트 (T1~T15).
   - 런타임 렌더와 완전 분리되어 안전하게 병합 가능.

2. **Step 2 — TemplateRenderer 에 feature flag 로 연결**
   - `LineGridView` 추가 + `useLineEngineFlag()`.
   - 기본값 `band`, QA 수동 전환.

3. **Step 3 — debug=lines 시각화**
   - 위 §5 CSS + line div 렌더. console dump.

4. **Step 4 — preview / runtime parity 확인**
   - `TemplateResponsivePreview` 와 `DashboardCard` 모두 `TemplateRenderer`
     경유이므로 자동 parity. 추가로 ScreenDesigner 미리보기에 LineGridView
     를 read-only 로 심어 편집–렌더 일치 확인.

5. **Step 5 — normalizeRoles Stage 2 제거**
   - §7 패치 적용. 사용자 role 보존 테스트.

6. **Step 6 — band 제거 준비**
   - 기본 flag 를 line 으로 전환 (P2).
   - 1~2주 모니터링 후 `buildBands`, `.itpl-band*`, BlockSlot (기존),
     OverlaySlot (기존), band-line CSS 전부 삭제.
   - bandId 저장 중단 (templateAdapter 등 저장 경로 확인 필요).

---

## 10. 결정 포인트

- **aspectPolicy='free'** (세로 auto) 일 때 rowSizing='auto'. grid cell 내부
  `overflow: hidden` 이 content 세로 확장과 충돌 가능 → `.policy-scroll` 만
  내부 overflow 허용, 나머지는 유지.
- **responsivePolicy 해석** (line 엔진 하의 CSS):
  - `fixed` : cell 내부 `min-width:0; min-height:0; width:100%; height:100%;`
  - `scroll`: 동일 + 내부 컴포넌트가 `overflow: auto;` 처리
  - `reflow`: cell 내부 `display:grid; grid-template-columns: repeat(auto-fit, minmax(...));`
  - `wrap`  : cell 내부 `display:flex; flex-wrap:wrap;`
  (cell 자체의 위치/크기는 좌표 그대로. 내부 레이아웃만 policy 별로 달라짐)
- **action 의 우측 정렬 기본 없음**: 좌표 유지가 원칙. 필요 시
  `.role-action` 의 `justify-content` 를 `flex-end` 로 명시하는 옵트인 클
  래스를 컴포넌트 쪽에서 추가.

---

## 11. 완료 기준

- [ ] `lineLayout.ts` + T1~T15 테스트 모두 통과.
- [ ] feature flag ON 상태에서 기존 템플릿 시각 동일성(또는 개선) 확인.
- [ ] `?debug=lines` 로 선/셀/overlay 사유 확인 가능.
- [ ] `TemplateResponsivePreview` 크기 전환 시 열 폭이 디자이너 배치 비율을
      유지한 채 자연 축소.
- [ ] `normalizeRoles` 가 사용자 role 을 덮어쓰지 않음 (수동 확인).
- [ ] band 엔진 비활성 + 제거까지 완료.
