genetics-mcp 2.16.7 → 2.17.1

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.
@@ -4,14 +4,14 @@
4
4
  "lang": "ko",
5
5
 
6
6
  "title_tag": "시스템 수정 내역 보고 · 예시 고객사",
7
- "description": "이번 회차에 처리한 2건의 수정 내역과, 확인이 필요한 1건을 정리한 것임",
7
+ "description": "처리한 2건의 수정 내역과, 확인이 필요한 1건을 정리한 것임",
8
8
 
9
9
  "doc_no": "LIM-2026-0824-01",
10
10
  "client": "예시 고객사",
11
11
  "kicker": "우리회사 · 예시 고객사",
12
12
  "title": "시스템 수정 내역",
13
13
  "title_sub": "1차 보고",
14
- "subtitle": "이번 회차에 처리한 2건과, 확인이 필요한 1건을 정리한 것임. 검수 결과는 별도로 회신 주시면 됨.",
14
+ "subtitle": "처리한 2건과, 확인이 필요한 1건을 정리한 것임. 검수 결과는 이 문서를 보낸 담당자에게 회신해 주시기 바랍니다.",
15
15
  "cover_date": "2026.08.24",
16
16
  "issuer": ["우리회사", "예시 고객사", "2026. 8."],
17
17
  "running_footer": "예시 고객사 · LIM-2026-0824-01",
@@ -48,7 +48,7 @@
48
48
  "bar_note": "「처리 완료」는 운영 반영까지 확인한 것을 뜻함. 남은 1건은 01절에 모아 둠.",
49
49
  "stats": [
50
50
  { "value": "1", "unit": "가지", "label": "확인·처리가 필요한 것", "accent": true, "href": "#sec-1", "go": "01절로" },
51
- { "value": "2", "unit": "건", "label": "이번 회차에 끝난 것", "href": "#sec-4", "go": "04절로" },
51
+ { "value": "2", "unit": "건", "label": "처리를 마친 것", "href": "#sec-4", "go": "04절로" },
52
52
  { "value": "2", "unit": "종", "label": "적용한 확인 등급" }
53
53
  ],
54
54
 
@@ -62,10 +62,10 @@
62
62
  "lede": "확인·처리가 필요한 사항. 이 PART 가 수신 측 조치 사항의 전부임.",
63
63
  "questions": ["무엇을 확인해야 하는가?", "그 판단의 근거는 어디에 있는가?"] },
64
64
  { "no": "2", "title": "경과", "tone": "neutral",
65
- "lede": "이번 회차에 처리한 것과 그 근거.",
65
+ "lede": "처리한 것과 그 근거.",
66
66
  "questions": ["무엇이 어떻게 달라졌는가?", "어디까지 확인한 것인가?"] },
67
67
  { "no": "3", "title": "한계", "tone": "hold",
68
- "lede": "이번 확인이 닿지 않은 범위. 다음 회차로 넘기는 몫임.",
68
+ "lede": "이 문서의 확인이 닿지 않은 범위와 그 까닭.",
69
69
  "questions": ["확인하지 않은 것은 무엇인가?", "그것은 언제 확인되는가?"] }
70
70
  ],
71
71
 
@@ -85,12 +85,12 @@
85
85
  "advice": "각 건의 「근거」 단추는 04·05절 상세로 연결됨."
86
86
  } },
87
87
 
88
- { "no": "02", "id": "sec-2", "label": "02", "name": "이번 회차 요지", "kind": "summary", "part": "2",
88
+ { "no": "02", "id": "sec-2", "label": "02", "name": "요지", "kind": "summary", "part": "2",
89
89
  "lede": "접수 1건과 자체 발견 1건의 처리 결과 요약.",
90
- "sub": "네 문단으로 훑는 이번 회차",
90
+ "sub": "네 문단으로 훑는 처리 결과",
91
91
  "short": "요지",
92
92
  "marg": {
93
- "why": "이번 회차에 무엇이 달라졌는가?",
93
+ "why": "무엇이 달라졌는가?",
94
94
  "key": "두 건 모두 운영 반영까지 확인함.",
95
95
  "advice": "각 문단 끝의 「해당 절」이 근거의 출처."
96
96
  } },
@@ -135,7 +135,7 @@
135
135
  "short": "한계",
136
136
  "count": "3",
137
137
  "count_unit": "가지",
138
- "limit_label": "이번 회차에서 확인하지 않은 것",
138
+ "limit_label": "확인하지 않은 범위",
139
139
  "paragraphs": [
140
140
  "확인 범위는 두 건이 일어나는 주문 목록 화면까지임. 같은 데이터를 쓰는 다른 화면은 대조하지 않음.",
141
141
  "§5.1 의 속도 개선은 운영 데이터 대조까지임. 화면을 실제로 열어 재현하지는 않음.",
@@ -159,19 +159,19 @@
159
159
  "body": "목록을 그리는 자리가 하나라, 저장 반영과 표시 속도가 같은 지점에서 갈렸음(§4.1). 그래서 함께 처리함.",
160
160
  "refs": ["4.1"] },
161
161
  { "lead": "확인이 필요한 것은 1건임.",
162
- "body": "총 건수 표시가 실제와 다르게 나올 수 있어 확인해 주시면 됨(§5.1). 01절에 모아 둠.",
162
+ "body": "총 건수 표시가 실제와 다르게 나올 수 있는 상황임(§5.1). 확인이 필요한 항목은 01절에 모아 둠.",
163
163
  "refs": ["5.1"] }
164
164
  ],
165
165
 
166
166
  "open_items": [
167
167
  { "title": "목록 위 총 건수 표시 확인 요청",
168
168
  "priority": "must",
169
- "body": "총 건수가 예전 데이터 때문에 실제보다 몇 건 많게 나올 수 있는 상황. 목록에 표시되는 내용 자체는 정상임. 실제 수치와 대조해 알려 주시면 기준을 맞출 예정.",
169
+ "body": "총 건수가 예전 데이터 때문에 실제보다 몇 건 많게 나올 수 있는 상황. 목록에 표시되는 내용 자체는 정상임. 실제 수치와 대조해 알려 주시면 기준을 맞추겠습니다.",
170
170
  "refs": ["5.1"] }
171
171
  ],
172
172
 
173
173
  "cta": [
174
- "검수는 각 건에 기재한 <strong>확인 방법</strong>대로 한 번씩 열어 보는 것으로 충분함."
174
+ "검수는 각 건에 기재한 <strong>확인 방법</strong>대로 한 번씩 열어 봐 주시기 바랍니다."
175
175
  ],
176
176
 
177
177
  "appendix": [
@@ -192,8 +192,8 @@
192
192
  ],
193
193
  "mark": "이 문서는 2026년 8월 24일 기준임.",
194
194
  "badge": "고객 공유용",
195
- "note": "※ 이 문서에는 입력·저장 기능이 없음. 검수 결과는 별도로 회신 주시면 됨.",
196
- "tail": "이후 진행 사항은 차기 회차 보고로 공유 예정."
195
+ "note": "※ 이 문서에는 입력·저장 기능이 없음. 검수 결과는 이 문서를 보낸 담당자에게 회신해 주시기 바랍니다.",
196
+ "tail": "이후 진행 사항은 다음 보고로 공유함."
197
197
  },
198
198
 
199
199
  "forbid": ["티켓", "스프린트", "레포지토리", "커밋", "(?<!나)머지", "(은|는|을|를) 제외(함|됨|했음|했다|한다|하였음|하였다)", "지원(하지|되지) 않음"]
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ // 개념도 렌더 + 가독성 게이트 — HTML 한 장을 헤드리스 크롬으로 PNG 로 찍고, **열람 화면(본문 단 760px)에
3
+ // 앉혔을 때 읽히는가**를 references/readability.md 의 규칙표(R1~R10)로 잰다. 값의 정본은 그 표다.
4
+ //
5
+ // node figure.mjs <in.html> <out.png> [--width 760] [--scale auto|1|2] [--selector .wrap]
6
+ // [--display 760 | --wide] [--min-font 15] [--main-font 17] [--vh 900]
7
+ // [--img-text 15] [--img-dpr 1] [--forbid <정규식>] [--allow <규칙,…>] [--allow-small] [--json]
8
+ //
9
+ // 왜 이렇게 넓게 재는가: 2026-09-08 「글씨 작아서 문제」 반려의 처방이 「표시 14px 게이트」였는데, 그것을 통과한
10
+ // 그림이 2026-09-09 「가독성에 하자 있는 상태로 만드는 짓을 왜 계속 하느냐」로 다시 반려됐다(앳플리 #173 그림 1-2).
11
+ // 라벨 15px · 행간 1.3 · 라벨 간격 2px · 표식 간 7px · 띠 대비 1.2:1 · 뜻 색 5 · 그림 안 문단 150자 · 1x 손실 인코딩 —
12
+ // 글자 크기 하나만 재고 나머지 아홉을 안 잰 것이다. 그린 뒤 눈으로 보면 이미 늦다. 여기서 전부 센다.
13
+ //
14
+ // 규칙(readability.md §1): R1 글자 크기 · R2 위계(그림 안 제목 금지) · R3 행간 · R4 그림 안 문장 금지 · R5 근접성(겹침·간격·
15
+ // 표식) · R6 대비(글자 4.5 · 도형 3) · R7 뜻 색 ≤ 3 · R8 선명도(밀도·무손실) · R9 세로(78vh) · R10 금칙어.
16
+ // ✗ 는 실패(exit 3), ⚠ 는 경고. 정당한 예외는 --allow <규칙> 으로 규칙 단위로만 면제한다(면제 사유가 출력에 남는다).
17
+ // playwright-core 는 gazette-report 스킬의 검사기와 같은 것을 찾아 쓴다 — 여기서 따로 설치하지 않는다.
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
+ // ── 인자 ──
25
+ const args = process.argv.slice(2);
26
+ const FLAGS = new Set(['--allow-small', '--wide', '--json']);
27
+ const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
28
+ const has = (k) => args.includes(k);
29
+ const pos = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--') && !FLAGS.has(args[i - 1])));
30
+ const [inHtml, outPng] = pos;
31
+ if (!inHtml || !outPng) {
32
+ console.error('usage: node figure.mjs <in.html> <out.png> [--width 760] [--scale auto|N] [--selector .wrap] [--display 760|--wide] [--min-font 15] [--main-font 17] [--vh 900] [--img-text 15] [--forbid <regex>] [--allow <rules,…>] [--json]');
33
+ process.exit(2);
34
+ }
35
+ // 계약 상수 — readability.md §1 · gazette.py PLATE_* · genetics.mjs ASSET_LONG_EDGE/ASSET_MAX_ONE 과 같은 값
36
+ const COLUMN = 760, WIDE = 1030, LONG_EDGE = 1400, VH_RATIO = 0.78, BUDGET_ONE = 80000;
37
+ const DENSITY_MIN = 1.8; // R8 출력 px 폭 ÷ 표시 폭
38
+ const display = has('--wide') ? WIDE : Number(opt('--display', COLUMN));
39
+ const width = Number(opt('--width', display)); // 소스 폭 기본 = 표시 폭 (1:1)
40
+ const scaleOpt = opt('--scale', 'auto');
41
+ const scale = scaleOpt === 'auto' ? Math.min(2, LONG_EDGE / width) : Number(scaleOpt);
42
+ const selector = opt('--selector', '.wrap');
43
+ const minFont = Number(opt('--min-font', 15));
44
+ const mainFont = Number(opt('--main-font', 17));
45
+ const vh = Number(opt('--vh', 900));
46
+ const imgText = Number(opt('--img-text', 15)); // 캡처 <img> 안 글자의 원본 px (데스크톱 웹 15 · 휴대폰 40)
47
+ const imgDpr = Number(opt('--img-dpr', 1)); // 캡처의 픽셀 밀도(shot.mjs 기본 2) — 원본 px 를 CSS px 로 되돌린다
48
+ const forbid = opt('--forbid', null) ? new RegExp(opt('--forbid'), 'u') : null;
49
+ const allow = new Set((opt('--allow', '') || '').split(',').map(s => s.trim()).filter(Boolean));
50
+ if (has('--allow-small')) allow.add('R1'); // 옛 이름 호환
51
+ const wantJson = has('--json');
52
+
53
+ // ── 브라우저 ──
54
+ const CANDIDATES = [
55
+ process.env.PLAYWRIGHT_CORE,
56
+ path.join(os.homedir(), '.claude/skills/gazette-report/scripts/node_modules/playwright-core/index.mjs'),
57
+ path.join(process.cwd(), 'node_modules/playwright-core/index.mjs'),
58
+ ].filter(Boolean);
59
+ let chromium = null;
60
+ for (const c of CANDIDATES) { if (fs.existsSync(c)) { ({ chromium } = await import(pathToFileURL(c).href)); break; } }
61
+ if (!chromium) { console.error('playwright-core 를 못 찾음 — PLAYWRIGHT_CORE=<index.mjs 절대경로> 로 주거나 gazette-report 스킬 scripts 에 npm i playwright-core'); process.exit(2); }
62
+ const EXE = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
63
+ if (!fs.existsSync(EXE)) { console.error('크롬을 못 찾음 — CHROME=<실행 파일 경로>'); process.exit(2); }
64
+
65
+ const b = await chromium.launch({ executablePath: EXE });
66
+ const ctx = await b.newContext({ viewport: { width: width + 100, height: 900 }, deviceScaleFactor: scale });
67
+ const pg = await ctx.newPage();
68
+ await pg.goto(pathToFileURL(path.resolve(inHtml)).href);
69
+ await pg.waitForTimeout(300);
70
+ const el = await pg.$(selector);
71
+ if (!el) { console.error(`선택자 ${selector} 가 없음`); await b.close(); process.exit(2); }
72
+ await el.screenshot({ path: outPng });
73
+ const box = await el.boundingBox();
74
+
75
+ // ── 탐침: 글자·도형·표식·캡처를 색·크기·자리와 함께 읽는다 ──
76
+ const probe = await el.evaluate((root) => {
77
+ const R = root.getBoundingClientRect();
78
+ const lum = ([r, g, b]) => { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
79
+ const nums = s => (String(s).match(/-?\d+(\.\d+)?/g) || [0, 0, 0]).map(Number);
80
+ const alpha = s => { const n = nums(s); return n.length > 3 ? n[3] : 1; };
81
+ const PORTAL_BG = [245, 246, 247]; // 포털 도판 바탕 #f5f6f7 — 투명 PNG 뒤에 깔린다
82
+ // 요소 뒤에 실제로 보이는 색 — 조상 배경을 알파 합성. 끝까지 투명이면 포털 바탕.
83
+ const bgOf = (el, skipSelf) => {
84
+ const st = []; let e = skipSelf ? el.parentElement : el;
85
+ while (e && e !== root.parentElement) {
86
+ const c = getComputedStyle(e).backgroundColor;
87
+ if (c && !/transparent/.test(c)) { const a = alpha(c); if (a > 0) { st.push([nums(c).slice(0, 3), a]); if (a >= 1) break; } }
88
+ e = e.parentElement;
89
+ }
90
+ let o = PORTAL_BG.slice();
91
+ for (let i = st.length - 1; i >= 0; i--) { const [c, a] = st[i]; o = [0, 1, 2].map(k => c[k] * a + o[k] * (1 - a)); }
92
+ return o;
93
+ };
94
+ const ratio = (a, b) => { const L1 = lum(a), L2 = lum(b); return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); };
95
+ const rgbOf = (s) => { const n = nums(s); return n.length >= 3 ? [n[0], n[1], n[2]] : null; };
96
+ const rel = (r) => ({ x: r.left - R.left, y: r.top - R.top, w: r.width, h: r.height });
97
+ const texts = [], shapes = [], markers = [], imgs = [];
98
+ for (const e of root.querySelectorAll('*')) {
99
+ if (e.closest('script,style')) continue;
100
+ const cs = getComputedStyle(e);
101
+ if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue;
102
+ const r = e.getBoundingClientRect();
103
+ if (e.tagName === 'IMG') { imgs.push({ ...rel(r), nat: e.naturalWidth, src: (e.getAttribute('src') || '').split('/').pop().slice(0, 40) }); continue; }
104
+ const own = [...e.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').replace(/\s+/g, ' ').trim();
105
+ if (own) {
106
+ const px = parseFloat(cs.fontSize);
107
+ const lhRaw = cs.lineHeight; const lh = lhRaw === 'normal' ? px * 1.2 : parseFloat(lhRaw);
108
+ const fg = rgbOf(cs.color) || [0, 0, 0];
109
+ // 글자 색이 반투명이면 배경에 얹은 뒤 대비를 잰다
110
+ const fa = alpha(cs.color); const bg = bgOf(e, false);
111
+ const fgc = fa >= 1 ? fg : [0, 1, 2].map(k => fg[k] * fa + bg[k] * (1 - fa));
112
+ // 글자 상자는 요소 상자가 아니라 **글리프 상자**다 — 띠 가운데 놓인 「정상」을 띠 전체로 세면 옆 띠와 0px 로 붙는다
113
+ let gr = null;
114
+ for (const n of e.childNodes) { if (n.nodeType !== 3 || !n.textContent.trim()) continue; const rg = document.createRange(); rg.selectNodeContents(n); const b = rg.getBoundingClientRect(); if (!b.width) continue; gr = gr ? { left: Math.min(gr.left, b.left), top: Math.min(gr.top, b.top), right: Math.max(gr.right, b.right), bottom: Math.max(gr.bottom, b.bottom) } : { left: b.left, top: b.top, right: b.right, bottom: b.bottom }; }
115
+ const gbox = gr ? { left: gr.left, top: gr.top, width: gr.right - gr.left, height: gr.bottom - gr.top } : r;
116
+ texts.push({ tag: e.tagName.toLowerCase(), text: own, chars: own.length, px, lh: lh / px, weight: parseInt(cs.fontWeight, 10) || 400,
117
+ color: fgc, bg, contrast: ratio(fgc, bg), lines: Math.max(1, Math.round(gbox.height / lh)), ...rel(gbox) });
118
+ } else {
119
+ // 글자 없는 상자 — 배경이나 테두리가 보이면 도형, 가늘면 표식(눈금·핀·점)
120
+ const bgc = cs.backgroundColor, bc = cs.borderTopColor;
121
+ const hasBg = bgc && !/transparent/.test(bgc) && alpha(bgc) > 0 && r.width > 0 && r.height > 0;
122
+ const hasBorder = parseFloat(cs.borderTopWidth) > 0 && bc && alpha(bc) > 0;
123
+ if (!hasBg && !hasBorder) continue;
124
+ const col = hasBg ? rgbOf(bgc) : rgbOf(bc);
125
+ const a = (hasBg ? alpha(bgc) : alpha(bc)) * (parseFloat(cs.opacity) || 1); // 배경 알파 × 요소 불투명도
126
+ const behind = bgOf(e, true);
127
+ const shown = a >= 1 ? col : [0, 1, 2].map(k => col[k] * a + behind[k] * (1 - a));
128
+ const item = { color: shown, alpha: a, contrast: ratio(shown, behind), ...rel(r) };
129
+ const thin = (r.width <= 8 || r.height <= 8) && cs.position === 'absolute';
130
+ (thin ? markers : shapes).push(item);
131
+ }
132
+ }
133
+ return { W: R.width, H: R.height, texts, shapes, markers, imgs };
134
+ });
135
+ await b.close();
136
+
137
+ // ── 표시 배율 (readability.md §1 R1·R8·R9) ──
138
+ const W = probe.W, H = probe.H;
139
+ const outW = Math.round(W * scale), outH = Math.round(H * scale);
140
+ const capScale = Math.min(1, LONG_EDGE / Math.max(outW, outH)); // 서버가 긴 변 1400 으로 깎는다
141
+ const servedW = Math.round(outW * capScale), servedH = Math.round(outH * capScale);
142
+ let eff = display / W; // CSS px → 표시 px (폭 기준)
143
+ const dispH = H * eff, maxH = VH_RATIO * vh;
144
+ const tall = dispH > maxH; // R9: 포털 max-height 78vh 가 폭까지 깎는다
145
+ if (tall) eff *= maxH / dispH;
146
+ const shownW = Math.round(W * eff);
147
+ const narrowEff = eff * (682 / display); // 창 1000px 일 때 본문 단 682px
148
+ const density = servedW / shownW; // R8: 표시 CSS px 당 원본 px
149
+
150
+ // ── 판정 ──
151
+ const fails = [], warns = [], lines = [];
152
+ const say = (rule, level, msg) => {
153
+ const allowed = allow.has(rule);
154
+ const mark = level === 'fail' ? (allowed ? '△' : '✗') : '⚠';
155
+ lines.push(`${mark} ${rule} ${msg}${allowed && level === 'fail' ? ' (--allow 로 면제)' : ''}`);
156
+ if (level === 'fail' && !allowed) fails.push(rule); else warns.push(rule);
157
+ };
158
+ const d1 = (x) => +x.toFixed(1);
159
+ const T = probe.texts.map(t => ({ ...t, disp: d1(t.px * eff) }));
160
+
161
+ // R1 글자 — 주 글자(글자 수 가중 최빈)와 최소
162
+ const byPx = new Map();
163
+ for (const t of T) byPx.set(t.disp, (byPx.get(t.disp) || 0) + t.chars);
164
+ const mainDisp = [...byPx.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 0;
165
+ const minDisp = T.length ? Math.min(...T.map(t => t.disp)) : 0;
166
+ const smalls = T.filter(t => t.disp < minFont);
167
+ if (T.length && mainDisp < mainFont) say('R1', 'fail', `주 글자 ${mainDisp}px < 본문 ${mainFont}px (소스 ${d1(mainDisp / eff)}px × 배율 ${eff.toFixed(3)})`);
168
+ if (smalls.length) say('R1', 'fail', `표시 ${minFont}px 미만 글자 ${smalls.length}곳 — ` + [...new Set(smalls.map(t => `${t.px}→${t.disp}px「${t.text.slice(0, 14)}」`))].slice(0, 4).join(' · '));
169
+
170
+ // R2 위계 — 그림 안 제목 금지, 크기 단계, 강조 비율
171
+ const heads = T.filter(t => /^h[1-3]$/.test(t.tag));
172
+ if (heads.length) say('R2', 'fail', `그림 안 제목 ${heads.length}개(${heads.map(h => h.tag).join(',')}) — 제목은 캡션이 맡는다. 지우고 캡션 한 문장으로`);
173
+ const steps = [...new Set(T.map(t => Math.round(t.disp)))].sort((a, b) => a - b);
174
+ if (steps.length > 3) say('R2', 'warn', `글자 크기 단계 ${steps.length}(${steps.join('/')}px) — 셋 안으로`);
175
+ const maxDisp = T.length ? Math.max(...T.map(t => t.disp)) : 0;
176
+ const hasCapture = probe.imgs.length > 0; // 캡처 그림은 안의 화면이 위계를 나른다 — 라벨만 보고 세지 않는다
177
+ if (!hasCapture && steps.length > 1 && maxDisp / mainDisp < 1.3) say('R2', 'warn', `강조 값 ${maxDisp}px 이 주 글자 ${mainDisp}px 의 ${(maxDisp / mainDisp).toFixed(2)}배 — 1.3배 이상이어야 위계가 보인다`);
178
+
179
+ // R3 행간 — 2줄 이상 접힌 요소
180
+ for (const t of T.filter(t => t.lines >= 2 && t.lh < 1.4)) say('R3', 'fail', `행간 ${t.lh.toFixed(2)} < 1.4 「${t.text.slice(0, 18)}」(${t.lines}줄)`);
181
+
182
+ // R4 그림 안 문장 — 길이 또는 2줄 서술문
183
+ const SENT = /[.。!?]\s|[함음임됨다]\.?$/;
184
+ for (const t of T) {
185
+ if (t.chars > 60) say('R4', 'fail', `${t.chars}자 글이 그림 안에 있다 「${t.text.slice(0, 20)}…」 — 캡션 한 문장이나 본문으로`);
186
+ else if (t.lines >= 2 && SENT.test(t.text) && t.chars > 24) say('R4', 'fail', `${t.lines}줄 서술문 「${t.text.slice(0, 20)}…」 — 라벨은 12자 안, 설명은 캡션으로`);
187
+ }
188
+
189
+ // R5 근접성 — 겹침 · 텍스트 간격 · 표식 간 거리 · 라벨↔표식 오프셋
190
+ const em = mainDisp || mainFont;
191
+ const gap = (a, b) => { const dx = Math.max(0, Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)); const dy = Math.max(0, Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h)); return Math.hypot(dx, dy) * eff; };
192
+ let overlaps = 0, minGap = Infinity, minGapPair = null;
193
+ for (let i = 0; i < T.length; i++) for (let j = i + 1; j < T.length; j++) {
194
+ const a = T[i], c = T[j];
195
+ if (a.x + a.w <= c.x || c.x + c.w <= a.x || a.y + a.h <= c.y || c.y + c.h <= a.y) { // 안 겹침
196
+ const g = gap(a, c); if (g < minGap) { minGap = g; minGapPair = [a.text.slice(0, 10), c.text.slice(0, 10)]; }
197
+ } else if (!(a.x <= c.x && a.y <= c.y && a.x + a.w >= c.x + c.w && a.y + a.h >= c.y + c.h) && !(c.x <= a.x && c.y <= a.y && c.x + c.w >= a.x + a.w && c.y + c.h >= a.y + a.h)) {
198
+ overlaps++; say('R5', 'fail', `글자 상자 겹침 「${a.text.slice(0, 12)}」↔「${c.text.slice(0, 12)}」`);
199
+ }
200
+ }
201
+ if (minGapPair && minGap < em) say('R5', 'warn', `글자 상자 사이 최소 ${d1(minGap)}px < 1em(${d1(em)}) 「${minGapPair[0]}」↔「${minGapPair[1]}」`);
202
+ const M = probe.markers;
203
+ for (let i = 0; i < M.length; i++) for (let j = i + 1; j < M.length; j++) {
204
+ const sameLane = !(M[i].y + M[i].h <= M[j].y || M[j].y + M[j].h <= M[i].y); // 세로로 겹치는 표식만 같은 레인
205
+ if (!sameLane) continue;
206
+ const dx = Math.abs((M[i].x + M[i].w / 2) - (M[j].x + M[j].w / 2)) * eff;
207
+ if (dx < 0.5 * em && dx > 0) say('R5', 'fail', `같은 레인의 표식 둘이 ${d1(dx)}px 거리 < 0.5em — 레인을 나누거나 확대 창으로`);
208
+ }
209
+ let farLabels = 0;
210
+ if (M.length) for (const t of T.filter(t => /\d/.test(t.text))) { // 값 라벨(숫자 포함)만 — 띠 이름·범례는 표식이 없다
211
+ const cx = t.x + t.w / 2, cy = t.y + t.h / 2;
212
+ let best = null;
213
+ for (const m of M) { const mx = m.x + m.w / 2, my = m.y + m.h / 2; const dy = Math.abs(my - cy) * eff; if (dy > 3 * em) continue; const dx = Math.abs(mx - cx) * eff; if (best === null || dx < best) best = dx; }
214
+ if (best !== null && best > em) farLabels++;
215
+ }
216
+ if (farLabels) say('R5', 'warn', `표식에서 가로로 1em 넘게 떨어진 라벨 ${farLabels}개 — 라벨 중심을 표식에 맞춘다`);
217
+
218
+ // R6 대비 — 글자 4.5(큰 굵은 글자 3) · 뜻 도형 3
219
+ for (const t of T) {
220
+ const big = t.disp >= 24 && t.weight >= 700;
221
+ const need = big ? 3 : 4.5;
222
+ if (t.contrast < need) say('R6', 'fail', `글자 대비 ${t.contrast.toFixed(2)}:1 < ${need} 「${t.text.slice(0, 14)}」`);
223
+ else if (t.disp < 19 && t.contrast < 7 && isGray(t.color) && lumOf(t.bg) > 0.5) say('R6', 'warn', `작은 회색 글자 대비 ${t.contrast.toFixed(2)}:1 < 7 「${t.text.slice(0, 14)}」`);
224
+ }
225
+ const meaningShapes = probe.shapes.filter(s => isSaturated(s.color));
226
+ for (const s of meaningShapes) if (s.contrast < 3) say('R6', 'fail', `뜻 도형 대비 ${s.contrast.toFixed(2)}:1 < 3 (${hex(s.color)}${s.alpha < 1 ? `, 투명도 ${s.alpha}` : ''}) — 불투명하게 칠한다`);
227
+
228
+ // R7 색 수 — 채도 있는 색을 색상 30° 버킷으로
229
+ const hues = new Set();
230
+ for (const c of [...T.map(t => t.color), ...probe.shapes.map(s => s.color), ...M.map(m => m.color)]) if (isSaturated(c)) hues.add(Math.floor(hueOf(c) / 30));
231
+ if (hues.size > 3) say('R7', 'fail', `뜻 색 ${hues.size}가지 — 셋 안으로(검정·회색·흰은 세지 않음)`);
232
+
233
+ // R8 선명도 — 밀도
234
+ if (density < 1) say('R8', 'fail', `원본 ${servedW}px 이 표시 ${shownW}px 보다 작아 늘려 그려진다(흐림) — --scale 2 또는 소스 폭을 표시 폭으로`);
235
+ else if (density < DENSITY_MIN) say('R8', 'warn', `밀도 ${density.toFixed(2)}x < ${DENSITY_MIN} — --scale auto(=${Math.min(2, LONG_EDGE / W).toFixed(2)}) 로 굽는다`);
236
+
237
+ // R9 세로
238
+ if (tall) say('R9', 'warn', `표시 높이 ${Math.round(dispH)}px > ${Math.round(maxH)}px(78vh) — 포털이 폭을 ${shownW}px 로 깎는다. 위아래 두 장으로`);
239
+ if (H > W * 1.8) say('R9', 'warn', `세로가 폭의 ${(H / W).toFixed(2)}배 — 1.8 배를 넘으면 서버 상한이 폭을 깎는다`);
240
+
241
+ // R10 금칙어
242
+ if (forbid) for (const t of T) if (forbid.test(t.text)) say('R10', 'fail', `금칙 표기 「${t.text.slice(0, 20)}」`);
243
+
244
+ // 캡처 <img> — 안의 글자는 셀 수 없으니 배율 × 원본 글자로
245
+ const imgRows = probe.imgs.map(im => { const r = im.nat ? (im.w / (im.nat / imgDpr)) * eff : null; return { ...im, r, glyph: r === null ? null : d1(imgText * r) }; });
246
+ for (const im of imgRows) if (im.glyph !== null && im.glyph < minFont) say('R1c', 'fail', `캡처 ${im.src}: 원본 ${im.nat}px → 배율 ${im.r.toFixed(3)} → 안의 ${imgText}px 글자가 ${im.glyph}px — 뷰포트를 좁혀 다시 찍거나 자리만 잘라 싣는다`);
247
+
248
+ // 예산·provider — cwebp 가 있으면 무손실/손실 크기를 재서 권고
249
+ let lossless = null, lossy = null;
250
+ try {
251
+ const png = fs.readFileSync(outPng);
252
+ const ll = spawnSync('cwebp', ['-quiet', '-lossless', '-exact', '-o', '-', '--', '-'], { input: png, maxBuffer: 64 * 1024 * 1024 });
253
+ const ly = spawnSync('cwebp', ['-quiet', '-q', '82', '-sharp_yuv', '-o', '-', '--', '-'], { input: png, maxBuffer: 64 * 1024 * 1024 });
254
+ if (ll.status === 0) lossless = ll.stdout.length;
255
+ if (ly.status === 0) lossy = ly.stdout.length;
256
+ } catch { /* cwebp 없음 — 권고만 생략 */ }
257
+ const isCapture = hasCapture;
258
+ let provider = isCapture ? 'user-provided (화면 캡처 포함 · origin 필수)' : 'deterministic (평면 그림 · 무손실)';
259
+ if (lossless !== null) {
260
+ if (!isCapture && lossless > BUDGET_ONE) say('R8', 'warn', `무손실 ${lossless.toLocaleString()}B > 예산 ${BUDGET_ONE.toLocaleString()}B — --scale 1.5 로 굽거나 여백을 줄인다`);
261
+ if (isCapture && lossless <= BUDGET_ONE && lossy !== null && lossless <= lossy) provider = 'deterministic 권고 — 무손실이 손실보다 작다(평면 그림). 캡처가 실제 사진이면 user-provided';
262
+ }
263
+
264
+ // ── 출력 ──
265
+ const summary = {
266
+ out: outPng, sourceCss: [Math.round(W), Math.round(H)], outputPx: [outW, outH], servedPx: [servedW, servedH],
267
+ display, shownW, eff: +eff.toFixed(3), narrowEff: +narrowEff.toFixed(3), density: +density.toFixed(2),
268
+ mainFont: mainDisp, minFont: minDisp, mainFontNarrow: d1(mainDisp * 682 / display), sizeSteps: steps,
269
+ texts: T.length, shapes: probe.shapes.length, markers: M.length, hues: hues.size, overlaps,
270
+ lossless, lossy, provider, fails, warns, allow: [...allow],
271
+ };
272
+ console.log(`${outPng} ${outW}x${outH} ${fs.statSync(outPng).size}B (서버 상한 뒤 ${servedW}x${servedH})`);
273
+ console.log(`판독 — 소스 ${Math.round(W)}×${Math.round(H)} CSS px → 표시 ${shownW}px(배율 ${eff.toFixed(3)}${has('--wide') ? ', 전폭' : ''}) · 밀도 ${density.toFixed(2)}x · 좁은 창(682px)이면 배율 ${narrowEff.toFixed(3)}`);
274
+ console.log(` 글자 — 주 ${mainDisp}px(좁은 창 ${summary.mainFontNarrow}) · 최소 ${minDisp}px · 단계 ${steps.join('/')}px · 글자 상자 ${T.length} · 도형 ${probe.shapes.length} · 표식 ${M.length} · 뜻 색 ${hues.size}`);
275
+ for (const im of imgRows) console.log(` 캡처 ${im.src}: 원본 ${im.nat}px → 표시 배율 ${im.r === null ? '?' : im.r.toFixed(3)} → 안의 ${imgText}px 글자 ≈ ${im.glyph ?? '?'}px`);
276
+ if (lossless !== null) console.log(` 인코딩 — 무손실 ${lossless.toLocaleString()}B · 손실 q82 ${lossy?.toLocaleString() ?? '?'}B · 예산 ${BUDGET_ONE.toLocaleString()}B → provider: ${provider}`);
277
+ else console.log(` 인코딩 — cwebp 없음(brew install webp) → provider 권고: ${provider}`);
278
+ for (const l of lines) console.log(' ' + l);
279
+ console.log(fails.length ? `✗ 실패 ${fails.length} · ⚠ 경고 ${warns.length} — readability.md §1 의 규칙 번호를 보라` : `통과 · ⚠ 경고 ${warns.length}`);
280
+ if (wantJson) console.log(JSON.stringify(summary));
281
+ process.exit(fails.length ? 3 : 0);
282
+
283
+ // ── 색 도우미 ──
284
+ function hueOf([r, g, b]) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); const d = max - min; if (!d) return 0; let h; if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; return ((h * 60) + 360) % 360; }
285
+ function isSaturated([r, g, b]) { const R = r / 255, G = g / 255, B = b / 255; const max = Math.max(R, G, B), min = Math.min(R, G, B); const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); return s >= 0.15 && l > 0.1 && l < 0.95; }
286
+ function isGray(c) { return !isSaturated(c); }
287
+ function lumOf([r, g, b]) { const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }
288
+ function hex([r, g, b]) { return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join(''); }
@@ -0,0 +1,44 @@
1
+ // 화면 도안 촬영 — 고객 앱 화면 폭(휴대폰 375 CSS px)으로 그린 HTML 도안을 2배 밀도 PNG 로 찍는다.
2
+ // 결정 요청이 이용자 화면의 변화를 낳을 때 붙이는 도안의 둘째 단계다(references/visual-first.md 「화면 도안」).
3
+ //
4
+ // node mockshot.mjs <in.html> <out.png> [--sel .phone] [--dpr 2] [--w 375] [--h 900]
5
+ //
6
+ // 찍은 PNG 는 그대로 싣지 않는다 — 도판 HTML 에 <img> 로 앉힌 뒤 figure.mjs 로 가독성 관문을 지난다
7
+ // (전폭 두 칸 485px 이면 `--wide --img-text <화면의 가장 작은 글자 px> --img-dpr 2`).
8
+ // playwright-core 와 크롬은 figure.mjs 와 같은 것을 찾아 쓴다 — 여기서 따로 설치하지 않는다.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ const args = process.argv.slice(2);
15
+ const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
16
+ const [inHtml, outPng] = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--')));
17
+ if (!inHtml || !outPng) { console.error('usage: node mockshot.mjs <in.html> <out.png> [--sel .phone] [--dpr 2] [--w 375] [--h 900]'); process.exit(2); }
18
+ const sel = opt('--sel', '.phone');
19
+ const dpr = Number(opt('--dpr', 2));
20
+ const w = Number(opt('--w', 375));
21
+ const h = Number(opt('--h', 900));
22
+
23
+ const CANDIDATES = [
24
+ process.env.PLAYWRIGHT_CORE,
25
+ path.join(os.homedir(), '.claude/skills/gazette-report/scripts/node_modules/playwright-core/index.mjs'),
26
+ path.join(process.cwd(), 'node_modules/playwright-core/index.mjs'),
27
+ ].filter(Boolean);
28
+ let chromium = null;
29
+ for (const c of CANDIDATES) { if (fs.existsSync(c)) { ({ chromium } = await import(pathToFileURL(c).href)); break; } }
30
+ if (!chromium) { console.error('playwright-core 를 못 찾음 — PLAYWRIGHT_CORE=<index.mjs 절대경로> 로 주거나 gazette-report 스킬 scripts 에 npm i playwright-core'); process.exit(2); }
31
+ const EXE = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
32
+ if (!fs.existsSync(EXE)) { console.error('크롬을 못 찾음 — CHROME=<실행 파일 경로>'); process.exit(2); }
33
+
34
+ const b = await chromium.launch({ executablePath: EXE });
35
+ const ctx = await b.newContext({ viewport: { width: w + 40, height: h }, deviceScaleFactor: dpr });
36
+ const pg = await ctx.newPage();
37
+ await pg.goto(pathToFileURL(path.resolve(inHtml)).href);
38
+ await pg.waitForTimeout(400);
39
+ const el = await pg.$(sel);
40
+ if (!el) { console.error(`선택자 ${sel} 가 없음`); await b.close(); process.exit(2); }
41
+ await el.screenshot({ path: outPng });
42
+ const box = await el.boundingBox();
43
+ console.log(`${path.basename(outPng)} ${Math.round(box.width)}×${Math.round(box.height)} CSS px × ${dpr} = ${Math.round(box.width * dpr)}×${Math.round(box.height * dpr)} px · ${fs.statSync(outPng).size.toLocaleString()}B`);
44
+ await b.close();
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // 실제 화면 캡처 — 페이지의 한 요소를 번호 표식(①②③…)을 얹은 채 찍는다(visual-first.md §4).
3
+ // node shot.mjs <url> <selector> <out.png> [--badge "<selector>=①" ...] [--mask "<원문>=<대체>" ...]
4
+ // [--unclip] [--scale 2] [--wait <ms>] [--viewport 1400]
5
+ // --badge : 그 요소의 왼쪽 위에 빨간 원 배지를 얹고 테두리를 두른다. 보고서 표의 번호와 같게 둔다.
6
+ // --mask : 정확히 그 글자인 텍스트 노드를 대체 글자로 바꾼다(닉네임 가림).
7
+ // --unclip : 고정(sticky/fixed) 요소를 풀고 스크롤 컨테이너를 펼친다 — 스크롤 영역 안의 요소를 통째로 찍을 때.
8
+ // ⚠ absolute 는 건드리지 않는다. 전부 static 으로 풀면 막대·표식이 왼쪽으로 몰린다(2026-09-08 실사고).
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ const args = process.argv.slice(2);
15
+ const pos = [], badges = [], masks = []; let unclip = false, scale = 2, wait = 1200, viewport = 1400;
16
+ for (let i = 0; i < args.length; i++) {
17
+ const a = args[i];
18
+ if (a === '--viewport') viewport = Number(args[++i]); // 웹 캡처는 뷰포트 = 표시 폭(760)으로 찍어야 글자가 1:1 (readability.md R1·R8)
19
+ else if (a === '--badge') badges.push(args[++i]);
20
+ else if (a === '--mask') masks.push(args[++i]);
21
+ else if (a === '--unclip') unclip = true;
22
+ else if (a === '--scale') scale = Number(args[++i]);
23
+ else if (a === '--wait') wait = Number(args[++i]);
24
+ else pos.push(a);
25
+ }
26
+ const [url, selector, outPng] = pos;
27
+ if (!url || !selector || !outPng) { console.error('usage: node shot.mjs <url> <selector> <out.png> [--badge sel=①] [--mask 원문=대체] [--unclip] [--scale 2]'); process.exit(2); }
28
+
29
+ const CANDIDATES = [process.env.PLAYWRIGHT_CORE,
30
+ path.join(os.homedir(), '.claude/skills/gazette-report/scripts/node_modules/playwright-core/index.mjs'),
31
+ path.join(process.cwd(), 'node_modules/playwright-core/index.mjs')].filter(Boolean);
32
+ let chromium = null;
33
+ for (const c of CANDIDATES) { if (fs.existsSync(c)) { ({ chromium } = await import(pathToFileURL(c).href)); break; } }
34
+ if (!chromium) { console.error('playwright-core 를 못 찾음 — PLAYWRIGHT_CORE=<index.mjs 절대경로>'); process.exit(2); }
35
+ const EXE = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
36
+
37
+ const b = await chromium.launch({ executablePath: EXE });
38
+ const ctx = await b.newContext({ viewport: { width: viewport, height: 1200 }, deviceScaleFactor: scale });
39
+ const pg = await ctx.newPage();
40
+ await pg.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
41
+ await pg.waitForSelector(selector, { timeout: 60000 });
42
+ await pg.waitForTimeout(wait);
43
+ const info = await pg.evaluate(({ selector, badges, masks, unclip }) => {
44
+ if (unclip) {
45
+ for (const el of document.querySelectorAll('*')) { const p = getComputedStyle(el).position; if (p === 'sticky' || p === 'fixed') el.style.setProperty('position', 'static', 'important'); }
46
+ for (const el of document.querySelectorAll('*')) { const cs = getComputedStyle(el); if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && el.scrollHeight > el.clientHeight) { el.style.setProperty('overflow', 'visible', 'important'); el.style.setProperty('max-height', 'none', 'important'); el.style.setProperty('height', 'auto', 'important'); } }
47
+ }
48
+ const root = document.querySelector(selector); root.style.position = 'relative';
49
+ const R = root.getBoundingClientRect();
50
+ let masked = 0;
51
+ for (const m of masks) { const [from, to] = m.split('='); for (const el of root.querySelectorAll('*')) if (el.children.length === 0 && el.textContent.trim() === from) { el.textContent = to; masked++; } }
52
+ const placed = [];
53
+ for (const bd of badges) {
54
+ const i = bd.lastIndexOf('='); const sel = bd.slice(0, i), label = bd.slice(i + 1);
55
+ const el = root.querySelector(sel); if (!el) { placed.push([label, null]); continue; }
56
+ const r = el.getBoundingClientRect(); el.style.outline = '3px solid #D8493A'; el.style.outlineOffset = '2px';
57
+ const d = document.createElement('div'); d.textContent = label;
58
+ Object.assign(d.style, { position: 'absolute', left: (r.left - R.left - 14) + 'px', top: (r.top - R.top - 14) + 'px', width: '30px', height: '30px', borderRadius: '50%', background: '#D8493A', color: '#fff', font: '700 18px/30px sans-serif', textAlign: 'center', zIndex: '99', boxShadow: '0 0 0 3px #fff' });
59
+ root.appendChild(d); placed.push([label, Math.round(r.top - R.top)]);
60
+ }
61
+ return { w: Math.round(R.width), h: Math.round(R.height), masked, placed };
62
+ }, { selector, badges, masks, unclip });
63
+ await pg.waitForTimeout(300);
64
+ await (await pg.$(selector)).screenshot({ path: outPng });
65
+ console.log(JSON.stringify({ out: outPng, bytes: fs.statSync(outPng).size, ...info }));
66
+ await b.close();
@@ -285,7 +285,9 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
285
285
  ### 4.2 치환 유형 — 원고를 쓰면서 적용한다
286
286
 
287
287
  내부 기록을 그대로 옮기면 걸리는 자리 열 가지임.
288
- **전부 개조식으로 적음** — 6장의 어휘 게이트가 경어체를 0건으로 요구함.
288
+ **여기는 기록 자리이므로 전부 개조식으로 적음.**
289
+ 요청 자리(`cta`·`open_items`·PART I 간지·판권지 회신 안내)는 이 표가 아니라 SKILL 5a 를 따름 —
290
+ 거기서는 하십시오체로 맺고, 6장 어휘 게이트의 경어체 경고를 **그대로 둠**.
289
291
 
290
292
  | # | 유형 | 방향 | 왜 | 예 (내부 → 고객) |
291
293
  |---|---|---|---|---|
@@ -303,7 +305,7 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
303
305
 
304
306
  **경계선 둘.**
305
307
  K 는 **우리 근거의 약함을 지우라는 뜻이 아님** — 약한 근거는 등급을 달아 노출함(process.md §11 원리 3). 지우는 것은 「독자의 무력함」 서술임.
306
- 브리핑(§12.3b)에서는 J 보다 K 가 앞섬 — 기각안은 지우지 않고 K 의 틀로 적음.
308
+ 브리핑(§12.3b)에서는 J 보다 K 가 앞섬 — 고르지 않을 길도 지우지 않고, 선택지 표의 「정할 자리」·「다시 볼 때」 행에 조건을 주어로 적음.
307
309
 
308
310
  **경계선 하나.**
309
311
  `별도 티켓` 은 금지, `별건`·`별도 건` 은 허용.
@@ -412,11 +414,18 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
412
414
 
413
415
  | 검사 | 기준 |
414
416
  |---|---|
415
- | **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` → **0건** |
417
+ | **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` → **기록 자리에서 0건** |
416
418
  | **한 문장 한 줄** | 문장 경계가 있는데 `<span class="sn">` 도 `<br>` 도 없는 블록 → **0개** |
417
419
 
418
- 개조식은 `~함 / ~했음 / ~필요 / ~한 상황` 임.
419
- 문체를 안 바꾸면 관보 문법 위에서 겉돎 — **낱말이 아니라 조판의 문제임.**
420
+ ⚠ **경어체 0건은 기록 자리에만 거는 기준임.**
421
+ 요청 절(`cta`·`open_items`)·PART I 간지·판권지 회신 안내는 **하십시오체로 맺어야 하므로**,
422
+ 거기서 나는 경어체 경고는 **의도된 것이고 고치지 않음**(SKILL 5a · `visual-first.md` §8).
423
+ 서버 톤 경고(`lib/report_tone.php`)는 자리를 못 가림 — 경고 목록에서 요청 자리의 것을 사람이 걸러 읽음.
424
+ 로컬 `tone.mjs` 는 판단 항목 본문(`p.ask__body`)과 「」 인용을 빼고 셈(2026-09-12) — 판권지·간지의 요청 문장은 여전히 사람이 거름.
425
+
426
+ 개조식 `~함 / ~했음 / ~임` 은 **우리·사물이 주어인 기록 자리**의 문체임.
427
+ 명사형 종결(`-ㅁ/-음`)은 청자 높임 등급이 없어, 상대에게 말 거는 자리에 쓰면 하대로 읽힘 —
428
+ 「부탁드림」도 존대가 아님. 자리를 안 가리고 개조식을 걸어 **네 번 반려됨**(SKILL 5a 표).
420
429
 
421
430
  ### 6.2 S2 — 늘어짐 지표 (천 자당 밀도)
422
431