구현 핸드북 · 029 · 042 · 043
쇼케이스가 아니다. 다른 Claude Code 세션이 이 문서만 읽고 에이전트·스킬·CLAUDE.md를 세팅해 바로 작업에 들어갈 수 있도록, 세 프로젝트의 데이터 모델·암묵적 계약·실측 수치·탈락한 대안·조용한 실패 지점을 전부 적었다.
~/.claude/agents/에 저장 → ② 07장의 스킬을 .claude/skills/<name>/SKILL.md로 저장 → ③ 새 캔버스/에디터 프로젝트라면 08장 CLAUDE.md 템플릿을 복사해 계약 절부터 채움 → ④ 코드를 쓰기 전에 09장 조용한 실패 카탈로그를 읽고 해당 항목의 검증 스크립트를 먼저 만든다.
| 프로젝트 | 도메인 | 즉시 재사용 가능한 자산 | 상태 |
|---|---|---|---|
| 029 보고서 슬라이드 에디터 |
A4 가로(297×210mm) 슬라이드 → .pptx + .pdfReact 18 · Vite · zustand · react-moveable |
비율(0~1) 단일 데이터 모델 · 내보내기 암묵적 계약 4조 · PPT 표 회전 제약의 근거와 탈락 대안 3종 · 캡처 배율 상한 금지 근거(실측) · K-1~K-6 실패 카탈로그(다음 프로젝트의 설계 입력이 됨) | 7단계 전부 완료. 실사용은 K-3(저장 부재)로 막힘 — 실패가 자산인 프로젝트 |
| 042 디자인 템플릿 스튜디오 |
다규격 템플릿 플랫폼 → PNG/JPG/PDF React 18 · Vite · zustand · react-moveable |
계층 의존 규칙(model←store←UI, export는 panels 금지) · A-1~A-5 불변식 · 테마 10토큰 계약 코드 · 오프스크린 ExportStage · 한글 IME 인라인 편집 해법 · 검증 스크립트 12종 · 쿼터 초과 실측 처리 | 완성·배포됨 design-template-studio-bzf.pages.dev |
| 043 텔레그램 논의 봇 |
폰 ↔ Claude 논의 브리지 python-telegram-bot 22.8 · anthropic SDK |
메모리 폴더 자동 주입(색인 우선 + 문자 상한) · 프롬프트 캐시 블록 분리(persona/projects) · 사용자 ID 화이트리스트 · 4096자 자연 분할 · 실패 턴 히스토리 롤백 · 히스토리 트리밍(user 시작 보장) | 파이프라인 완주 확인 API 크레딧 충전 대기 |
두 에디터의 공통 1원칙은 "보이는 그대로 나온다"이고, 이를 지키는 유일한 방법은 출력별 좌표를 따로 들지 않는 것이다.
| 항목 | 029 · 비율(0~1) | 042 · 절대 디자인 px |
|---|---|---|
| 좌표 저장 | x,y,w,h = 슬라이드 대비 비율 0~1, 폰트는 pt | frame{x,y,w,h}·size = 아트보드 기준 절대 px |
| 화면 맞춤 | .page 폭이 창 크기에 종속 → 폰트까지 함께 스케일 | .artboard 폭은 상수, .artboard-scaler의 transform:scale()만 변경 |
| 줄바꿈 | 창 크기마다 달라진다 (결정적이지 않음) | 창·줌과 무관하게 결정적 |
| 파생 결과 | overflow:hidden이 얹혀 K-1(조용한 텍스트 잘림) 발생 | 오프스크린 내보내기·넘침 정책이 성립 |
| 내보내기 변환 | x_inch = x × 11.69 (A4 가로 폭) | 같은 문서 JSON을 같은 <Artboard>로 오프스크린 재렌더 |
.page key 금지, .exporting 숨김 동기화, selectedId 복원, 내보내는 중 편집 차단, 페이지 전환 깜빡임)가 발생 자체를 하지 않는다.
절대 디자인 px + 상수 레이아웃 폭. 줌은 transform만.
요소 기능보다 먼저. 비협상 항목(029 K-3).
화면 DOM을 캡처하지 않는다.
자르지 않는다. 못생긴 게 소실보다 낫다.
불변식 1개당 1개. 전체를 한 번에 돌린다.
경로 Desktop/claude code project/보고서_슬라이드_에디터 · 호출어 "보고서 에디터" / "슬라이드 에디터"
// ─── 단일 데이터 모델 ────────────────────────────────────────────
// pages: [{ id, objects: [{ id, type, x, y, w, h, rotation, style, content }] }]
// x/y/w/h는 슬라이드 대비 비율(0~1). objects 배열 순서 = 레이어 순서(뒤가 위).
// 화면 렌더링 / PPT / PDF 내보내기 모두 이 모델 하나만 읽는다.
export const DEFAULT_TEXT_STYLE = {
fontFamily: '맑은 고딕', // ⚠️ K-2의 원인 — 재배포 불가 폰트를 기본값으로 두면 안 된다
fontSize: 18, // pt (px가 아니다)
color: '#222222', bold: false, align: 'left', lineHeight: 1.4,
};
const x = obj.x * SLIDE_W_IN; (src/export/pptxExport.js). A4 가로 = 11.69 × 8.27 inch. 파생값을 모델에 저장하는 순간 이 단순함이 사라진다.| 계약 | 깨뜨렸을 때 증상 |
|---|---|
.page에 key={page.id}를 붙이지 말 것 | 캡처 루프가 시작 시점의 querySelector('.page') 참조를 끝까지 재사용한다. remount되면 참조가 죽은 노드를 가리켜 빈/구버전 페이지가 캡처된다. |
편집 오버레이 추가 시 .page.exporting 숨김 규칙을 함께 추가 | 여백 가이드·스냅선 같은 편집 UI가 출력물에 그대로 찍힌다. |
캡처 중 selectedId=null → finally에서 복원 | 복원을 빼면 내보내기 후 선택이 풀린다. 안 내리면 선택 핸들이 출력물에 찍힌다. |
.page에 flex-shrink:0 필수 | .canvas-wrap이 flex라 기본값이면 좁은 창에서 캔버스가 찌그러진다(실측: 320px 선언 → 292px 렌더). A4 √2 비율이 깨지고 PDF까지 왜곡된다. |
PPT 내 편집 불가 — 라이브러리 한계가 아니라 제품 제약
types/index.d.ts에서 rotate는 Image·Shape·Text에만 있고 TableProps(1676~1794행)엔 없음addTable에 rotate 전달 → 옵션 없음·무시텍스트 검색·복사 불가를 의도적으로 수용
renderingMode:'invisible') 검토 — 착수 전 "한글이 실제로 복사되는가" 3줄 스파이크 먼저상한이 화질만 떨어뜨리는 역효과
captureScaleFor() = 목표 픽셀 ÷ 현재 캔버스 폭 (150dpi 고정)max(목표, 화면 폭)으로 수렴 → 배율이 커져도 메모리 불변| # | 증상 | 실측 근거 | 042에서의 처리 |
|---|---|---|---|
| K-1 | 텍스트가 화면에서 조용히 잘리는데 PPT엔 안 잘린 채 나감 | .text-view{overflow:hidden} ↔ addText(wrap:true). 90자 문단에서 scrollHeight 154px vs clientHeight 73px — 절반이 무표시 소실. 같은 데이터로 세 출력이 전부 다름 | A-3: clip 자체를 스키마에서 제거. 넘쳐도 보이게 둔다 |
| K-2 | 화면 글꼴 ≠ PPT 글꼴 | 기본값 맑은 고딕이 이 Mac에 없음 → 화면은 Apple SD Gothic Neo 폴백, PPT는 fontFace:'맑은 고딕' 기록. 같은 문자열 폭 약 13% 차이 → K-1과 곱해짐 | C-2: OFL 폰트만 셀프호스팅. PNG 주력이라 원리적 소멸 |
| K-3 | 저장·Undo 없음 | grep -rn "localStorage|persist|beforeunload|undo" src/ → 0건. 새로고침=전체 소실, Delete는 확인 없이 즉시·복구 불가 | Step 1에 배치. "요소 먼저, 저장 나중" 순서를 금지 — 비협상 |
| K-4 | 내보내는 중에도 편집 가능 | disabled={!!exporting}이 헤더 버튼 2개에만. 캡처 대상이 실시간 DOM이라 그 사이 편집하면 출력물 깨짐. 50p면 초당 ~10장 전환 → 광과민성 우려 | A-2: 오프스크린이라 화면 편집이 출력에 영향 없음 → 차단 자체가 불필요 |
| K-5 | 숫자 입력 회전 시 Moveable 박스가 안 따라 돎 | 핸들 회전은 정상. 프로그램적 변경 시 컨트롤 박스가 축 정렬로 남아, 리사이즈가 화면 축으로 늘어남 | 프로그램적 rotation 변경 시 moveableRef.current.updateRect() 동반 호출 |
| K-6 | 내보내기 고지가 파일 저장보다 늦음 | 실측: alert t+225ms → download t+226ms. 되돌릴 수 없는 결과를 통보만. "N개"라고만 해서 어느 표인지 못 찾음. alert()로 err.message 원문 노출 | 상태바·인라인 배너로 상시 표기(쿼터 초과도 동일 원칙) |
경로 042_디자인_템플릿_스튜디오 · 호출어 "템플릿 스튜디오" / "카드뉴스 에디터" / "디자인 템플릿"
src/
├── model/ 최하층 — 아무것도 import하지 않는다
│ presets.js 규격 프리셋 테이블 (규격 추가는 여기 한 줄)
│ themes.js 테마 5종 + 10토큰 계약 검사
│ fonts.js 번들 폰트 4종 (OFL만)
│ schema.js 문서/요소 스키마 · 기본값 · normalizeDoc
│ resolve.js "@primary" → hex. 색을 해석하는 유일한 지점
│ units.js px → mm/inch/pt (PDF 내보내기 전용)
├── store/ useStore · history(스냅샷) · storage(localStorage) · autosave
├── canvas/ Artboard(화면·썸네일·내보내기 공용) · ArtboardStage(scale) · 요소 뷰
├── panels/ Header · LeftRail · Inspector · StatusBar
├── gallery/ 템플릿 갤러리 · 내 디자인
├── templates/ 템플릿 = 문서와 같은 스키마 (.js 모듈)
├── library/ 아이콘 레지스트리 · 도형 · 이미지 인테이크
└── export/ ExportStage · capture · pngExport
# 의존 방향 (어기면 리뷰에서 반려)
panels/canvas/gallery → store → model
export → model (+ 렌더용 canvas/Artboard 만)
# ⛔ export가 panels를 import하면 안 된다
# → 내보내기에 에디터 UI가 새어든다. 029가 .page.exporting 숨김 규칙을
# 필요로 했던 이유가 정확히 이 경계의 부재였다.
검증 test_scale_invariance.mjs — 창 3종에서 아트보드 폭·줄 수 동일
.artboard width/height = 문서 디자인 px 그대로.artboard-scaler의 transform:scale()로만같은 문서 JSON → 같은 <Artboard> → 오프스크린 렌더 → 캡처
grow(기본) | shrink 둘뿐. 한계에서도 자르지 않음
overflow:hidden 금지더하거나 빼면 계약 위반. dev 빌드가 검사·경고
genesis-team-cards의 구조/테마 토큰 2계층 계약 이식업로드 이미지는 assets 슬라이스에만, 요소는 assetId만 참조
// ⚠️ 아키텍처 A-4: 토큰 계약.
// 모든 테마는 TOKEN_KEYS와 "정확히 같은" 키 집합을 갖는다.
// 키를 더하거나 빼면 계약 위반 — 템플릿이 어떤 테마에서는 그려지고
// 어떤 테마에서는 안 그려지는 상태가 되기 때문이다.
export const TOKEN_KEYS = [
'bg', // 아트보드 배경
'surface', // 카드·패널 면
'fg', // 기본 글자색
'muted', // 보조 글자색
'mutedBg', // 보조 면(연한 강조 배경)
'primary', // 강조색
'pfg', // primary 면 위의 글자색
'line', // 구분선·테두리
'r', // 모서리 반경
'font', // 기본 폰트 id (fonts.js의 키)
];
// 템플릿은 "@primary" 같은 토큰 참조만 쓰고, hex 해석은 model/resolve.js 한 곳에서만 한다.
// → 테마 전환이 "색만" 바꾸고 좌표·폰트 크기는 건드리지 않음이 보장된다.
// 검증: test_template.mjs — 5개 테마 전환에도 좌표·폰트 크기 완전 동일
contentEditable 내부를 다시 그리면 글자가 깨진다. 해법: 편집 중에는 내용을 비제어로 두고(editing ? null : el.content), composition 이벤트로 조합 상태를 추적해 blur에서만 스토어에 반영한다.const composing = useRef(false);
const commitText = () => {
// 조합 중이면 아직 반영하지 않는다
if (composing.current) return;
onCommitText(elRef.current?.innerText ?? '');
};
<div
contentEditable={editing || undefined}
suppressContentEditableWarning={editing || undefined}
spellCheck={editing ? false : undefined}
onCompositionStart={editing ? () => { composing.current = true; } : undefined}
onCompositionEnd={editing ? () => { composing.current = false; } : undefined}
onBlur={editing ? commitText : undefined}
onPointerDown={editing ? (e) => e.stopPropagation() : undefined}
style={{ outline: editing ? '2px solid #246bfe' : undefined, userSelect: editing ? 'text' : undefined }}
>
{editing ? null : el.content} {/* ← 편집 중 React가 내부를 관리하지 않게 한다 */}
</div>
// 추가로 반드시 함께 처리할 것:
// · 편집 중에는 선택 핸들을 숨긴다 (글자를 가린다)
// · 편집 중에는 전역 단축키를 막는다 (Backspace가 요소를 삭제해버린다)
// · 편집 상태가 내보내기·썸네일로 새어나가지 않는지 검증 (test_inline_edit.mjs)
| # | 제약 | 근거와 재발 조건 |
|---|---|---|
| C-1 | html2canvas가 재현 못 하는 CSS는 스키마에 넣지 않는다 | filter/mix-blend-mode/mask/backdrop-filter 제외. 화면-출력 불일치의 원천. 넣고 싶으면 먼저 캡처 재현을 실측하고 되면 그때 스키마에 추가 |
| C-2 | 폰트는 셀프호스팅 woff2 단일 파일. CDN·unicode-range 분할 금지 | Pretendard Variable(2.0MB)·Black Han Sans·Jua·Song Myung — 전부 OFL, 라이선스 원문 동봉. CDN 금지=캡처 시점에 폰트 미도착/크로스오리진 실패 가능. 분할 금지=미로드 서브셋 글자만 대체 폰트로 찍힘. PPTX 내보내기를 나중에 붙이면 K-2가 되살아난다 |
| C-3 | 스톡사진 검색 API 없음 | 직접 업로드만. 라이선스 회피가 목적 |
| C-4 | localStorage 5MB — 모든 접근은 src/store/storage.js 한 파일에만 | IndexedDB 전환이 이 파일 교체로 끝나야 한다. 업로드는 긴 변 2048px 리샘플. 쿼터 초과는 상태바에 붉게 명시 — 조용히 삼키지 않는다 |
스크립트 (docs/verify/*.mjs) | 지키는 불변식 / 실측 결과 |
|---|---|
test_scale_invariance.mjs | A-1 · 029 K-1의 근본 원인 — 창 크기 무관 레이아웃 폭·줄 수, 외부 폰트 요청 0건 |
test_persist.mjs | 029 K-3 회귀 — 새로고침 복귀 · Undo/Redo 10회 왕복 · 문서 2개 상호 전환 |
test_manipulate.mjs | scale 0.707에서 드래그 좌표 오차 0.4px 이내 · 드래그 1회 = Undo 1회 |
test_overflow.mjs | A-3 · grow에서 frame.h 자동 추종, shrink 배율 0.766 표시, 조용한 클립 0건 |
test_image.mjs | A-5 · C-4 · 리샘플 · 문서에 base64 없음 · 히스토리 경량 |
test_template.mjs | A-4 · 템플릿 10종 전부 열림·고유 id · 5개 테마 전환에도 좌표·폰트 크기 완전 동일 |
test_export.mjs | A-2 · 창 3종에서 출력 2160×2700px 동일 · 편집 UI 미혼입 · 캡처 중 화면 상태 불변 |
test_inline_edit.mjs | 한글 IME 조합 단계(ㅎ→하→한) 유지 · Backspace가 요소를 지우지 않음 · 커밋 경계 |
test_deployed.mjs | 배포본 완주 — 기본 대상이 라이브 URL. 외부 도메인 요청 0건 · 콘솔 에러 0건 |
test_largedoc.mjs | 50페이지 구성 0.1초 · 저장·복원·Undo·삭제 정상 · 이미지 없는 문서는 localStorage 85KB |
test_largedoc_fullexport.mjs | 50페이지 전체 내보내기 실측 10.2초 (20p 실측 1.9초에서의 추정 10.3초와 거의 일치) |
test_quota.mjs | 약 4.9MB(에셋 25개)에서 실제 초과 → saveState가 error로 전환, 상태바 rgb(229,72,77)로 실제 붉게 표시, 앱 생존·직전 저장분 무손상·미처리 예외 0건 |
patchElement가 부분 frame 갱신에서 w/h를 잃음(Object.assign이 el.frame을 부분 객체로 통째 교체 후 다시 스프레드 → 드래그마다 크기 소실) ② 페이지 스트립 썸네일이 같은 data-el-id 중복 생성. UI를 바꾸면 검증 스크립트도 같이 고친다. 스위트는 부분만 돌리면 못 잡는다.경로 ~/telegram-claude-bot/ (메인 폴더 밖) · 303줄 단일 파일 · 롱폴링이라 공개 서버·웹훅 불필요
웹훅은 공개 HTTPS 엔드포인트가 필요하다. 롱폴링은 맥이 켜져 있을 때만 동작하는 대신 도메인·인증서·터널이 전부 불필요하다.
run_polling오토메모리 .md를 통째로 읽어 <projects> 블록으로 주입한다. 색인(MEMORY.md)을 먼저 넣고 나머지는 이름순, 문자 상한에서 자른다.
안정적인 projects 블록에만 cache_control을 건다. persona는 짧아 캐시 이득이 없고, 블록을 합치면 캐시 경계가 흐려진다.
def load_context() -> None:
"""CONTEXT_DIR 안의 .md 파일들을 모아 프로젝트 컨텍스트 문자열로 만든다."""
global _project_context, _project_titles
_project_titles = []
parts: list[str] = []
total = 0
# MEMORY.md(색인)를 먼저, 나머지는 이름순
files = sorted(CONTEXT_DIR.glob("*.md"))
files.sort(key=lambda p: (p.name != "MEMORY.md", p.name))
for f in files:
text = f.read_text(encoding="utf-8")
block = f"\n\n### {f.name}\n{text}"
if total + len(block) > CONTEXT_CHAR_LIMIT: # 상한에서 멈춘다(조용히 넘기지 않고 로그)
log.info("컨텍스트 길이 상한 도달, %s 이후 생략", f.name)
break
parts.append(block); total += len(block)
_project_titles.append(f.name)
_project_context = "".join(parts)
def build_system() -> list[dict]:
"""캐시 가능한 system 블록(persona + projects)을 만든다."""
persona_block = {"type": "text", "text": BASE_PERSONA}
if not _project_context:
return [persona_block]
projects_block = {
"type": "text",
"text": f"<projects>{_project_context}\n</projects>",
"cache_control": {"type": "ephemeral"}, # 안정적 → 프롬프트 캐시
}
return [persona_block, projects_block]
| 처리 | 구현과 이유 |
|---|---|
| 권한 잠금 | ALLOWED_USER_IDS 화이트리스트. 비어 있으면 누구나 대화 가능이라 main()에서 경고 로그를 띄운다. 거부 시 상대의 user id를 응답에 돌려줘 등록이 쉽다. |
| 4096자 자연 분할 | 텔레그램 1건 한도 4096. rfind("\n", 0, limit)로 개행에서 끊고, 적당한 개행이 limit//2 앞에 없으면 그냥 자른다 — 단어 중간 절단보다 "문단 보존 우선, 실패 시 강제 절단" 정책. |
| 실패 턴 롤백 | API 호출 실패 시 history.pop()으로 방금 넣은 user 메시지를 되돌린다. 안 하면 실패한 질문이 맥락에 남아 다음 턴부터 대화가 어긋난다. |
| 히스토리 트리밍 | MAX_HISTORY=30(user+assistant 합산) 초과 시 앞에서 자르고, 자른 결과가 assistant로 시작하면 하나 더 버려 항상 user로 시작하게 만든다(Messages API 제약). |
.env 기본값이 CLAUDE_MODEL=claude-opus-4-8, 주석의 대안이 claude-sonnet-4-6으로 되어 있다. 현행 최신은 Claude 5 계열이므로 claude-opus-5(품질) 또는 claude-sonnet-5(비용·속도)로 올려 쓰는 것이 맞다. 참고로 이 봇은 thinking={"type":"adaptive"} + messages.stream()으로 호출한다.# 필수
TELEGRAM_BOT_TOKEN= # @BotFather → /newbot
ANTHROPIC_API_KEY= # console.anthropic.com
ALLOWED_USER_IDS= # @userinfobot으로 내 id 확인. 복수는 쉼표. 비우면 전체 공개(비권장)
# 선택
CONTEXT_DIR=/Users/mac/.claude/projects/-Users-mac/memory
CLAUDE_MODEL=claude-opus-5 # 비용 절감: claude-sonnet-5
MAX_TOKENS=8000
MAX_HISTORY_MESSAGES=30
CONTEXT_CHAR_LIMIT=120000
# 실행
# cd ~/telegram-claude-bot && ./venv/bin/python bot.py
# 백그라운드: nohup ./venv/bin/python bot.py > bot.log 2>&1 &
# 명령어: /start /help /reset /projects /reload
claude plugin validate ~/.claude/agents로 스키마를 확인하고, 호출 후 어느 경로(정식 위임 / 인라인)로 동작했는지 사용자에게 알리는 규칙을 함께 둘 것.---
name: canvas-architect
description: 캔버스·에디터·문서 편집기의 데이터 모델과 출력 파이프라인을 설계·감사한다. "에디터 만들어줘", "좌표계 어떻게 잡지", "내보내기가 화면과 다르다", "PPT/PDF/PNG로 내보내기" 같은 요청에 사용한다.
tools: Read, Grep, Glob, Write, Edit, Bash
model: opus
---
너는 WYSIWYG 에디터의 데이터 모델과 출력 파이프라인을 설계하는 아키텍트다.
판단 근거는 029(보고서 슬라이드 에디터)의 실패 실측과 042(디자인 템플릿 스튜디오)의 불변식이다.
## 설계 시 반드시 먼저 확정하는 3가지 (순서 고정)
1. **좌표계**: 절대 "디자인 px" + 레이아웃 폭 상수. 화면 맞춤은 transform: scale()만.
- 레이아웃 폭을 창 크기에 종속시키면 줄바꿈이 비결정적이 되고,
오프스크린 내보내기와 넘침 정책이 함께 무너진다. 이 셋은 한 묶음이다.
2. **저장·Undo**: 요소 기능보다 **먼저** 만든다. 비협상 항목.
- 029는 요소를 다 만든 뒤 저장이 없어서(K-3) 7단계를 완료하고도 실사용 불가가 됐다.
3. **출력 경계**: 내보내기는 화면 DOM을 읽지 않는다. 같은 문서 JSON을 오프스크린에 재렌더해 캡처.
- export 레이어가 UI 레이어를 import하면 반려한다.
## 불변식 (어기는 설계는 거부하고 대안을 제시한다)
- 단일 데이터 모델. 출력별 좌표를 따로 저장하지 않는다. 파생값은 내보내기 시점 계산.
- 텍스트를 자르지 않는다(clip 금지). 넘쳐서 못생긴 것이 내용이 사라진 것보다 낫다.
- 테마는 고정 키 집합의 토큰으로만. 키를 더하거나 빼면 계약 위반.
- 이미지 원본(base64)은 문서에 넣지 않는다. 에셋 슬라이스 + id 참조. 안 그러면 Undo 스냅샷이 수 MB씩 복제된다.
- 캡처 라이브러리가 재현 못 하는 CSS는 스키마에 넣지 않는다(filter·mix-blend-mode·mask·backdrop-filter).
- 폰트는 재배포 가능 라이선스(OFL 등)만 셀프호스팅. CDN·unicode-range 분할 금지.
## 작업 방식
- 기존 코드를 감사할 때는 먼저 `grep -rn "localStorage|undo|beforeunload"`로 저장 경로 유무를 확인한다.
- "화면과 출력이 다르다"는 신고를 받으면 증상이 아니라 **좌표계와 캡처 대상**부터 본다.
대개 원인은 (가) 레이아웃 폭이 창에 종속 (나) 실시간 DOM 캡처 둘 중 하나다.
- 개선안을 낼 때는 **탈락한 대안과 그 탈락 이유**를 함께 적는다. 근거 없는 "더 나은 방법"은 적지 않는다.
- 실측하지 않은 수치를 쓰지 않는다. 추정이면 추정이라고 표시한다.
- 변경 금지 절(아키텍처·결정된 제약)을 건드려야 하면, 먼저 그 절에 적힌 근거가 무효가 됐는지 확인하고 보고한다.
---
name: export-auditor
description: 내보내기·캡처·출력 파이프라인에서 "에러 없이 조용히 틀리는" 실패를 찾아낸다. "내보낸 결과가 화면과 다르다", "PDF에 편집 UI가 찍혔다", "글자가 잘렸다", "출력 크기가 창마다 다르다" 같은 신고에 사용한다.
tools: Read, Grep, Glob, Bash
model: opus
---
너는 출력 파이프라인 감사자다. 목표는 **예외를 던지지 않으면서 잘못된 결과를 내는 경로**를 찾는 것이다.
에러 로그가 깨끗하다는 사실은 무죄의 근거가 아니다.
## 점검 체크리스트 (029에서 실측으로 확인된 것들)
□ 텍스트 넘침: 화면 `overflow:hidden` ↔ 출력 `wrap:true` 조합이 있는가?
→ `scrollHeight` vs `clientHeight`를 실제로 재서 보고한다. (029 실측: 154px vs 73px, 절반 무표시 소실)
□ 폰트: 화면 폴백 폰트와 출력 파일에 기록되는 fontFace가 같은가?
→ 재배포 불가 폰트(맑은 고딕·바탕·돋움)가 기본값이면 수신자 화면에서만 줄이 밀린다(폭 약 13% 차이).
□ 캡처 대상: 실시간 화면 DOM인가, 오프스크린 재렌더인가?
→ 실시간이면 캡처 중 편집 차단·선택 핸들 해제·편집 오버레이 숨김 규칙이 **모두** 있는지 확인.
□ DOM 참조 수명: 캡처 루프가 시작 시점의 노드 참조를 재사용하는데 그 노드가 remount되는가?
(리스트 key 추가가 흔한 원인)
□ 레이아웃 종속성: 창 크기를 3종으로 바꿔도 출력 픽셀 크기와 줄 수가 동일한가?
□ flex 찌그러짐: 캔버스 컨테이너가 flex인데 `flex-shrink:0`이 없는가?
(029 실측: 320px 선언 → 292px 렌더, 비율 붕괴)
□ 배율 상한: 캡처 배율에 불필요한 상한이 걸려 좁은 창에서 해상도가 떨어지는가?
(029 실측: 상한 4배 → 820×640 창에서 100dpi 추락)
□ 고지 타이밍: 비가역 결과(파일 저장)보다 경고가 늦게 뜨는가?
(029 실측: alert t+225ms vs download t+226ms — 통보일 뿐 선택권이 아니다)
□ 쿼터·용량: 저장 한도 초과를 사용자에게 **명시**하는가, 조용히 삼키는가?
## 보고 형식
각 항목을 **확인 방법 → 실측값 → 판정(문제/정상/미확인)** 으로 적는다.
"미확인"을 "정상"으로 바꿔 쓰지 않는다. 재현 스크립트를 만들 수 있으면 경로를 제안한다.
---
name: context-bridge
description: Claude를 외부 채널(텔레그램·슬랙·웹훅 등)에 연결하는 브리지를 만들고 운영한다. "폰에서 쓰고 싶다", "봇으로 붙여줘", "메모리를 주입해서 대화하게 해줘" 같은 요청에 사용한다.
tools: Read, Write, Edit, Bash, Grep, Glob, WebFetch
model: sonnet
---
너는 Claude API를 메신저 채널에 붙이는 브리지를 만든다. 레퍼런스 구현은 043 텔레그램 논의 봇(303줄, python-telegram-bot + anthropic SDK)이다.
## 기본 설계 결정
- **롱폴링을 먼저 검토한다.** 웹훅은 공개 HTTPS·인증서·터널이 필요하다.
개인용이고 상시 가동이 아니면 롱폴링이 운영 비용을 0으로 만든다.
- **컨텍스트는 파일 폴더에서 주입한다.** 색인 파일을 먼저, 나머지는 이름순,
문자 상한에서 끊고 **끊었다는 사실을 로그로 남긴다**(조용히 생략하지 않는다).
- **캐시 경계를 나눈다.** 안정적인 대용량 블록에만 `cache_control: {type: "ephemeral"}`.
짧은 persona와 합치지 않는다 — 캐시 적중 경계가 흐려진다.
- **모델 id를 하드코딩하지 않는다.** 환경변수로 빼고 기본값은 현행 최신(claude-opus-5 / claude-sonnet-5).
## 운영 필수 처리 (하나라도 빠지면 미완성으로 본다)
1. 권한 화이트리스트. 비어 있으면 기동 시 경고. 거부 응답에 상대 id를 돌려줘 등록을 쉽게.
2. 채널 길이 한도 분할. 개행 경계 우선, 적당한 경계가 없으면 강제 절단.
3. 호출 실패 시 방금 추가한 user 메시지를 히스토리에서 되돌린다.
4. 히스토리 트리밍 후 **항상 user 역할로 시작**하게 보정한다.
5. 비밀값은 `.env`에만. `.env.example`로 계약을 문서화하고 `.gitignore`를 확인한다.
## 금지
- 토큰·키를 코드·로그·커밋에 남기지 않는다.
- 파일을 수정하는 권한을 봇에 주지 않는다(043은 "대화만, 파일은 안 건드림"이 설계다).
파일 조작이 필요하면 사용자에게 먼저 확인받는 경로를 만든다.
---
name: verify-suite
description: 프로젝트의 아키텍처 불변식마다 Playwright 검증 스크립트를 1:1로 만들고, 전체를 한 번에 돌려 회귀를 잡는다. "검증 스크립트 만들어줘", "회귀 테스트", "불변식 깨졌는지 확인" 요청에 사용한다.
---
# 검증 스위트 (불변식 1개 = 스크립트 1개)
042에서 확립된 방식이다. 유닛 테스트가 아니라 **아키텍처 불변식의 회귀 감지**가 목적이다.
## 규칙
1. **불변식 1개당 스크립트 1개.** 파일명은 지키는 성질을 그대로 쓴다
(`test_scale_invariance.mjs`, `test_overflow.mjs`, `test_quota.mjs` …).
2. **각 스크립트 머리에 "무엇을 지키는가"를 주석으로 쓴다.** CLAUDE.md의 절 번호(A-1 등)를 인용한다.
3. **Playwright는 의존성으로 설치하지 않는다.** 필요할 때만 `npm i -D playwright`.
(webkit은 Safari가 아니므로 자동화로 Safari를 검증했다고 판단하지 말 것)
4. **스토어 접근은 dev 전용 전역으로.** `window.__store`를 `src/main.jsx`에서 dev 모드에만 노출하고
프로덕션 번들에서 제외한다.
5. 실행: `npm run dev` 후 `node docs/verify/<이름>.mjs` (포트가 다르면 `BASE_URL=...`).
배포 검증 스크립트는 **기본 대상을 라이브 URL로** 둔다.
## 반드시 측정으로 끝낸다 (주장 금지)
- "창 크기와 무관" → 창 3종에서 **같은 값**임을 출력해 비교한다.
- "성능 괜찮다" → 20p 실측 후 50p를 **실제로** 돌린다. (042: 추정 10.3초 → 실측 10.2초)
- "쿼터 초과를 처리한다" → **실제로 초과시켜서** 상태바 색(rgb 값)과 앱 생존, 직전 저장분 무손상,
미처리 예외 0건을 확인한다. (042: 약 4.9MB / 에셋 25개에서 초과)
## ⚠️ 가장 흔한 실패 — 이 스킬의 존재 이유
**UI를 바꾸면 기존 검증 스크립트도 같이 고쳐야 한다.**
042 Step 3에서 좌측 레일이 "추가 버튼"→"탭 전환"으로 바뀌었는데 스크립트 3종을 갱신하지 않아
한동안 조용히 깨져 있었다. 전체를 한 번에 돌려보고서야 발견했고, 그때 진짜 버그 2건도 함께 나왔다.
→ **UI 변경 커밋에는 검증 스크립트 갱신을 포함한다. 스위트는 부분만 돌리지 않는다.**
## 산출물
- `docs/verify/*.mjs` — 스크립트
- `docs/EXPORT-CHECKLIST.md` — 수동 절차와 미검증 항목(브라우저 등)을 명시적으로 남긴다.
미검증을 공백으로 두지 말고 "사양"인지 "미확인"인지 구분해 적는다.
---
name: claude-md-contract
description: 프로젝트 CLAUDE.md를 "계약서" 형식으로 쓰고 유지한다. 아키텍처 불변식·결정된 제약(탈락한 대안 포함)·확인된 미해결 문제를 구분해 적는다. "CLAUDE.md 정리해줘", "프로젝트 규칙 문서화", "왜 이렇게 했는지 기록" 요청에 사용한다.
---
# CLAUDE.md = 계약서 (설명서가 아니다)
029·042에서 검증된 절 구성이다. 목적은 **미래의 세션이 같은 함정을 다시 밟지 않게** 하는 것이다.
## 절 구성 (이 순서)
1. **호출** — 어떤 말로 부르면 이 프로젝트인지.
2. **실행 / 배포** — 복붙 가능한 명령. 배포 함정도 함께(예: 최신 wrangler가 `pages project create`를
Workers로 위임해 실패 → 이미 생성된 프로젝트는 `pages deploy`만 쓴다).
3. **대상 환경** — 지원 범위를 **사양으로 선언**한다.
"Chrome 전용 개인 도구다. Safari 미검증은 공백이 아니라 사양이다." 식으로.
4. **핵심 아키텍처 (변경 금지)** — 번호를 붙인다(A-1, A-2 …). 각 항목에:
- 무엇을 고정하는가
- **왜 중요한가** (이걸 깨면 무엇이 연쇄로 무너지는가)
- **검증 스크립트 경로**
5. **결정된 제약 (되돌리기 전에 읽을 것)** — 번호(C-1 …). 각 항목에:
- 결정 / 근거(실측·공식문서 인용) / **탈락한 대안과 탈락 이유** / 유지할 것
- 조건부 대안이 있으면 "착수 조건"을 명시한다("요구가 생기기 전에는 착수하지 않는다").
6. **암묵적 계약** — 깨면 **에러 없이 조용히** 틀린 결과가 나오는 전제들.
7. **확인된 미해결 문제** — 번호(K-1 …) + 우선순위 + 실측 근거.
"재발견에 시간 쓰지 말 것"이라고 적어둔다.
8. **검증** / **진행 상태** — 체크박스. 완료와 미실시를 섞지 않는다.
## 작성 규칙
- **근거 없는 규칙을 쓰지 않는다.** 숫자를 쓰면 어떻게 측정했는지 함께 쓴다.
- **"개선으로 보이는 방향이 이미 탈락한 대안"임을 명시한다.** 이게 가장 비싼 정보다.
- 실패를 지우지 않는다. 029의 K-1~K-6은 042의 A-1~A-5로 전환되어 **설계 입력**이 됐다.
- 해결된 항목은 삭제하지 말고 "어떻게 구조로 죽였는지"를 남긴다.
---
name: cf-pages-deploy
description: 정적 사이트·SPA를 Cloudflare Pages에 wrangler로 배포한다(git 연동 없이 디렉터리 직접 업로드). "배포해줘", "Pages에 올려줘", "재배포" 요청에 사용한다.
---
# Cloudflare Pages 배포 (wrangler 직접 업로드)
이 환경의 모든 배포는 git remote 없이 디렉터리를 직접 올리는 방식이다. 배포 이력은 `~/.wrangler/logs/`.
## 패턴 A — wrangler.toml이 있는 프로젝트 (권장)
```toml
name = "my-project"
compatibility_date = "2026-10-01"
pages_build_output_dir = "dist" # 또는 outputs
```
```bash
# 프로젝트 루트에서 (positional '.' 없이 — toml이 출력 디렉터리를 지정한다)
npx wrangler pages deploy --branch=main --commit-dirty=true
```
## 패턴 B — 빌드가 필요한 SPA
```bash
npm run build
npx wrangler pages deploy dist --project-name --branch main --commit-dirty=true
```
## 함정
- **최신 wrangler는 `pages project create`를 Workers 배포로 위임해 실패한다.**
이미 생성된 프로젝트는 `pages deploy`만 쓰면 되고, 신규 생성이 막히면 `--force` 1회로 만든 뒤
그 다음부터는 쓰지 않는다.
- **특정 페이지를 홈으로 만들려면** 출력 디렉터리에 `_redirects`를 둔다: `/ /portfolio.html 200`.
단 Pages는 `*.html`과 `index.html`을 확장자 없는 경로로 **자동 308 리다이렉트**하므로,
최종 결과는 `/` → `/portfolio`가 된다(URL이 바뀐다). 또한 이 설정을 넣으면 루트의
`index.html`은 브라우저로 접근할 수 없게 된다 — 남겨둬야 하면 다른 파일명으로 옮긴다.
- **개인정보가 담긴 빌드 산출물을 그대로 올리지 않는다.** 마스킹 빌드를 따로 만들어 그것만 올리고,
배포 전 `grep`으로 실제 번호·이메일이 소스에 없는지 확인한다.
- 비밀값은 `npx wrangler pages secret put NAME --project-name `로. toml·코드에 두지 않는다.
## 배포 후
`curl -sI `로 상태코드를, `curl -sL | grep -o "[^<]* "`로
실제 서빙되는 문서를 확인한다. "배포 성공" 로그는 의도한 내용이 서빙된다는 증거가 아니다.
# NNN. <프로젝트명>
<한 줄 정의: 무엇을 편집해서 무엇으로 내보내는가>. <스택>.
## 호출
"<별칭1>", "<별칭2>" → 이 프로젝트.
## 실행 / 배포
```bash
npm install
npm run dev # http://localhost:5173
npm run build
bash deploy.sh # 배포
```
공개 주소: <URL>
## 대상 환경
**<브라우저> 전용 개인 도구다.** 그 외 미검증은 공백이 아니라 **사양**이다.
공유 계획이 생기면 그때 `docs/EXPORT-CHECKLIST.md`를 채운다.
---
## 핵심 아키텍처 (변경 금지)
### A-1. 좌표는 절대 디자인 px. 레이아웃 폭은 상수, 줌은 transform만
<구현 위치>
> **왜 중요한가**: 레이아웃 폭이 창에 종속되면 줄바꿈이 비결정적이 되고,
> 그 위에 overflow:hidden이 얹히면 내용이 조용히 사라진다(029 K-1).
> 이 성질이 A-2·A-3의 전제다. **셋은 한 묶음으로 무너진다.**
> 검증: `docs/verify/test_scale_invariance.mjs`
### A-2. 내보내기는 화면 에디터 DOM을 읽지 않는다
오프스크린 스테이지가 같은 문서 JSON을 같은 컴포넌트로 재렌더해 캡처한다.
> 실시간 DOM 캡처는 부채 5개를 부른다 — 노드 key 금지, 숨김 규칙 동기화,
> 선택 상태 복원, 내보내는 중 편집 차단, 페이지 전환 깜빡임.
### A-3. 넘쳐도 자르지 않는다 (clip 금지)
`fit`은 grow | shrink 둘뿐. 텍스트용 overflow:hidden을 어디에도 쓰지 않는다.
> 배지로 알리는 방식은 기각 — 배지를 놓치면 내용이 소실된 결과물이 나간다.
### A-4. 테마 토큰은 고정 키 집합
키를 더하거나 빼면 계약 위반. dev 빌드가 검사해 경고한다.
### A-5. 바이너리 원본은 문서에 넣지 않는다
에셋 슬라이스 + id 참조. 문서에 base64가 들어가면 Undo 스냅샷마다 복제된다.
---
## 결정된 제약 (되돌리기 전에 읽을 것)
### C-1. <제약명>
- **결정**: <무엇을 하기로 했는가>
- **근거**: <실측값 또는 공식 문서 인용 + 확인 날짜>
- **탈락한 대안**: ① <대안> → <왜 탈락> ② … ③ …
- **유지할 것**: <분기를 넓히지 말라는 등의 경계>
- **조건부 대안**: <요구가 생기면 검토할 것 + 착수 전 스파이크>. 요구 전에는 착수하지 않는다.
---
## 암묵적 계약 (깨면 에러 없이 조용히 틀린다)
- <전제 1> — 깨뜨렸을 때 증상
- <전제 2> — 깨뜨렸을 때 증상
---
## 확인된 미해결 문제 (재발견에 시간 쓰지 말 것)
### K-1. <증상> 〔우선순위 N〕
<실측 근거: 수치와 측정 방법>
→ <해결 방향>
---
## 검증
불변식 1개당 스크립트 1개. `docs/verify/*.mjs`, 절차는 `docs/EXPORT-CHECKLIST.md`.
**UI를 바꾸면 검증 스크립트도 같이 고친다. 스위트는 전부 한 번에 돌린다.**
## 진행 상태
- [x] Step 0 스캐폴드 — 검증: <…> (날짜)
- [x] Step 1 저장·Undo — **요소보다 먼저. 비협상 항목**
- [ ] Step N <…>
화면 overflow:hidden + 출력 wrap:true. 좌측 패널에는 전체 문장이 남아 있어 사용자는 들어간 줄 안다.
실측: scrollHeight 154px vs clientHeight 73px.
UI를 바꿨는데 셀렉터를 안 고쳐서 스크립트가 "통과"하는 상태. 부분 실행으로는 못 잡는다.
발견 경로: 스위트 전체 재실행.
Object.assign(el, patch)가 el.frame을 부분 객체로 통째 교체한 뒤 다시 스프레드 → {x,y}만 전달하는 드래그에서 w/h가 사라진다.
로컬에 없는 폰트를 기본값으로 두면 화면은 폴백, 출력 파일에는 원래 이름이 기록된다.
실측: 같은 문자열 폭 약 13% 차이. 넘침 문제와 곱해진다.
flex 컨테이너 안에서 flex-shrink:0이 없으면 좁은 창에서 선언값보다 작게 렌더된다.
실측: 320px 선언 → 292px 렌더. 비율이 깨지고 출력까지 왜곡.
비가역 동작(파일 저장) 뒤에 뜨는 고지는 통보일 뿐 선택권이 아니다.
실측: alert t+225ms → download t+226ms.
헤드리스 Chrome 스크린샷에서 종료 코드 정상·15KB PNG 생성인데, 열어보니 ERR_FILE_NOT_FOUND 에러 페이지였다(저장 경로와 Chrome에 넘긴 경로 불일치). 배포도 같다 — "Deployment complete" 로그는 의도한 문서가 서빙된다는 증거가 아니다.
규칙: 캡처한 이미지는 반드시 다시 열어 확인한다. 배포 후에는 curl -sL <url> | grep -o "<title>[^<]*</title>"로 실제 서빙 문서를 확인한다.
| Step | 할 일 | 완료 판정(검증으로만) |
|---|---|---|
| 0 | 스캐폴드 — 폰트 셀프호스팅(OFL), 규격 프리셋, 테마 토큰, 아트보드 렌더 | 창 3종 × 규격 3종에서 레이아웃 폭·줄 수 동일, 외부 폰트 요청 0건 |
| 1 | 저장·Undo·셸 — 스냅샷 히스토리(상한 50), 자동저장(디바운스 1.5s + 강제 flush 10s + beforeunload), 라우팅 | 새로고침 후 전체 복원, Undo/Redo 10회 왕복, 문서 2개 상호 전환 ⚠️ 이 순서가 비협상 — 029는 이걸 미뤄서 7단계 완료 후에도 실사용 불가 |
| 2 | 조작·스냅 — 드래그/리사이즈/회전, 클릭 즉시 드래그, 방향키 1px·Shift 10px | scale 0.707에서 드래그 좌표 오차 0.4px 이내, 드래그 1회 = Undo 1회 |
| 3 | 요소 종류 + 넘침 정책 — text/image/shape/icon, 아이콘은 명시 import, 이미지 긴 변 리샘플 | grow에서 높이 자동 추종, shrink 배율 표시, 조용한 클립 0건, 큰 사진 넣어도 문서·히스토리 경량 |
| 4 | 템플릿 + 테마 스와핑 + 다중 페이지 — 템플릿은 문서와 같은 스키마 | 템플릿 전부 열림·고유 id, 테마 전환이 좌표·폰트 크기를 바꾸지 않음 |
| 5 | 내보내기 — 오프스크린 스테이지, 배수 옵션, 다중 페이지 | 창 3종에서 출력 픽셀 크기 동일, 편집 UI 미혼입, 캡처 중 화면 상태 불변 |
| 6 | 배포 — 정적 호스팅, 라이브 URL 대상 검증 스크립트 | 배포본에서 전체 시나리오 완주, 외부 도메인 요청 0건, 콘솔 에러 0건 |
| 7+ | 남은 리스크를 추정에서 실측으로 바꾼다 — 대용량, 저장 쿼터 초과, IME 편집 | 50p 전체 내보내기 실제 소요 측정, 쿼터를 실제로 초과시켜 표시·생존·무손상·예외 0건 확인 |
test_quota.mjs 전문을 그대로 싣고 한 줄씩 해부한다. 이 한 편을 이해하면 나머지 11종을 쓸 수 있다. 검증 스크립트는 유닛 테스트가 아니라 아키텍처 불변식의 회귀 감지기라서 구조가 거의 동일하다.어떤 불변식(CLAUDE.md 절 번호)을 지키는지 주석으로 명시
pageerror·console 수집기를 먼저 건다
localStorage clear → reload → 결정론적 시작점
실제로 그 상황을 만든다(추정 금지)
check() 누적 → exit(0/1)
// localStorage 쿼터 초과 검증 — CLAUDE.md C-4 "쿼터 초과는 상태바에 붉게 명시한다
// (조용히 삼키지 않는다)"를 실제로 쿼터를 채워서 확인한다.
// 지금까지는 리샘플 동작만 봤고(test_image.mjs), "진짜로 가득 찼을 때" 경로는 미검증이었다.
import { chromium } from 'playwright';
const BASE = process.env.BASE_URL ?? 'http://localhost:5173'; // ← 배포본 검증 시 주입
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1440, height: 900 } });
// ⚠️ 관측 장치를 "탐색 전에" 건다. goto 뒤에 걸면 초기 로딩 중 에러를 놓친다.
const errors = [];
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
page.on('console', (m) => m.type() === 'error' && errors.push(`console: ${m.text()}`));
let pass = true;
const check = (l, ok, x = '') => {
if (!ok) pass = false;
console.log(` ${ok ? '✓' : '✗'} ${l}${x ? ' — ' + x : ''}`);
};
// 결정론적 시작점: 이전 실행이 남긴 상태를 지우고 새로 띄운다.
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
await page.locator('.preset-card:has-text("인스타 정사각")').click();
await page.waitForSelector('.editor');
// 실제 리샘플 이미지 크기(~144KB)와 비슷한 더미 base64를 합성해 넣는다.
// 목적은 "쿼터 초과 시 앱이 어떻게 반응하는가"이지 이미지 디코딩이 아니므로
// 진짜 이미지 파일을 매번 업로드하지 않고 store.addAsset을 직접 호출한다.
const result = await page.evaluate(async () => {
const chunk = 'A'.repeat(200 * 1024); // 약 200KB 상당 더미
let added = 0, hitQuota = false;
for (let i = 0; i < 200; i++) { // 상한을 둬 무한루프 방지
const asset = {
id: `as_quota_test_${i}`, type: 'image/jpeg', w: 800, h: 600,
bytes: chunk.length,
dataURL: `data:image/jpeg;base64,${chunk}_${i}`,
createdAt: new Date().toISOString(),
};
const got = window.__store.getState().addAsset(asset);
if (got === null) { hitQuota = true; break; } // ← 실패를 null로 돌려받는 계약
added++;
}
return {
added, hitQuota,
saveState: window.__store.getState().saveState, // ← dev 전용 전역
saveError: window.__store.getState().saveError,
};
});
window.__store인가: UI를 200번 클릭해 쿼터를 채우면 느리고 깨지기 쉽다. 스토어를 개발 모드에서만 전역으로 노출해(src/main.jsx, 프로덕션 번들 제외) 상태를 직접 조작한다. 단 관측(검증)은 반드시 UI 쪽에서 한다 — 스토어 값만 보면 "화면에 실제로 붉게 떴는가"를 못 본다.check('반복 추가 중 쿼터 초과에 도달함', result.hitQuota,
`${result.added}개 추가 후 초과 (약 ${(result.added * 200 / 1024).toFixed(1)}MB)`);
check('쿼터 초과 시 saveState가 error로 전환', result.saveState === 'error');
check('에러 메시지가 비어있지 않음(조용히 실패 아님)', !!result.saveError);
// ── 생존 확인: 초과 "이후에도" 앱이 살아 있는가 ──────────────
await page.reload({ waitUntil: 'networkidle' });
const afterReload = await page.evaluate(() => ({ hasDoc: !!window.__store.getState().doc }));
check('새로고침 후 앱이 정상 기동(크래시 없음)', afterReload.hasDoc !== undefined);
// QuotaExceededError가 콘솔에 찍히는 건 문제가 아니다.
// 우리가 throw하지 않고 state로 흡수했는지가 핵심이므로 "미처리 예외"만 실패로 본다.
const uncaught = errors.filter((e) => e.startsWith('pageerror'));
check('미처리 예외 없음', uncaught.length === 0, uncaught.join(' / '));
console.log(pass ? '\n✅ 쿼터 초과 검증 통과' : '\n❌ 실패 항목 있음');
await b.close();
process.exit(pass ? 0 : 1); // ← CI가 읽는 신호. console.log만 하면 아무도 실패를 모른다
class QuotaError extends Error {
constructor(message) { super(message); this.name = 'QuotaError'; }
}
// 브라우저마다 쿼터 초과 신호가 다르다 — 이름·코드를 모두 본다
function isQuotaError(e) {
return e && (e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || // Firefox
e.code === 22 || e.code === 1014);
}
// 쓰기 실패를 조용히 삼키지 않는다 — 호출부가 사용자에게 보여줘야 한다.
function write(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
if (isQuotaError(e)) {
throw new QuotaError('저장 공간이 가득 찼습니다. 이미지가 많은 디자인을 정리해 주세요.');
}
throw e; // 쿼터가 아닌 에러는 그대로 올린다(원인 은폐 금지)
}
}
function read(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
// 손상된 항목은 없는 것으로 취급한다(앱 전체가 못 뜨는 것보다 낫다).
return fallback;
}
}
// <불변식 요약> 검증 — CLAUDE.md <A-n / C-n> "<원문 인용>"을 실제로 확인한다.
// 실행: npm run dev 후 node docs/verify/test_xxx.mjs
// 배포본: BASE_URL=https://<live> node docs/verify/test_xxx.mjs
import { chromium } from 'playwright';
const BASE = process.env.BASE_URL ?? 'http://localhost:5173';
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
let pass = true;
const check = (l, ok, x = '') => { if (!ok) pass = false;
console.log(` ${ok ? '✓' : '✗'} ${l}${x ? ' — ' + x : ''}`); };
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// TODO 1. 조건을 "실제로" 만든다 (추정값으로 단정하지 않는다)
// TODO 2. 결과를 측정한다 (가능하면 숫자로 — 나중 회귀 비교의 기준이 된다)
// TODO 3. check()로 단언한다 (실패 시 측정값을 함께 출력)
check('미처리 예외 없음', errors.filter((e) => e.startsWith('pageerror')).length === 0);
console.log(pass ? '\n✅ 통과' : '\n❌ 실패');
await b.close();
process.exit(pass ? 0 : 1);
# Playwright는 의존성으로 설치하지 않는다. 필요할 때만:
npm i -D playwright && npx playwright install chromium
# 개발 서버를 띄운 채 전부 돌린다
npm run dev &
for f in docs/verify/test_*.mjs; do
echo "── $f"
node "$f" || echo "❌ FAILED: $f"
done
# 배포본 검증(라이브 URL 대상)
BASE_URL=https://<your-site> node docs/verify/test_deployed.mjs
| 증상 | 진짜 원인 | 조치 |
|---|---|---|
| 창 크기를 바꾸면 줄바꿈 위치가 달라진다 | 레이아웃 폭이 창에 종속(029 구조). 폰트까지 함께 스케일됨 | 아트보드 폭을 상수로 고정하고 줌은 transform:scale()만. test_scale_invariance.mjs로 창 3종 비교 |
| 화면엔 멀쩡한데 내보낸 파일에 편집 UI(가이드선·핸들)가 찍힘 | 실시간 화면 DOM을 캡처하고 있고, 숨김 규칙이 새 오버레이를 커버하지 못함 | 오프스크린 스테이지로 전환(근본). 당장은 .page.exporting 숨김 규칙에 해당 클래스 추가 |
| 드래그할 때마다 요소 크기가 줄어든다/사라진다 | Object.assign(el, patch)가 frame을 부분 객체로 통째 교체 | frame·style을 분리 병합: {...el, frame:{...el.frame, ...patch.frame}} |
| 내보내기가 빈 페이지/구버전 페이지를 캡처 | 캡처 루프가 쥔 DOM 참조가 remount로 죽음(리스트 key 추가가 흔한 원인) | 캡처 대상 노드에 key를 주지 말 것. 또는 매 루프마다 querySelector 재조회 |
| 좁은 창에서 캔버스 비율이 깨짐 | flex 컨테이너 안에서 flex-shrink 기본값(1)로 압축됨 | 캔버스에 flex-shrink:0. 실측: 320px 선언 → 292px 렌더 |
| 회전 숫자 입력 후 리사이즈가 이상한 축으로 늘어남 | Moveable 컨트롤 박스가 프로그램적 변경을 모름 | moveableRef.current.updateRect()를 rotation 변경과 함께 호출 |
| 한글 입력 중 글자가 깨짐(ㅎ→하→한이 "ㅎ하한") | 조합 중 React가 contentEditable 내부를 리렌더 | 편집 중 비제어(editing ? null : content) + compositionstart/end 추적, blur에서만 커밋 |
| 편집 중 Backspace가 요소를 통째로 지움 | 전역 단축키가 편집 상태를 모름 | 편집 중 전역 키 핸들러 비활성화(shortcuts.js) |
| 증상 / 에러 | 진짜 원인 | 조치 |
|---|---|---|
QuotaExceededError(Firefox: NS_ERROR_DOM_QUOTA_REACHED, code 22/1014) | localStorage 5MB 초과. 대개 이미지 base64가 문서에 들어간 경우 | 에셋을 별도 슬라이스로 분리하고 요소는 id만 참조. 업로드는 긴 변 2048px 리샘플. 실측: 에셋 25개 ≈ 4.9MB에서 초과 |
| Undo 몇 번에 앱이 느려지고 메모리가 급증 | 스냅샷에 base64가 포함돼 되돌리기마다 수 MB 복제 | 문서에서 바이너리 제거(A-5). 실측: 분리 후 4000×3000 사진에도 문서 1KB·히스토리 6단계 3KB |
| 새로고침하면 작업이 전부 사라짐 | 저장 경로 자체가 없음(029 K-3) | grep -rn "localStorage\|beforeunload\|undo" src/로 확인. 0건이면 요소 기능보다 먼저 저장·Undo를 넣는다 |
| 저장은 되는데 가끔 마지막 편집이 유실 | 디바운스만 있고 강제 flush·beforeunload가 없음 | 디바운스 1.5s + 10s 강제 flush + beforeunload 3중 장치 |
| 손상된 데이터 하나 때문에 앱이 아예 안 뜸 | 읽기 실패를 throw | 읽기는 fallback으로 흡수, 쓰기는 올린다(위 비대칭 원칙) |
| 증상 | 진짜 원인 | 조치 |
|---|---|---|
| 받는 사람 PC에서만 줄이 한 줄 더 넘어감 | 로컬에 없는 폰트를 출력 파일에 이름으로 기록(029 K-2). 폭 약 13% 차이 | OFL 폰트만 셀프호스팅하고 목록을 거기로 제한. 재배포 불가 폰트(맑은 고딕·바탕·돋움) 금지 |
| 캡처 결과에서 글자가 네모(두부)로 나옴 | CDN 폰트가 캡처 시점에 미도착, 또는 unicode-range 분할된 서브셋 미로드 | woff2 단일 파일 셀프호스팅. CDN·분할 금지(042 C-2) |
| 그림자·블러·블렌드가 출력물에서 사라짐 | html2canvas가 filter/mix-blend-mode/mask/backdrop-filter를 재현 못 함 | 해당 CSS를 스키마에 넣지 않는다(042 C-1). 쓰고 싶으면 먼저 캡처 재현을 실측 |
| 좁은 창에서 출력 해상도가 떨어짐 | 캡처 배율에 상한을 걺 | 상한 제거. 실측: 상한 4배 시 820×640 창에서 100dpi로 추락 |
| PPT에서 회전된 표가 이미지로 나옴 | PowerPoint 제품 자체가 표 회전 미지원(라이브러리 한계 아님) | 회전 0°는 진짜 표, 회전≠0만 이미지. 사용자에게 고지. 분기를 넓히지 말 것 |
| 증상 / 에러 | 진짜 원인 | 조치 |
|---|---|---|
400 invalid_request_error: "Your credit balance is too low..." | API 크레딧 잔액 0 (봇 코드 문제 아님) | 콘솔에서 충전하거나 CLAUDE_MODEL을 저비용 모델로 |
| 첫 질문 실패 후 대화가 계속 어긋남 | 실패한 user 메시지가 히스토리에 남음 | 예외 처리에서 history.pop()으로 되돌린다 |
| messages API가 역할 순서 오류를 반환 | 히스토리 트리밍 결과가 assistant로 시작 | 자른 뒤 첫 항목이 user가 아니면 하나 더 버린다 |
| 답변이 중간에 잘림 | 텔레그램 1건 4096자 제한 | 개행 경계 우선 분할, 경계가 없으면 강제 절단 |
| 매 턴 비용이 줄지 않음 | 캐시 블록 경계가 흔들림(매번 바뀌는 내용과 합침) | 안정적인 대용량 블록에만 cache_control. persona와 분리 |
| 봇이 아무에게나 응답 | ALLOWED_USER_IDS 비어 있음 | 화이트리스트 설정. 기동 시 비어 있으면 경고 로그 |
화면·PPT·PDF·PNG 등 모든 출력이 읽는 유일한 상태. 출력별 좌표를 따로 저장하지 않는 것이 핵심이고, 파생값(inch·px)은 내보내는 순간 계산한다.
언제나 참이어야 하는 성질. 예: "창 크기와 무관하게 줄 수가 같다". 테스트 케이스가 아니라 구조가 보장해야 하는 약속이라, 깨지면 기능 하나가 아니라 설계가 무너진다.
코드에 적혀 있지 않지만 깨면 에러 없이 잘못된 결과가 나오는 전제. 예: "캡처 중 .page는 remount되지 않는다". 문서화하지 않으면 반드시 재발한다.
예외도 로그도 없이 결과만 틀린 상태. 이 문서의 적. 발견 수단은 측정뿐이라 검증 스크립트가 필요하다.
화면에 보이지 않는 별도 영역에 같은 문서를 다시 그려서 캡처하는 방식. 화면 상태와 출력이 분리돼 편집 차단·핸들 숨김 같은 부채가 사라진다.
Undo를 위해 상태 전체를 복사해 쌓는 방식(diff 방식의 반대). 구현이 단순한 대신 상태에 바이너리가 있으면 즉시 무너진다.
모든 테마가 정확히 같은 키 집합을 갖는다는 약속. 키가 다르면 "어떤 테마에서만 깨지는" 상태가 된다. 구조 토큰(좌표·크기)과 테마 토큰(색·폰트)을 2계층으로 분리한다.
반복 전송되는 큰 컨텍스트를 서버가 재사용하게 해 비용·지연을 줄이는 기능. cache_control: {type:"ephemeral"}. 블록 경계가 안정적일 때만 효과가 난다.
서버가 먼저 호출하는 대신(웹훅) 클라이언트가 계속 물어보는 방식. 공개 HTTPS 엔드포인트가 필요 없어 개인 환경에 적합하다. 대신 프로세스가 떠 있어야 한다.
한글·일본어처럼 여러 키 입력이 한 글자로 합쳐지는 과정. 조합 중 DOM을 건드리면 글자가 깨지므로 compositionstart/end로 경계를 잡아야 한다.
"저장 기능은 나중에 붙이면 된다. 먼저 요소부터."
029는 7단계를 전부 완료하고도 저장이 없어 실사용 불가였다. 저장·Undo는 Step 1이고 비협상이다. 나중에 붙이면 이미 쌓인 상태 구조를 전부 손봐야 한다.
"넘치는 텍스트는 잘라서 숨기고 배지로 알려주면 된다."
배지를 놓치면 내용이 사라진 결과물이 나간다. 042는 이 방향을 기각했다 — 넘쳐서 못생긴 것이 내용 소실보다 낫다. clip을 스키마에서 아예 제거했다.
"렌더가 성공 로그를 냈으니 결과도 맞다."
빈 컴포지션도, 경로가 틀린 스크린샷도 "성공"한다. 실제로 15KB PNG가 생성됐는데 열어보니 ERR_FILE_NOT_FOUND 페이지였던 사례가 있다. 결과를 열어서 본다.
"테스트가 다 통과하니 회귀는 없다."
UI를 바꾸면 셀렉터가 어긋나 스크립트가 조용히 통과한다. 042에서 3종이 한동안 죽어 있었다. UI 변경 커밋에 검증 갱신을 포함하고, 스위트는 전부 한 번에 돌린다.
"에러는 전부 잡아서 로그만 남기면 안전하다."
쓰기 실패를 삼키면 사용자는 저장된 줄 알고 작업을 잃는다. 쓰기는 올리고 읽기는 흡수 — 사용자가 잃을 것이 무엇인지로 결정한다.
"브라우저 호환성을 다 맞춰야 완성이다."
029·042 둘 다 Chrome 전용 개인 도구로 범위를 선언했다. 미검증을 "공백"이 아니라 사양으로 적으면, 하지 않은 일이 빚으로 쌓이지 않는다. (Playwright의 webkit은 Safari가 아니다.)
~/.claude/agents/<name>.md, 스킬은 .claude/skills/<name>/SKILL.md(폴더가 한 겹 더 들어가면 인식 안 됨) ② claude plugin validate ~/.claude/agents로 스키마 확인 ③ 세션을 새로 시작해야 반영된다 ④ description에 호출 트리거가 되는 말이 들어 있는지 확인(사용자가 실제로 쓸 표현으로).□ 좌표계를 정했다 (절대 디자인 px + 레이아웃 폭 상수)
□ 줌이 transform: scale()만 쓰는지 확인했다
□ 저장·Undo를 Step 1에 배치했다 (요소 기능보다 먼저)
□ 내보내기를 오프스크린으로 설계했다 (화면 DOM 캡처 아님)
□ 넘침 정책을 정했다 (clip 금지 — grow | shrink)
□ 테마 토큰 키 집합을 고정했다
□ 바이너리를 문서에서 분리했다 (에셋 슬라이스 + id 참조)
□ 캡처 라이브러리가 재현 못 하는 CSS를 스키마에서 제외했다
□ 폰트를 재배포 가능 라이선스로만 좁혔다
□ 지원 범위를 "사양"으로 선언했다 (미검증 ≠ 공백)
□ UI를 바꿨다면 검증 스크립트의 셀렉터도 함께 고쳤다
□ 검증 스위트를 "전부" 한 번에 돌렸다 (부분 실행 아님)
□ 새 편집 오버레이를 추가했다면 내보내기 숨김 규칙도 갱신했다
□ 새 CSS 속성을 스키마에 넣었다면 캡처 재현을 실측했다
□ diff에 요청과 무관한 변경이 없다
□ 추정으로 쓴 수치가 없다 (추정이면 "추정"이라고 표시했다)
□ 변경 금지 절(A-n/C-n)을 건드렸다면 근거 무효화를 먼저 확인했다
□ 빌드가 통과한다 (출력을 봤다)
□ 핵심 시나리오를 실제로 한 번 돌렸다
□ 산출물을 "열어서" 확인했다 (파일 생성 ≠ 올바른 내용)
□ 배포했다면 라이브 URL에서 실제 서빙 문서를 확인했다
□ 측정한 숫자를 보고에 포함했다
□ 실행 못 한 항목을 "미검증"으로 명시했다
□ 남은 리스크를 적었다 (없으면 "없음"이라고 적는다)
| 순서 | 파일 | 역할 / 읽을 때 볼 것 |
|---|---|---|
| 1 | src/constants.js | 슬라이드 비율·mm 단위 상수. 가장 먼저 — 이 숫자들이 전체 좌표계의 기준이다 |
| 2 | src/store.js | zustand 스토어 + 단일 데이터 모델 주석. 이 파일의 주석이 사실상 계약서. 페이지·객체 스키마, 기본 스타일(pt 단위) |
| 3 | src/App.jsx | 전체 레이아웃 조립, 내보내기 트리거, exporting 상태. K-4(내보내는 중 편집 가능)의 현장 |
| 4 | src/components/EditorCanvas.jsx | 캔버스 + react-moveable 연결. 클릭 즉시 드래그 구현(042가 재사용한 자산). K-5(회전 박스 미갱신)의 현장 |
| 5 | src/components/TextBlock.jsx | 텍스트 렌더. .text-view{overflow:hidden} — K-1의 발원지 |
| 6 | src/components/TableBlock.jsx | 표 렌더. 회전 시 PPT 이미지 전환(C-1)의 대상 |
| 7 | src/components/ImageBlock.jsx · imageUtils.js | 이미지 요소와 전처리 |
| 8 | src/components/Toolbar.jsx · LeftPanel.jsx · RightPanel.jsx | 편집 UI. RightPanel의 회전 숫자 입력이 K-5 트리거 |
| 9 | src/components/PageStrip.jsx | 페이지 썸네일·추가/삭제. 삭제가 복구 불가(K-3와 연결) |
| 10 | src/export/captureUtils.js | 내보내기의 심장. captureScaleFor()(150dpi 고정, 상한 금지=C-3), forEachRenderedPage()의 암묵적 계약 4조 |
| 11 | src/export/pptxExport.js | 비율→inch 변환(obj.x * SLIDE_W_IN), fontFace 기록(K-2), 회전 표 분기(C-1) |
| 12 | src/export/pdfExport.js | html2canvas→JPEG(0.92)→jsPDF. 이미지 기반 선택의 근거는 C-2 |
| 13 | src/styles.css | .page.exporting 숨김 규칙 — 편집 오버레이 추가 시 반드시 함께 수정 |
| — | docs/EXPORT-CHECKLIST.md · docs/verify/*.mjs | 수동 절차와 Playwright 스크립트(의존성 미설치 상태) |
| 순서 | 파일 | 역할 / 읽을 때 볼 것 |
|---|---|---|
| 1 | CLAUDE.md | 코드보다 먼저 읽는다. A-1~A-5(불변식)·C-1~C-4(제약)·Step별 검증 기록이 전부 여기 |
| 2 | src/model/presets.js | 규격 프리셋 테이블. 규격 추가는 여기 한 줄이면 끝나도록 설계됨 |
| 3 | src/model/schema.js | 문서·요소 스키마, 기본값, normalizeDoc(버전 간 보정 지점) |
| 4 | src/model/themes.js | TOKEN_KEYS 10개 계약(A-4) + 테마 5종. dev 빌드가 키 집합을 검사 |
| 5 | src/model/resolve.js | "@primary"→hex를 해석하는 유일한 지점. 색 관련 버그는 항상 여기부터 |
| 6 | src/model/fonts.js · units.js | 번들 폰트 4종(OFL만) · px→mm/inch/pt(PDF 전용) |
| 7 | src/store/storage.js | localStorage 접근의 유일한 창구(C-4). 쿼터 에러 처리·읽기 fallback. IndexedDB 전환은 이 파일 교체로 끝나야 한다 |
| 8 | src/store/history.js · autosave.js | 스냅샷 히스토리(상한 50) · 디바운스 1.5s + 강제 flush 10s + beforeunload |
| 9 | src/store/useStore.js | 액션 모음. patchElement의 frame/style 분리 병합(회귀 버그의 현장) |
| 10 | src/canvas/Artboard.jsx | 화면·썸네일·내보내기가 공유하는 단 하나의 렌더러. 이 공유가 A-2를 가능하게 한다 |
| 11 | src/canvas/ArtboardStage.jsx | transform: scale()만으로 화면 맞춤(A-1). 레이아웃 폭은 절대 건드리지 않는다 |
| 12 | src/canvas/TextElement.jsx | IME 안전 인라인 편집(비제어 + composition 추적) |
| 13 | src/export/ExportStage.jsx · capture.js · pngExport.js | 오프스크린 렌더 → 캡처 → 파일. panels를 import하면 안 된다 |
| 14 | src/templates/**/*.js | 템플릿 10종. 템플릿은 문서와 같은 스키마라 별도 포맷이 없다 |
| — | docs/verify/*.mjs (12종) | 불변식 1개당 1개. 11장에서 1편을 해부했다 |
CLAUDE.md의 A·C 절 ② model/schema.js(데이터가 무엇인가) ③ store/storage.js(어떻게 살아남는가) ④ canvas/Artboard.jsx(어떻게 그려지는가) ⑤ export/(어떻게 나가는가). UI 컴포넌트는 마지막에 본다 — 가장 자주 바뀌고 가장 덜 중요하다.load_context()로 메모리 폴더 .md를 읽어 문자열 하나로
롱폴링으로 메시지 수신 → 화이트리스트 검사
persona + <projects>(캐시) + 히스토리
messages.stream + adaptive thinking
4096자 분할 → 히스토리 적재 → 트리밍
| 명령/작업 | 동작과 주의 |
|---|---|
/start | 인사 + 로드된 프로젝트 수 안내. 권한 없는 사용자에겐 본인 user id를 돌려줘 등록을 돕는다 |
/reset | 현재 채팅의 대화 히스토리만 비운다. 프로젝트 컨텍스트는 유지 |
/projects | 로드된 메모 파일 목록. 기대한 파일이 없다면 CONTEXT_CHAR_LIMIT에서 잘렸을 가능성 |
/reload | 메모 폴더를 다시 읽는다. 메모를 수정한 뒤 봇을 재시작할 필요가 없다 |
| 백그라운드 실행 | nohup ./venv/bin/python bot.py > bot.log 2>&1 & — 맥이 꺼지면 함께 멈춘다(롱폴링의 대가) |
| 로그 확인 | tail -f bot.log — 기동 시 "프로젝트 컨텍스트 로드: N개 파일, M자"가 찍힌다. 여기서 N이 기대보다 작으면 상한에 걸린 것 |
CONTEXT_CHAR_LIMIT을 키우기 전에 무엇이 들어가는지부터 본다. 색인(MEMORY.md)을 먼저 넣는 정렬이 이미 있으므로, 상한을 올리는 대신 프로젝트별 선택 로드(명령으로 토글)가 비용·품질 모두 낫다.
.env의 CLAUDE_MODEL 한 줄. 품질은 claude-opus-5, 비용·속도는 claude-sonnet-5. 코드에 모델 id를 하드코딩하지 않는다 — 043의 기본값이 구세대로 남아 있던 이유가 그것.
현재는 텍스트 전용이다(filters.TEXT). 사진을 받으려면 filters.PHOTO 핸들러를 추가하고 get_file()로 받아 base64로 messages에 넣는다. 단 043의 설계 원칙은 "파일을 안 건드린다"이므로 저장 경로를 주기 전에 권한 경계를 먼저 정한다.
ALLOWED_USER_IDS에 추가하면 되지만, 히스토리는 chat_id별로 분리되는 반면 프로젝트 컨텍스트는 전원 공유다. 개인 메모가 들어 있다면 그대로 노출된다 — CONTEXT_DIR을 공유용 폴더로 분리할 것.
.env에만 두고 .gitignore를 확인한다. 토큰이 유출되면 봇을 누구나 조종할 수 있고, API 키가 유출되면 과금이 발생한다. 로그에 전체 메시지를 찍는 디버그 코드를 켠 채 두지 말 것 — 대화 내용이 평문으로 쌓인다(043의 bot.log는 이미 1.2MB다).| 단계 | 산출물(이것이 나와야 다음 단계) | 여기서 막히는 지점 | 통과 기준 |
|---|---|---|---|
| Step 0 스캐폴드 |
빈 아트보드가 규격대로 화면에 뜬다. 폰트가 로컬에서 로드된다 | 폰트 라이선스 확인을 건너뛰고 시작 → 나중에 전부 교체해야 함. CDN으로 시작 → 캡처에서 두부 발생 | 창 3종 × 규격 3종에서 레이아웃 폭·줄 수 동일, 외부 폰트 요청 0건(네트워크 탭) |
| Step 1 저장·Undo |
새로고침해도 작업이 남고 Undo가 된다 | 가장 많이 건너뛰는 단계. "요소부터 만들고 나중에"가 029를 실사용 불가로 만들었다. 스냅샷에 바이너리가 섞이면 여기서 이미 무너진다 | 새로고침 후 전체 복원 · Undo/Redo 10회 왕복 · 문서 2개 상호 전환 |
| Step 2 조작 |
드래그·리사이즈·회전이 스케일된 캔버스에서 정확하다 | 줌 배율을 좌표 계산에 반영하지 않아 커서와 요소가 어긋남. 드래그 1회가 Undo 수십 개를 만듦(커밋 경계 없음) | scale 0.707에서 좌표 오차 0.4px 이내 · 드래그 1회 = Undo 1회 |
| Step 3 요소·넘침 |
텍스트·이미지·도형·아이콘이 추가되고 넘침이 보인다 | 아이콘 라이브러리를 동적 import하면 캡처에서 누락. 이미지 원본을 문서에 넣으면 히스토리 폭발. 여기서 UI를 바꾸면 기존 검증이 조용히 깨진다 | grow에서 높이 자동 추종 · 조용한 클립 0건 · 4000×3000 사진에도 문서 1KB |
| Step 4 템플릿·테마 |
템플릿을 고르면 복사본이 열리고 테마 전환이 된다 | 템플릿을 별도 포맷으로 만들면 스키마가 두 벌이 된다. 테마가 좌표까지 바꾸면 계약 위반 | 템플릿 전부 열림·고유 id · 5개 테마 전환에도 좌표·폰트 크기 완전 동일 |
| Step 5 내보내기 |
창 크기와 무관하게 같은 픽셀 크기의 파일이 나온다 | 화면 DOM을 캡처하면 부채 5개가 한꺼번에 따라온다. 오프스크린이면 전부 소멸 — 여기서 되돌리면 비용이 가장 크다 | 창 3종에서 출력 2160×2700px 동일 · 편집 UI 미혼입 · 캡처 중 화면 불변 |
| Step 6 배포 |
공개 URL에서 전체 시나리오가 돈다 | 최신 wrangler가 pages project create를 Workers로 위임해 실패. 배포 성공 로그를 서빙 증거로 오인 |
배포본에서 템플릿 렌더·저장·복원·내보내기 완주 · 외부 도메인 요청 0건 · 콘솔 에러 0건 |
| Step 7 리스크 실측 |
"추정"으로 남긴 항목이 "실측"으로 바뀐다 | 추정으로 끝내고 완료 선언. 042는 20p 실측에서 50p를 추정(10.3초)했다가 실제로 돌려 10.2초를 확인했다 | 대용량 실제 측정 · 쿼터를 실제로 초과시켜 표시·생존·무손상·미처리 예외 0건 |
{
pages: [
{
id: "uuid",
objects: [ // 배열 순서 = 레이어 순서 (뒤가 위)
{
id: "uuid",
type: "text" | "image" | "table",
x: 0.12, y: 0.08, // ★ 슬라이드 대비 비율 0~1 (px 아님)
w: 0.40, h: 0.15, // ★ 같은 기준
rotation: 0, // deg. ≠0이면 PPT에서 표는 이미지로(C-1)
style: {
fontFamily: "맑은 고딕", // ⚠️ 재배포 불가 폰트 = K-2의 원인
fontSize: 18, // ★ pt (px 아님) — 내보내기에서 그대로 쓰인다
color: "#222222", bold: false,
align: "left", lineHeight: 1.4,
},
content: "문단 텍스트" // table이면 2차원 배열
}
]
}
],
currentPageId: "uuid",
selectedId: "uuid" | null, // 캡처 중 null로 내렸다가 finally에서 복원
}
// 내보내기 변환 — 파생값은 저장하지 않고 이 순간에만 계산한다
// PPT: x_inch = obj.x * 11.69 (A4 가로 폭)
// y_inch = obj.y * 8.27
// PDF: 화면 DOM을 html2canvas로 캡처 (좌표 변환 없음)
{
id: "doc_xxx",
title: "내 디자인",
preset: "insta-square", // model/presets.js의 키 (규격 추가는 거기 한 줄)
theme: "toss", // model/themes.js의 키 — 색만 바꾼다(A-4)
pages: [
{
id: "pg_xxx",
elements: [
{
id: "el_xxx",
type: "text" | "image" | "shape" | "icon",
frame: { x: 120, y: 80, w: 840, h: 220 }, // ★ 절대 디자인 px
rotation: 0,
style: {
size: 48, // ★ 절대 px (아트보드 기준)
color: "@fg", // ★ 토큰 참조 — hex 해석은 model/resolve.js에서만
bg: "@surface",
font: "pretendard", // model/fonts.js의 키 (OFL만)
fit: "grow" | "shrink", // ★ clip 없음 — 자르지 않는다(A-3)
radius: "@r",
},
content: "제목 텍스트",
assetId: "as_xxx" // ★ image일 때만. base64는 여기 없다(A-5)
}
]
}
],
updatedAt: "2026-10-09T13:15:50.914Z"
}
// ── 에셋은 완전히 별도 슬라이스 ────────────────────────────────
// 키: ds_assets (문서와 분리 저장)
{
"as_xxx": {
id: "as_xxx", type: "image/jpeg",
w: 2048, h: 1536, // 긴 변 2048px로 리샘플된 결과
bytes: 147320,
dataURL: "data:image/jpeg;base64,...", // ★ 여기에만 존재
createdAt: "..."
}
}
| 결정 | 029 | 042 | 결과 |
|---|---|---|---|
| 좌표 단위 | 비율 0~1 | 절대 디자인 px | 042는 줄바꿈이 결정적 → 오프스크린 내보내기 가능 |
| 폰트 크기 | pt | px(아트보드 기준) | 029는 화면 스케일과 폰트 기준이 어긋남 |
| 색 | hex 직접 | 토큰 참조(@fg) | 042는 테마 교체가 색만 바꿈을 보장 |
| 이미지 | 문서 내 포함 | 에셋 슬라이스 + id | 042만 스냅샷 Undo가 성립 |
| 넘침 | overflow:hidden | grow/shrink만 | 029는 조용한 소실(K-1), 042는 소실 없음 |
| 저장 | 없음 | localStorage + 3중 flush | 029는 실사용 불가 |
// 042: src/model/schema.js
// 저장된 문서를 읽을 때 반드시 통과시키는 정규화 함수.
// 필드 추가·이름 변경은 전부 여기서 흡수한다 — 컴포넌트에 分기를 흩뿌리지 않는다.
export function normalizeDoc(raw) {
if (!raw) return createEmptyDoc();
return {
...createEmptyDoc(), // 기본값을 먼저 깔고
...raw, // 저장된 값으로 덮고
pages: (raw.pages ?? []).map((p) => ({
...p,
elements: (p.elements ?? []).map((el) => ({
...defaultElement(el.type),
...el,
frame: { ...defaultFrame(), ...el.frame }, // ← 부분 교체 금지(회귀 버그의 교훈)
style: { ...defaultStyle(el.type), ...el.style },
})),
})),
};
}
// 체크리스트 — 스키마를 바꿀 때
// □ normalizeDoc이 옛 문서를 열 수 있는가 (실제 옛 JSON으로 테스트)
// □ 기본값을 추가했다면 기존 문서에도 들어가는가
// □ 필드를 제거했다면 남아 있어도 깨지지 않는가
// □ 템플릿(= 같은 스키마)도 함께 갱신했는가
// □ 검증 스크립트가 새 필드를 확인하는가