[id] 전체보기에만 있던 개요 rail/점프를 메인 /documents 3-pane 중앙 리더로 확장
(사용자 주 사용 표면). 경로 A anchor 인프라 그대로 재사용.
- /documents/{id}/sections fetch(loadSections, doc.id 가드) → 좌측 SectionOutline rail
(showRail = 표시가능 절 有 + markdown-ish 본문). window 빈제목 31% 노이즈는 outlineSections
필터로 표시 제외(클린업, 코퍼스 무터치).
- anchorMap = buildAnchorMap(mdRenderText, sections) — 각 분기가 실제 렌더하는 텍스트 기준.
MarkdownDoc(markdown/pdf/hwp/article)에 anchorMap 전달 → <span id=sec-N> splice.
- jumpTo = scrollEl 내 #sec-{id} scrollIntoView. scroll-spy = scrollEl scroll 리스너로
상단 통과 마지막 .md-anchor → activeKey(SectionOutline 강조). $effect cleanup.
- 본문을 [rail | scrollEl] flex 로 래핑(비-섹션 문서는 rail 미표시=기존 그대로). pdf 분기는
자체 overflow 제거하고 scrollEl 단일 스크롤로 정리(iframe h-[80vh]).
id↔id 점프라 중복제목·비-ATX 정확, anchor 없는 절=비활성(폴백). FE only, BE 무변.
vite build + node test 10/10 + lint:tokens(신규0) PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
불만② 개요→본문 점프의 deterministic anchor 좌표 산출(경로 A, FE-only).
게이트 측정상 textContent 매칭은 중복 63%·비-ATX 로 5% + silent 오점프 → md_content
에서 각 절 heading 라인 offset 을 찾아 <a id="sec-{chunk_id}"> 주입 좌표를 만든다.
★ false-early-match 방어 3중 (적대 리뷰 반영):
- 라인-시작(전체-라인) 매칭 → 본문 중간 상호참조("see Part UW")는 라인 전체가 제목과
같지 않아 제외(forward-cursor 가 못 막던 핵심 구멍).
- 전체 매칭 + truncation(builder [:200]) 처리 → '제1조'가 '제1조의2' 오매칭 차단.
- 단조 커서 + 코드펜스 회피 → 역행/펜스 매칭 거부 = anchor 없음(점프 비활성, 오점프 금지).
window/section_split 조각·빈 제목은 skip. node test 10/10 PASS(상호참조 선행·중복 단조·
prefix·평문 제N조·펜스·window·miss·heading_path fallback). 순수 함수, vite build PASS.
다음 commit = MarkdownDoc splice + SectionOutline 점프 + DocumentViewer rail/scroll-spy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
메인 /documents 3-pane 의 중앙 리더(DocumentViewer)가 md_content 를 안 쓰고
PDF=raw iframe·md/txt=plain marked(extracted_text)만 렌더하던 이원화 제거.
"전부 MD화" 한 canonical markdown 이 전체보기 없이 메인에서 바로 보이게 함(불만①).
- viewerType.ts 신설: 분류 단일 source(상세페이지와 공유 예정, drift 차단).
csv/json/xml/html→text(<pre>, 콤마 뭉침 회피), office→preview-pdf, hwp→hwp-markdown.
- DocumentViewer: 자체 getViewerType/renderMd(본문) 제거 → viewerType.ts + MarkdownDoc.
- pdf: canShowMarkdown(isMdSuccess+md_content) 시 MarkdownDoc 기본 + [Markdown|PDF원본]
토글 + MarkdownStatusBadge, 아니면 PDF iframe. lastDocId 가드는 fullDoc.id(prop) 키잉.
- markdown(md/txt): MarkdownDoc(extracted_text=표시·편집 단일 필드), 편집 유지.
- hwp-markdown/article: MarkdownDoc(앵커/KaTeX/이미지). 편집 미리보기만 plain marked 유지.
- article/preview-pdf/image/text/cad/synology/unsupported 분기 보존(회귀 금지) + synology 신설.
API md_status='completed'(S1 validator live) 대응 = isMdSuccess. FE only, BE/스키마 무변.
vite build + lint:tokens(신규 위반 0) PASS. 후속: 개요 rail·안전점프(commit 2), [id] 정합(commit 3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S1 backend(이미 main 머지, app/api/documents.py field_validator
_db_success_to_completed)가 직렬화 시 DB 'success'를 API 'completed'로 remap한다.
그런데 프론트 3곳이 raw 'success' 만 검사 → S1 backend 배포 시 침묵 회귀:
- documents/[id]/+page.svelte canShowMarkdown: completed PDF가 markdown-first
대신 raw PDF로 표시
- documents/+page.svelte 인스펙터 칩 게이트: success 문서 칩 사라짐
- MarkdownStatusBadge: 'completed'→default→null (성공 칩 사라짐)
DB↔API enum divergence guard: 두 어휘를 모두 성공으로 취급해야 S1 배포
전(API='success')·후(API='completed') 모두 안전. 단일 source 헬퍼로 수렴.
- lib/utils/mdStatus.ts 신설: isMdSuccess / isMdStatusVisible (raw 비교 산재 금지)
- [id] canShowMarkdown → isMdSuccess()
- documents 인스펙터 게이트 → isMdStatusVisible()
- MarkdownStatusBadge: case 'completed' 를 'success' 동의어로 추가
FE only, 백엔드/스키마/마이그레이션 무변. vite build + lint:tokens(신규 위반 0) PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
documents/+page.svelte 인스펙터의 md상태 칩이 doc.md_status==='completed'
비교였는데 실제 enum은 success/partial/skipped/failed/pending 이라 'completed'가
존재하지 않음 → success 여도 항상 text-warning(노랑)으로 표시되던 라이브 버그.
- documents/+page.svelte: 깨진 삼항을 MarkdownStatusBadge 재사용으로 교체.
success→success(초록) 자동, pending/null→null 이라 article(news) 칩 자동 suppress.
표시 조건을 badge 가 렌더하는 5상태로 명시(빈 라벨 행 방지).
- MarkdownStatusBadge: partial case 추가(tone warning 'Markdown 일부') →
대형 split 일부 실패 문서도 칩 노출 + md_status 표시 어휘를 단일 컴포넌트에 완결.
FE only, 백엔드/스키마 무변. vite build + lint:tokens(신규 위반 0) PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diagnosis는 cross-topic(사용자 단위) 코칭 표면인데 기존엔 /study/topics 상단에만
노출돼 발견성이 낮았다. 허브(/study)에 '학습 진단' 카드 추가 + 전용 라우트
/study/diagnosis 신설(향후 weekly_recap·review_set_draft 코치 표면의 정식 홈).
패널은 StudyDiagnosisPanel 공유 컴포넌트로 추출 — topics·diagnosis 양쪽이 단일
청크 참조(복붙 drift 0). 백엔드 무변경(기존 POST /diagnosis/generate 재사용).
검증: vite build OK, lint:tokens 내 파일 위반 0, 새 라우트·허브 링크·공유 청크
번들 반영 확인.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/study/review-box: GET /study-cards/due(review_stage) 를 2탭 분리(오늘 할 일=review_stage 보유 / 미확인=review_stage null 신규). 카드 멀티셀렉트 → pendingReviewCards store 로 cards-study 복습 세션에 선택분 전달(백엔드 세션 X = eid contention 중 fastapi 무재빌드). '이 탭 전체 복습'도. 완료 탭은 졸업카드 엔드포인트 필요라 비활성('추후'). 허브에 복습함 진입 카드.
- 신규 store /stores/studySession.ts(pendingReviewCards). cards-study startReview 가 consume. 전부 frontend-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
plan v6 PR-2 scope. 5초 행동 기록 UX 가 핵심 가설.
Backend:
- GET /api/events/{id}/history — events_history timeline 조회 (lifecycle op 자동 기록)
Frontend (SvelteKit 5 runes mode):
- /events 메인 — 4-tab (오늘/Inbox/예정/활동) + 빠른 행동 기록 widget
· 단일 입력 + Enter → POST /api/events kind=activity_log
· status=done + 시간 default 채워짐 (서버 측) → Activity 탭 즉시 반영
· 새 항목을 list 최상단 prepend (refetch 불필요)
· 연속 입력 위해 입력 ref focus 유지
· lifecycle 버튼 (complete/defer/cancel/reactivate) — activity_log 는 lifecycle 대상 X
- /events/[id] 상세 — PATCH 허용 필드 edit (title/desc/시간/priority/project_tag) + history timeline
· PATCH 금지 필드는 UI 노출 X (status/completed_at/cancelled_at/defer_until 은 별 버튼)
- /events/new — kind 선택 (task/calendar_event/activity_log) 후 필드 분기 form
· task: due_at + start_at (선택, "14:00 전화" 같은 시각 task 허용 — 라운드 10)
· calendar_event: start_at 필수 + end_at + all_day
· activity_log: started_at/ended_at 비우면 서버 default now()
- Sidebar 메모 옆에 events 진입점 (CalendarCheck icon)
API helpers: frontend/src/lib/utils/events.ts (createEvent / logActivity / list*
/ lifecycle ops / kind&status enum label/color).
quickref doc: docs/events_api_quickref.md (이전 commit, PR-2 frontend reference).
PR-2 핵심 가설 검증 = 빠른 입력 → 저장 → Activity 즉시 반영 → 새로고침 유지.
PR-1 deferred HTTP behavior 5건도 본 UI 의 자연 사용으로 닫힘.
`<img src=>` 가 Authorization header 를 못 보내서 /api/documents/{id}/images/{key}/raw
가 401 반환 → 이미지 안 보임. 기존 /file?token= iframe 패턴과 동일하게 access token
쿼리 파라미터로 전달.
backend: get_current_user 의존성 제거하고 token 쿼리 파라미터 직접 검증 (기존 /file
엔드포인트와 동일 흐름).
frontend: MarkdownDoc 의 swap selector 가 img.src 에 ?token={getAccessToken()} 부여.
로그아웃 상태면 placeholder 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Markdown Canonical Phase 1B.5 — marker 가 추출하던 이미지를 NAS 에 영구 저장하고
DB 메타 + 인증 라우트 + 프론트 swap 까지 wiring.
핵심 변경:
- marker-service /convert 응답에 base64 image 리스트 포함 (stateless 유지, NAS write 권한 X)
- marker_worker 가 NAS `/documents/extracted_images/{doc_id}/` 에 persist + UPSERT +
고아 row DELETE + md_content ref 를 `docimg:img_NNN` stable scheme 으로 정규화
- /api/documents/{id}/images/{key}/raw 인증 라우트 (Cache-Control private + ETag = content_hash)
- frontend MarkdownDoc 가 placeholder card 안의 docimg ref 를 실제 <img> 로 swap
원칙:
- 이미지 binary = NAS, metadata = Postgres (학습 섹션 패턴 동일)
- image_key sequence 기반 결정적 → 재변환 idempotent
- MARKDOWN_IMAGE_PERSIST=false env 로 rollback 가능 (placeholder card 폴백 자연 유지)
기존 28건 marker success 문서는 본 PR 에서 건드리지 않음 — deploy + 신규 업로드 1건 +
sample 5건 검증 후 scripts/marker_reprocess_existing_success.py 로 targeted reprocess.
plan: ~/.claude/plans/piped-humming-crystal.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1B marker_worker 결과(현재 success 29건, 전부 PDF)를 사용자 흐름에
연결하고 1D pilot 품질 평가 데이터를 확보하기 위한 viewer 마무리 작업.
빠진 부분 3가지를 닫는다:
1) PDF viewerType 기본 view = Markdown
- md_status='success' AND md_content 비어있지 않음일 때 MarkdownDoc 기본 표시.
- 사용자가 "PDF 원본" 토글 시 iframe.
- pdfViewMode 초기화는 doc.id 변경 시에만 (lastDocId tracker) — reactive cycle
이 사용자 토글을 덮어쓰지 않도록 보호.
- markdown 사라지는 케이스(success → failed 재처리)는 자동으로 pdf 로 보호.
2) Image renderer → placeholder card (docMarkdown.ts)
- md_content 의 69%(20/29)에 image syntax 포함. asset serving(1B.5) 미구현
상태에서 raw <img> 를 emit 하면 깨진 아이콘 → 1D pilot 평가가 markdown
품질이 아닌 viewer 미완성 문제로 오염됨.
- href / alt / basename 모두 escape 후 figure.md-image-placeholder 로 렌더.
- 원본 src 는 data-md-image-src 에 escape 보존 → 1B.5 ImgAuth selector 로
실제 <img> 로 교체할 entry point 마련.
- DOMPurify ADD_ATTR 에 data-md-image-src 추가.
3) MarkdownStatusBadge (신규) — 4-state badge
- pending 숨김(legacy 9792건 시각 노이즈 회피).
- processing/success/skipped/failed 표시.
- success tooltip: md_extraction_quality 의 metrics raw 일부
(markdown_heading_count / markdown_table_row_count / markdown_image_count /
text_length_ratio / warnings) 만 노출. text_length_ratio / null /
metrics nested / flat fallback 모두 방어.
- skipped/failed tooltip: md_extraction_error 또는 정책 문구.
- MarkdownDoc 내부 + PDF iframe fallback 양쪽에서 재사용 → failed 같이
MarkdownDoc 가 안 렌더되는 경로에서도 사용자가 상태를 알 수 있음.
기존 markdown/hwp-markdown/article 분기에도 mdExtractionQuality prop 전달.
Out of scope (1B.5 또는 후속):
- ImgAuth blob URL 실제 wiring (data-md-image-src selector + Bearer raw)
- /data/assets/<doc_id>/ 저장 + 서빙
- Caddy /data/assets/* 라우팅
- localStorage 사용자 view preference 저장
- side-by-side viewer (1D pilot 결과 본 후)
- quality chip 별도 UI (1D 후)
Verify:
- npm run build 통과
- npm run lint:tokens 신규 파일 위반 0
- 관련 plan: ~/.claude/plans/iterative-nibbling-catmull.md
- pre-flight: md_extraction_quality 실제 shape 확인 ({score, metrics:{...}, warnings:[]})
Risks:
- feature/design-system worktree 가 [id]/+page.svelte 의 stale 버전 보유
(main 보다 212 commits behind, MarkdownDoc 부재). 1C 머지 후 worktree
머지 시 conflict 확정 — 그쪽 rebase 필요 (별건).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
문서 상세 페이지에서 canonical markdown(md_content) 을 우선 렌더하고
없으면 extracted_text fallback. md_frontmatter 가 있으면 본문 위에 메타
박스. h1~h6 에 GFM heading id + hover 시 # 링크 표시. 이미지 alt 가
있으면 figure + figcaption. KaTeX 수식 ($...$ / $$...$$) 지원.
Backend:
- DocumentDetailResponse 신규 (DocumentResponse + extracted_text + md_*)
- GET /documents/{doc_id} 응답 모델 전환
- 리스트 응답은 DocumentResponse 그대로 (페이로드 비대화 회피)
Frontend:
- lib/utils/docMarkdown.ts — 별도 Marked 인스턴스 (study mathMarkdown.ts
영향 0). marked-katex-extension + marked-gfm-heading-id + custom image
renderer (figure/figcaption + data-md-img marker).
- lib/components/MarkdownDoc.svelte — md_content/extracted_text 우선순위,
frontmatter 박스, mdStatus=failed 안내 배지, heading anchor DOM 후처리.
- /documents/[id] markdown / hwp-markdown / article viewer 3 곳 wiring.
- app.css — .markdown-doc heading-anchor / md-figure / katex 가로 스크롤.
이미지 ImgAuth 후처리(blob URL 교체) wiring 은 Phase 1B.5 에서. 현재는
data-md-img="1" 마킹만 두고 marker 출력 src 그대로.
Plan: ~/.claude/plans/plan-idempotent-sundae.md (Phase 1C)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AI 응답이 마크다운 자체를 \`\`\` 으로 감싸서 오는 패턴 (시작만 있고 닫음 누락 포함)
때문에 explanation/AI 해설 영역이 raw 코드블록으로 보이는 회귀.
- frontend/lib/utils/mathMarkdown.ts: stripOuterFence helper.
- terminated wrap 처리 (inner 에 \`\`\` 추가 있으면 보존)
- unterminated 처리 (백틱 그룹 == 1 인 경우만 안전하게 unwrap)
- 본문 중간 정상 코드블록은 보존
- scripts/strip_outer_fences.py: dry-run + --apply 양 모드.
- 5개 필드 (question_text, choice_1~4, explanation, ai_explanation, content) 검사.
- 운영 결과 explanation 34건 unwrap 적용 완료, recount 0 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
문제별 N개 이미지 첨부. 회로도/그래프 등이 필요한 시험 문제 지원.
입력·편집·복습 모두에서 표시.
데이터 모델 (migration 198):
- study_question_images: id, user_id FK CASCADE, study_question_id FK CASCADE,
file_path, file_size, mime_type, sort_order, created_at
- partial idx (study_question_id, sort_order, id)
저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext}
file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨.
API:
- POST /api/study-questions/{qid}/images (multipart, MIME PNG/JPEG/WEBP/GIF,
10MB/파일 제한, sort_order 자동 max+1)
- GET /api/study-questions/{qid}/images/{img_id}/raw (FileResponse, Bearer 인증)
- DELETE /api/study-questions/{qid}/images/{img_id} (DB row + 파일 시스템 정리)
- StudyQuestionResponse / ReviewQuestionItem 응답에 images 배열 포함
- StudyQuestionSummary 응답에 has_images bool 추가
프론트:
- 신규 lib/components/ImgAuth.svelte — Bearer 인증 endpoint 의 이미지를 fetch +
blob URL 로 변환해 <img> 표시. unmount 시 URL.revokeObjectURL.
- /questions/new: 입력 폼에 이미지 dropzone (client-side 보유) → POST
/questions 받은 qid 로 자동 multipart 업로드. "저장 후 계속 입력" 시 reset.
- /questions/[qid]/edit: 별도 카드 — 기존 이미지 grid + 추가/삭제. 즉시 업로드.
- /review: 문제 본문 아래 이미지 grid (max-h-72 object-contain).
- 모든 표시는 ImgAuth 컴포넌트 — accessToken 만료 케이스 대비.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기사시험 문제·해설에 √, ρ, R̄, α/β/γ, ㎥, $\\sqrt{...}$ 같은 수식이 자주
들어가는데 기존 plain text 표시는 LaTeX 문법이 그대로 노출되거나 깨짐.
표시·미리보기 영역에서만 KaTeX 렌더링 (입력 textarea 는 plain text 유지).
의존성: marked-katex-extension + katex (frontend/).
공통 유틸 frontend/src/lib/utils/mathMarkdown.ts:
- renderMathMarkdown(text): block 렌더 (문제 본문·해설·AI 해설용)
- renderMathMarkdownInline(text): inline parseInline (보기 1~4 button 안)
- 별도 marked 인스턴스 사용 → 글로벌 marked 영향 없음
- $...$ inline / $$...$$ block 모두 지원
- KaTeX throwOnError=false → 잘못된 수식은 빨간색 fallback (페이지 안 깨짐)
- DOMPurify USE_PROFILES.html + ADD_ATTR style/aria-hidden + FORBID
script/iframe/onclick 등 — XSS 차단 유지
- 실패 시 text-only fallback (HTML escape)
CSS (app.css):
- .math-area .katex-display { overflow-x: auto } — 모바일 가로 overflow
생기면 수식만 가로 스크롤, 페이지 레이아웃 보존
- .katex { white-space: nowrap } — KaTeX 자체 줄바꿈 방지
적용 위치 (표시·미리보기만, textarea 무변경):
- review: 문제 본문, 보기 1~4(inline), 답 제출 후 explanation, AI 해설
- edit: AI 해설 본문 (기존 marked → 통일)
- new 화면 preview / 통합뷰 카드 snippet: 무변경 (1차 보류, 사용자 요청 시 추가)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 분석: 수치 튜닝 무관해 보이면 pressure 입력 자체가 안 들어오는 케이스. perfect-
freehand 옵션 변경 의미 없음. 먼저 PointerEvent.pressure 가 실제로 변동하는지 확인 필요.
진단 패널 (?debug=1) 에 추가:
- PRESSURE PIPELINE 섹션:
· raw = PointerEvent.pressure 원본
· mapped = getStrokePressure 의 inputP (raw 매핑 또는 속도 fallback)
· final = fixedPressure update 후 perfect-freehand 에 전달되는 값
· raw min/max — 세션 내 raw pressure 범위 (사용자가 펜 강약 시도 후 확인)
- tiltX, tiltY, ptr width/height, buttons — Pencil 추가 입력 필드.
판별:
- raw 가 항상 0.5 또는 1.0 → 디바이스/브라우저에서 pressure 미전달.
현재 환경에서는 속도 기반 fallback 이 유일.
- raw 가 변동 (0.1~1.0) 인데 mapped/final 이 일정 → 우리 코드가 무시 중.
- raw + mapped + final 모두 변동 → perfect-freehand 가 무시 (thinning, simulatePressure).
사용자 보고: 빠른 stroke 가 점선처럼 끊겨 보임 ("선이 이어지지 않음").
원인: 속도/raw pressure 기반 inputP floor 가 0.25 ~ 0.3 → thinning 0.5 적용 시
outline 폭이 size × 0.5 미만 → 픽셀 단위 정렬 안 되면 dot 패턴.
Fix:
- 속도 기반 inputP floor 0.25 → 0.5. 가장 빠른 stroke 도 size × 0.825 폭 보장.
- raw pressure 매핑 0.3~1.0 → 0.5~1.0. min 폭 보장.
- thinning 0.5 → 0.35. 변동 폭 줄임 (min 폭 더 보장).
Trade-off: 굵기 변동 폭 줄어듦. 하지만 사용자 우선순위 = visual continuity.
inputP 0.5~1.0 + thinning 0.35 → 폭 변동 ±17.5% (충분히 보임).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 빡세게 눌러도 굵기 차이 거의 안 남.
원인 분석:
1. raw pressure 0.1~0.99 만 활용했는데 dynamic range 그대로 → 변동 작음.
2. 속도 기반 변동 폭 0.3~1.0 작음 + dist/25 비율 작음.
3. INTENT alpha 0.25 너무 느림 → 강한 변화도 stroke 내내 못 따라감.
4. thinning 0.4 변동 폭 부족.
Fix:
- raw pressure 0.1~0.99 → 0.3~1.0 으로 매핑. dynamic range 확장.
- 속도 기반 0.25~1.0 + 비율 dist/18. 변동 폭 키움.
- 3단계 threshold:
· dev < 0.15 (잡음) → alpha 0.03 (fixed 유지)
· 0.15 ≤ dev < 0.3 (의도적) → alpha 0.5 (이전 0.25 → 빠르게 따라감)
· dev ≥ 0.3 (매우 큼, 빡세게 누름) → 즉시 update (alpha 1.0)
- thinning 0.4 → 0.5. 폭 변동 더 명확.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고 통합:
1. "기본이 두꺼움" — 평소 stroke 가 두껍게 느껴짐
2. "힘 줘도 일정 이상 안 두꺼워짐" — max 굵기 부족
3. "약하게 그리면 점선" — min 폭 너무 작음
4. "압력 정해지면 stroke 그 굵기 유지" — Notability felt = stroke 내부 일정
5. "의도적 압력 변화 시 굵기 변동" — 단 명확한 변화는 따라옴
Fix:
- baseSize 6 → 7. max 두꺼움 보장.
- WIDTH_FACTOR { 0.4, 0.6, 1.0 } → { 0.35, 0.5, 0.85 }. 기본 살짝 가늘게.
결과 normal = 7×0.5 = 3.5 (이전 3.6 비슷), thick = 5.95 (충분히 두꺼움).
- thinning 0.55 → 0.4. fixedPressure 가 잡음 흡수하니 폭 변동 더 키워도 안정.
Smart pressure (getStrokePressure):
- raw pressure 정상 시 → 그것 사용 (Pencil pressure 활용).
- 비정상 시 → 점 간 거리 기반 속도 추정 (mouse / Pencil 미지원 빌드).
- fixedPressure: stroke 시작 시 inputP 로 초기화. 그 후 hybrid update:
· 변동 < 15% (잡음/평소) → alpha 0.03 (거의 무시) → 균일 굵기
· 변동 ≥ 15% (의도적 변화) → alpha 0.25 (빠르게 따라감) → 굵기 변화
- simulatePressure: true → false. getStrokePressure 가 자체 처리.
기존 smoothPressureWindow 제거. fixedPressure 가 동일 역할 + Notability felt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 마우스도 Pencil 도 굵기 변화 없음. iPadOS Safari 의 일부 빌드에서
Apple Pencil PointerEvent.pressure 가 정상 도달 안 하거나 일정 → 우리 thinning 0.55
적용해도 input pressure 가 일정이라 효과 0.
Fix: perfect-freehand 의 simulatePressure: true 항상.
- 점 간 속도 (거리) 기반 자동 pressure 추정.
- 빠른 stroke = 가늘게, 천천히 = 굵게.
- Notability 도 동일 felt (속도 기반 ink flow).
- pen 의 실제 pressure 는 무시되지만, 들어오지 않는 빌드에서는 어차피 무관.
stroke 별 simPressure 필드 / serializableStrokes 로직은 유지 (향후 분기 옵션 위해).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
해석 오류 정정: 사용자 "필압 너무 차이나" = "차이가 너무 *안* 난다" 의미였음. 종이
만년필 reference (4 stroke 굵기 차이 5:1) 가 *원하는* 수준이었던 걸 반대로 해석해서
thinning 줄였던 회귀.
Fix:
- thinning 0.18 → 0.55. 폭 변동 ±55%.
- MIN_PRESSURE 0.4 → 0.25. dynamic range 넓게 (0.25~1.0).
- PRESSURE_WINDOW 12 → 8. 압력 변화 빠르게 따라옴.
조합 시 실제 굵기 비율: 약한 stroke ≈ size×0.42, 강한 stroke ≈ size×1.0 → 약 2.4:1.
종이 reference (5:1) 보다는 약하지만 만년필 felt 명확.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 필압이 너무 차이남 (stroke 마다 굵기 들쭉날쭉) + stroke 끝에 dot 점.
종이 만년필 reference 와 비교 시 우리 앱이 작은 압력 변동에 너무 민감.
Fix:
- thinning 0.28 → 0.18. 폭 변동 ±18%. 작은 압력 차이가 큰 굵기로 변환되지 않음.
- PRESSURE_WINDOW 8 → 12. 평균 더 안정 → stroke 간 일관성.
- cap: true → false. round cap 이 짧은 stroke 에서 dot 처럼 보이던 회귀 제거.
taper 가 끝을 자연스럽게 마무리하므로 cap 불필요.
- start.taper size*0.15 → 0.2. end.taper size*0.3 → 0.4. cap 없으니 taper 가 직접
마무리 — 살짝 더 길게 두어 만년필 nib felt 유지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 글자가 작아지면 제대로 인식 못 함 (스크린샷의 작은 "유" 가 부서져 보임).
원인:
1. streamline 0.86 = 입력 점이 펜 위치보다 lazy 하게 따라옴. 긴 stroke 에선 부드러움
이지만 짧은 stroke (작은 글자) 에선 lag 누적 > stroke 길이 → 펜이 떨어져도
stroke 가 못 따라감 → 부서진 dot 처럼 보임.
2. start.taper size*0.3 + end.taper size*0.5 = 짧은 stroke (length ≈ size × 1~2) 의
거의 전체가 taper 영역 → stroke 가 모두 가늘게 그려짐.
Fix:
- streamline 0.86 → 0.75. 부드러움 + 짧은 stroke 정확성 균형.
- start.taper size*0.3 → 0.15.
- end.taper size*0.5 → 0.3.
만년필 nib felt 는 유지 (taper 비율 그대로) 하되 영향 길이 줄임.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: Notability 의 그 맛이 안 남. 만년필 nib 의 핵심 felt 누락.
Notability 의 만년필 stroke 특징:
- 시작 = nib 이 종이에 닿는 순간. 짧게 가늘게 시작.
- 끝 = nib 이 종이에서 떨어짐. 좀 더 길게 가늘어짐.
- ease-out 곡선: 빠르게 굵어졌다 천천히 안정.
Fix:
- start.taper: size * 0.3, easing: t * (2-t) (ease-out)
- end.taper: size * 0.5, easing: t * (2-t)
- cap: true 유지 (round 끝점)
이전에 taper 가 흔들림 원인이라 뺐었지만, 그건 thinning 0.18 + 보간 점 micro 변동 +
EMA 와 겹친 회귀였음. 지금은 마디/흔들림 모두 차단됐으니 taper 안전하게 도입 가능.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 쓰다보면 필압이 줄어드는데 그러면 "학" 의 ㅡ 같은 부분이 거의 안
보이고 점선처럼 됨. 사용감 별로.
원인: thinning 0.4 + Pencil pressure 0.2~0.3 (약한 누름) → stroke 폭이 너무 줄어듦.
Fix:
- normalizePressure 에 MIN_PRESSURE 0.4 floor. pressure 0.05~0.4 도 0.4 로 고정.
dynamic range 0.4~1.0. 약한 pressure 에서도 stroke 가 충분히 보임.
- thinning 0.4 → 0.28. 폭 변동 줄임. floor 와 조합 시 ±17% 정도 변동.
기존 폭 시작점은 유지 (만년필 nib 변화 명확).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
마디 해결 후 사용자 피드백: 굵기 변동이 거의 없음. 만년필 느낌 (pressure 따른 명확한
굵기 차이) 원함.
원인: thinning 0.22 + window 16 = 변동 흡수 너무 강함. Pencil pressure 0.3~0.8
변동 → window 평균 거의 일정 + 22% 폭 반응 → 시각적으로 미세.
Fix:
- PRESSURE_WINDOW 16 → 8. pressure 변화 빠르게 따라옴 (마디는 보간 점 16px 으로
이미 차단됨).
- thinning 0.22 → 0.4. stroke 폭 ±40% 반응. 만년필 nib 처럼 약한 압력 = 가는,
강한 압력 = 굵은. 명확한 차이.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
스크린샷 진단: 빠른 곡선 stroke 에서 일정 간격 마디 (점선 효과) 명확. 윗쪽 천천히
쓴 글씨는 마디 거의 없음. 차이 = stroke 속도. 빠른 stroke = 보간 점 많이 추가됨.
가설: 8px gap 보간이 *일정 간격 dense vertex* 만들고, perfect-freehand outline
polygon 의 vertex 위치가 anti-aliasing 효과로 약간 dim 하게 표현 → 시각적 점선.
Fix:
- MAX_GAP_PX 8 → 16. 보간 점 절반.
- perfect-freehand smoothing 0.99 + streamline 0.86 이 sparse 점에서도 부드러운 곡선
생성 → 16px 간격 충분. 점선 방지는 30px+ gap 만 보간.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: 굵기 변동 없고 선 사이사이 마디 (점선 같은 끊어짐) 보임.
원인: EMA(α=0.15) 가 매 점마다 pressure 살짝 변동 + thinning=0.15 → outline polygon
에 점 간 micro 폭 변동 = 마디. 큰 흐름 변동은 약함.
Fix:
- smoothPressure (EMA) → smoothPressureWindow (마지막 16점 평균).
매 점 변동은 1/16 수준 → micro 변동 평균화 (마디 차단). 큰 흐름은 따라옴.
- 보간된 점 (8px gap interpolation) 의 pressure 도 모두 sp 동일.
점진 보간 (lp → sp) 이 outline 에 micro 변동 일으키던 부수 원인 제거.
- thinning 0.15 → 0.22. window 평균이 micro 변동 흡수하니 폭 반응 더 크게 두어도
마디 안 발생. 큰 흐름의 굵기 변화 명확.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 요청: stroke 굵기가 너무 일정해서 단조로움. Notability 처럼 살짝 압력에 따라
변화 있으면 좋겠다.
이전 thinning 0.18 + PRESSURE_SMOOTH_RATE 5% 조합은 점 간 5% 즉시 변동 가능 →
누적 시 들쭉날쭉. thinning 0 으로 회귀했었음.
Fix:
- Pressure smoothing 알고리즘 변경: rate-limit (±5%) → EMA (alpha 0.15).
새 값 15% + 이전 값 85% 가중. 잡음/덜컥 변동 제거하면서도 자연스러운 흐름.
- thinning 0 → 0.15. pressure 변화에 stroke 폭 ±15% 반응.
- EMA + thinning 조합 → "부드러운 흐름에 따른 자연스러운 굵기 변화". 흔들림 없음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Stroke 별 size 저장 — 사용자 보고 "굵기 변경하면 기존에 입력했던거 전부 바뀜"
- 회귀 원인: drawStroke 가 매 redraw 시 effectiveSize ($derived) 사용 →
widthMode 변경 시 모든 stroke 재그려짐.
- Fix: Stroke type 에 size 추가. inflight 생성 시 size=effectiveSize 저장.
drawStroke 가 s.size 사용. legacy stroke (size 없음) 은 첫 draw 시점의
effectiveSize 로 fix (refW/refH 패턴 동일).
- cache 무효화 로직 정리: stroke.size 가 불변이므로 _path2d 캐시는 영원 유효.
기존 _size 비교 제거.
- serializableStrokes 에 size 포함 — 다음 load 시 굵기 보존.
2. Stroke 부드러움 살짝 더:
- smoothing 0.98 → 0.99 (사실상 max).
- streamline 0.82 → 0.86 (input lazy 강화, 손떨림 보정 큼).
- 0.9 이상은 lag 위험.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 요청:
1. 굵기 단계 한 단계씩 가는 쪽 시프트 — 새 thin (0.4) 추가, 새 normal (0.6) =
이전 thin, 새 thick (1.0) = 이전 normal. 이전 thick (1.6) 제거.
2. 만년필 같은 부드러움 + 약한 압력에도 안정.
Stroke 옵션 튜닝 (선 흔들림 차단):
- thinning 0.18 → 0. pressure 변동에 따른 stroke 폭 변화 제거 → 일정 굵기 → 흔들림
최소화. 사용자 보고 "선이 흔들림" 의 직접 원인이었음.
- smoothing 0.95 → 0.98. 점 간 보간 거의 최대. Pencil 240Hz 미세 떨림 + 손떨림 흡수.
- streamline 0.7 → 0.82. input lazy 강하게. 0.85 이상은 lag 발생 위험.
- start/end taper effectiveSize*0.5 → 0. 짧은 stroke 시작/끝에서 굵기 급변이 흔들림
인식 강화. cap round 만 유지로 충분.
Pressure smoothing 함수 추가 (선택적 만년필 효과 잔존):
- pushPointWithInterp 에서 점 간 pressure 변동 5% 이내로 제한.
- thinning 0 인 현재는 visible 영향 없지만, 향후 thinning 도입 시 재활용 가능.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. 지우개 인디케이터 (Notability 스타일):
- 지우개 모드에서 펜/마우스 hover 만으로도 cursor 위치에 원형 표시.
- eraserRadius 크기 outline + 12% 반투명 fill — 어디를 지우게 될지 시각 피드백.
- tool=pen 으로 변경 / canvas pointerleave / 자동 복귀 시 자동 hide.
2. Pencil stroke 부드러움 (사용자 보고: Pencil 글씨가 마우스 대비 들쭉날쭉):
- thinning: 0.25 → 0 (pressure 변동 무시 = 마우스처럼 일정 굵기).
- smoothing: 0.85 → 0.95 (점 간 보간 더 강함, Pencil 240Hz 미세 떨림 흡수).
- streamline: 0.65 → 0.7 (손떨림 보정 강화).
3. 지우개 stroke 종료 시 자동 펜 복귀 (사용자 요청):
- eraser pointerup/cancel 시 tool='pen' set + cursor null.
Apple Pencil 더블탭 도구 토글은 Web 표준 미지원 — iPadOS 가 OS 차원에서 인식해
시스템 동작으로 처리, 페이지엔 이벤트 미도달. 대안 (캔버스 두 손가락 탭, etc.) 은
별도 결정 필요.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
진단 도구로 확정: 펜 클릭 시 canvas:1512×677 정상 → 지우개 클릭 시 canvas:3024×1354
정확히 2배 (= cssWidth × dpr). canvas.style.width 가 사라져 internal pixel 그대로
displayed → 화면상 2배 확대.
원인: <canvas style="...; cursor: {tool === 'eraser' ? ...}"> 가 reactive variable
(tool) 포함한 inline style. tool 변경 시 Svelte 가 inline style attribute *전체*
재설정 → resizeCanvas() 의 imperative `canvas.style.width = ...px` 가 덮어써져 사라짐.
새로고침 / 창 이동 시 resizeCanvas 다시 호출되며 복구되던 이유.
Fix:
- style:cursor / style:width / style:height directive 로 분리. Svelte 의 style:property
는 해당 property 만 set 하고 다른 inline style 안 건드림.
- 정적 inline style="..." 에서 cursor 제거.
- resizeCanvas 의 imperative style.width/height 라인 제거 (svelte directive 가 처리).
내부 pixel 은 그대로 imperative set 유지 (canvas.width = cssWidth × dpr — DOM
attribute 라 inline style 과 별개).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
추측 fix 8회 모두 미해결. 진짜 측정값 없이 코드 추론만으로는 한계.
디버그 패널에 다음 추가:
- tool / widthMode 현재 값
- button click 카운터 (pen / eraser / width)
- cssWidth × cssHeight (컴포넌트 내부 좌표 시스템)
- canvas getBoundingClientRect (실제 DOM dimension)
- container getBoundingClientRect
button click 시 어느 dimension 이 변하는지 *숫자로* 즉시 보임. 변화량으로 trigger
element 추적 가능. ?debug=1 query 활성.
(stroke 0개 상태에서는 확대 여부 시각 확인 불가 — dimension 직접 측정이 진단 핵심.)
이전 commit (33060e9) 의 drawStrokeScaled 가 refW 없는 legacy stroke 는 1배로
그려서 fix 효과가 새 stroke 에만 적용. 사용자 환경의 기존 stroke 129개에는 비례
보정 안 됐음.
Fix: drawStrokeScaled 안에서 refW 없으면 *첫 draw 시점*의 cssWidth/cssHeight 로
자동 set. 그 후 cssWidth 변화 (button click 의 layout shift / 창 크기 조정) 시
ctx.scale 비례 적용. load 시점 cssWidth = 사용자가 그 strokes 를 보는 환경의
dimension 이므로 일관된 기준.
→ 기존 세션 그대로 두어도 button click / 창 이동 시 stroke 위치 보존.
스크린샷 비교로 root cause 확정: 큰 창에서 그린 stroke 가 작은 창에서 보면 캔버스
전체 차지하는 비례 (반대도 마찬가지). stroke 좌표가 cssWidth/cssHeight 절대 px 로
저장되어 cssWidth 변경 시 시각적 위치/비율 깨짐. 사용자 보고 "펜/지우개 누르면
해당 부분 확대" = button click → reactive cascade → toolbar flex-wrap 임계 또는
다른 layout shift → cssWidth 일시 변경 → stroke 좌표 비례 깨짐.
Fix A: stroke 별 reference dimension
- Stroke type 에 refW / refH (그렸을 시점의 cssWidth/cssHeight) 추가.
- inflight 생성 시 refW=cssWidth, refH=cssHeight 저장.
- redraw 의 drawStrokeScaled() 가 ctx.scale(cssWidth/refW, cssHeight/refH) 적용.
stroke 좌표는 그대로 두고 transform 만 stroke 별. R3 의 Path2D 캐시 그대로 재활용.
- legacy stroke (refW 없음) 은 1배 (load 시점의 cssWidth 기준).
- serializableStrokes 에 refW/refH 포함 — 다른 환경에서 load 시 비례 복원.
Fix B: toolbar layout shift trigger 차단
- flex-wrap 제거 → overflow-x-auto. 자릿수 변화 (99→100) 등으로 wrap 발생 시
ResizeObserver 가 cssHeight 변경 → 비례 깨짐의 trigger 였음.
- stroke 카운터에 tabular-nums + shrink-0. 자릿수 변화 시 텍스트 width 일정.
새로고침 / 창 이동 시 정상 복귀하던 이유 = 그 시점에 cssWidth 가 새로 결정되며
모든 stroke 가 같은 기준. button click 시 일시적 layout shift 가 trigger 였던 것.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 보고: "펜이나 지우개를 누르면 자동으로 해당 부분 확대". iOS Safari 의 button
focus 가 mousedown/pointerdown 단계에 발동 → 그 영역으로 자동 zoom in. click 시점의
clickThenBlur 는 이미 늦음 (focus 잡힌 후 blur 시켜도 zoom 유지).
Fix: 모든 toolbar / header button 에 onmousedown={preventDefault} +
onpointerdown={preventDefault} 추가. focus 자체가 안 잡혀서 zoom trigger 없음.
click 이벤트는 별도라 onclick 정상 작동. clickThenBlur 는 잔존 케이스 2차 안전망으로 유지.
대상 buttons:
- HandwriteCanvas toolbar: 펜 / 지우개 / 가늘게/보통/굵게 / Undo/Redo/Trash / PNG 저장
- [id]/+page 헤더: 패널 토글 / 다음 시도
IconButton.svelte Props 에 onmousedown/onpointerdown prop 명시 추가 (기존
{...rest} spread 가 button element 로 전달은 됐지만 TypeScript caller 측 type
narrow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 진단 (디버그 카운터): "ㄱ 쓸때 정상, ㅏ 바로 시도하면 down 카운터도 안 늘어남,
시간 지나면 들어감" → 짧은 main thread block.
코드 검토 결과 endStroke 안의 backup() 호출이 동기 I/O:
localStorage.setItem(key, JSON.stringify({strokes: 73개...}))
stroke 73 × 평균 30점 ≈ 65KB JSON. JSON.stringify + sync localStorage write 합쳐
iPad CPU 에서 50~200ms main thread block. 그 사이 native pointer event queue 적체.
사용자가 그 시간 안에 펜 댔다 떼면 down/up 짝이 깨져 OS 가 입력 무시 → "ㅏ 안 들어감".
Fix:
- backup() 을 500ms idle debounce. 빠른 연속 stroke 시 backup 0회 → main thread
block 0 → pointer event 적체 없음 → ㄱ 직후 ㅏ 즉시 진입.
- flushBackup() 별도 함수로 분리. onBeforeUnload / onDestroy 에서 pending 강제 실행
(페이지 unload 시 backup 손실 방지).
이번 fix 후에도 cooldown 잔존하면 OS Apple Pencil Scribble 흡수 가설로 — iPadOS
설정 > Apple Pencil > Scribble 비활성화 필요.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
사용자 가설 적중: "ㄱ을 그릴때 ㄱ이 다 그려질때까지 다음 입력이 안되는거 아니야?" =
R3 (redraw 누적 frame budget 초과 → main thread block → 입력 적체).
매 RAF frame 마다 모든 stroke 의 perfect-freehand outline + new Path2D 를 재계산.
stroke 73 × 평균 30 점 ≈ 2200 점 outline 매 frame. iPad CPU 에서 16ms frame budget
초과 → next pointermove/down 이벤트가 main thread queue 에 적체 → 사용자 인식상
"ㄱ 다 그려지기 전엔 ㅏ 입력 안 됨".
Fix:
- Stroke 타입에 _path2d / _size 런타임 캐시 추가. 완료 stroke 는 첫 draw 시점에
outline + Path2D 생성 후 캐시. 이후 redraw 는 ctx.fill(cachedPath) 만 (GPU 가속).
- inflight 만 매 frame 재계산 (점 추가됨).
- effectiveSize (가늘게/보통/굵게 토글) 변경 시 _size mismatch 로 자동 캐시 무효화.
직렬화 안전:
- _path2d / _size 는 `_` prefix 가 marker. backup()/flushSave() 가 serializableStrokes()
로 {id, points} 만 추출. 서버/localStorage 에 cruft 안 들어감.
기대 효과:
- redraw 비용: O(strokes × points) → O(strokes × 1 ctx.fill) → O(1 GPU fill ×N).
- main thread block 해소 → pointer 이벤트 큐 적체 사라짐 → 다음 stroke 즉시 진입.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
스크린샷 진단: 사용자 시나리오에서 stroke 자체는 들어가지만 글씨가 흩어지고 ㄱ→ㅏ 가
의도와 다르게 연결됨. 코드 재검토 결과 명백한 누락 — pointermove 가 e.buttons===0
케이스 (Apple Pencil hover, iPadOS 17+) 를 잡지 않아 hover 이동이 stroke 의 점으로
추가됨. ㄱ 그리고 → 펜 살짝 떼고 (hover 모드, pointerup 안 옴) → ㅏ 위치로 hover
이동 → hover pointermove 가 점 push → ㄱ 끝점에서 ㅏ 위치까지 직선/엉킴.
Fix:
- onPointerMove 에서 e.pointerType==='pen' && e.buttons===0 감지 시 stroke 즉시
finalize: capture release + isDrawing=false + inflight 보존 (pointerup 흐름).
pointerup 안 와도 hover 모드 = 사실상 펜 떼짐. 다음 stroke 진입 보장.
- onPointerDown 에서도 같은 가드 (hover-down reject) — hover 진입을 stroke 시작으로
오인 차단.
Diagnostic:
- DBG = import.meta.env.DEV || (?debug=1 query). prod 에서도 사용자 iPad 진단용으로
디버그 패널 토글 가능. URL 에 ?debug=1 추가 후 reload.
- 디버그 패널 {#if DBG} 로 게이트.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전 commit (7f3955c) 의 element-level pointerleave 안전망이 부족 — 펜이 캔버스
영역 *안*에서 hover 해제되면 pointerleave 미발화 (pointerout 만), 캔버스 element
의 setPointerCapture 가 silently 풀린 케이스도 캔버스 element 핸들러로 못 잡음.
isDrawing 락이 영구 → 다음 stroke 진입 거부 → ㄱ→ㅏ 회귀 잔존.
A. window 레벨 pointerup/pointercancel 안전망 (핵심)
- window.addEventListener('pointerup'|'pointercancel', onWindowPointerEnd).
- onWindowPointerEnd 가 isDrawing && pointerId == activePointerId 시 endStroke 호출.
- 캔버스 element 의 capture 가 풀려도 window 에는 거의 항상 도달 → 락 영구 해제.
B. inflight 를 $state 에서 plain 변수로
- Svelte 5 deep proxy 가 매 pointermove 의 coalesced push 마다 reactive notify.
60Hz × 8~12 coalesced = 480회/초 의 reactive trigger 가 onPointerMove 핸들러
실행 시간을 누적시켜 native event queue 적체 → capture race 가능성 증가.
- UI 는 redraw 함수가 호출 시점에 inflight 직접 read 하므로 reactive 불필요.
- dbgInflightPts $derived 제거, 패널은 inline `inflight?.points.length` 사용.
C. dbg state mutation DEV 게이트
- DBG = import.meta.env.DEV 상수. 모든 dbg = ... 호출을 if (DBG) 로 감쌈.
- prod 빌드에서 Vite 가 if (false) ... 를 DCE → mutation 비용 0.
- pointerleave 의 capture 활성 가드는 DBG 와 무관하게 항상 적용 (실제 안전망 로직).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
증상:
- ㄱ stroke 후 ㅏ stroke 가 안 그려짐. iOS Safari 가 setPointerCapture 를 silently
풀어 pointerup 이 캔버스로 routing 안 되는 케이스에서 isDrawing 락 잔존 → 다음
pointerdown 이 onPointerDown:298 가드 에서 거부.
- 캔버스가 1사분면으로 확대되는 OS 핀치줌. element-level gesturestart 차단이 일부
iOS 빌드에서 흡수만 되고 줌이 진행.
A. pointerleave 안전망 (HandwriteCanvas.svelte)
- onpointerleave={endStroke} 복구.
- endStroke 내 pointerleave 분기: canvas.hasPointerCapture true 면 ignore (정상
흐름, pointerup 곧 도착). false 면 안전망 finalize → isDrawing 락 해제.
- capture 가 정상 잡힌 케이스엔 영향 없음 (leave 자체가 안 옴).
B. viewport meta 강화 ([id]/+page.svelte)
- maximum-scale=1, user-scalable=no 추가. iOS 13+ 에서 OS 핀치줌 원천 차단.
- 페이지별 meta 라 다른 페이지 접근성 영향 0. zoom UI 는 Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 라이브 디버그 패널 / build timestamp 를 import.meta.env.DEV 로 게이트.
prod 번들에서 Vite 가 dead-code-eliminate.
- onpointerleave={endStroke} 바인딩 제거. setPointerCapture 가 잡히면 leave 자체가
안 오고, 캡처 실패 케이스는 OS 가 pointercancel 로 흘려보냄. 주석과 동작 일치.
- eraseAt(x,y) 단일 점 검사 → eraseSegment(x0,y0,x1,y1) 로 교체.
distSqPointToSegment 헬퍼 추가. eraserLast 추적 (pointerdown set, move 의 segment
시작점, end 에서 null). 빠른 지우개 stroke 에서 점 사이 stroke 누락 방지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>