문제 본문 + 보기 1~4 → bge-m3 1024차원. status 자체가 큐 역할 (별도 큐
테이블 없음 — ProcessingQueue 인프라 영향 0). APScheduler 1분 cron 이
status in {none, failed, stale} 행을 batch=10 처리. 새 문제는 default
'none' 으로 자동 backfill.
데이터 모델 (migrations 193~194):
- study_questions: embedding vector(1024), embedding_status VARCHAR(20)
DEFAULT 'none' (none/pending/ready/failed/stale), embedding_updated_at,
embedding_model
- HNSW partial index (vector_cosine_ops) WHERE deleted_at IS NULL AND
embedding IS NOT NULL — bge-m3 cosine 기준, documents.embedding (ivfflat)
과 ops 일관
재계산 트리거: question_text / choice_1~4 변경 시 ready→stale 자동.
correct_choice / explanation / subject / scope 변경은 재계산 안 함
(의미 검색에 영향 없음).
워커 (workers/study_question_embed_worker.py):
- race-safe pending 마킹 (조건부 UPDATE WHERE status IN none/failed/stale)
- AIClient.embed(text) bge-m3 호출, 15s timeout
- 실패 시 status='failed', 직전 embedding 보존, 다음 cron 틱에 재시도
- 본문 = "문제: ...\n보기:\n1. ...\n2. ...\n3. ...\n4. ..." (subject/scope
의도 제외 — 분류명이 의미 검색 노이즈)
후속 PR 예정: 비슷한 문제 검색 UI / 중복 입력 감지 / RAG 정확도 향상 /
오답 클러스터링. 본 PR 은 임베딩 저장·재계산·backfill 까지만.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
복습 답 제출 후 또는 편집 화면에서 사용자가 명시적으로 누를 때만 AI 가
4지선다 풀이 생성. 자동 일괄 생성 금지 (하루 100문제 입력 시 MLX 부하·
잘못 입력 문제 해설 위험).
데이터 모델 (migrations 191~192):
- study_questions 4 컬럼 추가: ai_explanation TEXT, ai_explanation_status
VARCHAR(20) DEFAULT 'none' (none/pending/ready/failed/stale),
ai_explanation_generated_at, ai_explanation_model
- partial idx (study_topic_id, ai_explanation_status) WHERE status != 'none'
PATCH stale 자동 전이: question_text/choice_*/correct_choice 변경 시
status='ready' 만 'stale' 로. 본문은 보존, UI 배지 + "다시 생성" 동선.
신규 엔드포인트: POST /api/study-questions/{id}/ai-explanation
- regenerate=false + ready/stale → 캐시 즉시 (MLX 호출 없음, is_stale 플래그)
- pending → 409 (race-safe 조건부 UPDATE 로 동시 호출 차단)
- 그 외 → 새 생성
RAG 입력 풀:
- 1순위: study_topic 매핑 documents 청크 + ai_summary, bge-reranker top-5
- 2순위: 같은 토픽 다른 questions (자기 자신 제외, ai_explanation 은 ready
상태만 포함 — 재귀적 hallucination 방지), reranker top-3
- 제외: 필기 OCR / 외부 웹 / Premium 모델
모델: Mac mini MLX gemma-4-26b primary 단독. get_mlx_gate() Semaphore(1) 경유,
30s timeout. 실패 시 status='failed' + 직전 본문 보존.
프롬프트 (app/prompts/study_question_explanation.txt): 자료 우선순위·인용
형식·할루시네이션 방지 절대 규칙 (법령명·조항·수치·표준 번호 단정 금지,
"자료에서 확인되지 않음" 명시).
프론트:
- 복습 화면 답 제출 후 인라인 expand. status별 버튼 분기 (ready 캐시 /
stale "이전 풀이"+"다시 생성" / failed "다시 시도")
- 편집 화면 별도 카드. 상태 배지 + "이전 풀이 보기" / "다시 생성" 분리
- 참고 근거 토글 (source_type 별 아이콘 📄/❓ + 제목 + snippet)
후속 PR 보류: 오답노트/통계, AI 일괄 백그라운드 생성, 필기 OCR RAG,
Premium/Claude 재생성, /api/search/ask retrieval scope 통합.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
가스기사처럼 한 워크스페이스에 273건 자료가 묶이면 평면 리스트로 쭉 나열
되어 통합뷰가 무너졌음. /study/topics/[id] 자료 섹션을 자료실 카테고리
경로 기반 트리로 그룹핑하고 노드별 접기/펼치기 도입. 기본값 모두 접힘.
백엔드: StudyTopicDocumentSummary 에 library_paths(`@library/<path>` 태그
에서 prefix 제거) 필드 추가. 그룹핑은 첫 path 만 사용 (단순화).
프론트: documents 를 path segment 별로 트리 빌드 → snippet 재귀 렌더링.
헤더에 "자료 N개 · 카테고리 K개 · [모두 펼치기/접기]" 컨트롤. 분류 없는
자료는 "분류 없음" 그룹으로 별도. 자료 0건 path 는 자동 누락.
필기/문제 섹션은 분류축이 달라(certification/subject vs subject) 동일
트리 못 쓰므로 본 PR 범위 밖. 후속에서 패턴 일관성 검토.
study_topic 워크스페이스에 4지선다 문제은행 자산 트랙 추가. 기사시험 필기
대비 시나리오 — 빠른 반복 입력 + 과목별 균등 추출 복습 + 정오답 누적.
데이터 모델 (migrations 186~190):
- study_questions: study_topic 1:N, soft delete, is_active 토글, correct_choice
SMALLINT CHECK 1~4
- study_question_attempts: 답 제출 1행 누적. study_question_id FK는 ON DELETE
RESTRICT (이력 보존 원칙 — hard delete 실수로 풀이 기록 소실 차단)
설계 원칙:
- 문제 삭제는 API 에서 soft delete only. attempts FK RESTRICT 로 DB 레벨도 보호
- correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존)
- 복습 default = 과목별 target_per_subject(20) 무작위 균등 추출. 한 과목이
부족하면 가용한 만큼만
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제 (latest-wrong, ever-wrong 아님)
- 출제 응답에서 정답·해설 비공개. 답 제출 시점에만 노출
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속)
API: /api/study-topics/{id}/questions, /review/questions, /api/study-questions/{id},
/attempt. 통합뷰(/study-topics/{id}) 응답에 sections.questions / stats.question_count
추가. 기존 question_set_count 는 후속 PR(회차/모의고사 묶음)용으로 보존.
프론트: /study/topics/[id]에 문제 섹션 + "새 문제"/"복습 시작" 진입.
/questions/new (저장 후 계속 입력 + sessionStorage persistent),
/questions/[qid]/edit (정답 변경 시 attempts 재계산 안 됨 안내 배너),
/review (시작 옵션 → 풀이 → 마지막 요약).
후속 PR 예정: 오답노트/취약 과목 리포트, AI 해설/클러스터링, spaced
repetition, 이미지 OCR 입력, CSV import, study_question_sets 묶음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
페이지네이션으로 여러 페이지에서 전체선택을 누적하면 100건 초과로 백엔드
StudyTopicDocumentLinkRequest 의 max_length=100 위반 → 422. 백엔드 제약은
abuse 방어용으로 유지하고, 프론트에서 100개씩 chunk 로 분할 POST + 결과
카운트 누적해 단일 토스트로 보고.
기존 page_size=50 으로 박혀 있어서 한 카테고리에 50건 초과 자료가 있을
때 51번째부터 안 보였음. page_size 를 백엔드 max(100)로 올리고 이전/다음
페이지 컨트롤 + 총 건수/페이지 표시 추가. 100건 초과 시 모달 상단에
"좌측 트리 폴더+ 아이콘으로 한 번에 추가" 안내 배너.
자료 추가 모달이 1건씩 검색·체크박스만 지원해서 같은 카테고리에 자료가
많을 때 비효율적. /api/library/tree 의 카테고리 구조를 모달 좌측에 띄우고,
노드 옆 아이콘 한 번으로 그 path 하위 자료 전체를 한 번에 매핑.
백엔드: POST /api/study-topics/{id}/documents/by-path 추가. user_tags
@library/<path> prefix 매칭(documents.py 의 list_library_documents 와
동일한 EXISTS 쿼리)으로 100건 limit 우회. 응답은 linked_count /
skipped_existing_count / total_in_path 카운트만 노출.
프론트: 모달을 max-w-4xl + grid(트리/자료) 레이아웃으로 개편. 트리 노드
클릭 = 우측 자료 목록 path 필터링, 노드 옆 FolderPlus 버튼 = 즉시 일괄
추가. 검색·체크박스·전체선택은 그대로. 모바일은 트리가 상단 max-h-40vh
영역으로 stack.
필기 세션과 자료(library document)를 한 학습 주제(예: 가스기사) 아래로 묶는
1차 컨테이너. 향후 단어장/오디오/문제세트 등 학습 자산이 같은 묶음으로 들어올 수
있도록 응답 구조(sections + stats)를 dict 기반으로 설계.
데이터 모델 (migrations 179~185):
- study_topics: user_id × name partial unique (active 행만), soft delete
- study_sessions.study_topic_id: 1:N nullable FK (ON DELETE SET NULL)
- study_topic_documents: 자료 N:M 매핑 (user_id 반정규화로 권한 격리)
설계 원칙:
- documents.category(자료실 UI 축)와 직교 → 자료실 facet/카테고리 미터치
- StudySession.certification/subject/topic 보존 (세부 메타로 계속 사용)
- study_type은 느슨한 분류 (강한 enum 미사용, jlpt_n3 등 확장 여지)
- polymorphic study_topic_items 영구 금지 → 자산 타입별 조인 테이블 추가 방식
API: /api/study-topics CRUD + /by-document/{id} + 자료/세션 매핑 엔드포인트.
프론트: /study/topics 목록 + /study/topics/[id] 통합 뷰(필기·자료 두 트랙) +
write 폼에 워크스페이스 드롭다운 + study hub 진입 카드.
후속 PR-2 어학 UX, PR-3 오디오 자산, PR-4 AI retrieval scope.
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>
증상: 모바일에서 좌측 트리 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>
자료실 자료를 사용자가 명시적으로 "1회독 완료" 클릭 시 +1 누적.
detail 진입 자동 카운트 ❌. append-only 로그.
데이터:
- migrations 174~176: document_reads 테이블 + 인덱스 2개 (단일 statement 분할)
ORM:
- app/models/document_read.py: DocumentRead (user_id, document_id, read_at)
API (app/api/document_reads.py, /api/documents prefix):
- POST /api/documents/{id}/read — 회독 +1
- GET /api/documents/{id}/read-stats — {read_count, last_read_at}
- DELETE /api/documents/{id}/read/last — 현재 사용자의 그 문서 마지막 1건만
· ownership: WHERE user_id=current_user.id AND document_id=:doc_id
· documents 에 user_id 부재 (single-user). multi-user 전환 시 ownership
check 추가 필요 — 코드 주석 명시.
응답 확장:
- DocumentResponse: read_count(default 0), last_read_at(default None)
- /api/documents/library: 페이지 N건 한정 LEFT JOIN 으로 read 통계 매핑 (N+1 회피)
- /api/library/tree CategoryTreeNode: unread_count 추가
· 기존 path_docs 가 ancestor 누적 구조라 그대로 활용 — 하위 경로 합산 자동
규칙 (사용자 명시 — 변경 금지):
· 같은 날 여러 번 클릭 → 각각 별개 회독
· 실수 클릭 취소 = DELETE /read/last
· documents 에 read_count 컬럼 추가 ❌, 로그 기반 COUNT(*) 만
plan: ~/.claude/plans/scalable-chasing-stonebraker.md
브랜치: feature/library-reads (손글씨 트랙과 분리)
- 라이브 디버그 패널 / 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 만으로 부족한 케이스 (펜이 이미 선택된 영역
위에서 시작) 방어.