사용자 요청:
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>
증상: 모바일에서 좌측 트리 hidden md:block 으로 숨겨져 정렬된 최근
자료 외에는 원하는 카테고리를 찾기 어려움.
Fix:
- 헤더 아래 모바일 전용 (md:hidden) 카테고리 진입 바:
· "카테고리" 버튼 (FolderTree 아이콘) — 좌측 drawer 띄움
· breadcrumb: 전체 / 가스기사 / 01_유체역학 / 01_basics
각 segment 클릭 시 해당 path 로 즉시 이동
· 가로 스크롤 (overflow-x-auto) — 깊은 path 도 자연스럽게
- aside 좌측 트리 모바일 drawer 화:
· mobileTreeOpen state. fixed left-0 top-0 bottom-0 w-72 max-w-[85vw]
· 백드롭 클릭 / X 버튼 / 카테고리 선택 시 자동 닫기
· 데스크톱(md+)에선 기존 normal layout 유지
- navigateAndClose 헬퍼 — 카테고리 클릭 시 navigate + close 한 번에
[Backend]
- /api/documents/{id}/library-neighbors — 같은 library_path 내
prev/next 자료 (title_asc 정렬). user_tags 의 첫 @library/* 태그를
path 로 사용. category='library' 만 응답.
[Frontend]
- routes/documents/[id]/+page.svelte:
· 마크다운 본문: 모바일 prose-base (가독성), lg+ prose-sm 유지
+ leading-relaxed
· onMount 시 자료실 자료면 loadNeighbors 자동 호출
· 모바일 sticky 하단 바 (lg:hidden):
[< 이전] [✓ 1회독 완료 + 다음 (primary)] [다음 →]
- 가운데 버튼: POST /read 후 next 자료로 goto. 마지막 자료면
"1회독 완료 (마지막 자료)" 텍스트 + next 버튼 disabled.
- 좌/우 버튼: 회독 카운트 안 함, 단순 이동 (이전 자료 / 회독 안 한 다음)
· 본문 하단 패딩 (lg:hidden h-20) — sticky 바에 가리지 않음
자료실 자료 detail 에 "필기" 버튼 → 본문 아래에 HandwriteCanvas 띄움.
자료당 사용자별 1개 캔버스 (UNIQUE user×document). upsert 방식.
Backend:
- migrations 177~178: document_notes (user_id, document_id, strokes_json,
canvas 크기) + UNIQUE(user_id, document_id) + 인덱스
- app/models/document_note.py: DocumentNote ORM
- app/api/document_notes.py:
· GET /api/documents/{id}/note — 단건 조회 (없으면 strokes_json=null)
· PUT /api/documents/{id}/note — upsert (PostgreSQL ON CONFLICT)
· DELETE /api/documents/{id}/note
· ownership: WHERE user_id=current_user.id (single-user 가정)
- app/main.py: document_notes_router 등록 (/api/documents prefix)
Frontend:
- routes/documents/[id]/+page.svelte:
· 자료실 자료 (category='library') 의 affordance row 에 "필기" 토글 추가
· 클릭 시 GET /note 로 strokes 로드 → HandwriteCanvas 본문 카드 아래 마운트
· 캔버스 onChange → PUT /note 자동 저장 (HandwriteCanvas 내부 3초 idle 디바운스 활용)
· 60vh / min-h-[400px] 분할. 모바일에선 본문 아래 스크롤로 자연스럽게.
- HandwriteCanvas 재사용 — sessionId prop 에 documentId 전달.
localStorage 키도 그대로 사용 (자료별로 namespacing).
스크린샷 root cause: ㄱ stroke 후 iPadOS Apple Pencil Scribble / Apple Intelligence
가 펜 stroke 를 텍스트 선택 제스처로 해석 → "복사하기 / 선택 영역 찾기 / 찾아보기 /
번역" callout 메뉴 등장 → 메뉴 떠 있는 동안 펜 입력이 메뉴 인터랙션으로 흡수되어
캔버스 stroke 차단 (체감상 ㄱ→ㅏ hang). 메뉴 등장 시 페이지 fit 변경이 사용자에겐
"1사분면 확대" 로 인식. 즉 두 증상 모두 같은 root cause.
element CSS user-select:none 만으로는 OS 레벨 Pencil 인식 차단 못 함.
Fix:
- document.addEventListener('selectstart', ..., { capture: true }) — 모든 자식의
selection start 를 capture phase 에서 가로채기 + preventDefault.
- selectionchange 시 즉시 removeAllRanges — 어떤 경로로든 selection 이 잡히면 해제.
- document.documentElement / document.body 에 webkitUserSelect=none, userSelect=none,
webkitTouchCallout=none inline 강제. Svelte 컴포넌트 스코프가 닿지 않는 root
element 가 selection origin 인 케이스 차단.
- onDestroy 에서 모두 원복 (다른 페이지 selection 영향 없음).
OS 레벨 추가 비활성화 옵션 (사용자 직접): iPadOS 설정 > Apple Pencil > Scribble.
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>
기존 /library 의 회독 UI 를 학습 hub 로 분리. 학습 의도 인터페이스를
공부(/study) 트랙에 모아 자료실(library) 의 일반 자료 관리와 분리.
신규:
- /study (hub): "자료 학습" / "손글씨 필사 세션" 두 카드 메뉴.
Phase 2~ 예정 항목 (모바일 카드 / 퀴즈 / SRS) 안내.
기존 /study → /study/write 자동 redirect 제거.
- /study/sources (자료 학습):
· 좌측 트리: /api/library/tree 활용. 노드별 회독 안 본 카운트
(예: "3 / 12") 표시. 활성 경로 자동 펼치기.
· 우측 본문: /api/documents/library 활용 (path/sort/unread/page).
DocumentCard 재사용 — 회독 배지 (안 봄/N회독) 그대로 노출.
· 안 본 자료만 토글 + 정렬 선택 + 페이지네이션.
· 자료실 관리 기능 (CRUD/업로드/facet/승인 대기) 제외 — 순수 학습 UI.
backend 변경 없음. PR-A 의 /api/documents/{id}/read* 와 library API 응답
read_count/unread_count 그대로 활용.
기존 /library 페이지의 회독 UI (배지/토글/ReadCounter) 는 일관성 위해 유지.
자료를 어디서 들어가든 회독 가능 (자료실 자료 detail 의 ReadCounter 도 그대로).
직전 commit (7f3955c) 의 page-level svelte:head viewport meta 는 SvelteKit 의 SSR
인증 redirect 시 학습 페이지 컴포넌트가 마운트 안 되어 head 에 미주입. iPad 에서
페이지 reload 시 root template 의 default viewport (initial-scale=1 만) 만 적용되어
OS 핀치줌이 다시 가능 — "1사분면 확대" 회귀의 잔존 trigger.
app.html 의 default viewport meta 자체를 maximum-scale=1, user-scalable=no 로 강화.
- 페이지/라우트/인증 상태와 무관하게 root 차원에서 보장.
- single-user PKM 이라 시각 접근성 zoom trade-off 적음.
- PDF/이미지 viewer 는 자체 zoom 컨트롤 (PDF.js 내장 + 이미지 모달) 사용 → 영향 미미.
- study/write/[id] 의 page-level svelte:head viewport meta 는 동일 값으로 그대로 둠
(인증된 사용자 SSR 케이스의 의도 표시 + 이중 정의되어도 무해).
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>
진단 (사용자 디버그 패널): up:3 cancel:4 — pointerup 보다 cancel 이 더 많음.
iPad OS 가 multi-touch / 시스템 gesture 인식 시 active pen pointer 를
강제 cancel. cancel 된 stroke 가 strokes 에 들어가면서 의도 아닌 짧은
노이즈 stroke 누적 → 사용자 글자 망가짐.
[Fix 1] pointercancel 시 inflight 폐기:
- 기존: cancel 도 endStroke 에서 inflight.points.length >= 1 면 strokes 에 추가
- 변경: cancel 은 inflight = null 로 폐기, scheduleRedraw 만
- pointerup 만 정상 finalize
[Fix 2] isDrawing 중 새 pointerdown 무시:
- multi-touch / 두번째 pen 시도 시 진행 stroke 보호
- onPointerDown 첫줄에 if (isDrawing) return
[Fix 3] document level touchstart/touchmove preventDefault 제거:
- blockMultiTouch 가 touch 이벤트 default 처리 차단 → iOS Safari 자체
palm rejection 메커니즘 망가뜨려 pointercancel 발생률 증가시킴
- 캔버스의 touch-action: none + 영역 외 일반 동작 허용으로 변경
- 핀치줌 차단은 wheel+ctrlKey (데스크톱) + gesture 이벤트 (iOS) 만 유지
추측 fix 그만하고 사용자가 어디서 누락 발생하는지 직접 보도록 좌상단에
실시간 pointer event 카운터 표시.
표시 항목:
- lastType / lastPressure (Apple Pencil 인지, pressure 값 정상인지)
- down / move / up / cancel — 각 이벤트 발생 횟수
- rejType (pointerType 거부) / rejId (pointerId 미스매치 거부) / coalesced
- drawing flag / activePointerId / inflight 점 개수 / strokes 개수
진단 시나리오:
- "ㅏ 가 입력 안됨" — down 카운트는 올라가는데 strokes 안 늘면
endStroke 의 rejId 또는 inflight 가 1점이라 finalize 거부.
- "type 이 touch" 면 손가락 입력. Apple Pencil 인식 안 되는 환경.
- "rejType 카운트 큼" — pen 외 입력이 다수 들어와서 거부됨.
[#1 모든 획이 안 들어옴]
- pointerleave 핸들러 제거 — stale leave 가 isDrawing=false 만들어 다음
pointermove 가 다 무시되던 핵심 누락 원인 차단.
pointerup / pointercancel 만으로 finalize.
- 1점 stroke (짧은 탭) 도 strokes 에 보존. length>1 검사 제거.
[#2 점선 stroke (긴 직선이 ........)]
- pushPointWithInterp: 점 사이 거리가 8px 초과 시 중간 점 자동 보간.
iPad 60Hz pointermove + 빠른 펜 이동에서 sparse point 일 때도 매끈.
- perfect-freehand 옵션 재튜닝:
thinning 0.4 → 0.25 (얇아지지 않게)
smoothing 0.62 → 0.85 (sparse point 도 부드럽게)
streamline 0.5 → 0.65 (손떨림 보정 강화)
[#4 Safari 팝업 가끔 뜸]
- pointerdown 시점에 document.getSelection().removeAllRanges() 강제 clear.
selectstart preventDefault 만으로 부족한 케이스 (펜이 이미 선택된 영역
위에서 시작) 방어.
기존 문제: 점선 stroke / 연속 입력 누락 / 버튼 focus zoom / Safari 선택 팝업.
원인을 4축으로 분리해서 한꺼번에 fix.
[1] 입력 수집 (PointerEvent 상태머신)
- isDrawing flag + activePointerId 매칭으로 stroke 누락 방지
- pointerdown: 이전 inflight 가 살아있으면 finalize 후 새 stroke 시작
- setPointerCapture (try-catch) — element 외 pointer move 도 받음
- pointerup / pointercancel / pointerleave 통합 endStroke
- pointerType === 'pen' (mouse 도 데스크톱) 만, 손가락 거부
[2] coalesced events
- pointermove 의 e.getCoalescedEvents() 모두 points 에 push
- 빠른 필기에서 sparse point → 점선 현상 방지 핵심
- normalizePressure: 0/비정상 값은 0.5 fallback
[3] 렌더링: perfect-freehand polygon fill
- getStroke(thinning:0.4, smoothing:0.62, streamline:0.5, last:true)
- getSvgPathFromStroke (perfect-freehand README 표준 builder)
→ Path2D → ctx.fill() — anti-aliased polygon
- 1점 케이스: arc fill 폴백
- last: true 항상 (진행 중에도 polygon 닫힘)
[4] autosave 입력 분리
- 3초 idle debounce
- flushSave 는 setTimeout 0 으로 다음 macrotask
- PATCH 응답이 strokes 를 덮어쓰지 않음 (응답 무시, fire-and-forget)
[5] Safari/Chrome hardening
- 캔버스/컨테이너: touch-action: none + user-select: none +
-webkit-touch-callout: none + -webkit-tap-highlight-color: transparent
- canvas 에 oncontextmenu / onselectstart preventDefault
- 모든 toolbar 버튼: clickThenBlur(fn) + tabindex=-1 + BTN_STYLE
→ button focus zoom 차단 (사용자 보고 "버튼 누르면 화면 확대" 핵심)
[6] resize 정책
- ResizeObserver + window resize/orientationchange 만 트리거
- pointermove 마다 resize 절대 안 함
- DPR 반영 + setTransform(dpr,...) 으로 retina 선명
수정 범위 (사용자 명시): HandwriteCanvas.svelte 만. 다른 영역 무수정.
증상 1 (사용자 보고): 펜/지우개/굵기 등 어떤 toolbar 버튼이든 누르면 화면
확대. 창을 옮기면 정상 크기. 다시 누르면 또 확대.
원인: iPad/Chrome 의 button focus 시 자동 zoom (focus 후 layout 변경 또는
브라우저 자체 zoom). 우리 fix 들이 핀치줌만 보고 focus zoom 을 놓침.
Fix 1 — clickThenBlur + tabindex=-1:
- 모든 toolbar/header button 의 onclick 을 clickThenBlur(fn) 로 감쌈.
click 시 즉시 e.currentTarget.blur() 호출 → focus 안 받음 → zoom 안 일어남.
- tabindex={-1} 추가 — 키보드 포커스 자체 차단.
증상 2 (사용자 사진): 빠르게 그린 stroke 가 점선처럼. perfect-freehand 의
polygon outline 이 sparse point 에서 깨짐.
Fix 2 — perfect-freehand 제거, 단순 quadratic bezier:
- ctx.moveTo + 점-점 사이 quadraticCurveTo 보간 + ctx.stroke() 한 번 호출.
- lineCap/lineJoin round, lineWidth = effectiveSize.
- 압력 효과는 미반영 (단일 굵기) — 안정성 우선. 점선 안 됨.
- 1점/2점 케이스 폴백 (arc / lineTo).
P1 데스크톱 trackpad pinch 줌 차단 (Chrome/Firefox macOS):
- wheel + ctrlKey/metaKey preventDefault 추가 (페이지 zoom 방지)
- 데스크톱 Chrome 은 gesture 이벤트 미발화, wheel + ctrlKey 만 발화
- 사용자 사진 8854/8855: 모드 토글 사이 trackpad pinch 로 페이지 zoom 발생
P2 iPad 입력 씹힘 — main thread 블록 해소:
- offscreen buffer canvas 도입. 완료 stroke 들은 buffer 에 한 번만
perfect-freehand getStroke + Path2D fill 로 그림.
- 매 frame 의 redraw 는 ctx.drawImage(buffer) + inflight 만 처리.
- strokes 변경 시만 bufferDirty=true → 다음 redraw 에서 rebuild.
- iPad CPU 에서 33+ stroke 매 frame 재계산이 16ms 초과해 pointer event
누락하던 문제 해소.
Helper:
- setStrokes(next): strokes 재할당 시 buffer rebuild 자동 마킹.
모든 strokes 갱신 (snapshot, eraseAt, finalize, undo, redo, clear,
restoreFromLocalStorage) 에 적용.
여전히 발생하는 입력 누락 / 지우개 누르면 확대 재시도.
P1 줌 차단 강화:
- gesturestart/change/end 를 document level 로 다시 등록 (element-level
ongesturestart 가 일부 iPad Safari 빌드에서 미발화)
- touchstart/touchmove 의 e.touches.length > 1 도 preventDefault — gesture
이벤트 자체가 안 들어오는 경우의 핀치 zoom 백업 방어
P2 입력 누락 — 입력 루프와 redraw/저장 분리:
- pointermove 의 redraw() 를 RAF throttle (scheduleRedraw) — 60Hz 보다 빠른
pointermove 에서 매번 redraw 하던 부담 제거. input 처리 즉시, render 는 frame 당 1회.
- autosave: 5 stroke 즉시 flush 제거 — 빠른 필기 중 JSON.stringify 부하 차단.
3초 idle debounce 만 유지.
- onChange 호출을 setTimeout 0 으로 다음 macrotask 에 ship — 직렬화가
pointer event 와 충돌 안 함.
P1 Safari 줌 차단:
- viewport meta 의 maximum-scale / user-scalable=no 제거 (접근성)
- 페이지 root div 의 ongesturestart/change/end preventDefault — 영역 제한
- 모든 toolbar/header button 에 직접 inline style 적용:
touch-action: manipulation, user-select/-webkit-user-select: none,
-webkit-touch-callout: none, -webkit-tap-highlight-color: transparent
P2 연속 stroke 누락:
- onPointerDown: 이전 inflight 강제 finalize 후 새 stroke 시작
- onPointerMove: pointerId 매칭 완화, isPenLike + inflight 만 체크
(Apple Pencil pointerId 재사용/변경 케이스 방어)
- endStroke: pointerleave race 방어, pointerup/pointercancel 은 무조건 finalize
- 자동 저장 (PATCH) 은 fire-and-forget 그대로 — 입력과 분리
P3 점선 렌더링 품질:
- perfect-freehand 표준 getSvgPathFromStroke + Path2D fill 로 교체
(직접 quadraticCurveTo 보다 안정적)
- thinning 0.5, smoothing 0.7, streamline 0.55 로 튜닝
- normalizePressure: 0/비정상 값은 0.5 fallback (점선 방지)
- coalesced events 모두 points 에 push (빠른 필기 샘플 간격 좁힘)
- 단일 점 (탭) 은 작은 원으로 폴백
증상 (사용자 사진 8856): 펜으로 쓰는데 "복사하기 / Google 으로 검색" 같은
iOS 텍스트 선택 메뉴가 뜸. Safari 가 펜 입력을 텍스트 선택으로 해석.
Fix:
- 캔버스 + 컨테이너 + 페이지 root 에 user-select / -webkit-user-select /
-webkit-touch-callout / -webkit-tap-highlight-color 적용
- canvas 에 oncontextmenu preventDefault — long-press 후 메뉴 차단
증상 (사용자 사진 8854/8855): 펜 → 지우개 토글 사이에 두 손가락이 캔버스에
닿으면서 페이지 전체가 핀치줌되어 글자가 커보이고 stroke 점들이 띄엄띄엄
표시. undo/redo 도 zoom 된 좌표계라 효과 안 보임.
원인: touch-action: none / manipulation 만으로 iOS Safari 의 visualViewport
스케일 기반 핀치줌이 차단되지 않음.
Fix:
- /study/write/[id] 페이지 단위 viewport meta override:
maximum-scale=1, minimum-scale=1, user-scalable=no
(페이지 unmount 시 svelte:head 가 자동 해제)
- document level gesturestart/gesturechange/gestureend 이벤트
preventDefault — iOS 비표준 gesture 이벤트 차단
- onDestroy 에서 cleanup
필기감:
- perfect-freehand 재도입 (effect race 제거됐으니 안전)
- thinning 0.6, smoothing 0.65, streamline 0.5
- simulatePressure false → 실제 e.pressure 반영
- outline polygon 을 quadratic bezier 로 연결 → 부드러운 곡선 (직선 segment ❌)
- ctx.fill() anti-aliased
UI:
- 굵기 토글 (가늘게/보통/굵게) — baseSize × {0.6, 1, 1.6}
- Pencil only (touch 차단)
연속 stroke race fix:
- setPointerCapture/release 제거 → 빠른 pointerup→pointerdown race 차단
- onPointerDown 시 이전 inflight 강제 보존 (드물지만 stale 한 경우)
- pointerleave 핸들러는 inflight 가 살아있을 때만 endStroke
- endStroke: inflight 없으면 즉시 return, activePointerId 만 정리
이전 보고: "ㄱ 쓰고 ㅏ 바로 쓰면 ㅏ 가 입력 안됨" 핵심 원인은 stale
pointerleave 가 두번째 stroke 를 강제 종료시킨 것. 위 race fix 로 해결.
- isPenLike: 'touch' 제거. pen/mouse 만 허용 → 손가락 stroke/지우개 차단
- 페이지/툴바 영역에 touch-action: manipulation → 버튼 빠른 두 번 탭 시
iOS Safari 더블탭 줌 차단. 지우개/펜 토글 시 화면 확대되던 현상 fix.
원인: \$effect(initialStrokes 동기화) 가 strokes 도 의존성으로 추적함.
사용자가 펜으로 그린 후 strokes 변경 → effect 재실행 → 조건
"initialStrokes.strokes !== strokes" 가 true → strokes 를 옛 initialStrokes
값으로 되돌림 → 새 stroke 사라짐.
지우개 누르면 글자가 커지는 현상도 같은 effect 가 trigger 되며 strokes 가
옛 값으로 reset + canvas 비율 재계산이 겹쳐 발생.
Fix:
- \$effect 제거. 초기 strokes 는 \$state initial value 로 한 번만 set.
부모가 prop 새 값을 줘도 무시 (사용자 진행 stroke 우선).
- traceText effect 는 명시적 prev 비교로만 redraw 트리거.
- 디버그용 빨간 사각형 / 빨간 strokeStyle 제거. 정상 색 (--text) 복귀.
stroke 가 안 보이는 원인 격리. iPad 화면에서:
- 좌상단 빨간 50x50 사각형 보임 + 빨간 stroke 보임 → 토큰 색 문제
- 사각형 보임 + stroke 안 보임 → drawStroke / strokeStyle 문제
- 사각형도 안 보임 → redraw 미호출 또는 canvas 자체 가려짐
증상: stroke count 는 올라가는데 화면에 그려지지 않음 + 위치 어긋남.
원인 격리 시도:
- perfect-freehand 의 polygon fill 이 일부 환경에서 제대로 그려지지 않는 것으로
보여 단순 ctx.beginPath/moveTo/lineTo/stroke() 로 갈아치움. lineCap/lineJoin
'round' + lineWidth=baseSize 로 자연스러운 라인. 압력 효과는 일시 제거.
- getLocalXY 에 scale 보정 추가: canvas.style.width(cssWidth) 와 rect.width 가
다른 ResizeObserver 지연 케이스에서 좌표가 어긋나지 않도록 비율 보정.
이번 변경으로도 stroke 가 안 보이면 디버그 오버레이의 좌표/크기를 보고
다른 경로 (캔버스 자체 비활성, layer 가림 등) 추적.
증상: iPad 에서 펜 입력이 안 들어가거나 다른 위치에 그려지는 보고. 원인은
좌우 분할 layout 에서 우측 캔버스 영역이 좁거나 layout 이 stale.
UI:
- /study/write/[id] layout 을 캔버스 풀스크린 + 좌측 floating panel 로 변경
- 헤더에 패널 토글 버튼. 패널 default closed → 캔버스가 화면 거의 전체
- 캔버스 컨테이너에 border-default/30 추가 (영역 가시화)
좌표/입력:
- isPenLike: 'touch' 도 허용 (iPad 일부 빌드에서 Pencil 이 'pen' 으로 안 들어오는 케이스 방어)
- 디버그 오버레이: 캔버스 크기 + 마지막 pointer 좌표/pressure/type 표시
- ResizeObserver 외에 window resize / orientationchange 리스너 추가
- 마운트 직후 RAF×2 후 한 번 더 resizeCanvas (flex 레이아웃 0x0 첫 paint 방어)
stroke 가 안 그려지는 이슈 수정 + 사용자 요청한 부분 지우개 추가.
렌더링 fix:
- last:true 항상 (진행 중 stroke 도 양쪽 outline + cap 완성, polygon 닫힘 보장).
이전엔 inflight 일 때 last:false 라서 outline 한쪽만 그려져 fill 영역 거의 0.
- thinning 0.5 → 0.3 (시작/끝 부분이 너무 얇아지지 않게)
- baseSize default 4 → 6
- pointermove: main 점을 항상 push (coalesced 는 보간 보조)
부분 지우개:
- tool: 'pen' | 'eraser' state. 툴바에 펜/지우개 토글
- eraser 모드: pointer 가 지나가는 stroke 를 점-원 hit-test 로 즉시 삭제
- eraserRadius = baseSize * 4 (최소 16 px)
- 삭제된 stroke 는 undoStack 으로 — undo 로 복구 가능
- cursor: eraser 면 'cell', 펜이면 'crosshair'
- 전체 지우기는 별도 Trash2 버튼으로 분리
문제: dark mode 에서 stroke #111 이 --bg #0f1117 와 거의 같아 안 보임 +
Apple Pencil pressure 0 케이스 방어 부재.
수정:
- strokeColor 를 마운트 시 --text 토큰 실측 (e4e4e7 등) 으로 갱신
- simulatePressure true 로 변경 — 압력 0 으로 들어와도 속도 기반으로 굵기 보장
- thinning 0.55 → 0.5
3일 telemetry (599 triage / 555 deep) 기반 임계치 재평가:
1. 에스컬레이션 비율 — 임계치 의미 reframe
- 기존: >20% 적색 (튜닝 필요) → 항상 적색 (운영 패턴 97%)
- 신규: <80% 적색 (정책 매칭 실패 증가)
- 메시지: "safety 정책상 95~100% 가 정상" 보조 표시
- safety_reference 99.7%, generic 100% (fallback risk_flag), msds 46.2%
→ 운영 정상 패턴 확인
2. Deep summary 안정성 — 신규 카드 추가
- mode='summary_deep' 의 error_code IS NOT NULL 비율
- 현재 5.2% (call_failed 21 + parse:ValidationError 8)
- >5% 적색 임계
- MLX 호출 timeout / JSON 파싱 실패 모니터
3. triage JSON 건강도, Backlog Suppression — 임계치 유지
- 현재 0%, 1% — 매우 안정. 보수적 임계 유효.
Backend: TierHealthStack 에 deep_total / deep_err_total 추가
Frontend: 카드 그리드 3열 → 4열 (lg), Day 4 신규 카드.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
체크박스 체크 후 10초 경과 항목을 대시보드 핀 메모 / /memos 에서
자동 숨김, 메모 푸터 "완료 N개 보기" 버튼으로 토글.
- migration 161: documents.memo_task_state JSONB — {"<idx>":{"checked_at":"ISO"}}
- PATCH /memos/{id}/tasks/{task_index} 전용 엔드포인트:
· SELECT FOR UPDATE 로 동시 토글 race 차단
· task_index drift 시 stale state 자동 정리 (400 대신 200)
· AI 재처리/큐 enqueue 의도적 스킵 + memo_task_toggle_skip_ai 로그
- renderMemoHtml(taskStates, now) → 경과 항목에 memo-task-hidden 클래스
- Svelte 5 $effect cleanup 으로 setInterval 누수 방지
`/documents?category=law` 같은 URL 이 프론트에서 무시되던 버그 — `+page.svelte` 의 filter state 에 `category` 가 빠져 있어 API 호출 시 `?category=` 가 서버로 전달 안 됐음. 결과적으로 default 목록 (news/law 만 제외한 전체) 이 반환됐다.
Sidebar '법령 알림' 버튼 (e88640d) + API `category` 필터 (§§2A) 는 이미 반영됨 — 프론트 middleware 만 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- migrations/152: ALTER TYPE doc_category ADD VALUE 'law' (DDL only; PG16 단일-트랜잭션 제약상 backfill 은 별도)
- models/document.py: Enum 에 'law' 추가 (7 활성 + 3 유보)
- workers/law_monitor.py: Document(..., category='law') — 신규 유입부터 세팅
- workers/classify_worker.py: source_channel='law_monitor' early-return + 최소 필드 (ai_domain='법령', ai_tags=['법령'], importance='medium'). AI classify skip — 법령 구조 고정/외부 source of truth/자동 재수집
- scripts/backfill_category.py: law 분기 + WHERE re-target ((source_channel='law_monitor' AND category='document')) + VERIFY cat_law/law_source_count + fail 조건
- api/documents.py: default 목록 제외에 law_monitor 추가 (news 와 동일 패턴)
- api/dashboard.py: documents count FILTER 에 law_monitor 제외 (category_counts.law 는 기존 GROUP BY category 로 자동 노출)
- frontend/Sidebar.svelte: '법령 알림' 버튼 ?source=law_monitor → ?category=law (explicit category 경로가 default exclusion 을 skip)
plan: ~/.claude/plans/stateless-churning-raccoon.md
axis 원칙: category=UI 축, policy/telemetry=source_channel+ai_domain 축 (feedback_category_vs_ai_domain_axis.md)
배포 순서: push → GPU pull → compose up --build fastapi frontend → backfill --dry-run → --apply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- +layout.svelte 상단 nav 에 오디오/비디오 추가 (문서/자료실 옆,
카테고리 계열 그룹). Sidebar 는 §2 에서 추가했던 카테고리
블록 제거하고 기존 도메인 트리 전용으로 복구 — 상단 nav 와
중복되고, 사이드바가 카테고리 탐색 1차 진입점으로 적합하지
않다는 피드백 반영.
- app/Dockerfile uvicorn 에 --proxy-headers --forwarded-allow-ips=*
추가. FastAPI 의 trailing-slash 307 리다이렉트가 X-Forwarded-Proto
를 무시해 Location 헤더를 http:// 로 생성 → HTTPS 페이지에서
mixed-content block (/video 에서 목격). home-caddy → document-caddy
→ fastapi 체인에서 scheme 복구.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>