genetics-mcp 2.18.2 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,7 +105,7 @@ version: "4.0.0"
105
105
  - `cases[].grade` 는 `grades` 에 정의된 이름이어야 한다.
106
106
  - **`update_report` 는 같은 주소를 덮어쓴다** — 이미 고객에게 나간 링크가 새 내용을 가리키므로, 발행 후 수정은 재발행이 아니라 update 로 한다. 기존 문서는 `list_reports`/`get_report` 로 받아 고쳐 되돌려 보낸다.
107
107
  - **그림 계약**: `update_report` 에서 `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 `sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422. 한 장만 바꾸려면 그 id 에 새 `source`/`file`, 나머지는 `get_report` 가 준 표식 그대로.
108
- - 초안 상태로 올려 두고 다듬어도 발행은 막히지 않는다. 단 초안 `update_report` 도 **본문 판(`body_rev`)** 을 올리고 사용자가 그 판을 읽으므로 **독자 검토를 통과한 본문 판만 올린다** — 다듬는 중간 판은 로컬 렌더·dry_run 으로 본다.
108
+ - 초안 상태로 올려 두고 다듬어도 발행은 막히지 않는다. 단 초안 `update_report` 도 **본문 판(`body_rev`)** 을 올리고 사용자가 그 판을 읽으므로 **독자 검토를 통과한 본문 판만 올린다** — 다듬는 중간 판은 로컬 렌더·dry_run 으로 본다. 사용자가 「바로 반영」을 지시하면 먼저 올리고 검토는 곧바로 뒤따라 돌려 다음 판에 반영한다(2026-09-15 사용자 결정).
109
109
  ⚠ **운영 반영 확인 전 — 운영 서버는 초안에 `update_report` 를 보내면 그 순간 공개로 바꾼다**(저장된 공개일이 비었으면 오늘로 채운다, 수리는 레포 main 에만 있다). 배포 전까지 초안은 `dry_run` 으로만 다듬고, 굳은 뒤 `publish_report` 한 번 → 사람 확인 → `release_report`. 이미 썼으면 `list_reports` 대괄호가 「데이터로 발행된 문서」인지 보고 `unpublish_report(id)` 로 되돌린다 — 교체 응답 문구(「이미 나간 열람 링크가 그대로…」)는 초안에도 같아 그것만으로는 못 가른다. 결정 브리핑 한 편이 첫 교체로 공개돼 여러 판을 고치는 엿새 동안 열려 있었다(2026-09-09~15).
110
110
  **발행 이력 행(`history`)은 다른 것이다** — 공개 전 수정은 이력 행을 만들지 않는다(3c).
111
111
  - 내용 설계 규칙(아래 0~3단계, 고객 언어 치환, 카피 게이트)은 **전달 경로와 무관하게 동일하게** 적용된다 — 서버가 렌더한다고 해서 원고 품질 단계를 건너뛰지 않는다.
@@ -207,11 +207,11 @@ Lim 보고서, Lim 에 올려줘, 고객 보고서, 수정 내역, 검수 요청
207
207
  | 1 | **한 장 이야기** | 문제 → 원인 → 해법(지금 상태 포함) → 정할 것. 칸 수가 아니라 이야기 순서다 — 요지 칸 수는 4~6단계 「요지」 한 곳이 정본. 다루는 일의 **사건 경과** 표 한 장을 1절에 둔다(이 문서의 판 이력은 `history` — 3c) |
208
208
  | 2 | **예시 한 명** | 실존 이용자 하나의 실제 숫자로 문서 전체를 관통시킨다(같은 사람이 그림·표·건에 반복) |
209
209
  | 3 | **용어 잠금 표** | 같은 낱말이 두 값을 가리키면 그 자리에서 문서가 죽는다. 뜻이 갈리는 낱말은 표 한 장으로 값·정의·보이는 자리를 박는다. 장치 이름(건 행 `labels` 값)은 절 이름·부제·표 열·캡션에 다시 쓰지 않고, 선택지 기호(A·B·C)는 한 목록에만 붙인다. 집행자: `gazette.py` `text_scan()` ⚠(건 행 이름 충돌·선택지 기호 충돌) · 독자 검토 first-time 관점(한 낱말 두 값) · 뜻이 같은지는 사람 눈 |
210
- | 4 | **절마다 시각 장치 먼저** | 개념은 그림, 실제 화면은 번호 표식 캡처, 견주기는 표, 「A 면 B」는 흐름도, 분포는 막대 차트. 글은 그 옆 3줄. 도판 층 R0~R10·R13 은 `figure.mjs`(비교 컷은 `shot.mjs --after` → `pair.mjs`), 글 층 R11 은 렌더러 ⚠ — 값은 `references/readability.md` 「1. 규칙표」 한 곳(경위: `references/history.md` §3) |
210
+ | 4 | **절마다 시각 장치 먼저** | 개념은 그림, 실제 화면은 번호 표식 캡처, 견주기는 표, 「A 면 B」는 흐름도(원인 사슬은 `flow.mode:"cause"`), 분포는 막대 차트, **시간에 따른 값은 시계열**(`charts[].kind:"series"`), **공식 문서·회신 문구는 원문 인용**(`quotes`), **구조·흐름·순서·상태는 Archify 도해**(`diagrams`, `references/diagrams.md`). 글은 그 옆 3줄. 도판 층 R0~R10·R13 은 `figure.mjs`(비교 컷은 `shot.mjs --after` → `pair.mjs`), 글 층 R11 은 렌더러 ⚠ — 값은 `references/readability.md` 「1. 규칙표」 한 곳(경위: `references/history.md` §3) |
211
211
  | 5 | **판단 요청** | 요청은 **고객이 고를 물음**이다 — 제목은 물음, 본문 첫 문장은 기한과 두 길의 이름, 결과는 한눈표 **한 곳에만** 대칭으로. 모양·선택지 대칭 불변식·화면 도안 절차는 `references/visual-first.md` §5 와 `lim-portal-format.md` §12.3b. 집행자: `gazette.py` `brief_checks()` ⚠(기한·동일값 열) · ⚠ W1(권고 낱말) · 선택지 사이 대칭은 독자 검토 figures 관점 · 도안 유무는 **없음 — 사람의 눈**(`references/process.md` 7단계 표) |
212
212
  | 5a | **원본 확인** | 주장·그림마다 원본을 **댈 수 있어야** 한다. 출처 줄은 위치 한 구절(`references/readability.md` R12e — 우리 도안은 비움), 원본 이름·찾을 낱말은 건(`source_url`·`how_to_check`)이나 부록에, 제작 메타는 `origin` 으로(11번). 집행자: 독자 검토 source 관점(누를 수 있는 원본) · facts 관점(앱 동작·내부 수치·결정 주체) · `gazette.py` ⚠ W2 · `text_scan()` 제작 메타 ⚠ — 그 밖은 사람 눈 |
213
213
  | 6 | **표기** | 인라인 표기(`**굵게**`·`==강조==`·`{{ok|칩}}`·`[글자](주소)`·**맨 주소**)와 절·PART 의 `tone`, 차트 행 `tone`, 건의 `source_url`, 요약면 `notice.state`. 그림 가리킴은 손으로 번호를 치지 않고 `{{그림:<assets id>}}` 토큰으로(번호·조사를 렌더러가 셈) — ⚠ **운영 반영 확인 전**이라 배포 뒤 열람 DOM 에서 `a.ref[href^="#fig-"]` 를 세어 확인하기 전에는 쓰지 않는다(`lim-portal-format.md` §11-6). 색은 뜻으로(`lim-portal-format.md` 「11-6. 인라인 표기」) |
214
- | 7 | **독자 검토** | 발행 전 `references/reader-review.workflow.js` — 관점 여덟(상태 표 `args.state` 를 주면 아홉). **Verify 를 통과한 발견만 반영한다 — 검토자 주장도 틀린다.** 반영 순서는 사실 틀림 → 구조(합치기·빼기) → 나머지. **빼기가 포인터를 남기면 안 된다** — 「N절을 보라」「건 §n.n」「부록에 있다」로 대신하면 독자가 장을 오가는 시험지가 된다. 낱말·규칙·예시 선언은 **첫 등장 자리에서 한 구절**, 뒤 절의 번호·표 이름·§ 는 앞 절에서 쓰지 않는다(요약 근거는 `refs[]` 로). 선형 읽기 관점의 **뜀 지점(`jumps`) 0 이 목표**(실측은 `references/linear-read-test.workflow.js`, 독자 셋+결정권자). 관점·인자·JS 거름 규칙은 `references/visual-first.md` §7. 집행자: 워크플로 관점 + JS 거름 · 뒤 절 번호는 렌더러 ⚠ 「앞 절이 뒤 절을 가리키는 곳」(`gazette.py text_scan` W5 · readability R12h) · 정의 위치와 `questions`·`dropped` 는 사람 눈(경위: `references/history.md` §9·§12). **낱말만 고친 판도 검토 없이 내지 않는다** — 변경 자리만 보는 `references/edition-review.workflow.js`(세 관점 + 반박)로 돌린다. 렌더러 경고 0 만 보고 낸 15판이 다음 판 8자리 손질을 낳았다(2026-09-15) |
214
+ | 7 | **독자 검토** | 발행 전 `references/reader-review.workflow.js` — 관점 여덟(상태 표 `args.state` 를 주면 아홉). **Verify 를 통과한 발견만 반영한다 — 검토자 주장도 틀린다.** 반영 순서는 사실 틀림 → 구조(합치기·빼기) → 나머지. **빼기가 포인터를 남기면 안 된다** — 「N절을 보라」「건 §n.n」「부록에 있다」로 대신하면 독자가 장을 오가는 시험지가 된다. 낱말·규칙·예시 선언은 **첫 등장 자리에서 한 구절**, 뒤 절의 번호·표 이름·§ 는 앞 절에서 쓰지 않는다(요약 근거는 `refs[]` 로). 선형 읽기 관점의 **뜀 지점(`jumps`) 0 이 목표**(실측은 `references/linear-read-test.workflow.js`, 독자 셋+결정권자). 관점·인자·JS 거름 규칙은 `references/visual-first.md` §7. 집행자: 워크플로 관점 + JS 거름 · 뒤 절 번호는 렌더러 ⚠ 「앞 절이 뒤 절을 가리키는 곳」(`gazette.py text_scan` W5 · readability R12h) · 정의 위치와 `questions`·`dropped` 는 사람 눈(경위: `references/history.md` §9·§12). **낱말만 고친 판도 검토 없이 내지 않는다** — 변경 자리만 보는 `references/edition-review.workflow.js`(세 관점 + 반박)로 돌린다. 렌더러 경고 0 만 보고 낸 15판이 다음 판 8자리 손질을 낳았다(2026-09-15). 사용자가 「바로 반영」을 지시하면 먼저 내고 이 검토는 곧바로 뒤따라 돌려 다음 판에 반영한다(같은 날 사용자 결정) |
215
215
  | 8 | **실물 확인** | 발행할 때마다 체크리스트 9단계 두 행 — 블록 목록 대조와 열람 주소 열기. 집행자: 사람 — 발행 뒤 대조 기계는 아직 없다 |
216
216
 
217
217
  상세와 도판 예산(장당 80KB · 합계 400KB), 그림 만드는 법, 반려 사례는 `references/visual-first.md`.
@@ -369,6 +369,7 @@ render·css·폰트를 함께 얹는다. 직접 부르면 옛 서식 시트가
369
369
  | `assets/render.py` | `report.json` → 단일 HTML. **서버 등록부에서 서식을 읽어** 레포 `private/builder/build.py` 를 부른다 — 여기 사본을 두지 마라 |
370
370
  | `assets/subset_font.py` | 폰트 서브셋 생성 (보통 실행 불필요) |
371
371
  | `assets/example/report.json` · `cases.json` | 관보 필드를 전부 채운 본보기. 새 문서는 여기서 베낀다 |
372
+ | `assets/diagram.mjs` | Archify 도해를 관보 `diagrams[].svg` 로 굳힌다 — `render <type> <ir.json> <out.svg> --wide --min-text 16 --tight`(Archify deliver → 계산된 스타일을 속성으로 → 클래스·스크립트·애니메이션 걷음 → 작은 글자 키움·빈 띠 조임·겹침 측정) · `export <html> <out.svg>` · `inspect <svg>`. 렌더러와 같은 잣대(바이트 250KB · viewBox · 표시 글자 15px)로 재고 ✗ 면 exit 3. Archify 자체는 동봉하지 않는다(`references/diagrams.md` §1) |
372
373
  | `assets/figure.mjs` · `shot.mjs` · `mockshot.mjs` · `pair.mjs` | 도판 도구 — 도판 HTML 을 굽고 가독성·틀 문법 관문(R0~R10·R13)을 잰다, `--batch plates.json` 은 일괄 판정과 예산 합계, `--spec` 은 계약 상수(`figure.mjs`) · 웹 화면을 검정 원 번호 표식과 함께 찍고 `--after` 로 블록을 숨긴 전후 두 장을 같은 구간으로 찍는다(`shot.mjs`) · 고객 앱 화면 도안을 2배 밀도로 찍는다(`mockshot.mjs`, `references/visual-first.md` 「화면 도안」) · 전후 두 컷을 좌우 같은 틀·같은 배율로 조립하고 판정까지 찍는다(`pair.mjs`, 본보기 `assets/example/plate-pair.html`) |
373
374
  | `references/linear-read-test.workflow.js` | 선형 읽기 **실측** — 처음 읽는 사람 셋 + 결정권자가 앞에서 뒤로 한 번 읽으며 뛰어야 하는 자리를 전부 센다(`args: {file, reader, round}` — 재측정은 `round` 를 바꿔야 캐시를 안 탄다). 뜀 0 이 목표. 독자 검토(`reader-review.workflow.js`)의 linear 관점이 같은 읽기를 하나로 한다 |
374
375
  | `references/edition-review.workflow.js` | **판 갱신 검토** — 구조는 그대로이고 낱말·출처 줄·여백 주석만 고친 판의 **변경 자리**(edit-N.py 의 put/sub 호출)만 선형 독자·규칙 대조·사실 대조 세 관점으로 읽고 심각도 3 이상은 반박한다(`args: {file, backup, edit_script, doc}`). 사실 관점이 백업↔새 판 JSON diff 에 스크립트가 바꾼 자리가 빠짐없이 있는지(삽입·삭제가 섞이면 줄 수가 아니라 자리로)·수치 무변경을 검산한다. 구조가 바뀐 판은 `reader-review.workflow.js`. 낱말 손질 판을 검토 없이 발행하자 다음 판 8자리 손질이 생겼다(2026-09-15) |
@@ -414,7 +415,7 @@ render·css·폰트를 함께 얹는다. 직접 부르면 옛 서식 시트가
414
415
  `summary`, 절 도입문·여백 주석, `status` 대조표, 부록, 판권지의 사실 서술.
415
416
  **여기서만** `습니다|십시오|합니다|입니다|됩니다|주세요` 가 0건이어야 한다.
416
417
  5a. **상대에게 말 거는 자리는 하십시오체로 맺는다.** 종결까지 존댓말이어야 한다.
417
- 그 자리는 요청 절(`cta`·`open_items`)·PART I 간지·판권지의 회신 안내다.
418
+ 그 자리는 요청 절(`cta`·`open_items`)·PART I 간지·판권지의 회신 안내, 그리고 요청 절의 확인 요청을 가리키는 읽는 법(`guide`) 한 줄이다(2026-09-15 사용자 결정).
418
419
 
419
420
  | 상대가 할 일 | 쓰지 않는다 | 쓴다 |
420
421
  |---|---|---|
@@ -584,6 +585,7 @@ KS X 1001 완성형 2,350자. 관보 서식이 `--w-light: 300` 을 쓰므로 40
584
585
  | `references/history.md` | 규칙이 생긴 반려 경위·원문·실측 — 규칙이 아니다. 규칙 자리의 「(경위: §n)」이 가리킨다 |
585
586
  | `references/lim-portal-format.md` | **포털 발행할 때** — 관보 골격·여백 주석·PART·톤·요약 근거 게이트·운영 제약 |
586
587
  | `references/content-pipeline.md` | 데이터 모델 전수 — 절 `kind` 5종, 등급, 한국어 조판 규칙, 고객 언어 치환 |
588
+ | `references/diagrams.md` | **도해가 필요할 때** — Archify 설치 위치·IR 작성·굳히기(`diagram.mjs`)·가독성 잣대·관보에 앉히는 법 |
587
589
  | `references/font-embedding.md` | 폰트를 바꾸거나 커버리지를 조정할 때 |
588
590
  | `gazette-report` 스킬 (별도) | 조판 규칙의 원전 — 활자 스케일·괘선·인쇄 페이지 문법·검사기 8종 |
589
591
 
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ // diagram.mjs — Archify 도해를 관보 `sections[].diagrams[]` 에 앉힐 **독립 SVG** 로 굳힌다.
3
+ //
4
+ // node diagram.mjs render <type> <ir.json> <out.svg> [--archify <dir>] [--wide] [--json]
5
+ // Archify(`bin/archify.mjs deliver … --quality showcase`)로 HTML 을 만든 뒤 export 한다.
6
+ // node diagram.mjs export <archify.html> <out.svg> [--wide] [--json]
7
+ // 이미 만든 Archify HTML(또는 아무 HTML 의 첫 <svg role="img">)에서 SVG 하나를 뽑는다.
8
+ // node diagram.mjs inspect <in.svg> [--wide] [--json]
9
+ // 렌더러(gazette.py _svg_clean)와 같은 잣대로 잰다 — 바이트 · viewBox · 가장 작은 글자 · 표시 글자.
10
+ //
11
+ // 왜 export 가 필요한가: Archify 의 HTML 은 SVG 가 페이지 CSS(28만 자)의 클래스에 기대고, 뷰어 스크립트가
12
+ // 애니메이션·상세 단계를 만든다. 관보 열람은 스크립트 0·문서 활자 스케일이라, **계산된 스타일을 속성으로 굳히고**
13
+ // 클래스·스크립트·스타일·애니메이션을 걷은 SVG 만 앉힐 수 있다(gazette.py _svg_clean 이 한 번 더 걷는다).
14
+ // 글자 크기는 굳히되 font-family 는 **굳히지 않는다** — 문서의 Pretendard 를 물려받아야 본문과 같은 활자로 선다.
15
+ //
16
+ // 가독성 잣대(스킬 references/readability.md R1): 표시 글자 ≥ 15px. 표시 폭은 본문 단 760 · 전폭(--wide) 1030 이고
17
+ // 표시 글자 = 원본 font-size × 표시 폭 ÷ viewBox 폭. 15 미만이면 이 도구가 ✗ 로 멈추고(exit 3), 렌더러도 ⚠ 로 센다.
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { pathToFileURL } from 'node:url';
23
+
24
+ const COLUMN = 760, WIDE = 1030, MIN_FONT = 15, MAX_BYTES = 250000; // gazette.py 상수와 같은 값
25
+ const argv = process.argv.slice(2);
26
+ const flags = new Set(argv.filter(a => a.startsWith('--')));
27
+ const opt = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
28
+ const pos = argv.filter((a, i) => !a.startsWith('--') && (i === 0 || argv[i - 1] !== '--archify'));
29
+ const cmd = pos[0];
30
+ const JSON_OUT = flags.has('--json');
31
+ const wide = flags.has('--wide');
32
+ const usage = 'usage: node diagram.mjs render <type> <ir.json> <out.svg> [--archify <dir>] [--wide] [--json]\n'
33
+ + ' node diagram.mjs export <archify.html> <out.svg> [--wide] [--json]\n'
34
+ + ' node diagram.mjs inspect <in.svg> [--wide] [--json]';
35
+
36
+ function say(obj, text) { if (JSON_OUT) console.log(JSON.stringify(obj)); else console.log(text); }
37
+ function die(msg, code = 2) { console.error(msg); process.exit(code); }
38
+
39
+ // ── Archify 찾기: --archify > ARCHIFY_DIR > 에이전트 스킬 폴더 넷 ──
40
+ function findArchify(explicit) {
41
+ const home = os.homedir();
42
+ const cands = [explicit, process.env.ARCHIFY_DIR,
43
+ path.join(home, '.claude/skills/archify'), path.join(home, '.agents/skills/archify'),
44
+ path.join(home, '.cursor/skills/archify'), path.join(home, '.codex/skills/archify')].filter(Boolean);
45
+ for (const d of cands) if (fs.existsSync(path.join(d, 'bin/archify.mjs'))) return d;
46
+ return null;
47
+ }
48
+
49
+ // ── playwright-core 와 크롬: figure.mjs·shot.mjs 와 같은 자리를 찾는다 ──
50
+ async function browser() {
51
+ const home = os.homedir();
52
+ const cands = [process.env.PLAYWRIGHT_CORE,
53
+ path.join(home, '.claude/skills/gazette-report/scripts/node_modules/playwright-core/index.mjs'),
54
+ path.join(process.cwd(), 'node_modules/playwright-core/index.mjs'),
55
+ path.join(process.cwd(), 'node_modules/playwright/index.mjs')].filter(Boolean);
56
+ let chromium = null;
57
+ for (const c of cands) if (fs.existsSync(c)) { ({ chromium } = await import(pathToFileURL(c).href)); break; }
58
+ if (!chromium) { try { ({ chromium } = await import('playwright')); } catch { /* 아래에서 멈춘다 */ } }
59
+ if (!chromium) die('playwright(-core) 를 못 찾음 — PLAYWRIGHT_CORE=<index.mjs 절대경로> 로 알려 주거나 gazette-report 스킬의 scripts/node_modules 를 두십시오');
60
+ const exe = process.env.CHROME || process.env.PLAYWRIGHT_CHROMIUM || findChromium();
61
+ return chromium.launch(exe ? { executablePath: exe } : {});
62
+ }
63
+ function findChromium() {
64
+ const mac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
65
+ if (fs.existsSync(mac)) return mac;
66
+ const home = os.homedir();
67
+ for (const r of [path.join(home, 'Library/Caches/ms-playwright'), path.join(home, '.cache/ms-playwright')]) {
68
+ if (!fs.existsSync(r)) continue;
69
+ const dirs = fs.readdirSync(r).filter(d => /^chromium(_headless_shell)?-\d+$/.test(d)).sort().reverse();
70
+ for (const d of dirs) {
71
+ for (const rel of ['chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
72
+ 'chrome-headless-shell-mac-arm64/chrome-headless-shell', 'chrome-linux/chrome', 'chrome-headless-shell-linux64/chrome-headless-shell']) {
73
+ const p = path.join(r, d, rel); if (fs.existsSync(p)) return p;
74
+ }
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ // ── 페이지 안에서 돌 코드: 계산된 스타일을 속성으로, 런타임 흔적은 걷는다 ──
81
+ const INLINE = `(() => {
82
+ const PROPS = ['fill','fill-opacity','fill-rule','stroke','stroke-width','stroke-dasharray','stroke-dashoffset','stroke-linecap',
83
+ 'stroke-linejoin','stroke-miterlimit','stroke-opacity','opacity','font-size','font-weight','font-style','text-anchor',
84
+ 'dominant-baseline','letter-spacing','paint-order','clip-path','mask','filter','marker-start','marker-mid','marker-end',
85
+ 'text-decoration','transform','transform-origin','mix-blend-mode'];
86
+ const svg = document.querySelector('svg[role="img"]') || document.querySelector('svg');
87
+ if (!svg) return { error: 'svg 가 없다' };
88
+ // 상세 단계·애니메이션은 뷰어의 것 — 가장 자세한 단계로, 움직임 없이.
89
+ const css = [...document.querySelectorAll('style')].map(s => s.textContent).join('\\n');
90
+ const full = /data-detail-level="full"/.test(css) ? 'full' : (/data-detail-level="read"/.test(css) ? 'read' : null);
91
+ if (full) for (const el of document.querySelectorAll('[data-detail-level]')) el.setAttribute('data-detail-level', full);
92
+ const st = document.createElement('style');
93
+ st.textContent = '*{animation:none!important;transition:none!important}';
94
+ document.head.appendChild(st);
95
+ void document.body.offsetHeight;
96
+ const clone = svg.cloneNode(true);
97
+ const live = [svg, ...svg.querySelectorAll('*')];
98
+ const copy = [clone, ...clone.querySelectorAll('*')];
99
+ const DROP = new Set(['script','style','foreignObject','animate','animateTransform','animateMotion','set','iframe','object','embed','video','audio']);
100
+ let minFont = null, texts = 0;
101
+ for (let i = live.length - 1; i >= 0; i--) {
102
+ const a = live[i], b = copy[i];
103
+ if (!b) continue;
104
+ if (DROP.has(a.localName)) { b.remove(); continue; }
105
+ const cs = getComputedStyle(a);
106
+ if (cs.display === 'none' || cs.visibility === 'hidden' || (cs.opacity === '0' && a !== svg)) { b.remove(); continue; }
107
+ const par = (a.parentElement && a !== svg) ? getComputedStyle(a.parentElement) : null;
108
+ const isText = a.localName === 'text' || a.localName === 'tspan';
109
+ const TEXT_ONLY = new Set(['font-size','font-weight','font-style','text-anchor','dominant-baseline','letter-spacing','text-decoration']);
110
+ const DEFAULTS = { 'fill-opacity': '1', 'stroke-opacity': '1', 'opacity': '1', 'fill-rule': 'nonzero', 'stroke-miterlimit': '4',
111
+ 'stroke-dashoffset': '0px', 'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', 'paint-order': 'normal', 'mix-blend-mode': 'normal',
112
+ 'font-style': 'normal', 'letter-spacing': 'normal', 'dominant-baseline': 'auto', 'text-anchor': 'start',
113
+ 'transform-origin': '0px 0px', 'clip-path': 'none', 'mask': 'none', 'filter': 'none', 'stroke-dasharray': 'none',
114
+ 'marker-start': 'none', 'marker-mid': 'none', 'marker-end': 'none' };
115
+ const tf = cs.getPropertyValue('transform');
116
+ const identity = tf === 'none' || /^matrix\(1, 0, 0, 1, 0, 0\)$/.test(tf);
117
+ let style = '';
118
+ for (const p of PROPS) {
119
+ const v = cs.getPropertyValue(p);
120
+ if (!v) continue;
121
+ if (TEXT_ONLY.has(p) && !isText) continue; // 글자 속성은 글자에만 — 도형마다 되풀이하면 크기만 는다
122
+ if (p === 'transform' && identity) continue; // 항등 변환은 적지 않는다
123
+ if (p === 'transform-origin' && identity) continue;
124
+ if (p === 'text-decoration' && /^none\b/.test(v)) continue;
125
+ if (DEFAULTS[p] === v && (!par || par.getPropertyValue(p) === v)) continue; // 기본값이고 부모도 같다
126
+ if (par && par.getPropertyValue(p) === v && !['fill', 'stroke', 'font-size', 'font-weight', 'opacity'].includes(p)) continue;
127
+ style += p + ':' + v + ';';
128
+ }
129
+ if (a.localName === 'text' || a.localName === 'tspan') {
130
+ const fs = parseFloat(cs.fontSize);
131
+ if (Number.isFinite(fs) && (a.textContent || '').trim()) { texts++; minFont = minFont === null ? fs : Math.min(minFont, fs); }
132
+ }
133
+ for (const at of Array.from(b.attributes)) {
134
+ if (at.name === 'class' || at.name.startsWith('data-') || at.name.startsWith('on') || at.name === 'style' || at.name === 'tabindex') b.removeAttribute(at.name);
135
+ if ((at.name === 'href' || at.name === 'xlink:href') && !(at.value.startsWith('#') || at.value.startsWith('data:image/'))) b.removeAttribute(at.name);
136
+ }
137
+ if (style) b.setAttribute('style', style);
138
+ }
139
+ clone.removeAttribute('width'); clone.removeAttribute('height');
140
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
141
+ const vb = (clone.getAttribute('viewBox') || '').trim().split(/[\\s,]+/).map(Number);
142
+ return { svg: new XMLSerializer().serializeToString(clone), minFont, texts, vw: vb[2] || 0, vh: vb[3] || 0, level: full };
143
+ })()`;
144
+
145
+ function measure(svg, wideFlag, extra = {}) {
146
+ const bytes = Buffer.byteLength(svg, 'utf8');
147
+ const vb = /viewBox\s*=\s*"\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*"/i.exec(svg);
148
+ const vw = vb ? parseFloat(vb[3]) : 0, vh = vb ? parseFloat(vb[4]) : 0;
149
+ const sizes = [...svg.matchAll(/font-size\s*[:=]\s*"?\s*([\d.]+)\s*(px)?/gi)].map(m => parseFloat(m[1])).filter(Number.isFinite);
150
+ const minFont = extra.minFont ?? (sizes.length ? Math.min(...sizes) : null);
151
+ const display = wideFlag ? WIDE : COLUMN;
152
+ const shown = (vw && minFont !== null) ? minFont * display / vw : null;
153
+ const texts = extra.texts ?? (svg.match(/<text\b/gi) || []).length;
154
+ const verdicts = [];
155
+ if (!vw) verdicts.push(['R0', 'fail', 'viewBox 가 없다 — 폭을 알 수 없어 렌더러가 싣지 않는다']);
156
+ if (bytes > MAX_BYTES) verdicts.push(['R0', 'fail', bytes + 'B > 상한 ' + MAX_BYTES + 'B — 노드를 줄이거나 PNG 도판으로']);
157
+ if (shown !== null && shown < MIN_FONT) verdicts.push(['R1', 'fail', '가장 작은 글자 ' + minFont + 'px × (' + display + '/' + vw + ') = 표시 ' + shown.toFixed(1) + 'px < ' + MIN_FONT + 'px — ' + (wideFlag ? '노드·라벨을 줄인다' : '--wide 로 전폭에 세우거나 노드를 줄인다')]);
158
+ if (vw && vh / vw > 1.8) verdicts.push(['R9', 'warn', '세로가 폭의 ' + (vh / vw).toFixed(2) + '배 — 포털이 폭을 깎는다(1.8배 안으로)']);
159
+ if (!texts) verdicts.push(['R4', 'warn', '글자가 0 — alt 만으로 읽히는 도해인지 확인']);
160
+ return { bytes, vw, vh, minFont, display, shownMinFont: shown === null ? null : Math.round(shown * 10) / 10, texts, verdicts };
161
+ }
162
+ function report(out, m, extra) {
163
+ const bad = m.verdicts.some(v => v[1] === 'fail');
164
+ const lines = [out + ' — ' + m.bytes + 'B · viewBox ' + m.vw + '×' + m.vh + ' · 글자 ' + m.texts + '개 · 가장 작은 글자 ' + (m.minFont ?? '?') + 'px → 표시 ' + (m.shownMinFont ?? '?') + 'px(폭 ' + m.display + ')',
165
+ ...m.verdicts.map(v => ' ' + (v[1] === 'fail' ? '✗' : '⚠') + ' ' + v[0] + ' ' + v[2]),
166
+ bad ? ' → 렌더러도 같은 ⚠ 를 낸다. 고친 뒤 다시.' : ' ✓ 관보 diagrams[].svg 에 넣을 수 있다'];
167
+ say({ ok: !bad, out, ...m, ...extra }, lines.join('\n'));
168
+ process.exit(bad ? 3 : 0);
169
+ }
170
+
171
+ async function exportSvg(html, out) {
172
+ const b = await browser();
173
+ try {
174
+ const ctx = await b.newContext({ viewport: { width: 1600, height: 1000 }, reducedMotion: 'reduce', colorScheme: 'light' });
175
+ const page = await ctx.newPage();
176
+ await page.goto(pathToFileURL(path.resolve(html)).href, { waitUntil: 'load' });
177
+ await page.waitForTimeout(700);
178
+ const r = await page.evaluate(INLINE);
179
+ if (!r || r.error) die('export 실패: ' + (r && r.error));
180
+ fs.writeFileSync(out, r.svg, 'utf8');
181
+ return r;
182
+ } finally { await b.close(); }
183
+ }
184
+
185
+ (async () => {
186
+ if (cmd === 'inspect') {
187
+ const [, inp] = pos; if (!inp) die(usage);
188
+ return report(inp, measure(fs.readFileSync(inp, 'utf8'), wide));
189
+ }
190
+ if (cmd === 'export') {
191
+ const [, html, out] = pos; if (!html || !out) die(usage);
192
+ const r = await exportSvg(html, out);
193
+ return report(out, measure(r.svg, wide, { minFont: r.minFont, texts: r.texts }), { source: html, level: r.level });
194
+ }
195
+ if (cmd === 'render') {
196
+ const [, type, ir, out] = pos; if (!type || !ir || !out) die(usage);
197
+ const dir = findArchify(opt('--archify'));
198
+ if (!dir) die('Archify 를 못 찾음 — `npx skills add tt-a1i/archify -g` 로 설치하거나 --archify <dir> / ARCHIFY_DIR 로 알려 주십시오');
199
+ const tmp = path.join(os.tmpdir(), 'diagram-' + process.pid + '.html');
200
+ const d = spawnSync(process.execPath, [path.join(dir, 'bin/archify.mjs'), 'deliver', type, path.resolve(ir), tmp, '--quality', 'showcase', '--json'], { encoding: 'utf8' });
201
+ let receipt = null; try { receipt = JSON.parse(d.stdout); } catch { /* 아래에서 원문을 보인다 */ }
202
+ if (d.status !== 0 || !receipt || !receipt.ok) die('archify deliver 실패(exit ' + d.status + ')\n' + (d.stdout || '') + (d.stderr || ''), 4);
203
+ const r = await exportSvg(tmp, out);
204
+ fs.rmSync(tmp, { force: true });
205
+ return report(out, measure(r.svg, wide, { minFont: r.minFont, texts: r.texts }), { archify: dir, validation: receipt.validation, ir: path.resolve(ir), level: r.level });
206
+ }
207
+ die(usage);
208
+ })();