genetics-mcp 2.9.1 → 2.9.3

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.
@@ -1,725 +1,41 @@
1
- # 검수 — 자동(qa.py) 과 시각
1
+ # 검수 — 폐기됨 (2026-08-24 관보 전환)
2
2
 
3
- 빌드한 HTML 이 고객에게 나가도 되는지 판정한다. 두 층이다.
3
+ > 이 문서는 `assets/qa.py` 해설서였다.
4
+ > 그 검사기는 관보 문서에서 **죽는다** — 팔레트 대비 검사(`qa.py:67-75`)가 구조 검사보다 먼저 돌고,
5
+ > 관보에는 그 토큰이 없어 `KeyError` 로 멈춘다.
6
+ > 죽지 않게 고쳐도 v1 마크업을 세므로 `0/0` 거짓 초록이 된다. 어느 쪽도 관문이 아니다.
4
7
 
5
- | 층 | 무엇을 잡나 | 도구 |
6
- |---|---|---|
7
- | 자동 | **눈으로는 안 보이는 결함** — 대비 미달, 18건 중 1건에서 빠진 블록, 죽은 링크, 내부 어휘 유출, 고객본에 남는 편집기 | `assets/qa.py` |
8
- | 시각 | **눈으로만 보이는 결함** — 안 보이는 배지, 빈 목록 항목, 낱말 중간 줄바꿈, 인쇄 시 잘림, 톤·사실 | 사람 (§5) |
8
+ **안 재는 것을 재는 척하는 관문은 없는 관문보다 나쁘다.** 그래서 파일째 폐기했다.
9
9
 
10
- **둘 중 하나만 하면 반드시 샌다.** 자동 검수 48줄이 전부 초록이어도 브라우저에서 열면 깨진 곳이 나오고,
11
- 브라우저에서 멀쩡해 보여도 18건 중 한 건에 "확인 방법"이 통째로 빠져 있을 수 있다.
10
+ ## 검수는 이제 넷으로 갈린다
12
11
 
13
- 이 문서의 고유명사(고객사·수행사 이름, 18건, 등급 이름)는 **실사례 예시**다. 규칙은 일반화돼 있다.
14
-
15
- ---
16
-
17
- ## 0. 실행
18
-
19
- ```bash
20
- python3 ~/.claude/skills/genetics-report/assets/build.py report.json # 먼저 빌드
21
- python3 ~/.claude/skills/genetics-report/assets/qa.py report.json # 그다음 검수
22
- ```
23
-
24
- - 인자는 **HTML 이 아니라 `report.json`** 이다. 검수기가 설정에서 `out`(검사 대상), `grades`(등급 수),
25
- `forbid`(금지 정규식), `lang`, `allow_external`, `font.embed` 를 읽는다.
26
- 같은 규칙을 빌드와 검수가 **한 파일에서** 읽으므로 두 벌이 갈라질 수 없다.
27
- - `out` 경로는 `report.json` 이 있는 디렉터리 기준으로 푼다.
28
- - `qa.py` 는 `palette` 을 **레포에서** 가져온다(`<레포>/private/builder/palette.py`).
29
- 찾는 규칙은 `render.py` 의 `find_builder()` 하나뿐이고 `GENETICS_REPO` 로 덮을 수 있다.
30
- **`assets/` 에 사본을 두면 안 된다** — `refuse_fork()` 가 그것을 금지 목록으로 막는다
31
- (2026-08-18 에 사본을 지운 이유: 나흘 만에 887줄이 갈라져 고객 문서가 브랜드 서식 없이 나갔다).
32
- - 종료 코드: 실패 1건이라도 있으면 `1`, 없으면 `0`. **경고는 종료 코드를 바꾸지 않는다.**
33
-
34
- 실사례 출력(18건, 523,464B):
35
-
36
- ```
37
- ■ QA — ../고객사-수정내역-20260810.html 523,464B 건수 18
38
-
39
- ⚠️ rgb/rgba 리터럴 2건 (팔레트 대조 불가)
40
- ✅ 플레이스홀더 잔존 0건
41
- … (생략)
42
- 통과 48 · 경고 1 · 실패 0
43
- ```
44
-
45
- **통과 기준은 실패 0.** 경고는 사람이 판단해 넘긴다(그 판단법은 §3.5·§3.6).
46
-
47
- ---
48
-
49
- ## 1. 왜 이렇게 생겼는가 — 설계 원칙 넷
50
-
51
- ### 1.1 빌더의 의도가 아니라 **실제 나간 파일**을 읽는다
52
-
53
- 색 대비 검사는 `palette.py` 가 유도한 값을 다시 계산하지 않는다. **산출물에 박힌 `:root` 블록을 정규식으로
54
- 긁어 그 값으로 잰다.**
55
-
56
- ```python
57
- root = re.search(r':root \{(.*?)\n\}', doc, re.S)
58
- tok = dict(re.findall(r'--([a-z-]+):\s*([^;]+);', root.group(1))) if root else {}
59
- ```
60
-
61
- `build.py` 는 이미 `assert not bad` 로 유도 결과를 검증한다. 그런데도 검수기가 다시 재는 이유는,
62
- **빌드 이후에 파일이 손으로 편집될 수 있기 때문**이다. 편집기로 저장하거나 누가 에디터로 열어 `:root` 를
63
- 고치면 빌드의 assert 는 이미 지나갔다. 검수는 항상 마지막에, 나갈 파일을 대상으로 한다.
64
-
65
- > **어기면**: "빌드가 통과했으니 색은 괜찮다"고 믿고 검수를 건너뛰면, 편집 후 저장한 파일이 검증 없이 나간다.
66
-
67
- ### 1.2 필수(`err`) / 판단(`warn`) 2단, 그리고 항상 끝까지 돌린다
68
-
69
- `ok, warn, err` 세 리스트에 결과를 쌓고 마지막에 **실패 → 경고 → 통과** 순으로 찍는다.
70
-
71
- | 원칙 | 근거 | 어기면 |
72
- |---|---|---|
73
- | 첫 실패에서 중단하지 않는다 | 한 번 돌려 전체 상태를 본다 | 고치고-돌리기를 항목 수만큼 반복한다 |
74
- | 실패를 맨 위에 찍는다 | 통과 48줄이 실패 1줄을 화면 밖으로 밀어낸다 | 초록 사이에 낀 빨강을 못 본다 |
75
- | 경고는 빌드를 막지 않는다 | 경고까지 막으면 사람이 **검사를 지운다** | 판단이 필요한 항목이 사라진다 |
76
-
77
- 등급을 문서에만 적으면 지켜지지 않는다. `err`/`warn` 두 리스트와 종료 코드가 그 등급의 **구현**이다.
78
-
79
- ### 1.3 헤더 지문 — 파일명 · 바이트 수 · 건수
80
-
81
- ```
82
- ■ QA — ../고객사-수정내역-20260810.html 523,464B 건수 18
83
- ```
84
-
85
- 셋 다 **검사 결과와 독립적으로 변하는 값**이다. 빌드를 안 돌리고 옛 파일을 검사하면 결과는 똑같이 통과가
86
- 나오지만 바이트 수가 안 바뀐다. 실제로 이 프로젝트에서 폰트 임베드가 붙기 전후 파일이 119KB → 522KB 로
87
- 4배 커졌는데 **검사 결과 줄은 동일했다.** 지문이 없었으면 같은 파일이라고 착각했을 것이다.
88
-
89
- > **이식 규칙**: 첫 줄에 반드시 파일명 + 바이트 수 + 반복 단위 개수를 찍는다.
90
-
91
- ### 1.4 프로젝트 고유값은 `report.json` 에, 매체 상수만 `qa.py` 에
92
-
93
- `qa.py` 에 남은 상수는 셋뿐이다 — `FONT_ALLOW`(폰트 21종), `EDITOR_CSS_MARK`(편집기 CSS 마커),
94
- `SLOP_KO`(한국어 슬롭 어휘 8종). 티켓 번호 정규식·도구 이름·허용 도메인·언어는 전부 설정으로 나갔다.
95
-
96
- > **어기면**: 검수기를 프로젝트마다 포크하게 되고, 포크한 순간 상위 개선이 전파되지 않는다.
97
-
98
- ---
99
-
100
- ## 2. 검사 대상 — 뷰가 넷이다
101
-
102
- **이식할 때 사고가 가장 많이 나는 지점.** 검사마다 보는 문자열이 다르다.
103
-
104
- ```python
105
- doc = (base / cfg['out']).read_text(encoding='utf-8') # 뷰 A: 파일 전체
106
- body = doc.split('<body', 1)[1].split('<script', 1)[0] # 뷰 B: 본문 (편집기 JS 제외)
107
- text = re.sub(r'<[^>]+>', ' ', body) # 뷰 C: 태그 벗긴 순수 텍스트
108
- ```
109
-
110
- 네 번째는 색 검사 전용이다. 편집 레이어 CSS **구간만 도려낸다.**
111
-
112
- ```python
113
- i, j = doc.find(EDITOR_CSS_MARK), doc.find('</style>', doc.find(EDITOR_CSS_MARK))
114
- scan = doc[:i] + doc[j:]
115
- scan = re.sub(r'base64,[A-Za-z0-9+/=]+', 'base64,', scan) # 폰트 페이로드만 지운다
116
- ```
117
-
118
- | 뷰 | 쓰는 검사 | 왜 이 뷰인가 |
119
- |---|---|---|
120
- | A `doc` | 대비·역할위반·폰트·임베드·목차·고객본원칙·lang·외부리소스·내보내기 시뮬 | 파일에 실려 나가는 모든 바이트. **금지 표현 검사는 절대 좁히면 안 된다** — JS 문자열 상수 안에 있어도 그 문자열은 고객 파일에 실려 나간다 |
121
- | B `body` | 플레이스홀더·고정폭·구조 n/n·판정표·절 id·대시 헤딩·img alt | 문서 구조와 본문 카피. 편집기 JS 를 세면 구조 카운트가 오염된다 |
122
- | C `text` | 슬롭 어휘 | 금지어가 클래스명·속성값에 우연히 걸리는 오탐을 막는다 |
123
- | D `scan` | 하드코딩 색·rgba | 편집기 툴바의 회색(`#1b1f22`, `#c8ccd0`, `#8b9095`)은 문서 팔레트가 아니다. 세면 전부 오탐 |
124
-
125
- ### 2.1 `scan` 을 만드는 두 규칙 (둘 다 실패에서 배운 것)
126
-
127
- 1. **마커에서 `split` 하지 않는다.** 파일 배치는 언제나
128
- `편집기 CSS 마커` < `</style>` < `<body>` 이므로, 마커에서 문자열을 자르면 **`<body>` 전체가 함께
129
- 잘린다.** 실제로 그렇게 잘랐다가 본문과 인라인 SVG 를 통째로 검사에서 잃었다. 지금은 두 좌표(`i`, `j`)로
130
- **구간만 도려낸다.**
131
- 실측 확인: `'<body' in scan == True`, `'8b9095' in scan == False`,
132
- `'var(--accent-chip)' in scan == True`(다이어그램 SVG 도달).
133
- 2. **base64 페이로드는 자리표시자로 치환**한다. 400KB 짜리 문자열을 정규식이 반복해서 훑을 이유가 없다.
134
- 여기서도 `split` 이 아니라 `re.sub` 다 — 같은 이유다.
135
-
136
- > `EDITOR_CSS_MARK` 를 못 찾으면 검수기는 **죽지 않고 경고를 내고 파일 전체를 스캔한다.**
137
- > 편집 레이어가 없는 문서(고객본 등)에서도 검사가 계속 돌게 하려는 것이다(§5.3).
138
-
139
- ---
140
-
141
- ## 3. 검사 항목 전체
142
-
143
- 번호는 `qa.py` 안의 주석 번호다. 항목 하나가 여러 줄의 결과를 낸다(실사례 18건에서 총 49줄).
144
-
145
- | # | 검사 | 재는 것 | 통과 기준 | 등급 | 뷰 |
146
- |---|---|---|---|---|---|
147
- | 1 | 미치환 플레이스홀더 | `{...}` 잔존 | 0건 | 필수 | B |
148
- | 2 | 고정 px 폭 | `width:\d{3,}px` | 0건 | 경고 | B |
149
- | 3 | 색 대비 | `:root` × `palette.audit()` 17쌍 | 전부 기준 이상 | 필수 | A |
150
- | 4 | 역할 위반 | `--mid`·`--accent` 를 글자색으로 | 0건 | 필수 | A |
151
- | 4-b | 팔레트 밖 색 | hex 리터럴 − `:root` 값 / rgb·rgba | 공집합 / 판단 | 경고 | D |
152
- | 5 | 폰트 화이트리스트 | `font-family` 스택 전원 | `FONT_ALLOW` 안 | 경고 | A |
153
- | 6 | 폰트 임베드 | `@font-face` 존재 + `format` 문자열 | 존재 + `woff2` | 필수 | A |
154
- | 7 | 구조 일관성 | 건별 5요소 + 등급 칩 | 각 `n/n` | 필수 | B |
155
- | 8 | 목차·판정표 | 목차 항목 수 / 판정표 행 수 | 각 `n/n` | 필수 | A·B |
156
- | 9 | 죽은 절 참조 | `href="#sX-Y"` − 실제 id | 공집합 | 필수 | A·B |
157
- | 10 | 고객본 원칙 | `§` + `forbid` 정규식 | 각 0건 | 필수 | A |
158
- | 11 | 카피 게이트 | 대시 헤딩 / 슬롭 어휘 | 0건 | 필수 | B·C |
159
- | 12 | 접근성·단일 파일 | `lang` / `img alt` / 외부 URL | 선언 / 0건 / 0건 | 필수 | A·B |
160
- | 13 | 고객본 내보내기 | 편집기 잔존 표식 + 크기 | 없음 + 98% 미만 | 필수 | A |
161
-
162
- ### 3.1 미치환 플레이스홀더 (필수)
163
-
164
- `\{[A-Za-z_][^}]{0,40}\}` 가 본문에 남아 있는지. 고객이 `{customer_name}` 을 보는 것은 신뢰가 즉사하는
165
- 유형의 결함이다. 상한 40자는 CSS 블록 오탐을 억제하려는 것이고, **뷰 B 가 head 의 `<style>` 을 이미
166
- 제외**하므로 현재는 걸릴 일이 없다. 본문에 인라인 `<style>` 을 넣는 구조로 바꾸면 CSS 블록이 오탐으로
167
- 잡히니 그때는 스타일 블록을 먼저 제거하고 검사한다.
168
-
169
- ### 3.2 고정 px 폭 (경고)
170
-
171
- ```python
172
- fixed = re.findall(r'(?<![a-z-])width:\s*\d{3,}px', body)
173
- ```
174
-
175
- **lookbehind 가 핵심이다.** 없으면 `max-width:1080px`·`min-width:640px` 같은 정상 반응형 코드가 전부
176
- 걸린다 — `report.css` 에만 그런 선언이 **15개** 있고, lookbehind 를 붙이면 그중 남는 것은
177
- `table.verdict .c-m{width:118px}` 하나뿐이다(실측). 상시 오탐을 내는 검사는 곧 무시되고, 무시되는 검사는
178
- 검사가 아니다.
179
-
180
- 현재 이 검사가 0건인 것은 뷰 B 가 head 의 `<style>` 을 제외하기 때문이기도 하다. 즉 **지금 잡는 것은
181
- 본문의 인라인 `style` 속성뿐**이다. 검사 범위를 `doc` 으로 넓히려면 lookbehind 가 반드시 있어야 한다.
182
-
183
- 경고인 이유: HTML 은 pptx 와 달리 리플로우되므로 결과가 "잘림"이 아니라 "가로 스크롤"이다. 진짜 방어는
184
- CSS 쪽(`minmax(0,1fr)`, `.tbl-wrap{overflow-x:auto}`, `word-break:keep-all`)에 있고 이 검사는 보조 지표다.
185
-
186
- ### 3.3 색 대비 — 17쌍 (필수)
187
-
188
- `palette.audit(tok)` 이 돌려주는 17쌍을 그대로 옮겨 찍는다. **`qa.py` 에는 색도, 대비 임계값도, 쌍 목록도
189
- 없다.** 기준의 정본은 `palette.py` 한 곳이다.
190
-
191
- ```python
192
- for name, fg, bg, r, need, good in P.audit(tok):
193
- (ok if good else err).append(f'대비 {name}: {r}:1 (기준 {need})')
194
- ```
195
-
196
- 17쌍은 본문 4종(ink × paper/surface/primary-bg/accent-bg), 보조 2종, 링크 2종, 칩 3종, 강조 2종,
197
- 큰 활자 1종(`accent-mid/paper` 3:1), 표지 3종이다. **`need` 가 3.0 인 것은 `accent-mid` 하나뿐**이고,
198
- 그 하나는 `h2.sec .no{font-size:clamp(26px,3vw,40px);font-weight:700}` — 즉 최소 26px Bold 라
199
- WCAG large text(18.66px+Bold 또는 24px) 요건을 넘긴다. 나머지는 전부 4.5:1 이다.
200
-
201
- > **기본은 4.5, 3.0 은 예외다.** 새 쌍을 추가하며 3.0 을 쓰려면 `palette.py` 의 `audit()` 에
202
- > **실제 `font-size`(clamp 이면 최솟값)와 `font-weight` 를 주석으로** 남겨라. Bold 라는 사실만으로는
203
- > 3:1 이 허용되지 않는다 — 크기 경계를 함께 넘어야 한다.
204
-
205
- 이 검사가 왜 있는지는 `palette.py` docstring 이 기록해 뒀다: Korean Navy 의 Gold `#C9A961` 는 흰 바탕에서
206
- **2.25:1** 이라 글자로 쓰면 WCAG 를 통과하지 못한다. 화면으로는 "좀 연하네" 정도로만 보인다.
207
- 그래서 지금은 색을 손으로 고르지 않고 유도하며, 9개 팔레트 전부가 `audit()` 를 통과한다
208
- (`python3 assets/palette.py` 로 확인 가능).
209
-
210
- **실패의 의미**: 유도기를 고쳤거나, 산출물의 `:root` 가 손으로 편집됐거나, `palette` 항목에 6색 딕셔너리를
211
- 직접 써넣었는데 그 조합이 기준을 못 맞춘 것이다. 색을 눈대중으로 바꾸지 말고 `palette.py` 의 유도 규칙을
212
- 고쳐라.
213
-
214
- **두 가지 하드 실패 모드**(실측 확인):
215
-
216
- | 사고 | 결과 |
12
+ | 무엇 | 어디서 |
217
13
  |---|---|
218
- | `:root {` 표기가 달라짐(예: `:root{`) | `❌ :root 토큰을 찾지 못함` + 팔레트 밖 색 경고가 16개 쏟아진다(허용 목록이 비어서) |
219
- | `audit()` 이 참조하는 토큰이 `:root` 에 없음 | `KeyError` 로 **검수기가 죽는다** — 조용히 통과하지 않는다 |
220
-
221
- 둘 다 "조용한 통과"보다 낫다. **`:root {` 의 공백 한 칸은 `build.py` 와 `qa.py` 사이의 계약**이다.
222
-
223
- ### 3.4 역할 위반 — 괘선 전용 색이 글자로 샜는가 (필수)
224
-
225
- ```python
226
- leak = re.findall(r'(?<![a-z-])color:\s*var\(--mid\)', doc)
227
- leak2 = re.findall(r'(?<![-a-z])color:\s*var\(--accent\)(?![-a-z])', doc)
228
- ```
229
-
230
- 팔레트에는 **본문에 쓰면 안 되는 색이 반드시 하나 이상 있다.** 이 문서 체계에서는 둘이다.
231
-
232
- | 토큰 | 정체 | 왜 금지 |
233
- |---|---|---|
234
- | `--mid` | 팔레트 원본의 Mid 회색(`#B0AEA5`) | 흰 바탕 2.3:1. 회색이라 캡션 자리에 손이 간다 |
235
- | `--accent` | 팔레트 원본의 Secondary | 배경·괘선 기준으로 고른 색이지 글자 기준이 아니다 (Navy 의 Gold 는 2.25:1) |
236
-
237
- 두 정규식의 lookaround 는 오탐 방지가 목적이다.
238
- - 앞의 `(?<![a-z-])` 는 `border-color:var(--mid)`·`border-left-color:var(--accent)` 를 **정상 사용으로
239
- 인정**한다(실제로 `.toc-g a:hover{border-left-color:var(--accent)}` 가 쓰인다).
240
- - 뒤의 `(?![-a-z])` 는 `var(--accent-ink)`·`var(--accent-mid)` 를 위반으로 잡지 않게 한다.
241
- **이게 없으면 정상 글자색이 전부 실패로 뜬다.**
242
-
243
- `--mid` 는 죽은 토큰이 아니라 **일부러 남겨 둔 지뢰**이고 이 검사는 그 위의 철사다. 팔레트를 온전히 옮겨
244
- 적으면 언젠가 누군가 그 토큰을 글자에 쓴다. 그때 잡으려고 둘 다 남겼다.
245
-
246
- > **왜 4-b(팔레트 밖 색)와 합치면 안 되나.** `#B0AEA5` 는 **팔레트 안의 색**이다. 본문 글자색으로 써도
247
- > 팔레트 검사는 무죄를 낸다. **팔레트 준수와 대비 준수는 직교한다.** 반대로 이 검사는 토큰 **이름**만 보므로
248
- > 누가 `color:#B0AEA5` 로 하드코딩하면 못 잡는다 — 그건 4-b 가 잡는다. 둘 다 필요하다.
249
-
250
- ### 3.5 팔레트 밖 하드코딩 색 · rgb/rgba (경고 · 판단 필요)
251
-
252
- ```python
253
- allowed = {v.lstrip('#').upper() for v in tok.values() if v.startswith('#')} | {'FFFFFF', '000000'}
254
- ```
255
-
256
- **허용 목록을 손으로 관리하지 않는다.** 그 문서의 `:root` 에 실제로 박힌 hex 가 곧 팔레트다. 팔레트를
257
- 바꿔 다시 빌드하면 허용 목록도 자동으로 따라간다. 3자리 hex(`#fff`)는 6자리로 정규화해 함께 검사하고,
258
- `--nav-w` 처럼 hex 가 아닌 토큰은 자동으로 걸러진다.
259
-
260
- **이 검사가 필요한 이유는 인라인 SVG 다.** CSS 변수는 `<style>` 안에서만 편하고, 손으로 SVG 를 그리다
261
- `#666` 이나 `#4A90D9` 같은 "그냥 회색/파랑"을 찍는 순간 문서에 두 번째 디자인 언어가 생긴다.
262
- 현 다이어그램(`figure.html`)은 이 함정을 **`fill`/`stroke` 를 전부 `var()` 로 쓰는 것**으로 피했다 —
263
- 인라인 SVG 는 사용자 정의 속성을 상속하므로 팔레트를 바꾸면 도해 색도 따라온다. 실측:
264
- 색을 지정한 `fill` 18개와 `stroke` 9개가 **전부** `var(--primary)`·`var(--accent-chip)`·`var(--muted)`
265
- 형태이고, 나머지 `fill="none"` 4개를 빼면 하드코딩 hex 가 하나도 없다.
266
-
267
- **rgb/rgba 는 알파 때문에 팔레트 대조가 불가능**하므로 위반 판정을 하지 않고 **건수만 경고로 띄운다.**
268
- 사람이 하나씩 열어 보고 판단하라는 뜻이다.
269
-
270
- > **실사례 — 현재 남아 있는 경고 1건의 정체.** `⚠️ rgb/rgba 리터럴 2건` 은 `report.css` 의 둘이다.
271
- > · `.cover dl{border-top:1px solid rgba(255,255,255,.22)}` — 어떤 팔레트든 Primary 배경 위 흰색 반투명이라 **안전**.
272
- > · `.r-open .xref{border-bottom-color:rgba(111,87,33,.35)}` — `#6F5721` 은 **일반화 이전 팔레트의 손으로 고른 금색**이다.
273
- > 지금 korean-navy 가 유도하는 `--accent-ink` 는 `#876D30` 이라 **값이 이미 어긋나 있고**, 팔레트를 바꾸면 더 어긋난다.
274
- > hex 로 썼다면 4-b 가 잡았을 색이 rgba 라서 경고로만 남은 것 — **경고 목록을 실제로 열어 봐야 하는 이유의 표본이다.**
275
-
276
- 경고를 해소하는 방향은 팔레트 토큰으로 되돌리는 것이다(투명도가 꼭 필요하면 `color-mix()` 를 쓰거나
277
- `--primary-hair` 처럼 **유도된 옅은 톤 토큰**을 하나 더 만든다). 손으로 rgba 를 다시 찍지 마라.
278
-
279
- ### 3.6 폰트 화이트리스트 (경고)
280
-
281
- pptx 는 `fontFace` 하나를 검사하지만 HTML `font-family` 는 **스택**이다. 그래서 콤마로 쪼개
282
- **폴백 전원**을 검사한다. `FONT_ALLOW` 21종에 `system-ui`·`sans-serif`·`serif`·`monospace` 같은
283
- 일반 키워드와 `-apple-system`·`BlinkMacSystemFont` 가 들어 있는 건 그 귀결이다.
284
-
285
- **경고인 이유**: 새 폰트를 의도적으로 도입할 수 있다. 다만 화이트리스트는 "시스템 폰트만 허용"이라는
286
- 뜻이 **아니다.** 진짜 규칙은 **"외부 요청 0 을 지키면서 렌더가 결정론적일 것"** 이고, 그 해법은
287
- (a) 시스템·선탑재 폰트로만 스택을 짜거나 (b) `data:` URI 로 임베드하는 것이다.
288
- **이 커플링을 모르면 다음 사람이 화이트리스트에 Google Fonts 이름을 넣고 `@import` 로 불러 검사 12를 깬다.**
289
-
290
- ### 3.7 폰트 임베드 (필수 — `font.embed` 가 켜져 있을 때만)
291
-
292
- ```python
293
- m = re.search(r'@font-face \{[^}]*format\("([^"]+)"\)', doc)
294
- ```
295
-
296
- 두 줄만 본다: `@font-face` 가 존재하는가, `format` 문자열이 `woff2` 인가.
297
- `"woff2-variations"` 는 폐기된 표기라 인식 못 하는 브라우저가 `src` 를 통째로 건너뛰고 **조용히** 대체
298
- 폰트로 떨어진다 — 그래서 문자열 하나를 필수 검사로 둔다.
299
-
300
- `report.json` 의 `font.embed: false` 면 이 검사 자체를 건너뛴다(시안 반복 중 임베드를 끄는 용도).
301
-
302
- **임베드가 실제로 렌더에 먹었는지는 이 검사기가 판정하지 못한다.** 폰트 설계·서브셋·브라우저 콘솔
303
- 확인법은 전부 `references/font-embedding.md` 에 있다. 여기서 반복하지 않는다.
304
-
305
- ### 3.8 구조 일관성 — 눈으로는 절대 못 잡는 사고 (필수)
306
-
307
- ```python
308
- n = len(re.findall(r'<article class="case"', body))
309
- for pat, label, adj in [(r'class="num"', '절 번호', 0), (r'class="row r-sym"', '증상', 0),
310
- (r'class="row r-cause"', '원인', 0), (r'class="row r-fix"', '조치', 0),
311
- (r'class="row r-check"', '확인 방법', 0)]:
312
- c = len(re.findall(pat, body)) - adj
313
- (ok if c == n else err).append(f'{label} {c}/{n}')
314
- chips = sum(x.count('class="method "')
315
- for x in re.findall(r'<div class="case-top">.*?</div>', body, re.S))
316
- used = len({c for c in re.findall(r'<td class="c-m"><span class="method [a-z-]+">([^<]+)<', body)})
317
- legend_n = len(re.findall(r'<div class="lg">', body))
318
- ```
319
-
320
- **`n` 을 문서에서 세어 기준으로 삼는 것이 핵심이다.** 상수 18을 박지 않았으므로 건수가 바뀌어도 검사가
321
- 살아 있다. 매체 무관한 보편 원칙이다.
322
-
323
- **왜 필요한가.** `build.py` 의 `row()` 는 값이 비면 예외도 빈 칸도 없이 블록을 통째로 생략하고,
324
- CSS 가 그 생략을 시각적으로 덮는다 — `.ck` 는 두 칸 그리드지만 한 칸만 있으면 `.ck.one` 으로
325
- 한 칸 그리드가 되어 **레이아웃이 멀쩡해 보인다.** 18건 × 5요소 = 90개 블록 중 하나가 없는 것을 사람이
326
- 세는 건 불가능하다.
327
-
328
- **필수 목록에 무엇을 넣는가 — 데이터가 결정한다.** 실사례 `cases.json` 18건 집계:
329
-
330
- | 필드 | 채워진 건수 | 검사 목록 |
331
- |---|---|---|
332
- | `group` / `title` / `grade` / `symptom` / `cause` / `fix` / `how_to_check` | **18/18** | **있음** |
333
- | `remaining`(→ `.r-open`) | 16/18 | 없음 |
334
- | `source_url`(→ `.ref`) | 9/18 | 없음 |
335
-
336
- > **규칙**: **모든 건에 반드시 있어야 하는 것만** 넣고, 그 판단은 실제 데이터 집계로 확인한다.
337
- > **어기면**: 선택 항목을 넣은 검사는 매번 실패한다. 사람은 곧 그 빨간 줄을 무시하고, **진짜 실패가 같이
338
- > 묻힌다.** 상시 실패하는 검사는 검사가 아니다.
14
+ | 발행 전 관문 | `publish_report` / `update_report` 의 `dry_run` — **warnings 를 읽는 것이 실질적 유일한 품질 관문이다** |
15
+ | 자동 조판 검사 | `~/.claude/skills/gazette-report/scripts/` 8종 |
16
+ | 어휘·톤 게이트 | `references/content-pipeline.md` |
17
+ | 사람의 눈 | `references/process.md` 말미 |
339
18
 
340
- **등급 칩은 뺄셈으로 세지 않는다 — 세는 범위를 좁힌다.** `class="method "` 는 세 맥락에서 재사용된다
341
- (건 헤더 18 + 범례 + 판정표 18). 예전 구현은 `40 − len(cfg['grades']) − n` 으로 뺐는데, 범례는
342
- **실제로 쓰인 등급만** 렌더하므로(`build.py`: `for k in grades if mc.get(k)`) 어느 등급이 0건이면
343
- 뺄셈이 조용히 틀어졌다. 실제로 등급 2종만 쓰는 최소 예제에서 `확인 등급 칩 0/2` 가 나왔다.
19
+ ## 검사기 8종
344
20
 
345
- 지금은 `<div class="case-top">` 구간 **안에서만** 센다. 범례·판정표의 칩이 애초에 집계에 들어오지
346
- 않으므로 틀어질 것이 없고, `qa.py` 는 `cfg['grades']` 를 아예 읽지 않는다.
347
- 안 쓰는 등급을 `grades` 에 남겨 두어도 QA 는 통과한다(실측 확인).
348
-
349
- > **일반 원칙**: 검사에 뺄셈 상수가 나타나면 그것은 **세는 범위가 잘못됐다는 신호**다.
350
- > 상수를 고치지 말고 범위를 좁혀라. 범례는 별도로 `범례 항목 {legend_n}/{used}` 로 본다.
351
-
352
- ### 3.9 목차·판정표가 본문과 일치하는가 (필수)
353
-
354
- ```python
355
- toc_n = len(re.findall(r'<li><a href="#s\d+-\d+"', doc))
356
- v_n = len(re.findall(r'<td class="c-sec">', body))
357
- ```
358
-
359
- 목차와 판정표는 **편집기가 실행 중 다시 만든다**(`syncToc`/`syncVerdict`). 이 검사는 편집기가 손대기 전
360
- **초기 상태**가 맞는지를 본다. 초기 상태가 틀리면 빌더의 조립이 어긋난 것이고, 초기 상태가 맞는데
361
- 브라우저에서 비면 편집기 선택자와 마크업이 어긋난 것이다(→ `references/wysiwyg.md` 의 마크업 계약).
362
-
363
- 빈 판정표는 특히 위험하다. `syncVerdict` 는 `table.verdict tbody` 를 통째로 덮어쓰므로,
364
- 선택자가 어긋나면 **표가 빈 값으로 덮인 채 저장된다.** 그래서 그 표에는 `data-rte-derived` 가 붙어 있고
365
- 편집기는 파생표에 행 편집 핸들을 붙이지 않는다 — 손으로 넣은 행이 다음 재생성에서 조용히 사라지는 것을
366
- 막는 장치다.
367
-
368
- ### 3.10 죽은 절 참조 (필수)
369
-
370
- ```python
371
- ids = set(re.findall(r'id="(s\d+-\d+)"', body))
372
- dead = sorted({h for h in re.findall(r'href="#(s\d+-\d+)"', doc)} - ids)
373
- ```
374
-
375
- 원고의 `§3.9` 표기는 `build.py` 의 `link()` 가 `<a href="#s3-9">3.9</a>` 로 바꾼다. 원고가 존재하지 않는
376
- 절을 가리키면(건 순서를 바꿨거나 건을 뺐거나) 링크가 죽는다. **검수자는 건과 건을 오가며 대조하므로**
377
- 죽은 링크는 그를 헤매게 만든다. 요약·확인요청·`text_fix` 치환문에 절 참조가 많을수록 자주 걸린다.
378
-
379
- ### 3.11 고객본 원칙 (필수)
380
-
381
- ```python
382
- (ok if '§' not in doc else err).append(...)
383
- for pat in cfg.get('forbid', []):
384
- hits = re.findall(pat, doc)
385
- ```
386
-
387
- **독자 = 고객사 검수 담당자.** 이 한 줄이 없으면 아래 목록은 자의적인 금칙어 모음으로 보인다.
388
-
389
- `§` 만 코드에 하드코딩돼 있다(원고 마커는 이 스킬의 조판 규약이므로 매체 상수다). 나머지는
390
- `report.json` 의 `forbid` 배열이다. 실사례:
391
-
392
- ```json
393
- "forbid": ["1217\\d{12}", "[Aa]sana", "운영 배포", "별도 티켓", "자동 검증 \\d건"]
394
- ```
395
-
396
- 네 범주로 일반화된다. 새 프로젝트는 **범주를 유지하고 내용물만 갈아끼운다.**
397
-
398
- | 범주 | 실사례 | 새 프로젝트에서 |
399
- |---|---|---|
400
- | I 조판 규약 위반 | `§` | 절 번호 표기 규칙의 **위반 형태**를 적는다 |
401
- | II 내부 식별자 | `1217\d{12}`(트래커 gid) | **ID 의 모양**을 정규식으로. Jira `[A-Z]{2,10}-\d+`, GitHub `#\d{1,6}`, Linear `[A-Z]{3}-\d+` |
402
- | III 내부 도구명 | `[Aa]sana` — **대소문자 양쪽** | Notion / Jira / Slack / Sentry / 사내 코드명 |
403
- | IV 내부 프로세스 어휘 | `운영 배포`, `별도 티켓`, `자동 검증 \d건` | 가장 프로젝트 의존적. 아래 절차로 뽑는다 |
404
-
405
- **금지만 있으면 글이 안 써진다.** 짝이 되는 치환 사전이 `report.json` 의 `text_fix` 다.
406
-
407
- ```json
408
- {"from": "2026-08-09 운영 배포를 마쳤고, …", "to": "2026년 8월 9일 운영 환경에 반영했고, …"}
409
- ```
410
-
411
- > **핵심 — 반드시 전달할 것.** `운영 배포` 는 금지지만 **`운영 환경에 반영` 은 허용이다.**
412
- > 이 검사는 "개념을 숨겨라"가 아니라 **"독자의 어휘로 말하라"** 를 강제한다.
413
- > 이 구분을 전하지 않으면 다음 사람이 **정보를 삭제해버린다.** 고객은 언제 고쳐졌는지 알 권리가 있다.
414
- > 지워야 할 것은 사실이 아니라 **내부 절차 명칭**이다. 대응 어휘가 없는 문장만 `"to": ""` 로 지운다
415
- > (실사례에서 지운 것: 설계 검토 이력, 내부 잔여 정리 — 고객 보고서의 서술 대상이 아닌 것들).
416
-
417
- **범주 IV 를 뽑는 절차**: ①초안을 검열 없이 쓴다 → ②"고객이 이 단어를 읽고 행동을 바꿀 수 있는가"로
418
- 분류한다(못 바꾸면 내부 어휘) → ③고객 어휘 대응쌍을 `text_fix` 에 넣는다(대응이 없으면 문장 삭제) →
419
- ④그 내부 어휘의 정규식을 `forbid` 에 등록 → ⑤다시 빌드해 0건 확인.
420
-
421
- **이중 방어 — 그리고 이번엔 두 목록이 갈라질 수 없다.**
422
-
423
- | 층 | 시점 | 실패 방식 |
424
- |---|---|---|
425
- | `build.py` | 조립 직후, 파일을 쓴 뒤 | `assert` — 즉시 중단 |
426
- | `qa.py` | 나갈 파일 대상 | `err` — 종료 코드 1 |
427
-
428
- 둘 다 **`report.json` 의 같은 `forbid` 배열**을 읽는다. 예전에는 빌드와 QA 가 각자 워드리스트를 들고 있었고
429
- 실제로 갈라졌다. 지금은 한 곳이다. **이식할 때 이 구조를 깨지 마라.**
430
-
431
- 내부 식별자는 그 위에 0차 방어가 하나 더 있다 — 실사례의 `cases.json` 에는 **`ticket_id` 필드 자체가
432
- 없다.** 데이터에 넣지 않는 것이 가장 확실하고, `forbid` 는 손 타이핑·붙여넣기로 흘러든 것을 잡는다.
433
-
434
- ### 3.12 카피 게이트 (필수)
435
-
436
- ```python
437
- heads = [태그를 벗긴 h1/h2/h3 텍스트]
438
- dash = [h for h in heads if ' — ' in h or ' – ' in h]
439
- slop = [s for s in SLOP_KO if s in text]
440
- ```
441
-
442
- **대시 금지는 헤딩 한정이다.** 슬롭 패턴은 "대시를 쓴다"가 아니라 **"헤드라인을 대시로 두 동강 내어
443
- 대비를 만든다"** 이다. 본문의 `해당 절 — 3.7 · 3.8` 같은 용법은 이 문서의 확립된 조판 문법이므로,
444
- 범위를 본문으로 넓히면 하우스 스타일을 파괴한다. **스코프를 헤딩으로 유지하라.**
445
-
446
- `SLOP_KO` 8종(`혁신적인`, `차세대`, `재정의하는`, `한 차원 높은`, `최적화된 솔루션`, `완벽한 경험`,
447
- `새로운 패러다임`, `고도화된`)은 뷰 C(태그 벗긴 텍스트)에서만 찾는다 — 클래스명·속성값 오탐 방지.
448
-
449
- > **자동화되지 않은 것 — 착각하지 말 것.** 상위 스킬의 구조적 슬롭 3종 중 자동화된 것은 **대시 헤드라인
450
- > 하나뿐이다.** `SKILL.md` 의 카피 게이트 절이 "'A에서 B로' 전환 공식도 검수기가 잡는다"고 읽히지만,
451
- > 실제 코드에는 그 검사가 없다. 정규식으로 근사하려면 `[가-힣]+에서 [가-힣]+로` 정도인데 한국어 정상
452
- > 문장에도 흔해 오탐이 크다 — 넣더라도 `warn` 이상으로 올리지 마라. **사람이 본다(§5.6).**
453
- > 세 번째 패턴(조사·체언 종결 조각문)은 이 스킬에서 **의도적으로 완화**했다. 목차 항목과 건 제목이
454
- > 명사구로 끝나는 것은 한국어 보고서의 관례다.
455
-
456
- ### 3.13 접근성 · 단일 파일 (필수)
457
-
458
- | 검사 | 정규식 | 못 잡는 것 |
459
- |---|---|---|
460
- | `lang` 선언 | `lang="{cfg.lang}"` 문자열 존재 | — |
461
- | alt 없는 img | `<img(?![^>]*\balt=)[^>]*>` | 인라인 SVG 의 `<title>`/`aria-label` 유무는 안 본다 |
462
- | 외부 리소스 | `(?:src\|href)="(https?://…)"` − `allow_external` | CSS `url(https://…)`, `@import`, 프로토콜 상대 `//cdn…`, `srcset`, **작은따옴표 속성값** |
463
-
464
- **`allow_external` 예외의 원칙이 중요하다.** 실사례는 `figma.com` 하나다 — *"고객이 아는 참조는 본인이
465
- 제보한 원본"* 이기 때문이다. **"고객이 이미 접근권을 가진 시스템"만** 예외로 허용하고 그 이유를 적어라.
466
- CDN·폰트·분석 스크립트는 어떤 이유로도 예외가 아니다. 메일 첨부·사내망·오프라인에서 죽는다.
467
-
468
- `data:` URI 는 `src`/`href` 절대 URL 패턴에 걸리지 않으므로 폰트 임베드와 이 검사는 정상적으로 양립한다.
469
-
470
- ### 3.14 고객본 내보내기 시뮬레이션 (필수)
471
-
472
- ```python
473
- cust = re.sub(r'<!-- REPORT-EDITOR:BEGIN -->.*?<!-- REPORT-EDITOR:END -->', '', doc, flags=re.S)
474
- cust = re.sub(r'<(style|script)\b[^>]*\bdata-rte-src\b.*?</\1>', '', cust, flags=re.S)
475
- cust = re.sub(r'\sdata-report-edit="[^"]*"', '', cust, count=1)
476
- cust = re.sub(r'<[^>]*\bdata-internal="true".*?</\w+>', '', cust, flags=re.S)
477
- residue = [w for w in ('data-rte-ui', 'showSaveFilePicker', 'syncVerdict',
478
- 'data-report-edit', 'data-internal="true"') if w in cust]
479
- ```
480
-
481
- **`editor.js` 의 `serialize(customer=true)` 와 같은 규칙을 정규식으로 재현해 결과를 검사한다.**
482
- 브라우저를 띄우지 않고 "고객본이 정말 깨끗한가"를 판정하는 유일한 수단이다.
483
-
484
- 이 검사가 있는 이유는 실제 사고다. **표식을 안 붙이면 내보내기 코드는 조용히 아무것도 지우지 못하고,
485
- 고객본에 편집기가 그대로 실려 나간다.** 그래서 `build.py` 는 편집 레이어를 두 겹으로 표시한다 —
486
- `<style data-rte-src>` / `<script data-rte-src>` 와 `<!-- REPORT-EDITOR:BEGIN/END -->` 주석.
487
-
488
- 두 번째 줄 `고객본 크기 … (원본 대비 N%)` 는 **"정말 무언가 지워졌는가"** 를 보는 하한선이다.
489
- 기준은 98% 미만. 실사례는 94%(523,464B → 491,765B) 로 통과한다.
490
-
491
- 재현 확인:
492
-
493
- | 시나리오 | 결과 |
21
+ | 파일 | 무엇을 잡는가 |
494
22
  |---|---|
495
- | 정상 | `✅ 편집기 흔적 없음` · `✅ 94%` |
496
- | `<!-- REPORT-EDITOR -->` 주석만 훼손 | 여전히 통과 — `data-rte-src` 가 살아 있어 편집기는 제거된다(이중 표식의 효과) |
497
- | `data-rte-src` 속성 제거 | `❌ 흔적 5종 전부 잔존` · `❌ 100%` |
498
-
499
- > **주의 — 98% 는 휴리스틱이다.** 실사례에서 base64 폰트가 파일의 **77%** 를 차지하므로 편집기 31KB 는
500
- > **5.9%** 다(여유 있게 통과). 임베드를 끄면 편집기 비중이 커져 더 안전해지지만, 반대로 **대용량 이미지를
501
- > 넣은 문서**에서는 편집기가 2% 미만이 되어 이 줄이 오탐할 수 있다. 그때는 "지워진 절대 바이트 수"로 바꿔라.
502
-
503
- ---
504
-
505
- ## 4. 검사가 실제로 잡는지 확인한 기록
506
-
507
- **돌려서 전부 통과가 나오면 일부러 하나 깨뜨려 본다. 잡는 것을 확인하지 않은 검사는 검사가 아니다.**
508
- 실사례 산출물을 훼손해 실측한 결과:
509
-
510
- | 훼손 | 검수기 반응 |
511
- |---|---|
512
- | `<div class="row r-check">` 하나를 `class="row XX"` 로 | `❌ 확인 방법 17/18` |
513
- | `.ev` 배경을 `#4A90D9` 로 하드코딩 | `⚠️ 팔레트 밖 하드코딩 색 ['4A90D9']` |
514
- | 속성값에 `§` 한 글자 삽입 | `❌ 원고 마커 § 1건` |
515
- | 본문에 `1217296144360763 혁신적인 접근` 추가 | `❌ 금지 표현 /1217\d{12}/ 1건` · `❌ AI 슬롭 어휘 ['혁신적인']` |
516
- | `:root` 에서 `--muted` 한 줄 삭제 | `KeyError: 'muted'` — 검수기가 죽는다(조용한 통과 아님) |
517
- | `:root {` → `:root{` | `❌ :root 토큰을 찾지 못함` + 팔레트 경고 16건 |
518
- | 어느 건도 안 쓰는 등급을 `grades` 에 추가 | **통과** — 칩을 `case-top` 안에서만 세므로 영향 없음 |
519
-
520
- ---
521
-
522
- ## 5. 사람이 봐야 하는 것 — 시각 검수 목록
523
-
524
- 자동 검수 48줄이 전부 초록이어도 **아래는 하나도 검증되지 않았다.** 브라우저에서 파일을 더블클릭해
525
- 위에서부터 훑는다.
526
-
527
- ### 5.1 화면 (기본 폭, 1440px 안팎)
528
-
529
- - [ ] **표지** — 제목이 22ch 안에서 자연스럽게 접히는가. 부제가 4줄을 넘지 않는가. 메타 4칸의 값이 비어
530
- 보이지 않는가.
531
- - [ ] **좌측 고정 목차** — 스크롤할 때 따라오는가. 항목이 넘쳐 잘리지 않는가(`max-height:100dvh`).
532
- 목차 클릭 → 해당 절로 이동하는가.
533
- - [ ] **지표 밴드** — 숫자 자릿수가 흔들리지 않는가(`tabular-nums`). 단위(`건`/`가지`)가 숫자에 붙어 보이는가.
534
- - [ ] **등급 칩** — 네 등급이 서로 구분되는가. 특히 `m-code`(테두리만 있는 칩)가 **배경에 묻혀 안 보이지
535
- 않는가.** 자동 검수는 칩의 개수만 세지 보이는지는 모른다.
536
- - [ ] **빈 블록** — 목록 항목이 비어 있거나 `<p>` 가 빈 채로 남은 곳이 없는가.
537
- - [ ] **한국어 줄바꿈** — 낱말이 한가운데서 갈라진 곳이 없는가(`word-break:keep-all` 이 있어도 긴 영문·URL 은
538
- `overflow-wrap:anywhere` 로 쪼개진다 — 그건 정상).
539
- - [ ] **다이어그램** — SVG 안 글자가 도형 밖으로 나가거나 잘리지 않는가. **팔레트를 바꿔 다시 빌드했다면
540
- 도해 색이 따라왔는가**(`var()` 상속 확인).
541
- - [ ] **표** — 판정표가 가로로 넘칠 때 `.tbl-wrap` 안에서만 스크롤되는가(페이지 전체가 흔들리면 안 된다).
542
- - [ ] **일러스트** — 아래 5.1a 로 따로 본다. **이 목록에 일러스트 항목이 없어서 2026-08-17 까지
543
- 정본과 정반대인 그림이 세 판 연속 통과했다.**
544
-
545
- ### 5.1a 일러스트 (자산이 있는 문서)
546
-
547
- 기계가 먼저 판정한다 — `python3 ~/.claude/skills/genetics-report/assets/visual-baseline.py check --base <기준선>.json <png>...`
548
- (그 프로젝트에 호출기가 있으면 그것을 쓴다. 기준선 뜨는 법은 `illustrations.md` 3절.)
549
- 밴드는 **참조 세트에서 뜬 것**이고 **손으로 고치지 않는다.** 다른 고객사·다른 팔레트면
550
- 자기 기준선을 뜬다(`freeze --ref … --palette … --base …`) — 강조색이 다르면 상수로 박힌
551
- 판정은 전량 오판정한다.
552
-
553
- - [ ] **정본과 나란히 놓았는가** — 새 그림을 정본 8장 옆에 붙여 본다. 이 한 줄이 이번 사고의
554
- 전부다. 계약 문장만 읽고 만들면 「막대 하나 + 인물 둘」이 나온다.
555
- - [ ] **조형 문법이 살아 있는가** — 아이콘 든 **원형 노드**, **점선** 연결선, 검은 원 ✕ / 라임 원 ✓
556
- 배지. 정본은 도식이고 우리 그림도 도식이어야 한다.
557
- - [ ] **라임이 여러 곳에 작게** 있는가 — 옷 이너 · ✓ 배지 · 인물 실루엣 · 발밑 그림자. 큰 띠 하나로
558
- 칠해져 있으면 실패다(면적이 맞아도 문법이 틀렸다).
559
- - [ ] **선이 충분한가** — 기계가 `Ink` 로 잰다(밴드는 기준선 파일이 정한다). 미달이면 화면이 텅 빈 것이다.
560
- 2026-08-17 실측: 잘못된 프롬프트로 만든 6장이 **0.014–0.975%** 로 최대 116배 미달이었다.
561
- - [ ] **글자·숫자 0개** — hero·챕터 전부. 벡터면 `<text>` 개수를 센다.
562
- - [ ] **그림이 자기 칸을 채우는가** — 글과 그림은 하나의 테두리 안에서 **좌우로 따로**
563
- 선다(계약 4.2절 v1.4). 그림 위에는 아무것도 앉지 않으므로 한쪽을 비울 이유가 없다.
564
- 반쪽이 빈 그림은 칸의 절반을 죽인다.
565
- - [ ] **좌우 교차가 맞는가** — 홀수 장 글 왼쪽 / 짝수 장 글 오른쪽(정본 HTML 실측).
566
- - [ ] **세트 일관성** — 인물 디자인·선 굵기·채색량이 7장 내내 같은가. 기계가 못 잰다.
567
- - [ ] **같은 그림 재사용 0건** — 계약 §7 「챕터 이미지 재사용」 금지.
568
-
569
- ### 5.2 편집기 (`editor: true` 일 때)
570
-
571
- - [ ] 블록에 마우스를 올리면 **오른쪽 여백**에 핸들이 뜨는가. 창이 좁을 때 블록 위로 올라가는가.
572
- - [ ] 절을 위/아래로 옮긴 뒤 **절 번호 · 목차 · 판정표가 셋 다** 따라 바뀌는가.
573
- - [ ] 건 제목(`h3`)을 고치고 포커스를 빼면 목차와 판정표의 제목이 같이 바뀌는가.
574
- - [ ] **판정표에는 `행+`/`행−` 버튼이 나타나지 않아야 한다**(`data-rte-derived`). 나타나면 손으로 넣은 행이
575
- 다음 재생성에서 사라진다.
576
- - [ ] `내부` 토글 → 빗금 배경과 "내부 전용" 배지가 뜨는가 → `고객본 미리보기` 로 사라지는가.
577
- - [ ] 한글 입력 중(IME 조합 상태)에 `Cmd+S`·`Esc` 가 오작동하지 않는가.
578
- - [ ] `저장` 이 **원본 파일 자체**를 덮어쓰는가. 파일 선택 창을 지원하지 않는 브라우저에서는 내려받기로
579
- 떨어지는가(그때는 원본에 직접 덮어써야 한다).
580
-
581
- ### 5.3 고객본 — 내보낸 **그 파일**을 다시 검수한다
582
-
583
- `data-internal` 로 블록을 지웠다면 마스터는 18/18 인데 고객본은 17/18 일 수 있다.
584
- **구조 검사는 고객본에서 다시 돌려야 한다.**
585
-
586
- ```bash
587
- cp report.json report-cust.json # "out" 만 고객본 파일명으로 바꾼다
588
- python3 ~/.claude/skills/genetics-report/assets/qa.py report-cust.json
589
- ```
590
-
591
- 고객본에서는 **다음 두 줄이 원래 실패·경고한다**(실측 확인). 나머지 48줄이 통과하는지를 본다.
592
-
593
- ```
594
- ❌ 고객본 크기 … (원본 대비 100%) ← 이미 편집기가 없으니 더 지울 게 없다. 정상
595
- ⚠️ 편집기 CSS 마커 «…» 를 못 찾음 ← 편집 레이어가 제거됐다는 증거. 정상
596
- ```
597
-
598
- 그리고 **눈으로**:
599
-
600
- - [ ] 하단 편집 바·핸들이 **완전히 사라졌는가**(꺼진 게 아니라 없어야 한다).
601
- - [ ] 내부 전용 블록이 빠진 자리에 문맥 구멍이 생기지 않았는가 — 앞 문장이 없는 것을 가리키지 않는가.
602
- - [ ] 파일을 텍스트 에디터로 열어 검색: `data-rte`, `showSaveFilePicker` 가 0건인가(자동 검사와 중복이지만,
603
- 실제로 내보낸 파일에 대해서는 사람이 한 번 확인한다).
604
-
605
- ### 5.4 인쇄 · PDF (`Cmd+P`)
606
-
607
- - [ ] 표지가 **흰 배경으로 반전**되는가(잉크 절약 — `@media print` 가 처리). 좌측 강조선만 남는가.
608
- - [ ] 목차 뒤에서 페이지가 나뉘는가(`break-after:page`).
609
- - [ ] 건·확인요청 카드가 페이지 경계에서 **반으로 잘리지 않는가**(`break-inside:avoid`).
610
- - [ ] 링크 뒤에 URL 이 괄호로 따라붙는가(`.ref a::after`). 그 URL 이 페이지 밖으로 넘치지 않는가.
611
- - [ ] 폰트 임베드가 PDF 에도 실렸는가 — 화면과 줄바꿈 위치가 같은가.
612
-
613
- ### 5.5 반응형
614
-
615
- 브레이크포인트에서 한 번씩 본다: **1080px**(목차가 위로 올라가며 2열이 된다), **900px**, **860px**
616
- (건의 2열 그리드가 1열), **760px**, **620px**.
617
-
618
- - [ ] 어느 폭에서도 **페이지 본문이 가로로 스크롤되지 않는가.**
619
- - [ ] 목차가 위로 갔을 때 본문 첫 절을 가리지 않는가.
620
-
621
- ### 5.6 카피 · 사실 — 기계가 원리적으로 못 하는 것
622
-
623
- - [ ] **등급이 정직한가.** 확인 못 한 것을 확인한 것처럼 쓰지 않았는가. 검수기는 등급 문자열을 셀 뿐
624
- 내용의 진위를 모른다. **낮아 보이는 게 싫어 올린 한 건이 문서 전체의 신뢰를 깎는다.**
625
- - [ ] **"확인 방법"이 고객이 자기 화면에서 밟을 수 있는 순서인가.** 내부 도구·관리자 화면을 전제하고 있지
626
- 않은가. 이게 아니면 검수 요청이 아니라 통보다.
627
- - [ ] **"A에서 B로" 전환 공식**이 제목·리드에 숨어 있지 않은가(§3.12 — 자동화되지 않았다).
628
- - [ ] **요약 4칸의 리드 문장과 확인 요청의 제목이 완전문인가.** 여기가 판단이 일어나는 자리다.
629
- - [ ] **숫자·날짜·건수**가 본문과 지표 밴드와 요약에서 서로 어긋나지 않는가. 검수기는 `18/18` 처럼
630
- **개수의 일치**만 보지 값의 진위는 모른다.
631
- - [ ] **`source_url` 링크가 실제로 열리는가, 그리고 고객에게 권한이 있는가.**
632
- - [ ] **파괴적 동작 안내에 결과가 함께 적혀 있는가**(예: "[발송] 버튼은 실제로 고객에게 알림톡이 나갑니다").
633
- - [ ] **독자에게 읽어 보게 한다.** 톤·청중 적합성은 기계가 판정할 수 없다.
634
-
635
- ---
636
-
637
- ## 6. 새 프로젝트로 옮길 때
638
-
639
- ### 6.1 `report.json` 에서 바꾸는 것 (대부분 여기서 끝난다)
640
-
641
- | 키 | 무엇을 넣나 |
642
- |---|---|
643
- | `forbid` | §3.11 의 4범주로 재작성. **빌드와 QA 가 함께 읽는다** |
644
- | `text_fix` | `forbid` 와 **짝**이다. 금지만 넣고 대응 어휘를 안 만들면 다음 사람이 문장을 지운다 |
645
- | `allow_external` | "고객이 이미 접근권을 가진 시스템"만. 이유를 함께 남겨라 |
646
- | `grades` | 등급 라벨·클래스·설명. **안 쓸 등급은 정의하지 마라**(§3.8 오탐) |
647
- | `lang` | 문서 언어. 검사 12가 이 값으로 `lang="…"` 을 찾는다 |
648
- | `font.embed` / `file` / `family` | §3.7. `family` 를 바꾸면 `report.css` 의 `body{font-family:…}` 첫 항목도 같이 바꾼다 |
649
- | `palette` | 이름 하나. 대비 기준은 유도기가 보장한다 |
650
-
651
- ### 6.2 `qa.py` 안에서 바꾸는 것 (드물다)
652
-
653
- | 상수 | 언제 |
654
- |---|---|
655
- | `FONT_ALLOW` | 폰트 스택을 바꿀 때. **임베드 패밀리명 + 폴백 전원 + 일반 키워드**를 모두 등재 |
656
- | `SLOP_KO` | 언어·업종이 바뀔 때. 한국어면 그대로 계승 |
657
- | `EDITOR_CSS_MARK` | `editor.css` 첫 줄 주석을 바꿨을 때만. **못 찾으면 경고 후 파일 전체를 스캔**해 편집기 UI 색이 팔레트 위반으로 쏟아진다 |
658
- | 구조 셀렉터 | `build.py` 의 `case_html()` 을 바꿀 때만. 아래 마크업 계약 참조 |
659
-
660
- ### 6.3 마크업 계약 — 이 문자열들을 바꾸면 검사가 같이 죽는다
661
-
662
- `qa.py` 는 정규식으로 마크업을 센다. 아래는 **빌더·편집기·검수기 셋이 공유하는 계약**이다.
663
- 한쪽만 바꾸면 검사는 조용히 `0/18` 을 내거나(즉시 발각) **더 나쁘게는 통과한다.**
664
-
665
- ```
666
- <article class="case" class="num" class="method "
667
- class="row r-sym" class="row r-cause" class="row r-fix" class="row r-check"
668
- <td class="c-sec"> id="s{절}-{번}" href="#s{절}-{번}"
669
- :root { data-rte-src data-rte-derived
670
- <!-- REPORT-EDITOR:BEGIN/END --> data-internal="true"
671
- ```
672
-
673
- 바꿔야 한다면 `build.py` · `editor.js` · `qa.py` 를 **한 번에** 고치고, 고친 뒤 §4 처럼 일부러 깨뜨려
674
- 검사가 여전히 잡는지 확인한다.
675
-
676
- ### 6.4 그대로 두는 것 (매체·프로젝트 무관)
677
-
678
- | 구조 | 왜 보편인가 |
679
- |---|---|
680
- | **대비 기준을 `palette.py` 한 곳에 두고 QA 는 결과만 옮겨 찍기** | 임계값이 두 곳에 있으면 갈라진다. WCAG 4.5:1 / 3:1 은 협상 대상이 아니다 |
681
- | **`:root` 를 산출물에서 읽어 재기** | 빌드 이후의 편집을 잡는 유일한 방법 |
682
- | **허용 색 목록을 `:root` 에서 자동 도출** | 손으로 관리하는 팔레트 목록은 반드시 낡는다 |
683
- | **`ok`/`warn`/`err` 3버킷 + `exit(1 if err)`** | "필수는 막고 판단은 알린다" |
684
- | **끝까지 돌린 뒤 실패→경고→통과 순 출력** | 한 번에 전체 상태를 본다 |
685
- | **헤더 지문(파일명·바이트·건수)** | 옛 파일을 검사하고 통과했다고 믿는 사고를 막는다 |
686
- | **반복 단위 개수를 문서에서 세어 기준으로 삼기** | 건수 변화에 견디는 유일한 방법 |
687
- | **역할 위반 검사(괘선 전용 색이 글자로)** | 어떤 팔레트에나 "본문에 쓰면 안 되는 색"이 있고, 언젠가 샌다 |
688
- | **금지(QA) / 치환(빌드) 2단 구조를 한 설정 파일에** | 금지만 있으면 글이 안 써지고, 두 벌로 두면 갈라진다 |
689
- | **대시 헤드라인 금지 — 단, 헤딩 한정** | 범위를 넓히면 하우스 스타일을 파괴한다 |
690
- | **편집 레이어를 색 검사에서 도려내되 `split` 하지 않기** | 산출물에 도구가 동봉되는 구조라면 필수 |
691
- | **고객본 내보내기를 시뮬레이션해 검사하기** | 표식이 없으면 내보내기는 조용히 실패한다 |
692
-
693
- ---
23
+ | `kcheck.mjs` | 대비(알파·조상 opacity 합성) · 제목 레벨 · 표 접근명 · 앵커 · 7폭 넘침 |
24
+ | `read.mjs` | 한글 자폭 실측 글자/줄 · 행간 · 본문 대비 |
25
+ | `tone.mjs` | 경어체 잔존 · 늘어짐 지표 · **한 문장 한 줄** |
26
+ | `fontchk.mjs` | 렌더된 활자 크기·굵기 분포 |
27
+ | `flexcheck.mjs` | flex/grid 안에서 문장이 토막 나는 곳 |
28
+ | `cssaudit.sh` | 활자 스케일 · 간격 격자 · 표 마감 · 미봉책 |
29
+ | `pdfcheck.sh` | 실제 PDF 쪽 구조 |
30
+ | `contentdiff.sh` | 조판이 본문·id·앵커를 바꿨는지 |
694
31
 
695
- ## 7. 알려진 한계 — 통과가 "다 됐다"는 뜻이 아니다
32
+ 실행법과 설계 원리는 `gazette-report` 스킬에 있다. 여기 두 벌로 두지 않는다.
696
33
 
697
- 1. **등급 칩 뺄셈이 정의된 등급 수를 쓴다.** 안 쓰는 등급을 정의해 두면 오탐(§3.8). 재현 확인됨.
698
- 2. **외부 리소스 검사가 `src`/`href` 큰따옴표 절대 URL 만 본다.** CSS `url(https://…)`, `@import`,
699
- 프로토콜 상대 `//cdn…`, `srcset`, 작은따옴표 속성값은 못 잡는다. 빌더가 그런 걸 만들지 않으므로 현재는
700
- 충분하지만, 손으로 HTML 을 넣는 순간 구멍이 된다.
701
- 3. **rgb/rgba 는 판정하지 않고 세기만 한다.** 알파 때문에 팔레트 대조가 불가능하다. **경고 목록은 반드시
702
- 눈으로 연다** — 현재 2건 중 1건은 실제로 팔레트 밖 색이다(§3.5).
703
- 4. **플레이스홀더 검사는 뷰 B 한정.** 본문에 인라인 `<style>` 이 들어오면 CSS 블록이 오탐한다.
704
- 41자 이상 플레이스홀더도 통과한다.
705
- 5. **고객본 98% 는 휴리스틱**이다(§3.14).
706
- 6. **인라인 SVG 의 접근성은 검사하지 않는다.** `<img alt>` 만 본다.
707
- 7. **폰트가 실제로 렌더에 먹었는지는 검사하지 못한다.** `references/font-embedding.md` 의 콘솔 3줄로 잰다.
708
- 8. **슬롭 3종 중 2종, 톤·청중 적합성, 사실 검증은 전부 사람 몫**이다(§5.6).
34
+ ## 남긴 원리
709
35
 
710
- ---
36
+ **0표본 통과는 통과가 아니다.** 표본 수를 함께 본다.
711
37
 
712
- ## 8. 미확정
38
+ **검사가 통과해도 반드시 눈으로 연다.** 자동 검사는 안 보이는 결함을 잡고 눈은 보이는 결함을 잡는다.
39
+ 실제로 자동 검사를 다 통과한 문서에서 눈으로만 잡힌 것들이 있다 — 안 보이는 배지, 빈 목록 항목, 한국어 낱말 중간 줄바꿈.
713
40
 
714
- 1. **실제로 편집기에서 내보낸 고객본 파일에 검수기를 돌린 기록은 없다.** §5.3 의 결과는 `editor.js` 의
715
- `serialize(customer=true)` 와 같은 규칙을 스크립트로 재현해 얻은 것이다. 브라우저의 DOM 직렬화는
716
- 속성 순서·엔티티 표기가 원본과 미세하게 다를 수 있으므로, 실제 내보낸 파일에서 바이트 수치가 조금
717
- 달라질 수 있다. **판정 자체(구조 n/n, 잔존 표식)는 DOM 기준이라 영향받지 않는다.**
718
- 2. **9개 팔레트 중 실제 문서를 빌드해 본 것은 `korean-navy` 하나다.** `palette.audit()` 은 9개 전부
719
- 통과하지만, 다른 팔레트로 빌드했을 때 `report.css` 에 남은 rgba 2건(§3.5)이 어떻게 보이는지는 눈으로
720
- 확인하지 않았다.
721
- 3. **인쇄 검수는 브라우저 인쇄 미리보기 기준**이고, 실제 프린터 출력이나 다른 뷰어의 PDF 렌더는 확인하지
722
- 않았다.
723
- 4. **`FONT_ALLOW` 에 남아 있는 `구름 산스 코드`·`Menlo`·`ui-monospace` 는 현재 스택에서 쓰이지 않는다.**
724
- 상위 스킬 목록에서 계승한 것인지, 앞으로 쓸 여지를 둔 것인지는 코드만으로 판정할 수 없다. 지우면
725
- 화이트리스트가 좁아져 더 엄격해지지만, 지금 상태로도 실패를 만들지는 않는다.
41
+ **재는 판이 실제 판과 다르면 인과가 거짓으로 잡힌다.** 로컬 검증은 서버와 같은 구성으로 맞춘 뒤에 한다.