Skip to content

feat(preview): 프리뷰 확장(M4) — 다이어그램·수식·각주·앵커·콜아웃·코드 복사#7

Merged
Bori-github merged 44 commits into
mainfrom
feat/m4-preview-extensions
Jul 16, 2026
Merged

feat(preview): 프리뷰 확장(M4) — 다이어그램·수식·각주·앵커·콜아웃·코드 복사#7
Bori-github merged 44 commits into
mainfrom
feat/m4-preview-extensions

Conversation

@Bori-github

Copy link
Copy Markdown
Owner

변경 유형은 라벨로 표시한다.

요약 / 의도

분할 프리뷰를 문서 도구로 완성하는 M4. 다이어그램(mermaid)·수식(KaTeX)·각주에 문서 내 앵커 이동·콜아웃(GFM alerts)·코드 복사 버튼까지 — 완료 조건 전부: 렌더되고, 각주를 눌러 오갈 수 있고, 콜아웃이 상자로 뜨고, 코드가 한 번에 복사되고, 번들이 예산 이내.

범위

이번에 한 것

  • 프리뷰 확장 7항목(위) + 아이콘 코드젠 파이프라인(SVGR — 작업 중 결정)
  • 머지 전 자체 리뷰에서 나온 수정 9건(누수·로드 실패 영구화·GitHub slug 계약 등) + 테스트 강화(실패 경로 5건)
  • 마일스톤 종료 계약 문서 수동 대조(7곳 — 코드와 어긋난 계약 0건, 표기 문제 2건 정정) + 문서 전수조사 갈래 1
  • CI 도입(ci.yml) — 전체 게이트를 병합 경계에서 강제(문서가 약속한 2단 구조의 비어 있던 2단)

의도적으로 안 한 것 / 후속

  • 리뷰 후속 백로그: 각주 구역 스크롤 동기화 사각지대(footnote_tail 재배치×단조 가드) · 붙여넣기 하이재킹 방어(textContent→innerText) · 헤딩/각주 id 네임스페이스 충돌 · a11y 3건(라이브 리전·앵커 포커스·가로 스크롤 포커스) · 성능 2건(renderKey 문자열·빈 문서 재렌더) · mermaid SVG 신뢰 ADR · katex alias 3중복 통합 · svgr 색 게이트
  • 상태색(콜아웃은 아이콘·라벨·경계선만 — 열린 결정 유지) · #앵커 외 상대 경로 링크(열린 결정) · 접기(M5)
  • 문서 전수조사 갈래 2(머지 후 별도 docs 브랜치)

주요 변경점

  • 프론트(FSD): widgets/preview-pane — mermaid lazy 렌더(SVG 캐시·실패 격리·로드 실패 비캐시+재시도), 앵커 스크롤(콘텐츠 한정 탐색), 코드 복사 버튼(React 포털 — React 비소유 DOM 속 유일한 React 소유 예외), shared/ui/icons(SVGR 생성물)
  • 마크다운(packages/markdown): mermaid 펜스→퍼센트 인코딩 플레이스홀더, KaTeX(maxSize: 100), 각주, GitHub 호환 slug 앵커(렌더 텍스트 기준), GFM 콜아웃 5종. fuzzyLink 끔(파일명 오탐). DOMPurify에 MathML(semantics·annotation) 허용
  • Rust(src-tauri): 변경 없음
  • 스크립트/툴링(scripts·mise·게이트): mise run icons(아이콘 코드젠), CI 워크플로, 브라우저 테스트 직렬화
  • 문서(.claude): preview-strategy 계약 확정(함정 3건 실측 기록), testing.md "실패 경로 우선" 규칙 신설, tech-stack 표 신규 의존성 5종 등재, 게이트 자동화 절 재구성

설계 · 결정

  • 결정/근거:
    • sanitize 경계의 역전(다이어그램): 사용자 원문만 DOMPurify를 통과시키고, 엔진 SVG는 정화된 자리에 넣는다(securityLevel: strict). 이 경계가 엔진 신뢰에 얹히므로 mermaid는 정확 핀 필수
    • 프리뷰 DOM은 React 비소유: dangerouslySetInnerHTML은 재렌더마다 자식을 재생성해(실측) 직접 꽂은 SVG가 사라진다 — innerHTML 직접 삽입 + 복사 버튼만 포털
    • 번들 판정 기준은 dist가 아니라 .app(Tauri가 웹 자산을 압축 내장) — 실측 기록은 platform-strategy로 이동
  • 문서와 달라진 점(있으면): 없음 — 달라져야 했던 지점은 같은 커밋에서 문서를 먼저 갱신했다. 예시 문서 신설 없음(기존 examples/로 확인 — 예시 규칙의 "기존 예시로 확인되면 추가하지 않는다")
flowchart LR
    src[".md 소스"] --> mdit["markdown-it<br/>(콜아웃·slug·각주·KaTeX)"]
    mdit -->|"mermaid 펜스 → 빈 placeholder<br/>(원문은 퍼센트 인코딩 속성)"| dp["DOMPurify sanitize"]
    dp -->|"정화된 HTML"| dom["프리뷰 DOM<br/>(innerHTML · React 비소유)"]
    dom -.->|"placeholder 있을 때만 lazy import"| mm["mermaid render<br/>(securityLevel: strict)"]
    mm -.->|"신뢰된 엔진 SVG"| dom
Loading

검증 (체크가 아니라 값으로)

  • mise run check: 통과(exit 0, HEAD 기준 — 같은 게이트가 이 PR에서 CI로 첫 실행)
  • TDD(신규 기능·커맨드·상태 전이·파서는 실패 테스트 먼저): 예 — 파서 확장·복사·앵커·리뷰 수정 레드 우선. 재현 불가 2건(취소 경합·난수 id)은 사유·수동 검증 방법을 해당 커밋 본문에 기록
  • 실앱 E2E (mise run dev-webdrivermise run e2e): 10/10 통과(구현 완료 시점 — 이 브랜치는 E2E 대상 영역인 파일 생명주기를 건드리지 않음). 데모 녹화 시 프리뷰 시연 시나리오 3/3 추가 통과
  • 번들 크기 (tauri build --bundles appmise run bundle-size, <15MB): 12.75MB (여유 2.25MB)
  • 자동 검증 불가 항목(방법·이유): 서명 배포 빌드(tauri:// origin)의 클립보드 실동작 — WebDriver 피처가 릴리스에 없음, 수동 확인 · 복사 버튼 hover 배치 — 실 포인터 필요, 데모 영상으로 확인

관련 문서 (단일 출처)

  • .claude/docs/preview-strategy.md(계약 본체) · testing.md(실패 경로 우선) · tech-stack.md(의존성 5종 등재) · design/design-system.md#아이콘 · code-quality.md#게이트-자동화-훅과-ci · platform-strategy.md#번들-크기-측정

리뷰 포인트 / 위험 지점

  • sanitize 경계 역전부: use-mermaid.ts의 innerHTML 삽입과 실패 경로(취소×실패·로드 실패 재시도)
  • 포털 수명주기: 내용 교체 × 포털 재수집 × 비동기 SVG 도착의 상호작용
  • DOMPurify 허용 확장(ADD_TAGS/ADD_ATTR)이 의도 범위(MathML)를 넘지 않는지

체크리스트 (프로젝트 규칙)

  • 동작을 바꾸는 변경은 계약 문서를 먼저/같은 커밋에서 갱신
  • docs-drift 통과 (tech-stack 버전 ↔ 핀, Rust 커맨드 등재)
  • 비목표 경계(.claude/rules/non-goals.md)를 넘지 않음
  • 성능 규칙(뷰포트 한정 데코레이션·프리뷰 디바운스·번들 목표) 준수
  • 커밋·PR 제목이 Conventional Commits(한국어)

스크린샷 / 데모

실앱 시연(기본 렌더·콜아웃 → 코드 복사 버튼 → 수식 → 다이어그램 → 각주 왕복):

norii-preview-demo-540.mp4

마일스톤 / 비고

  • M4. 머지 후: CI 그린 확인 뒤 main 브랜치 보호(required check) 제안 · 문서 전수조사 갈래 2 · 리뷰 후속 백로그

🤖 Generated with Claude Code

Bori-github and others added 30 commits July 14, 2026 01:57
세 기능(각주·수식·다이어그램)을 붙여 실제 .app을 빌드해 쟀다 — 앱 번들 11.09 → 12.82MB로
15MB 예산 이내다(여유 2.18MB). dist는 +4.88MB지만 Tauri가 웹 자산을 압축해 바이너리에
넣어 앱에서는 +1.73MB에 그친다. 예산 판정의 기준은 dist가 아니라 .app이다.

lazy-load의 근거를 바로잡았다 — 분할된 청크도 앱에 함께 실리므로 번들 크기 대책이 아니다.
지키는 것은 다이어그램 없는 문서의 기동 비용이다.

구현하며 드러난 두 제약을 못박았다: DOMPurify가 값에 '-->'가 든 속성을 버린다는 것(플로차트의
기본 화살표라 다이어그램이 조용히 사라진다)과, React가 재렌더마다 dangerouslySetInnerHTML의
자식을 다시 만든다는 것(직접 꽂은 SVG가 날아간다).

열린 결정 'Mermaid 번들'을 해소해 목록에서 뺐다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mermaid는 markdown-it 플러그인이 아니다. 펜스를 빈 div로 바꾸고 원문만 data 속성에 실어
보낸다 — SVG는 소비 측이 DOM 삽입 후 클라이언트에서 그린다. 무거운 mermaid가 파서 패키지에
묶이지 않고, sanitize 순서(원문은 정화, 엔진 SVG는 정화된 자리에)가 지켜진다.

원문은 퍼센트 인코딩해 싣는다. DOMPurify는 값에 '-->'가 든 속성을 통째로 버리는데
(SAFE_FOR_XML 방어), '-->'는 플로차트의 기본 화살표라 날것으로 실으면 다이어그램이 조용히
사라진다. 그 케이스를 회귀 테스트로 고정했다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
React는 dangerouslySetInnerHTML 요소의 자식을 재렌더마다 다시 만든다 — HTML 문자열이
그대로여도, DOM을 건드리지 않아도, 값을 메모이즈해도 그렇다(최소 재현으로 확인). 그러면
클라이언트가 직접 꽂는 DOM(곧 들어올 다이어그램 SVG)이 아무 재렌더에나 조용히 사라진다.

내용의 소유권을 위젯이 가져와 이펙트에서 직접 innerHTML로 넣는다. React는 이 자식들을
건드리지 않고, 갱신은 렌더된 HTML이 실제로 바뀔 때만 일어난다. 삽입되는 것이 sanitize를
마친 HTML뿐이라는 계약은 그대로다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
파서가 낸 플레이스홀더를 SVG로 채운다. 세 가지를 함께 진다.

- lazy-load: 문서에 다이어그램이 있을 때만 동적 import한다. 초기 청크는 892→896KB로
  사실상 그대로고 mermaid는 별도 청크로 빠진다. 번들 예산이 아니라 기동 비용을 지킨다.
- 캐시: (테마, 원문)을 키로 SVG를 캐시해 디바운스 갱신마다 전부 다시 그리지 않는다.
  타이핑 중 다이어그램이 깜빡이지 않는다.
- sanitize 경계: 원문은 이미 DOMPurify를 통과했고 여기서 넣는 SVG는 신뢰된 엔진 출력이다.
  securityLevel 'strict'가 라벨 속 스크립트를 봉쇄한다.

다이어그램은 비동기로 도착해 블록 높이를 바꾸므로, 렌더 리비전을 스크롤 동기화의 렌더 키에
넣어 낡은 측정을 버리게 한다. 문법 오류는 배너로 올리지 않고 그 다이어그램 자리에만 알린다.

앱 번들 11.09 → 11.92MB (예산 15MB, 여유 3.08MB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
구현하며 드러난 두 제약을 못박았다.

katex는 ESM 빌드로 고정해야 한다 — 수식 플러그인이 내부에서 require("katex")로 잡는 CJS
빌드가 번들되면 함수 등록이 날아가 모든 제어 시퀀스가 '정의되지 않음'이 된다(\frac·\int).
명령어 없는 수식만 우연히 살아남아 더 위험하다. 방어는 패키지·앱 양쪽 설정에 다 걸려야 한다.

DOMPurify의 MathML 허용목록에 <semantics>·<annotation>이 없어, 그냥 두면 껍데기만 벗겨지고
안의 TeX 원문이 맨 텍스트로 남는다 — 수식이 두 번 읽힌다.

각주는 렌더되지만 눌러도 이동하지 않는다. 프리뷰가 #앵커를 무동작으로 두는 현 정책 때문이며,
각주가 그 열린 결정의 첫 실사용자다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
markdown-it-footnote와 @vscode/markdown-it-katex(Microsoft 유지보수 포크)를 붙였다.
인라인 $…$·블록 $$…$$, 각주는 본문 참조와 하단 목록으로 갈라진다.

sanitize와 두 번 부딪혔다. KaTeX의 MathML을 온전히 남기려면 <semantics>·<annotation>과
encoding 속성을 허용해야 한다(내용은 텍스트뿐이라 실행 표면이 아니다). 그리고 katex를 ESM
빌드로 고정해야 한다 — CJS 빌드가 번들되면 제어 시퀀스가 전부 죽는다. 두 케이스를 회귀
테스트로 고정했다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KaTeX의 CSS와 폰트를 로컬로 번들한다 — CDN에서 가져오면 CSP(style-src·font-src 'self')에
막히고 오프라인에서 수식이 깨진다. 앱의 vite/vitest 설정에도 katex ESM 고정을 걸었다. 이
방어가 앱 쪽에 없으면 패키지 테스트가 통과해도 실제 앱의 수식이 전부 죽으므로, 앱 레벨
통합 테스트로 따로 지킨다.

각주는 문서 끝에 얇은 경계선으로 갈라 두고, 긴 블록 수식은 패널을 밀지 않고 자기 안에서
가로 스크롤한다(표·코드·다이어그램과 같은 처리). 조판 실패는 붉은 경고색 대신 흐린 글자로
그 자리에만 남는다.

앱 번들 11.92 → 12.75MB (예산 15MB, 여유 2.25MB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
리뷰에서 잡은 네 가지다. 넷 다 증상이 조용해 테스트가 잡지 못하던 것들이다.

- 소스에 날것의 NUL 바이트가 섞여 있어 git이 이 파일을 바이너리로 취급했다(diff·blame이
  보이지 않는다). 캐시 키 구분자를 이스케이프로 바꿨다.
- 디코딩이 try 밖에 있어, 사용자가 원시 HTML로 흉내 낸 플레이스홀더 하나가 URIError를
  던지면 렌더 루프가 통째로 멈췄다 — 뒤에 오는 멀쩡한 다이어그램이 그려지지 않았다.
  실패는 그 플레이스홀더 하나에 가둔다.
- mermaid는 실패 시 임시 노드를 #d<id>로 남기는데 뒷정리가 #<id>만 지웠다. 문법 오류를
  고치는 동안 디바운스 틱마다 노드가 하나씩 샜다(실측).
- SVG 캐시에 상한이 없어 편집 중간 상태의 다이어그램이 무한히 쌓였다. 32개로 제한한다.

앞의 둘은 회귀 테스트로 고정했다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
앱으로 열어 눈으로 확인하는 .md 모음이다. 자동 테스트가 '동작한다'를 증명한다면 이 문서들은
'어떻게 보이는가'를 사람이 판정하게 한다 — 타이포·간격·스크롤·깜빡임처럼 통과/실패로 못 자르는 것들.

렌더 전반(sanitize 경계 포함) · 수식 · 각주 · 다이어그램 네 표면을 담았다. 각 문서는 평범한
사용뿐 아니라 경계(폭을 넘는 표·수식·도표)와 의도적인 실패(문법이 틀린 수식·도표)를 함께 넣어,
앱이 깨지지 않고 그 자리에만 알리는지 눈으로 보게 한다. 알려진 한계(각주를 눌러도 이동하지
않는다)도 문서 안에 적었다.

포매터는 이 폴더를 건드리지 않는다(.oxfmtrc.json ignorePatterns). 예시의 존재 이유가 사용자가
실제로 쓰는 원문을 보여 주는 것이기 때문이다 — 포맷을 걸면 [X]가 [x]로, *기울임*이 _기울임_으로
정규화되어 '이 변형도 지원한다'를 보여 줄 수 없다.

스크롤 동기화처럼 상호작용으로만 시연되는 기능은 전용 문서를 두지 않는다 — 수식·다이어그램
예시가 이미 그 재료다(소스 몇 줄이 화면 한 뭉텅이가 되어 양쪽 높이가 크게 어긋난다).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
문서로 시연되는 기능은 예시를 함께 낸다. 다만 규칙의 무게중심은 '만들라'가 아니라 '대부분은
만들지 마라'에 있다 — 억지로 만든 예시는 아무도 열지 않고 유지 부담만 남는다.

두 관문을 순서대로 통과해야 대상이다: .md 하나로 시연되는가, 그리고 사람이 눈으로 판정할 것이
있는가. 통과/실패가 갈리는 것은 테스트의 몫이다. 둘 다 통과해도 새 파일은 마지막 수단이며,
기존 예시로 이미 확인된다면 아무것도 하지 않는 것이 가장 흔한 정답이다.

판단 기준을 규칙 문서에 둔 이유: 에이전트가 매 세션 자동으로 읽는 것은 .claude/rules/* 뿐이다.
examples/README.md에 두면 정작 기능을 구현하는 순간 그 기준이 눈앞에 없다. README는 목록과
작성법만 소유한다.

대상인데 만들지 않기로 했다면 이유를 한 줄 남긴다 — 자동 검증이 불가능한 영역과 같은 원칙이다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
linkify의 fuzzyLink는 확장자를 TLD로 착각한다 — .md는 몰도바, .sh는 세인트헬레나의 실제
도메인이라 README.md·deploy.sh가 http://readme.md 링크가 됐다. 마크다운 문서에 파일명은 늘
나오므로 오탐이 상시적이고, 링크 정책상 클릭하면 OS 브라우저가 그 엉뚱한 도메인을 연다.
표준(GFM)도 파일명을 링크로 만들지 않는다.

fuzzyLink를 끈다. 프로토콜이 붙은 URL과 이메일은 그대로 자동 링크가 되고, 잃는 것은 www.로
시작하는 표기뿐이다 — 프로토콜을 붙이면 링크가 된다. 예시 문서로 렌더해 확인하니 의도한 둘
(https://tauri.app · mailto:)만 링크로 남는다.

밑줄 강조가 조사 앞에서 걸리지 않는다는 CommonMark 규칙도 예시에 넣었다. 한글은 조사가 바로
붙어 이 차이가 자주 드러난다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
브라우저 테스트가 Panda 스타일을 로드하지 않아, CSS에 의존하는 동작(표 스크롤·타이포·링크)은
지금까지 아무도 검증하지 않았다 — 브라우저 기본값을 재고 있었다. main.tsx가 넣는 스타일을
테스트에서도 로드한다.

그 위에서 표의 넘침 경계를 고정했다: 더 줄일 수 없는 넓은 표는 자기 안에서 가로 스크롤되고
패널 본문을 밀지 않는다. 셀 글자를 먼저 줄바꿈하고 그다음 스크롤하는 것은 표준 마크다운
뷰어와 같은 동작이므로 그대로 둔다(주석에 명시).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
문서에 적힌 12.82MB는 구현 전 스파이크 수치였다 — 구현이 끝난 지금 다시 재니 앱 번들
12.75MB, dist 5.15MB다. '실측'이라 적힌 값이 최종값과 달랐던 드리프트를 바로잡는다.

15MB 예산 값이 프리뷰 전략에 복제돼 있었다. 값과 측정 방법의 소유자는 플랫폼 전략이므로
링크로 넘긴다. 구조 문서가 examples/README와 같은 말을 반복하던 것도 정리했다.

SVG 캐시 상한(32개, 오래된 것부터 폐기)은 코드에만 있고 문서에 없었다 — 디바운스 값과 같은
성격의 런타임 정책이므로 계약 문서에 근거와 함께 기록한다.

예시 문서 규칙의 서술을 다른 문서와 같은 단정형으로 맞췄다. 문서 서술 규칙 자체는 아직 어디에도
명문화돼 있지 않다 — 다음 브랜치의 문서 정비에서 다룬다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
설정 화면은 M6인데 M5로 적혀 있었다. 마일스톤 표(implementation-plan.md)가 단일 출처이며
그와 어긋난 드리프트다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
M4를 닫지 않고 범위를 넓힌다 — 셋 다 프리뷰 안에서 끝나고, 앵커는 M4가 만든 각주의 마감이다
(누를 수 없는 각주는 반쪽이다). 마일스톤 번호를 밀면 다른 문서들이 참조하는 M5·M7이 어긋나므로
번호는 유지한다.

링크 정책을 두 갈래에서 세 갈래로 바꾼다. #앵커는 문서 밖으로 나가지 않으므로 스킴 허용목록
판정 이전에 갈라지고, 앱이 직접 스크롤한다. 웹뷰 내비게이션 차단은 링크 종류를 가리지 않는다.
스킴 허용목록의 단일 출처인 security.md에는 '이 절의 대상이 아니다'만 남기고 처리는 위임한다.

앵커 이동은 에코 가드를 경유하지 않는다. 가드는 '이 스크롤은 발행하지 마라'는 표시라 경유하면
에디터가 따라오지 않는다. 루프는 수신 측(에디터)이 이미 가드를 쓰므로 생기지 않는다. 설계
리뷰에서 두 요구가 모순임이 드러나 바로잡았다.

헤딩 앵커를 포함한다 — markdown-it은 헤딩에 id를 붙이지 않아 목차 링크가 죽어 있다. slug 규칙은
GitHub 호환으로 못박아(소문자화·공백은 하이픈·한글 유지·중복 시 -1) 기존 목차가 그대로 동작하게
한다. 앵커 조회는 프리뷰 콘텐츠 안에서만 한다 — 문서 전체에서 찾으면 앱 UI의 같은 id를 잡는다.

콜아웃은 GFM 5종만 지원하고(Obsidian 독자 확장은 채택하지 않는다), 아이콘은 마크업이 아니라
CSS로 그려 sanitize 표면을 늘리지 않는다. 5종 밖 타입은 평범한 인용문으로 남는다.

복사 버튼은 프리뷰 재렌더마다 사라지므로 다이어그램과 같은 재부착 경로를 타고, 클릭은 패널 단위
이벤트 위임으로 받는다. 클립보드는 실앱 실측 결과 웹 표준 API가 동작하지만(개발 모드 기준)
배포 빌드는 origin이 달라 자동 검증이 불가하므로 execCommand 폴백을 둔다 — Tauri 플러그인은
쓰지 않는다(권한·번들 증가 없음).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
markdown-it은 헤딩에 id를 붙이지 않는다. 그래서 목차 링크가 가리킬 대상이 없어 링크는 보이는데
죽어 있었다. 규칙을 GitHub과 맞춰(소문자화·공백은 하이픈·중복 시 -1) 사용자가 GitHub에서 쓰던
목차가 norii에서도 그대로 동작하게 한다.

한글을 남기는 것이 규칙의 핵심이다 — 유니코드 글자를 버리면 한글 제목의 slug가 전부 빈
문자열이 된다. 남는 글자가 없는 제목(예: '# !!!')은 자리 번호로 대체해 id 없는 헤딩을
남기지 않는다.

중복 번호는 문서 하나 안에서만 센다. 모듈 수준에 두면 렌더를 거듭할수록 번호가 자라 같은
문서가 매번 다른 id를 갖게 되고, 앵커가 재렌더마다 끊긴다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
M4가 각주를 넣었지만 눌러도 아무 일이 없었다. 모든 링크를 '외부로 나갈 것'으로만 취급해
#앵커가 '허용되지 않은 링크 = 조용한 무동작'에 걸렸기 때문이다. 목차 링크도 같은 이유로
죽어 있었다 — 각주 마감이자 반쪽짜리 기능의 완성이다.

링크를 가로챈 뒤 세 갈래로 나눈다. #앵커는 문서 밖으로 나가지 않으므로 스킴 판정 이전에
갈라지고 앱이 직접 스크롤한다. 웹뷰 내비게이션 차단은 그대로다.

에코 가드를 경유하지 않는다. 가드는 '이 스크롤은 발행하지 마라'는 표시라, 경유하면 에디터가
따라오지 않는다. 루프는 수신 측(에디터)이 이미 가드를 쓰므로 생기지 않는다 — 그 회귀를
테스트로 고정했다.

앵커 대상은 프리뷰 콘텐츠 안에서만 찾는다. 문서 전체에서 찾으면 프리뷰 밖 앱 UI의 같은 id를
잡는다. href는 퍼센트 인코딩되어 나오므로(#결론 → #%EA%B2%B0%EB%A1%A0) 디코딩 후 대조한다 —
없으면 한글 목차 링크가 전부 죽는다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
계약에 '색은 디자인 시스템의 상태색을 쓴다'고 적었으나, 이 팔레트에는 상태색이 없다. 없는 것을
있다고 가정한 계약이었다.

상태색 도입은 콜아웃 혼자 정할 문제가 아니다 — 탭 ⚠ 배지·에러 배너와 이해관계자가 겹치고,
라이트/다크 × 종이/유리 네 조합에서 대비 게이트를 다시 통과해야 한다. 콜아웃 하나 때문에 급히
팔레트를 만들면 나중에 배지·배너가 그 색에 끌려간다. 열린 결정 '탭 ⚠ 배지 색'을 '상태색 도입'으로
넓혀 셋을 한 결정으로 묶는다.

그래서 콜아웃은 아이콘·라벨·왼쪽 굵은 경계선으로 구별한다. 타입별 클래스가 이미 붙으므로
상태색이 확정되면 CSS만 바꾸면 된다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
인용문의 첫 줄이 [!NOTE] 꼴이면 강조 상자로 표시한다. 문법이 이미 마크다운이라는 것이 채택
근거다 — >는 인용문이고 [!NOTE]는 그 안의 텍스트라, norii를 안 쓰는 에디터에서도 평범한
인용문으로 읽힌다. 호환이 깨지지 않는다.

GitHub 표준 5종만 지원한다. Obsidian의 독자 확장(추가 종류·접기)은 독점 문법 복제 금지 규칙에
걸리므로 채택하지 않는다. 5종 밖의 타입은 손대지 않는다 — 평범한 인용문으로 남고 마커도 그대로
텍스트다. 임의로 상자를 씌우면 다른 에디터와 결과가 갈린다.

파서는 클래스만 붙인다. 아이콘·색·라벨은 CSS의 몫이다 — 마크업으로 아이콘을 넣으면 sanitize
허용 표면이 그만큼 늘고, 문서가 위조한 콜아웃도 아이콘을 갖게 된다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
아이콘과 라벨을 CSS ::before로만 그린다 — 마크업이 0이라 sanitize 허용 표면이 늘지 않는다.
라벨은 GitHub과 같은 이름을 쓴다(NOTE·TIP·IMPORTANT·WARNING·CAUTION) — GitHub에서 쓰던 문서를
열었을 때 같은 것을 보게 된다.

색으로 구별하지 않으므로(→ 상태색은 열린 결정) 왼쪽 굵은 경계선·옅은 틴트·아이콘·라벨이 구별을
맡는다. 상자 안의 문단은 인용문의 흐린 글자를 상속하지 않는다 — 강조 상자인데 본문보다 흐리면
목적이 뒤집힌다.

예시 문서에 콜아웃 절을 넣어 5종·상자 안 마크다운·미지원 타입을 앱에서 바로 확인할 수 있게 한다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
브라우저 테스트 파일이 병렬로 돌면 여러 파일이 동시에 무거운 동적 청크를 받아 오다가 로딩이
간헐적으로 실패한다('Importing a module script failed'). mermaid는 청크가 100개 가까이 되어
특히 잘 걸린다 — 파일 하나만 돌리면 늘 통과하고, 전체 실행에서만 깨졌다.

파일을 직렬화한다. 느려지는 대신 결과가 결정적이다(E2E 설정이 이미 같은 선택을 했다).
반복 실행으로 확인했다 — 직렬화 전 1~5건 실패, 후에는 두 번 연속 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
프리뷰 코드 블록에 마우스를 올리면 오른쪽 위에 복사 버튼이 뜨고, 누르면
코드 원문이 클립보드에 들어간다(1.5초간 체크 아이콘으로 피드백).

- 클립보드는 웹 표준 API + execCommand 폴백 — 배포 빌드의 tauri:// origin이
  secure context가 아닐 수 있다(Tauri 플러그인 없음, 문서 지정)
- 프리뷰 내용은 React 소유가 아니므로 버튼(React 컴포넌트)은 내용 교체 뒤
  코드 블록을 다시 수집해 포털로 꽂는다 — preview-strategy.md 갱신 포함
- 아이콘(copy-right·check)은 shared/ui의 React 컴포넌트로 둔다 —
  색은 currentColor(테마 대응), 크기는 CSS 소유, aria-hidden 장식
- 코드 블록의 가로 스크롤을 pre에서 안쪽 code로 옮긴다 — pre가 스크롤
  컨테이너면 절대배치 버튼이 코드와 함께 흘러간다

예시 문서는 추가하지 않는다 — 기존 examples/프리뷰-기본.md의 코드 블록을
열면 그대로 확인된다(예시 규칙: 기존 예시로 이미 확인되면 추가하지 않는다).

수동 검증 대상: hover 시 버튼 노출·배치의 시각 확인, 서명된 배포 빌드에서의
실제 복사 동작(자동 검증 불가 — preview-strategy.md#코드-복사-버튼).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
아이콘을 디자인 시스템의 일부로 관리한다 — SVG 원본을 단일 출처로 두고
SVGR CLI가 React 컴포넌트를 생성해 커밋한다(mise run icons).

- 빌드 플러그인(vite-plugin-svgr)이 아니라 CLI 코드젠 — vite·vitest 설정에
  침투하지 않아 설정 드리프트(katex alias류 함정)가 원천 차단되고,
  생성물이 평범한 TSX라 리뷰·게이트를 그대로 통과한다
- svgr.config.cjs가 규칙을 모든 아이콘에 강제한다: black→currentColor(테마),
  width/height 제거(크기는 CSS 소유), aria-hidden(장식), kebab 파일명
- 문서 먼저: 기술 스택 표(8.1.0 핀) · 개발 명령(icons 태스크) ·
  디자인 시스템(아이콘 절 — 구조·규칙·수정 금지 경계)

원본(svg/)·생성물(generated/)·배럴은 다음 커밋에서 기존 아이콘 2개를
이관하며 채운다. 원본이 없는 동안 mise run icons는 no-op이다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
손으로 쓴 shared/ui/icons.tsx를 지우고, copy-right·check 아이콘을
SVG 원본(svg/) + 생성물(generated/) + 공개 배럴(index.ts) 구조로 옮긴다.

- 원본은 디자인 산출물 그대로 둔다(stroke=black·고정 크기 포함) —
  currentColor·크기 제거·aria-hidden 정규화는 svgr.config.cjs가 강제한다
- 공개 이름(CopyRightIcon·CheckIcon)은 배럴이 관리한다. shared/ui/index.ts의
  "./icons" 경로가 새 디렉터리 배럴로 그대로 해석되어 소비자
  (copy-code-button)와 테스트는 무변경이다

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
paneClass에는 파서 DOM(pre)에 걸어야만 하는 규칙(기준점·pre:hover 노출)만
남기고, 버튼 자체의 생김새(배치·배경·상태색·아이콘 크기)는 React 컴포넌트인
copy-code-button.tsx가 css()로 소유한다. 두 파일로 갈라진 규칙은 주석이
서로를 가리킨다. 동작·모양 불변(테스트 무변경 통과).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
마일스톤(M4) 종료 절차로 계약 문서 7곳을 코드와 전수 대조했다(버전 표는
docs-drift 자동 검사 몫). 실질 불일치는 미래 계획이 현재형으로 적힌 2건뿐이고,
코드 동작과 어긋나는 계약은 없었다. 코드는 건드리지 않고 서술만 고친다.

- rust-commands: plugin-store에 "M6 예정, 미설치" 표기, 권한 절의 현재/예정
  구분, 삭제된 파일+expected_hash → Conflict 케이스 한 줄 보강
- editor-strategy: 접기/펼치기 단축키에 "M5 예정(미등록)" 표기, 채택 문장에
  defaultKeymap 명시
- preview-strategy: 콜아웃 색 토큰 열거를 실제 사용(border·bg.hover·text·
  text.muted·accent)으로 완성
- security: style-src 'unsafe-inline' 근거에 mermaid SVG style 추가
- 코드 주석 2건의 낡은 마일스톤 번호 정정(fileTree·폴딩 M4 → M5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
catch 블록의 취소 조기 반환이 임시 노드 제거보다 앞에 있었다. 깨진
다이어그램을 타이핑으로 고치는 동안은 디바운스 틱마다 진행 중 렌더가
취소되므로, 바로 그 상황에서 mermaid의 에러 임시 노드가 body에 계속
쌓였다 — 주석이 "실측으로 고쳤다"던 누수가 취소 경로로 재유입된 것.
제거를 취소 판정 앞으로 옮긴다.

자동 테스트 없음: 이 창은 렌더가 비행 중일 때 취소가 겹쳐야 열리는데,
실제 mermaid의 렌더 타이밍을 제어할 수 없고 mock은 임시 노드를 만들지
않아 검증 대상이 사라진다. 수동 검증: 깨진 mermaid 펜스를 연타로
고치면서 document.body에 dnorii-mermaid-* 노드가 쌓이지 않는지 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
거부된 import 프로미스가 캐시에 남아, 청크 로드가 한 번 삐끗하면 앱을
재시작할 때까지 모든 다이어그램이 죽었다. mermaid 청크 로드의 간헐 실패는
이 저장소가 실측으로 확인한 현상이다(브라우저 테스트 직렬화의 사유).
게다가 await가 try 밖 + void 호출이라 실패가 unhandled rejection으로 샜다.

- 실패한 로드는 캐시하지 않는다 — 다음 디바운스 갱신이 처음부터 재시도
- paint가 로드 실패를 잡아 그 틱을 조용히 끝낸다 (예외 유출 금지)
- 테스트 전용 로더 주입구(setMermaidImporterForTest)로 실패→회복 시나리오를
  실브라우저에서 검증한다 (기존 reset* 주입 패턴과 동일)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
마커 텍스트를 비운 토큰이 children[0]에 그대로 남아, softbreak 제거
분기가 영원히 거짓인 죽은 코드였다(두 리뷰 패스가 교차 확인). 보통은
잔재가 접히는 공백이라 안 보였지만, 마커 줄이 공백 2개로 끝나면
hardbreak(<br>)라 상자가 눈에 보이는 빈 줄로 시작했다.

빈 텍스트 토큰을 치운 뒤 soft/hardbreak를 지운다. 두 경우 모두
"상자 안이 마커 잔재 없이 본문으로 시작한다"를 테스트로 고정했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MARKER 정규식이 CALLOUT_TYPES와 같은 5종을 하드코딩 중복하고 있었다 —
한쪽에만 타입을 추가하면 조용히 어긋난다. 정규식을 배열에서 파생시켜
단일 출처로 만든다. 동작 불변(5종 전부 기존 it.each 테스트가 고정).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bori-github and others added 13 commits July 15, 2026 07:05
렌더 id가 예측 가능한 순번(norii-mermaid-N)이라, 문서가 원시 HTML로 같은
id를 선점하면(sanitize는 id 속성을 허용한다) 에러 경로의
getElementById().remove()가 mermaid 임시 노드 대신 **사용자 콘텐츠**를
지웠다. 헤딩 slug(# norii mermaid 3)로도 우연히 겹칠 수 있다.

- id에 난수 꼬리를 붙여 선점 자체를 막는다
- 제거는 프리뷰 패널 밖의 노드만 겨눈다 — 임시 노드는 body에 살고 문서
  콘텐츠는 패널 안에 산다

자동 테스트 없음: 난수 id라 충돌을 재현할 수 없고, 그것이 곧 수정의
내용이다. 기존 임시 노드 정리 테스트가 회귀를 덮는다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
미끼(#결론)가 패널 위쪽(문서 흐름상 body 맨 앞)에 있어, 전역 탐색으로
잘못 구현해도 목표 scrollTop이 음수→0으로 클램프되어 단언(0)이 그대로
통과했다 — 변이 검증(content.querySelector→document.querySelector)으로
공허함을 확인했다. 이 프로젝트에서 같은 부류(변이 검증 없는 공허한
브라우저 테스트)가 세 번째다.

- 미끼를 패널 아래쪽(top:5000px)에 절대배치 — 전역 탐색이면 양수 스크롤이
  생겨 단언이 실제로 실패한다(변이 검증으로 재확인)
- 실패 시에도 미끼가 다음 테스트로 새지 않게 정리를 finally로

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
링크가 든 헤딩(# [설계 문서](url))의 slug가 URL까지 섞인
설계-문서httpsexamplecomspec이 됐다 — GitHub은 렌더 결과의 글자만 보므로
설계-문서다. 문서가 못박은 "GitHub 호환"(GitHub에서 쓰던 목차가 그대로
동작한다) 계약 위반이라, 링크·이미지·강조가 든 헤딩의 목차가 조용히
죽어 있었다.

slug 재료를 inline.content(원시 마크업)에서 글자를 내는 자식 토큰
(text·code_inline)의 연결로 바꾼다 — 화면의 textContent와 같은 것.
링크·코드 스팬·강조 헤딩 세 경우를 테스트로 고정했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KaTeX maxSize 기본값이 Infinity라 \rule{9999em}{9999em} 수식 하나로
문서 높이를 수백만 px로 만들 수 있었다 — 레이아웃·메모리·스크롤 동기화
보간이 함께 무너진다. 문서는 못 믿는 입력이다.

maxSize: 100(em)으로 렌더 크기를 자른다(정상 수식이 닿지 않는 상한).
annotation의 TeX 원문에는 지정값이 남는 것이 정상이라(스크린리더용 보존)
테스트는 스타일·속성 값만 겨눈다. preview-strategy.md(플러그인 계약)에
같은 커밋으로 명시했다.

(katex 배포 소스로 기본값 확인)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mermaid·katex·@vscode/markdown-it-katex·markdown-it-footnote(+타입)가
캐럿 범위로 들어와 있었다 — 이 저장소의 다른 의존성은 전부 정확 핀이고,
특히 mermaid는 sanitize 우회 CVE 이력이 있는 엔진이라(프리뷰의 신뢰
경계가 그 내부 sanitizer에 얹혀 있다) 자동 상향은 곧 보안 경계 변화다.
katex도 앱의 CSS(0.16.47 정확 핀)와 렌더러가 갈라질 수 있었다.
설치된 버전 그대로 핀만 조인다(lockfile 해석 불변).

기술 스택 버전 표에는 이 5종 모두 등재가 빠져 있음을 확인했다 — 표 작업은
예약된 문서 전수조사 트랙에서 다룬다(임의 확장 금지).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
문서 전수조사가 찾은 누락 — mermaid·katex·@vscode/markdown-it-katex·
markdown-it-footnote(+타입)가 설치·핀되어 있는데 표에 없었다. 표는
"실제 설치된 핀의 단일 출처"인데, docs-drift가 단방향(표→핀)이라 표에
없는 의존성은 검사망 밖이었다. 등재로 이 5종이 자동 대조 안에 들어온다.

각 행의 비고에 소유 경계를 함께 남긴다 — katex는 앱(CSS)·패키지(렌더러)
동일 버전 필수, mermaid는 sanitize 경계가 엔진에 얹혀 있어 정확 핀 필수.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
문서는 다섯 경로를 서술했지만 .oxfmtrc.json은 일곱이다 — examples/**
(M4 예시 문서, 저작 콘텐츠)와 **/shared/ipc/bindings.ts(tauri-specta
생성물)가 설정에만 있고 서술에 없었다. 문서 전수조사가 찾은 드리프트로,
각 경로의 "왜"를 포함해 일곱으로 갱신한다. 단일 출처는 그대로
.oxfmtrc.json이다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M4 실측 기록(.app 11.09→12.75MB 등)이 preview-strategy에 있었지만,
번들 예산·측정 방법의 단일 출처는 platform-strategy#번들-크기-측정이다 —
측정 기록이 방법과 다른 문서에 살면 다음 실측이 어디에 쌓일지 갈라진다
(문서 전수조사 갈래 1 합의 항목).

기록과 ".app이 판정 기준" 통찰을 플랫폼 전략의 실측 기록 절로 옮기고,
preview-strategy에는 프리뷰의 결정("mermaid 포함해도 예산 안 → 범위
유지")만 링크와 함께 남긴다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
머지 전 리뷰가 잡은 결함들의 공통 원인은 코드가 아니라 질문이었다 —
구현·테스트 설계가 "동작하는가"만 묻고 "어떻게 실패하는가"를 묻지
않았다. 취소×실패 조합 누수, 거부된 로드의 영구 캐시, 명세 예시에 없던
입력, 자원 고갈 전부 후자의 질문에서만 나온다.

테스트 목록을 쓰기 전에 실패 공간을 먼저 열거하는 규칙을 명문화한다:
비동기 이펙트의 상태 곱집합, 입력 공간에서 도출하는 테스트 케이스(명세
예시 상속 금지), 서드파티 보안 문서·저장소 실측 기록 확인, 분기를
강제로 태우는 레드 테스트. 재현 불가 경로는 사유·수동 검증을 남긴다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
폴백의 제1 존재 사유(클립보드 API가 있지만 비 secure context라 거부)가
정작 미검증이었고, "부재" 분기만 검증돼 있었다. 실패 공간 열거 규칙
(testing.md)의 소급 적용:

- API 거부 → execCommand 폴백으로 원문 복사
- 피드백 복귀 타이머 — 영구 '복사됨'(타이머 유실) 회귀를 실시간으로 잡음
- 양쪽 모두 실패 → '복사됨'을 띄우지 않는다(거짓 성공 표시 금지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
실패 공간 열거 규칙(testing.md)의 소급 적용:

- 콜아웃: 마커 뒤 같은 줄 텍스트(`[!NOTE] 제목`)는 GitHub과 같이 평범한
  인용문으로 남는다 — 정규식이 완화되면 두 앱의 렌더가 갈리는 경계인데
  미고정이었다
- 수식: "문법 오류가 문서를 깨지 않는다"의 단언이 `<p` 포함뿐이라 KaTeX를
  통째로 빼도 통과하는 준공허였다 — 계약(예외 없음 + 원문이 화면에 남음)을
  직접 겨눈다
- render.test 머리 주석의 낡은 경계 서술 정정(확장 테스트는 각 파일이 다룸)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
캐시 키(테마, 원문)의 원문 절반("같으면 안 다시 그린다")만 테스트가
있었고 테마 절반("다르면 다시 그린다")은 주석으로만 주장돼 있었다 —
테마가 키나 이펙트 의존성에서 빠지는 회귀(다크 모드에 라이트 색 SVG
잔존)가 전부 통과했다. 실패 공간 열거 규칙(testing.md)의 소급 적용.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lefthook.yml과 code-quality.md는 "커밋 훅 = 변경 파일 빠른 검사, 전체
게이트 = CI에서 강제"라는 2단 구조를 약속했지만 2단이 비어 있었다 —
커밋 전 mise run check 통과 규칙이 사람의 손 순서에만 의존했고, 실제로
게이트 실패가 무시된 채 커밋이 시도되는 사고가 있었다(훅이 다른 이유로
우연히 차단).

PR·main 푸시마다 mac 러너에서 mise run check를 돌린다: mise-action으로
.mise.toml 툴 핀 그대로 설치, Rust 빌드 캐시, playwright WebKit 설치
포함. 문서의 "권한다"를 사실 서술로 갱신했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Bori-github Bori-github self-assigned this Jul 16, 2026
@Bori-github Bori-github added the feat 기능 추가/변경 label Jul 16, 2026
playwright는 루트가 아니라 apps/desktop의 개발 의존성이라 루트의
pnpm exec가 찾지 못했다(첫 CI 실행에서 발견). 같은 버전을 핀한 두
패키지가 공유 브라우저 캐시를 쓰므로 desktop 한 곳의 설치로 충분하다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Bori-github
Bori-github merged commit 32f45b0 into main Jul 16, 2026
1 check passed
@Bori-github
Bori-github deleted the feat/m4-preview-extensions branch July 18, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 기능 추가/변경

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant