@qualisoft/ai-skills 1.0.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.
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/bin/cli.mjs +331 -0
- package/package.json +53 -0
- package/skills/erd-visual/SKILL.md +294 -0
- package/skills/erd-visual/build.mjs +404 -0
- package/skills/erd-visual/engine/dbml.mjs +133 -0
- package/skills/erd-visual/engine/ingest.mjs +273 -0
- package/skills/erd-visual/engine/layout.mjs +165 -0
- package/skills/erd-visual/engine/overview.mjs +314 -0
- package/skills/erd-visual/engine/render.mjs +144 -0
- package/skills/erd-visual/engine/router.mjs +401 -0
- package/skills/erd-visual/engine/verify.mjs +138 -0
- package/skills/erd-visual/engine/wire.mjs +115 -0
- package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
- package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
- package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
- package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
- package/skills/erd-visual/fixtures/src-json/api.json +38 -0
- package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
- package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
- package/skills/erd-visual/readers/csv.mjs +81 -0
- package/skills/erd-visual/readers/index.mjs +55 -0
- package/skills/erd-visual/readers/jsonschema.mjs +86 -0
- package/skills/erd-visual/readers/prisma.mjs +102 -0
- package/skills/erd-visual/readers/sql.mjs +205 -0
- package/skills/erd-visual/readers/xlsx.mjs +220 -0
- package/skills/erd-visual/readers/xml.mjs +130 -0
- package/skills/erd-visual/readers/zip.mjs +74 -0
- package/skills/erd-visual/viewer/index.html +539 -0
- package/skills/project-build/SKILL.md +171 -0
- package/skills/project-build/templates/build-rules.md +88 -0
- package/skills/project-build/templates/change-log.md +29 -0
- package/skills/project-build/templates/coverage.md +49 -0
- package/skills/project-build/templates/parity.html +375 -0
- package/skills/project-build/templates/theme.css +45 -0
- package/skills/project-design/MEDIUMS.md +104 -0
- package/skills/project-design/QUESTIONS.md +126 -0
- package/skills/project-design/SKILL.md +365 -0
- package/skills/project-design/templates/audit.html +689 -0
- package/skills/project-design/templates/change-log.md +53 -0
- package/skills/project-design/templates/design-rules.md +118 -0
- package/skills/project-design/templates/mockup-app.html +96 -0
- package/skills/project-design/templates/mockup-slides.html +82 -0
- package/skills/project-design/templates/mockup-web.html +45 -0
- package/skills/project-design/templates/styleguide.html +436 -0
- package/skills/project-design/templates/tokens.css +69 -0
- package/skills/project-design/templates/tone-options.html +138 -0
- package/skills/project-init/GUIDE.md +370 -0
- package/skills/project-init/RUNBOOK.md +192 -0
- package/skills/project-init/SKILL.md +382 -0
- package/skills/project-init/build.mjs +2898 -0
- package/skills/project-init/evals/RUBRIC.md +81 -0
- package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
- package/skills/project-init/evals/cases/conflicting.md +9 -0
- package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
- package/skills/project-init/evals/cases/vague-idea.md +7 -0
- package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
- package/skills/project-init/evals/cases/well-formed.md +28 -0
- package/skills/project-init/markdown.mjs +0 -0
- package/skills/project-init/modules/a11y.json +46 -0
- package/skills/project-init/modules/ai.json +77 -0
- package/skills/project-init/modules/audience.json +48 -0
- package/skills/project-init/modules/backend.json +129 -0
- package/skills/project-init/modules/brand.json +78 -0
- package/skills/project-init/modules/core.json +132 -0
- package/skills/project-init/modules/design.json +72 -0
- package/skills/project-init/modules/engineering.json +86 -0
- package/skills/project-init/modules/mobile.json +22 -0
- package/skills/project-init/modules/ops.json +122 -0
- package/skills/project-init/modules/process.json +104 -0
- package/skills/project-init/modules/product.json +37 -0
- package/skills/project-init/modules/ux.json +52 -0
- package/skills/project-init/modules/web.json +129 -0
- package/skills/project-init/presets/ai-product.json +12 -0
- package/skills/project-init/presets/internal-system.json +15 -0
- package/skills/project-init/presets/mobile-app.json +20 -0
- package/skills/project-init/presets/web-corporate.json +116 -0
- package/skills/project-init/schema.json +80 -0
- package/skills/project-init/templates/log.md +54 -0
- package/skills/project-init/templates/readme.md +62 -0
- package/skills/project-init/templates/reference.md +34 -0
- package/skills/project-init/templates/register.md +63 -0
- package/skills/project-init/templates/spec.md +46 -0
- package/skills/project-interview/INTERVIEW.md +221 -0
- package/skills/project-interview/SKILL.md +156 -0
- package/skills/project-interview/fixtures/brief.md +49 -0
- package/skills/project-interview/fixtures/decisions.md +13 -0
- package/skills/project-interview/fixtures/open-questions.md +9 -0
- package/skills/project-interview/templates/brief.md +179 -0
- package/skills/project-interview/templates/decisions.md +55 -0
- package/skills/project-interview/templates/open-questions.md +40 -0
- package/skills/project-interview/validate.mjs +49 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>시안 검수 — audit</title>
|
|
7
|
+
<!--
|
|
8
|
+
시안 자동 검수. Docs/_design/audit.html
|
|
9
|
+
|
|
10
|
+
MEDIUMS.md 의 검수 표를 **실행 가능한 규칙**으로 옮긴 것이다.
|
|
11
|
+
문장으로 적힌 기준은 지켜지지 않는다. 여기서 빨간 줄이 뜨면 눈에 보인다.
|
|
12
|
+
|
|
13
|
+
브라우저에서 그냥 열면 된다. 의존성 없음.
|
|
14
|
+
시안을 iframe 으로 각 폭에 띄우고 실제 계산된 스타일을 읽는다.
|
|
15
|
+
|
|
16
|
+
── 왜 명도비를 여기서 또 보는가 ──
|
|
17
|
+
styleguide.html 은 **토큰 쌍**의 대비만 잰다. 그런데 실제로 터지는 결함은
|
|
18
|
+
토큰이 아니라 합성 결과에서 난다:
|
|
19
|
+
· 검정 배경 위 검정 글자 (CSS 우선순위 사고)
|
|
20
|
+
· opacity 0.55 를 먹은 회색 글자
|
|
21
|
+
· 고정 글라스 헤더가 어두운 섹션 위로 지나갈 때
|
|
22
|
+
그래서 여기서는 **보이는 텍스트마다 실제 합성 전경색 × 실제 합성 배경색**을 잰다.
|
|
23
|
+
-->
|
|
24
|
+
<style>
|
|
25
|
+
:root{
|
|
26
|
+
--bg:#ffffff;--panel:#f7f7f5;--ink:#111111;--muted:#6b6b68;--line:#dededb;
|
|
27
|
+
--ok:#0f7b6c;--okbg:#e6f4ea;--bad:#b02f1c;--badbg:#fdecea;--warn:#8a6100;--warnbg:#fdf4e3;
|
|
28
|
+
}
|
|
29
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
30
|
+
body{margin:0;background:var(--panel);color:var(--ink);
|
|
31
|
+
font:15px/1.6 -apple-system,BlinkMacSystemFont,"Pretendard Variable",Pretendard,"Segoe UI","Apple SD Gothic Neo",sans-serif;
|
|
32
|
+
word-break:keep-all}
|
|
33
|
+
.wrap{max-width:1100px;margin:0 auto;padding:36px 24px 120px}
|
|
34
|
+
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.02em}
|
|
35
|
+
.sub{margin:0 0 28px;color:var(--muted);font-size:14px}
|
|
36
|
+
fieldset{margin:0 0 22px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;background:var(--bg)}
|
|
37
|
+
legend{padding:0 8px;font-size:12.5px;font-weight:600;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
|
|
38
|
+
label{display:block;margin:0 0 4px;font-size:12.5px;color:var(--muted)}
|
|
39
|
+
input[type=text]{width:100%;margin-bottom:14px;padding:9px 11px;border:1px solid var(--line);border-radius:8px;
|
|
40
|
+
font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--bg);color:var(--ink)}
|
|
41
|
+
input[type=text]:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
|
|
42
|
+
button{min-height:44px;padding:0 22px;border:0;border-radius:999px;background:var(--ink);color:#fff;
|
|
43
|
+
font-size:14px;font-weight:600;cursor:pointer}
|
|
44
|
+
button:disabled{opacity:.5;cursor:default}
|
|
45
|
+
button:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
|
|
46
|
+
|
|
47
|
+
.score{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 22px}
|
|
48
|
+
.tile{flex:1 1 150px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}
|
|
49
|
+
.tile b{display:block;font-size:23px;letter-spacing:-.02em}
|
|
50
|
+
.tile span{font-size:12px;color:var(--muted)}
|
|
51
|
+
.tile.pass b{color:var(--ok)} .tile.fail b{color:var(--bad)} .tile.warn b{color:var(--warn)}
|
|
52
|
+
|
|
53
|
+
.check{margin:0 0 14px;border:1px solid var(--line);border-radius:12px;background:var(--bg);overflow:hidden}
|
|
54
|
+
.check > summary{display:flex;align-items:center;gap:10px;padding:14px 18px;cursor:pointer;
|
|
55
|
+
font-size:14.5px;font-weight:600;letter-spacing:-.01em;list-style:none}
|
|
56
|
+
.check > summary::-webkit-details-marker{display:none}
|
|
57
|
+
.check > summary::before{content:"▸";color:var(--muted);font-size:11px;transition:transform .15s}
|
|
58
|
+
.check[open] > summary::before{transform:rotate(90deg)}
|
|
59
|
+
.badge{margin-left:auto;padding:3px 10px;border-radius:999px;font-size:11.5px;font-weight:600}
|
|
60
|
+
.badge.ok{background:var(--okbg);color:var(--ok)}
|
|
61
|
+
.badge.bad{background:var(--badbg);color:var(--bad)}
|
|
62
|
+
.badge.warn{background:var(--warnbg);color:var(--warn)}
|
|
63
|
+
.hint{margin:0;padding:0 18px 12px;font-size:12.5px;color:var(--muted);line-height:1.55}
|
|
64
|
+
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
65
|
+
th,td{padding:8px 10px;text-align:left;border-top:1px solid var(--line);vertical-align:top}
|
|
66
|
+
th{background:var(--panel);font-size:11px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);font-weight:600}
|
|
67
|
+
td.mono,th.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
68
|
+
tr.bad td{background:var(--badbg)}
|
|
69
|
+
tr.warn td{background:var(--warnbg)}
|
|
70
|
+
.swatch{display:inline-block;width:11px;height:11px;border-radius:3px;border:1px solid rgba(0,0,0,.15);
|
|
71
|
+
vertical-align:-1px;margin-right:5px}
|
|
72
|
+
.empty{padding:0 18px 16px;color:var(--ok);font-size:13px}
|
|
73
|
+
.frame{position:fixed;left:-10000px;top:0;border:0}
|
|
74
|
+
.note{margin:26px 0 0;padding:16px 18px;border-left:3px solid var(--line);background:var(--bg);
|
|
75
|
+
border-radius:0 10px 10px 0;font-size:13px;color:var(--muted);line-height:1.7}
|
|
76
|
+
.note b{color:var(--ink)}
|
|
77
|
+
</style>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<div class="wrap">
|
|
81
|
+
<h1>시안 검수</h1>
|
|
82
|
+
<p class="sub">MEDIUMS.md 의 검수 기준을 실제 렌더 결과로 판정합니다. 시안을 고칠 때마다 다시 돌리세요.</p>
|
|
83
|
+
|
|
84
|
+
<fieldset>
|
|
85
|
+
<legend>대상</legend>
|
|
86
|
+
<label for="target">시안 경로 (이 파일 기준 상대 경로)</label>
|
|
87
|
+
<input type="text" id="target" value="mockups/home.html">
|
|
88
|
+
<label for="widths">확인 폭 (px, 쉼표로 구분)</label>
|
|
89
|
+
<input type="text" id="widths" value="390, 768, 1280, 1536">
|
|
90
|
+
<label for="exempt">글자 크기 하한 예외 선택자 — 제품 UI 일러스트처럼 '읽는 카피'가 아닌 것</label>
|
|
91
|
+
<input type="text" id="exempt" value="[data-illustration]">
|
|
92
|
+
<label for="state">상태 (선택) — <code>선택자:클래스</code> 로 클래스를 붙이고, <code>open:#시트id</code> 로 <code><dialog></code>를 엽니다.
|
|
93
|
+
<code>;</code> 로 여러 개. <b>닫힌 시트 안은 측정되지 않으므로</b> 시트가 있으면 반드시 열어서 한 번 더 돌리세요</label>
|
|
94
|
+
<input type="text" id="state" value="" placeholder="예: .site-header:is-stuck; open:#askSheet">
|
|
95
|
+
<button id="run">검수 실행</button>
|
|
96
|
+
</fieldset>
|
|
97
|
+
|
|
98
|
+
<div id="out"></div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
(function(){
|
|
103
|
+
"use strict";
|
|
104
|
+
|
|
105
|
+
/* ── 색 유틸 ────────────────────────────────────────────────────── */
|
|
106
|
+
function parseColor(s){
|
|
107
|
+
if(!s) return null;
|
|
108
|
+
var m = s.match(/rgba?\(([^)]+)\)/);
|
|
109
|
+
if(!m) return null;
|
|
110
|
+
var p = m[1].split(/[,\s\/]+/).filter(Boolean).map(Number);
|
|
111
|
+
if(p.length < 3 || p.some(isNaN)) return null;
|
|
112
|
+
return {r:p[0], g:p[1], b:p[2], a:p.length > 3 ? p[3] : 1};
|
|
113
|
+
}
|
|
114
|
+
function over(fg, bg){ // fg 를 bg 위에 합성
|
|
115
|
+
var a = fg.a;
|
|
116
|
+
return {r:fg.r*a + bg.r*(1-a), g:fg.g*a + bg.g*(1-a), b:fg.b*a + bg.b*(1-a), a:1};
|
|
117
|
+
}
|
|
118
|
+
function hex(c){
|
|
119
|
+
return "#" + [c.r,c.g,c.b].map(function(v){
|
|
120
|
+
return Math.round(Math.max(0,Math.min(255,v))).toString(16).padStart(2,"0");
|
|
121
|
+
}).join("");
|
|
122
|
+
}
|
|
123
|
+
function lum(c){
|
|
124
|
+
var a = [c.r,c.g,c.b].map(function(v){
|
|
125
|
+
v /= 255;
|
|
126
|
+
return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4);
|
|
127
|
+
});
|
|
128
|
+
return 0.2126*a[0] + 0.7152*a[1] + 0.0722*a[2];
|
|
129
|
+
}
|
|
130
|
+
function ratio(a, b){
|
|
131
|
+
var l1 = lum(a), l2 = lum(b);
|
|
132
|
+
return (Math.max(l1,l2) + 0.05) / (Math.min(l1,l2) + 0.05);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ── 합성 배경 ──────────────────────────────────────────────────
|
|
136
|
+
조상을 타고 올라가며 배경색을 겹친다. 불투명한 면을 만나면 멈춘다.
|
|
137
|
+
ponytail: 조상 opacity 는 전경 알파에만 반영한다. 조상에 opacity 를 걸고
|
|
138
|
+
그 안에 글자를 두는 경우는 드물고, 그때는 전경·배경이 함께 흐려져
|
|
139
|
+
대비가 대체로 보존된다. 정밀도가 필요해지면 레이어별 합성으로 올린다.
|
|
140
|
+
------------------------------------------------------------------ */
|
|
141
|
+
function pseudoLayer(node, win, which){
|
|
142
|
+
var ps;
|
|
143
|
+
try{ ps = win.getComputedStyle(node, which); }catch(e){ return null; }
|
|
144
|
+
if(!ps || ps.content === "none" || ps.content === "normal") return null;
|
|
145
|
+
var c = parseColor(ps.backgroundColor);
|
|
146
|
+
if(!c || c.a === 0) return null;
|
|
147
|
+
var o = parseFloat(ps.opacity);
|
|
148
|
+
if(isNaN(o)) o = 1;
|
|
149
|
+
if(o === 0) return null; // 전환 전 상태 — 면이 아직 없다
|
|
150
|
+
return {r:c.r, g:c.g, b:c.b, a: c.a * o};
|
|
151
|
+
}
|
|
152
|
+
function bgChain(el, win){
|
|
153
|
+
var layers = [], node = el, floating = false;
|
|
154
|
+
while(node && node.nodeType === 1){
|
|
155
|
+
var cs = win.getComputedStyle(node);
|
|
156
|
+
var isFloat = cs.position === "fixed" || cs.position === "sticky" ||
|
|
157
|
+
(cs.backdropFilter && cs.backdropFilter !== "none");
|
|
158
|
+
if(isFloat) floating = true;
|
|
159
|
+
|
|
160
|
+
// 의사요소가 만든 면(글라스 레이어 등)은 요소 자신의 배경보다 위에 온다
|
|
161
|
+
var pl = pseudoLayer(node, win, "::before") || pseudoLayer(node, win, "::after");
|
|
162
|
+
if(pl){
|
|
163
|
+
layers.push(pl);
|
|
164
|
+
if(pl.a >= 0.999) return {layers:layers, opaque:true, floating:floating};
|
|
165
|
+
}
|
|
166
|
+
var c = parseColor(cs.backgroundColor);
|
|
167
|
+
if(c && c.a > 0){
|
|
168
|
+
layers.push(c);
|
|
169
|
+
if(c.a >= 0.999) return {layers:layers, opaque:true, floating:floating};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 떠 있는 요소를 지나면 그 아래는 조상이 아니다. 스크롤된 임의의 면이다.
|
|
173
|
+
// 여기서 끊지 않으면 body 의 흰색을 물려받아 "항상 흰 배경"이라는
|
|
174
|
+
// 틀린 결론이 나온다 — 고정 헤더가 딱 그렇다.
|
|
175
|
+
if(isFloat) return {layers:layers, opaque:false, floating:true};
|
|
176
|
+
|
|
177
|
+
node = node.parentElement;
|
|
178
|
+
}
|
|
179
|
+
return {layers:layers, opaque:false, floating:floating};
|
|
180
|
+
}
|
|
181
|
+
function composite(layers, base){
|
|
182
|
+
var out = base;
|
|
183
|
+
for(var i = layers.length - 1; i >= 0; i--) out = over(layers[i], out);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
function opacityChain(el, win){
|
|
187
|
+
var o = 1, node = el;
|
|
188
|
+
while(node && node.nodeType === 1){
|
|
189
|
+
var v = parseFloat(win.getComputedStyle(node).opacity);
|
|
190
|
+
if(!isNaN(v)) o *= v;
|
|
191
|
+
node = node.parentElement;
|
|
192
|
+
}
|
|
193
|
+
return o;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ── 검사 ───────────────────────────────────────────────────────── */
|
|
197
|
+
var WHITE = {r:255,g:255,b:255,a:1}, BLACK = {r:0,g:0,b:0,a:1};
|
|
198
|
+
|
|
199
|
+
function visible(el, win){
|
|
200
|
+
var cs = win.getComputedStyle(el);
|
|
201
|
+
if(cs.display === "none" || cs.visibility === "hidden") return false;
|
|
202
|
+
if(parseFloat(cs.opacity) === 0) return false;
|
|
203
|
+
return el.getClientRects().length > 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function textLeaves(D, win){
|
|
207
|
+
return Array.prototype.filter.call(D.querySelectorAll("body *"), function(el){
|
|
208
|
+
if(el.children.length) return false;
|
|
209
|
+
if(!el.textContent.trim()) return false;
|
|
210
|
+
if(el.closest("script,style,svg")) return false;
|
|
211
|
+
// aria-hidden / presentation 은 읽히지 않는 장식이다. 구분점(·) 같은 것.
|
|
212
|
+
if(el.closest('[aria-hidden="true"],[role="presentation"]')) return false;
|
|
213
|
+
return visible(el, win);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function auditOne(D, win, W, exempt){
|
|
218
|
+
var d = D.documentElement, res = {};
|
|
219
|
+
|
|
220
|
+
/* 1. 가로 스크롤 */
|
|
221
|
+
res.hScroll = d.scrollWidth > d.clientWidth
|
|
222
|
+
? [{ what:"문서", detail: d.scrollWidth + "px > " + d.clientWidth + "px" }] : [];
|
|
223
|
+
|
|
224
|
+
/* 2. 뷰포트 이탈 */
|
|
225
|
+
res.overflow = [];
|
|
226
|
+
Array.prototype.forEach.call(D.querySelectorAll("body *"), function(el){
|
|
227
|
+
if(!visible(el, win)) return;
|
|
228
|
+
var r = el.getBoundingClientRect();
|
|
229
|
+
if(r.width === 0 || r.height === 0) return;
|
|
230
|
+
if(Math.round(r.right) > W + 1 || Math.round(r.left) < -1){
|
|
231
|
+
res.overflow.push({ what: sel(el), detail: "left " + Math.round(r.left) + " / right " + Math.round(r.right) });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/* 3. 터치 타깃 — AA 하한 24px 은 실패, 주요 조작 44px 은 권고
|
|
236
|
+
그리고 **크기가 0 인 조작 요소**는 따로 잡는다.
|
|
237
|
+
visible() 이 걸러내 버리면 "없는 것"이 되어 조용히 통과한다 —
|
|
238
|
+
실제로 스타일이 빠져 0×0 이 된 닫기 버튼을 그렇게 놓친 적이 있다. */
|
|
239
|
+
res.zeroSize = [];
|
|
240
|
+
// 자기 자신뿐 아니라 **조상까지** 봐야 한다. 반응형으로 감춘 메뉴는
|
|
241
|
+
// 부모가 display:none 이고 자식은 아니어서, 자기만 보면 전부 오탐이 된다.
|
|
242
|
+
var hiddenByCss = function(el){
|
|
243
|
+
var n = el;
|
|
244
|
+
while(n && n.nodeType === 1){
|
|
245
|
+
var c = win.getComputedStyle(n);
|
|
246
|
+
if(c.display === "none" || c.visibility === "hidden") return true;
|
|
247
|
+
n = n.parentElement;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
};
|
|
251
|
+
Array.prototype.forEach.call(D.querySelectorAll("a[href],button,input,select,textarea,[role=button]"), function(el){
|
|
252
|
+
if(hiddenByCss(el)) return; // 의도적으로 감춘 것
|
|
253
|
+
if(el.closest("dialog:not([open])")) return; // 닫힌 시트 안
|
|
254
|
+
var r = el.getBoundingClientRect();
|
|
255
|
+
if(r.width < 1 || r.height < 1){
|
|
256
|
+
res.zeroSize.push({
|
|
257
|
+
what: (el.textContent.trim().slice(0,16) || el.getAttribute("aria-label") || sel(el)),
|
|
258
|
+
detail: Math.round(r.width) + "×" + Math.round(r.height) + " — 스타일이 적용되지 않았을 수 있습니다",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
res.tapFail = []; res.tapWarn = [];
|
|
264
|
+
Array.prototype.forEach.call(D.querySelectorAll("a,button,input,select,textarea,[role=button]"), function(el){
|
|
265
|
+
if(!visible(el, win)) return;
|
|
266
|
+
var r = el.getBoundingClientRect();
|
|
267
|
+
var w = Math.round(r.width), h = Math.round(r.height);
|
|
268
|
+
var label = (el.textContent.trim() || el.getAttribute("aria-label") || sel(el)).slice(0,28);
|
|
269
|
+
// WCAG 2.5.8 예외: 문장 안에 흐르는 인라인 링크는 크기 요구에서 빠진다
|
|
270
|
+
var own = el.textContent.trim();
|
|
271
|
+
var inlineInProse = win.getComputedStyle(el).display === "inline" &&
|
|
272
|
+
el.parentElement && el.parentElement.textContent.trim().length > own.length + 1;
|
|
273
|
+
if(inlineInProse) return;
|
|
274
|
+
if(h < 24 || w < 24) res.tapFail.push({ what:label, detail: w + "×" + h });
|
|
275
|
+
else if(W <= 860 && (h < 44 || w < 44)) res.tapWarn.push({ what:label, detail: w + "×" + h });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/* 4. 글자 크기 하한 */
|
|
279
|
+
res.tiny = [];
|
|
280
|
+
textLeaves(D, win).forEach(function(el){
|
|
281
|
+
if(exempt && el.closest(exempt)) return;
|
|
282
|
+
var px = parseFloat(win.getComputedStyle(el).fontSize);
|
|
283
|
+
if(px < 12) res.tiny.push({ what: el.textContent.trim().slice(0,28), detail: px.toFixed(1) + "px" });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/* 5. 합성 명도비 — 이 도구의 핵심 */
|
|
287
|
+
res.contrast = []; res.contrastFloat = [];
|
|
288
|
+
// WCAG 1.4.3 은 비활성 컨트롤의 글자를 대비 요구에서 면제한다.
|
|
289
|
+
// 면제 대상까지 빨갛게 띄우면 사람이 이 도구를 무시하게 된다 — 그게 더 나쁘다.
|
|
290
|
+
var inactive = function(el){
|
|
291
|
+
return !!el.closest("[disabled],[aria-disabled='true'],:disabled");
|
|
292
|
+
};
|
|
293
|
+
textLeaves(D, win).forEach(function(el){
|
|
294
|
+
var cs = win.getComputedStyle(el);
|
|
295
|
+
var fg0 = parseColor(cs.color);
|
|
296
|
+
if(!fg0) return;
|
|
297
|
+
if(inactive(el)) return;
|
|
298
|
+
var px = parseFloat(cs.fontSize), wt = parseInt(cs.fontWeight,10) || 400;
|
|
299
|
+
var large = px >= 24 || (px >= 18.66 && wt >= 700);
|
|
300
|
+
var need = large ? 3 : 4.5;
|
|
301
|
+
|
|
302
|
+
var chain = bgChain(el, win);
|
|
303
|
+
var op = opacityChain(el, win);
|
|
304
|
+
var fgA = { r:fg0.r, g:fg0.g, b:fg0.b, a: fg0.a * op };
|
|
305
|
+
|
|
306
|
+
// 불투명한 바닥에 닿지 못했거나 떠 있는 요소(fixed/sticky/backdrop-filter)라면
|
|
307
|
+
// 흰 바닥과 검은 바닥 양쪽을 재서 **나쁜 쪽**으로 판정한다.
|
|
308
|
+
var settled = chain.opaque && !chain.floating;
|
|
309
|
+
var measure = function(base){
|
|
310
|
+
var bg = composite(chain.layers, base);
|
|
311
|
+
var fg = over(fgA, bg);
|
|
312
|
+
return { r: ratio(fg, bg), fg:fg, bg:bg };
|
|
313
|
+
};
|
|
314
|
+
if(settled){
|
|
315
|
+
var m = measure(WHITE);
|
|
316
|
+
if(m.r < need){
|
|
317
|
+
res.contrast.push({
|
|
318
|
+
what: el.textContent.trim().slice(0,30), sel: sel(el),
|
|
319
|
+
detail: m.r.toFixed(2) + ":1 (필요 " + need + ")",
|
|
320
|
+
fg: hex(m.fg), bg: hex(m.bg), note: ""
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}else{
|
|
324
|
+
var a = measure(WHITE), b = measure(BLACK);
|
|
325
|
+
var lo = Math.min(a.r, b.r), hi = Math.max(a.r, b.r);
|
|
326
|
+
var bad = (lo < a.r ? b : a);
|
|
327
|
+
if(lo < need){
|
|
328
|
+
res.contrastFloat.push({
|
|
329
|
+
what: el.textContent.trim().slice(0,30), sel: sel(el),
|
|
330
|
+
detail: lo.toFixed(2) + " ~ " + hi.toFixed(2) + ":1 (필요 " + need + ")",
|
|
331
|
+
fg: hex(bad.fg), bg: hex(bad.bg),
|
|
332
|
+
note: "배경에 따라 달라짐 — 이 요소가 지나가는 모든 면에서 확인"
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
/* 6. 포커스 링 — 안티패턴 '포커스 제거' */
|
|
339
|
+
res.focus = [];
|
|
340
|
+
var focusables = Array.prototype.filter.call(
|
|
341
|
+
D.querySelectorAll("a[href],button,input,select,textarea"), function(el){ return visible(el, win); });
|
|
342
|
+
if(focusables.length){
|
|
343
|
+
var anyRule = false;
|
|
344
|
+
// @layer · @media · @supports 안에 들어 있어도 찾아야 한다.
|
|
345
|
+
// Tailwind 4 는 거의 모든 규칙을 레이어로 감싸므로 최상위만 보면 못 찾는다.
|
|
346
|
+
var scanFocus = function(rules){
|
|
347
|
+
Array.prototype.forEach.call(rules || [], function(r){
|
|
348
|
+
if(r.selectorText && /:focus-visible|:focus\b/.test(r.selectorText)
|
|
349
|
+
&& r.style && /outline|box-shadow/.test(r.style.cssText)) anyRule = true;
|
|
350
|
+
if(r.cssRules) scanFocus(r.cssRules);
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
try{
|
|
354
|
+
Array.prototype.forEach.call(D.styleSheets, function(ss){
|
|
355
|
+
var rules; try{ rules = ss.cssRules; }catch(e){ return; }
|
|
356
|
+
scanFocus(rules);
|
|
357
|
+
});
|
|
358
|
+
}catch(e){}
|
|
359
|
+
if(!anyRule) res.focus.push({ what:"문서 전체", detail:":focus-visible 스타일 선언이 없습니다 — 기본 링에 의존" });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* 7. 죽은 CSS */
|
|
363
|
+
res.dead = [];
|
|
364
|
+
var used = new Set();
|
|
365
|
+
Array.prototype.forEach.call(D.querySelectorAll("[class]"), function(el){
|
|
366
|
+
el.classList.forEach(function(c){ used.add(c); });
|
|
367
|
+
});
|
|
368
|
+
var declared = new Set();
|
|
369
|
+
try{
|
|
370
|
+
Array.prototype.forEach.call(D.styleSheets, function(ss){
|
|
371
|
+
// 링크된 공용 시트(tokens.css)는 제외 — 이 시안이 안 쓴다고 죽은 것이 아니다
|
|
372
|
+
if(ss.href) return;
|
|
373
|
+
var rules; try{ rules = ss.cssRules; }catch(e){ return; }
|
|
374
|
+
Array.prototype.forEach.call(rules || [], function(r){
|
|
375
|
+
collectClasses(r, declared);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}catch(e){}
|
|
379
|
+
// 스크립트가 런타임에 붙이는 상태 클래스(.is-stuck 등)는 정적 DOM 에 없다.
|
|
380
|
+
// 페이지 자신의 스크립트에 이름이 나오면 살아 있는 것으로 본다.
|
|
381
|
+
var js = Array.prototype.map.call(D.querySelectorAll("script:not([src])"), function(t){
|
|
382
|
+
return t.textContent;
|
|
383
|
+
}).join("\n");
|
|
384
|
+
declared.forEach(function(c){
|
|
385
|
+
if(used.has(c)) return;
|
|
386
|
+
if(js.indexOf(c) !== -1) return; // 런타임에 붙는 클래스
|
|
387
|
+
res.dead.push({ what:"."+c, detail:"선언됐지만 쓰이지 않음" });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return res;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function collectClasses(rule, out){
|
|
394
|
+
if(rule.selectorText){
|
|
395
|
+
var m = rule.selectorText.match(/\.[-_a-zA-Z][-_a-zA-Z0-9]*/g);
|
|
396
|
+
if(m) m.forEach(function(c){ out.add(c.slice(1)); });
|
|
397
|
+
}
|
|
398
|
+
if(rule.cssRules) Array.prototype.forEach.call(rule.cssRules, function(r){ collectClasses(r, out); });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function sel(el){
|
|
402
|
+
if(el.id) return "#" + el.id;
|
|
403
|
+
var c = (el.getAttribute("class") || "").trim().split(/\s+/)[0];
|
|
404
|
+
return c ? "." + c : el.tagName.toLowerCase();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ── 스크롤 상태 전환 검증 ──────────────────────────────────────────
|
|
408
|
+
고정 헤더처럼 스크롤에 따라 모습이 바뀌는 요소가 있다.
|
|
409
|
+
자동화된 탭은 백그라운드로 렌더돼서 IntersectionObserver 가 아예 안 불린다.
|
|
410
|
+
그래서 "동작하는지 확인 못 함"으로 네 번 연속 넘어갔다.
|
|
411
|
+
|
|
412
|
+
렌더링을 기다리는 대신 **관찰자를 가로챈다.**
|
|
413
|
+
페이지 스크립트보다 먼저 IntersectionObserver 를 가짜로 바꿔치기해 두고,
|
|
414
|
+
여기서 직접 콜백을 불러 DOM 이 어떻게 변하는지 본다.
|
|
415
|
+
화면이 그려지지 않아도 상태 기계는 그대로 돌아간다.
|
|
416
|
+
------------------------------------------------------------------ */
|
|
417
|
+
var IO_STUB =
|
|
418
|
+
'<script>(function(){' +
|
|
419
|
+
'window.__io=[];' +
|
|
420
|
+
'window.IntersectionObserver=function(cb,opts){' +
|
|
421
|
+
'var rec={cb:cb,opts:opts||{},targets:[]};window.__io.push(rec);' +
|
|
422
|
+
'return{observe:function(el){rec.targets.push(el)},unobserve:function(){},' +
|
|
423
|
+
'disconnect:function(){},takeRecords:function(){return[]}}};' +
|
|
424
|
+
'window.__fire=function(i,hit){var r=window.__io[i];if(!r)return;' +
|
|
425
|
+
'r.cb(r.targets.map(function(t){return{isIntersecting:hit,target:t,' +
|
|
426
|
+
'intersectionRatio:hit?1:0,boundingClientRect:t.getBoundingClientRect()}}),null)};' +
|
|
427
|
+
'})();<\/script>';
|
|
428
|
+
|
|
429
|
+
function classSnapshot(D){
|
|
430
|
+
return Array.prototype.map.call(D.querySelectorAll("*"), function(e){
|
|
431
|
+
return e.getAttribute("class") || "";
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
function diffClasses(D, before, after){
|
|
435
|
+
var els = D.querySelectorAll("*"), out = [];
|
|
436
|
+
for(var i = 0; i < els.length; i++){
|
|
437
|
+
if(before[i] !== after[i]){
|
|
438
|
+
out.push({ el: sel(els[i]), from: before[i] || "(없음)", to: after[i] || "(없음)" });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function auditStateMachine(src){
|
|
445
|
+
var url = new URL(src, location.href);
|
|
446
|
+
var html;
|
|
447
|
+
try{
|
|
448
|
+
html = await (await fetch(url.href + (url.search ? "&" : "?") + "sm=" + Date.now())).text();
|
|
449
|
+
}catch(e){
|
|
450
|
+
return { error: "시안을 불러오지 못했습니다: " + e.message };
|
|
451
|
+
}
|
|
452
|
+
var base = url.href.replace(/[^/]*$/, "");
|
|
453
|
+
// <base> 로 상대 경로를 살리고, 페이지 스크립트보다 먼저 스텁을 넣는다
|
|
454
|
+
html = html.replace(/<head([^>]*)>/i, '<head$1><base href="' + base + '">' + IO_STUB);
|
|
455
|
+
|
|
456
|
+
return await new Promise(function(res){
|
|
457
|
+
var f = document.createElement("iframe");
|
|
458
|
+
f.className = "frame"; f.width = 1280; f.height = 900;
|
|
459
|
+
f.srcdoc = html;
|
|
460
|
+
document.body.appendChild(f);
|
|
461
|
+
f.onload = function(){
|
|
462
|
+
setTimeout(function(){
|
|
463
|
+
var D = f.contentDocument, W = f.contentWindow;
|
|
464
|
+
var io = W.__io || [];
|
|
465
|
+
|
|
466
|
+
// srcdoc 은 origin 이 null 이고 URL 이 about:srcdoc 이라
|
|
467
|
+
// BrowserRouter 같은 클라이언트 라우터가 URL 파싱에 실패한다.
|
|
468
|
+
// 그러면 앱이 아예 렌더되지 않는데, 그걸 "관찰자 없음" 으로 읽으면
|
|
469
|
+
// 검사하지 못한 것을 통과로 보고하게 된다. 그게 가장 위험하다.
|
|
470
|
+
var rendered = D.body && D.body.querySelectorAll("header,main,footer,[data-parity]").length > 0;
|
|
471
|
+
if(!rendered){ f.remove(); return res({ unrendered: true }); }
|
|
472
|
+
if(!io.length){ f.remove(); return res({ observers: [] }); }
|
|
473
|
+
|
|
474
|
+
var report = io.map(function(rec, i){
|
|
475
|
+
var before = classSnapshot(D);
|
|
476
|
+
W.__fire(i, false); // 관찰 대상이 화면 밖으로 나갔다
|
|
477
|
+
var out = classSnapshot(D);
|
|
478
|
+
var changed = diffClasses(D, before, out);
|
|
479
|
+
W.__fire(i, true); // 다시 들어왔다
|
|
480
|
+
var back = classSnapshot(D);
|
|
481
|
+
var restored = diffClasses(D, before, back).length === 0;
|
|
482
|
+
return {
|
|
483
|
+
index: i,
|
|
484
|
+
target: rec.targets.map(function(t){ return sel(t); }).join(", ") || "(없음)",
|
|
485
|
+
rootMargin: rec.opts.rootMargin || "0px",
|
|
486
|
+
threshold: String(rec.opts.threshold === undefined ? 0 : rec.opts.threshold),
|
|
487
|
+
changed: changed,
|
|
488
|
+
restored: restored
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
f.remove();
|
|
492
|
+
res({ observers: report });
|
|
493
|
+
}, 400);
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* ── 실행 ───────────────────────────────────────────────────────── */
|
|
499
|
+
var CHECKS = [
|
|
500
|
+
{ key:"hScroll", title:"가로 스크롤", hard:true, hint:"어느 폭에서도 문서가 가로로 넘치면 안 됩니다." },
|
|
501
|
+
{ key:"overflow", title:"뷰포트 이탈 요소", hard:true, hint:"화면 밖으로 나간 요소입니다. 가로 스크롤이 없어도 잘려 보입니다." },
|
|
502
|
+
{ key:"contrast", title:"명도비 — 확정", hard:true, hint:"불투명한 배경 위의 글자입니다. 값이 확정되므로 미달은 곧 결함입니다. 본문 4.5:1, 큰 글자 3:1. 비활성 컨트롤(<code>disabled</code>)은 WCAG 1.4.3 이 면제하므로 제외합니다 — 다만 눌러야 하는지 모를 만큼 흐리면 그건 따로 판단하세요." },
|
|
503
|
+
{ key:"zeroSize", title:"크기가 0 인 조작 요소", hard:true, hint:"버튼·링크가 0×0 으로 렌더됐습니다. 대개 클래스는 붙였는데 그 CSS 규칙이 없을 때 생깁니다. 보이지 않으니 눈으로도, 다른 검사로도 잡히지 않습니다." },
|
|
504
|
+
{ key:"tapFail", title:"터치 타깃 24px 미만", hard:true, hint:"WCAG 2.5.8(AA) 최소 기준입니다." },
|
|
505
|
+
{ key:"tiny", title:"글자 12px 미만", hard:true, hint:"읽는 카피의 하한입니다. 제품 UI 일러스트는 예외 선택자로 빼세요." },
|
|
506
|
+
{ key:"focus", title:"포커스 링", hard:true, hint:"안티패턴 '포커스 제거'. 검정 면 위에서는 링 색을 뒤집어야 합니다." },
|
|
507
|
+
{ key:"tapWarn", title:"터치 타깃 44px 미만", hard:false, hint:"모바일 폭 권고입니다. 주요 내비·CTA 는 44px 를 지키고, 밀집 정보 링크는 24px 로 둘 수 있습니다 — 그 예외는 규칙에 적으세요." },
|
|
508
|
+
{ key:"contrastFloat", title:"명도비 — 배경 의존", hard:false, hint:"고정·반투명 요소라 뒤에 무엇이 오는지 도구가 알 수 없습니다. 흰 바닥과 검은 바닥을 각각 재서 <b>범위</b>로 보고합니다. 이 요소가 지나가는 실제 면에서 최악값이 나오는지 사람이 확인하세요 — 고정 헤더가 어두운 섹션 위를 지날 때가 대표적입니다." },
|
|
509
|
+
{ key:"dead", title:"죽은 CSS", hard:false, hint:"이 시안의 <style> 에 선언됐지만 아무 요소도 쓰지 않는 클래스입니다. 공용 tokens.css 는 여러 시안이 나눠 쓰므로 제외합니다." }
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
function loadAt(src, w, state){
|
|
513
|
+
return new Promise(function(res){
|
|
514
|
+
var f = document.createElement("iframe");
|
|
515
|
+
f.className = "frame"; f.width = w; f.height = 900;
|
|
516
|
+
f.src = src + (src.indexOf("?") < 0 ? "?" : "&") + "audit=" + Date.now() + Math.random();
|
|
517
|
+
document.body.appendChild(f);
|
|
518
|
+
f.onload = function(){
|
|
519
|
+
// tokens.css 가 캐시되어 옛 값으로 재는 것을 막는다
|
|
520
|
+
try{
|
|
521
|
+
var link = f.contentDocument.querySelector('link[rel=stylesheet]');
|
|
522
|
+
if(link) link.href = link.getAttribute("href") + "?audit=" + Date.now() + Math.random();
|
|
523
|
+
}catch(e){}
|
|
524
|
+
setTimeout(function(){
|
|
525
|
+
try{
|
|
526
|
+
// 전환이 진행되지 않는 환경(백그라운드 탭)에서도 목표 값을 읽게 전환을 끈다
|
|
527
|
+
var kill = f.contentDocument.createElement("style");
|
|
528
|
+
kill.textContent = "*,*::before,*::after{transition:none!important;animation:none!important}";
|
|
529
|
+
f.contentDocument.head.appendChild(kill);
|
|
530
|
+
// 진입 모션의 시작 상태를 걷어낸다. 안 그러면 관찰자가 안 도는 환경에서
|
|
531
|
+
// 콘텐츠가 opacity:0 인 채로 측정되어 **검사를 통째로 건너뛴다.**
|
|
532
|
+
// 반드시 kill 스타일을 붙인 뒤에 지운다 — 먼저 지우면 전환이 시작되고,
|
|
533
|
+
// 전환이 진행되지 않는 환경에서는 계산값이 0 에 멈춘 채로 읽힌다.
|
|
534
|
+
f.contentDocument.documentElement.classList.remove("js-reveal");
|
|
535
|
+
// 상태 지시문. `;` 로 여러 개를 준다.
|
|
536
|
+
// .site-header:is-stuck → 클래스를 붙인다
|
|
537
|
+
// open:#askSheet → <dialog> 를 연다 (닫힌 시트 안은 측정되지 않는다)
|
|
538
|
+
state.split(";").forEach(function(one){
|
|
539
|
+
one = one.trim();
|
|
540
|
+
if(!one) return;
|
|
541
|
+
if(one.indexOf("open:") === 0){
|
|
542
|
+
var dlg = f.contentDocument.querySelector(one.slice(5).trim());
|
|
543
|
+
if(dlg && dlg.showModal) dlg.showModal();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
var parts = one.split(":");
|
|
547
|
+
var cls = parts.pop().trim();
|
|
548
|
+
var node = f.contentDocument.querySelector(parts.join(":").trim());
|
|
549
|
+
if(node) node.classList.add(cls);
|
|
550
|
+
});
|
|
551
|
+
}catch(e){}
|
|
552
|
+
setTimeout(function(){ res(f); }, 120);
|
|
553
|
+
}, 550);
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
document.getElementById("run").addEventListener("click", async function(){
|
|
559
|
+
var btn = this;
|
|
560
|
+
btn.disabled = true; btn.textContent = "검수 중…";
|
|
561
|
+
var target = document.getElementById("target").value.trim();
|
|
562
|
+
var widths = document.getElementById("widths").value.split(",")
|
|
563
|
+
.map(function(s){ return parseInt(s.trim(),10); }).filter(function(n){ return n > 0; });
|
|
564
|
+
var exempt = document.getElementById("exempt").value.trim();
|
|
565
|
+
var state = document.getElementById("state").value.trim();
|
|
566
|
+
|
|
567
|
+
var byWidth = {};
|
|
568
|
+
for(var i = 0; i < widths.length; i++){
|
|
569
|
+
var w = widths[i];
|
|
570
|
+
var f = await loadAt(target, w, state);
|
|
571
|
+
try{
|
|
572
|
+
byWidth[w] = auditOne(f.contentDocument, f.contentWindow, f.contentWindow.innerWidth, exempt);
|
|
573
|
+
}catch(e){
|
|
574
|
+
byWidth[w] = { error: String(e && e.message || e) };
|
|
575
|
+
}
|
|
576
|
+
f.remove();
|
|
577
|
+
}
|
|
578
|
+
var sm = await auditStateMachine(target);
|
|
579
|
+
render(byWidth, widths, target, sm);
|
|
580
|
+
btn.disabled = false; btn.textContent = "검수 실행";
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
function render(byWidth, widths, target, sm){
|
|
584
|
+
var out = document.getElementById("out");
|
|
585
|
+
var hardFail = 0, softFail = 0;
|
|
586
|
+
|
|
587
|
+
CHECKS.forEach(function(c){
|
|
588
|
+
widths.forEach(function(w){
|
|
589
|
+
var r = byWidth[w] && byWidth[w][c.key];
|
|
590
|
+
if(r && r.length){ if(c.hard) hardFail += r.length; else softFail += r.length; }
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
var html = '<div class="score">' +
|
|
595
|
+
'<div class="tile ' + (hardFail ? "fail" : "pass") + '"><b>' + (hardFail ? hardFail + "건" : "통과") + "</b><span>필수 항목</span></div>" +
|
|
596
|
+
'<div class="tile ' + (softFail ? "warn" : "pass") + '"><b>' + (softFail ? softFail + "건" : "없음") + "</b><span>권고 항목</span></div>" +
|
|
597
|
+
'<div class="tile"><b>' + widths.length + "</b><span>확인한 폭</span></div>" +
|
|
598
|
+
"</div>";
|
|
599
|
+
|
|
600
|
+
CHECKS.forEach(function(c){
|
|
601
|
+
var rows = "", n = 0;
|
|
602
|
+
widths.forEach(function(w){
|
|
603
|
+
var r = (byWidth[w] || {})[c.key] || [];
|
|
604
|
+
r.forEach(function(item){
|
|
605
|
+
n++;
|
|
606
|
+
var swatch = item.fg
|
|
607
|
+
? '<span class="swatch" style="background:' + item.fg + '"></span>' + item.fg +
|
|
608
|
+
' <span class="swatch" style="background:' + item.bg + '"></span>' + item.bg
|
|
609
|
+
: "";
|
|
610
|
+
rows += '<tr class="' + (c.hard ? "bad" : "warn") + '"><td class="mono">' + w + "</td>" +
|
|
611
|
+
"<td>" + esc(item.what) + (item.sel ? ' <span style="color:var(--muted)">' + esc(item.sel) + "</span>" : "") + "</td>" +
|
|
612
|
+
'<td class="mono">' + esc(item.detail) + "</td>" +
|
|
613
|
+
"<td>" + swatch + (item.note ? ' <span style="color:var(--muted)">' + esc(item.note) + "</span>" : "") + "</td></tr>";
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
var badge = n ? '<span class="badge ' + (c.hard ? "bad" : "warn") + '">' + n + "건</span>"
|
|
617
|
+
: '<span class="badge ok">통과</span>';
|
|
618
|
+
html += '<details class="check"' + (n ? " open" : "") + "><summary>" + c.title + badge + "</summary>" +
|
|
619
|
+
'<p class="hint">' + c.hint + "</p>" +
|
|
620
|
+
(n ? '<table><thead><tr><th class="mono">폭</th><th>대상</th><th class="mono">값</th><th>색</th></tr></thead><tbody>' + rows + "</tbody></table>"
|
|
621
|
+
: '<p class="empty">지적사항 없음</p>') +
|
|
622
|
+
"</details>";
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ── 스크롤 상태 전환 ──────────────────────────────────
|
|
626
|
+
var obs = (sm && sm.observers) || [];
|
|
627
|
+
var smBad = 0, smRows = "";
|
|
628
|
+
if(sm && sm.unrendered){
|
|
629
|
+
smRows = '<tr class="warn"><td colspan="4">이 하네스에서 페이지가 렌더되지 않았습니다 — ' +
|
|
630
|
+
'<b>확인하지 못했습니다.</b> 클라이언트 라우터를 쓰는 SPA 는 <code>srcdoc</code>(origin null)에서 ' +
|
|
631
|
+
'URL 파싱에 실패해 앱이 뜨지 않습니다. 시안 HTML 로는 검증되지만 <b>구현은 사람이 브라우저에서 확인</b>해야 합니다.</td></tr>';
|
|
632
|
+
} else if(sm && sm.error){
|
|
633
|
+
smBad = 1;
|
|
634
|
+
smRows = '<tr class="bad"><td colspan="4">' + esc(sm.error) + "</td></tr>";
|
|
635
|
+
}else{
|
|
636
|
+
obs.forEach(function(o){
|
|
637
|
+
var dead = o.changed.length === 0;
|
|
638
|
+
if(dead) smBad++;
|
|
639
|
+
var what = dead
|
|
640
|
+
? "관찰자가 등록됐지만 상태가 바뀌지 않습니다 — 배선이 끊겼습니다"
|
|
641
|
+
: o.changed.map(function(c){ return c.el + " : " + c.from + " → " + c.to; }).join("<br>");
|
|
642
|
+
smRows += '<tr class="' + (dead ? "bad" : "") + '"><td class="mono">#' + o.index + "</td>" +
|
|
643
|
+
"<td>" + esc(o.target) + "</td>" +
|
|
644
|
+
'<td class="mono">rootMargin ' + esc(o.rootMargin) + "<br>threshold " + esc(o.threshold) + "</td>" +
|
|
645
|
+
"<td>" + what + (dead ? "" : (o.restored ? '<br><span style="color:var(--ok)">되돌아옴 ✓</span>'
|
|
646
|
+
: '<br><span style="color:var(--bad)">되돌아오지 않음</span>')) + "</td></tr>";
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
hardFail += smBad;
|
|
650
|
+
var smBadge = (sm && sm.unrendered)
|
|
651
|
+
? '<span class="badge warn">확인 못 함</span>'
|
|
652
|
+
: (obs.length === 0 && !(sm && sm.error)
|
|
653
|
+
? '<span class="badge ok">해당 없음</span>'
|
|
654
|
+
: (smBad ? '<span class="badge bad">' + smBad + "건</span>"
|
|
655
|
+
: '<span class="badge ok">' + obs.length + "개 정상</span>"));
|
|
656
|
+
|
|
657
|
+
html += '<details class="check"' + (smBad || (sm && sm.unrendered) ? " open" : "") + "><summary>스크롤 상태 전환" + smBadge + "</summary>" +
|
|
658
|
+
'<p class="hint">고정 헤더처럼 스크롤에 따라 바뀌는 요소입니다. 백그라운드 탭에서는 ' +
|
|
659
|
+
'<code>IntersectionObserver</code> 가 불리지 않으므로, <b>관찰자를 가로채 콜백을 직접 호출</b>해 ' +
|
|
660
|
+
'DOM 이 실제로 바뀌는지 봅니다. 렌더링 없이도 상태 기계는 검증됩니다.<br>' +
|
|
661
|
+
'<b>여전히 사람이 볼 것</b> — 전환이 <i>어느 스크롤 위치에서</i> 일어나는지의 체감과 모션의 속도.</p>' +
|
|
662
|
+
(smRows
|
|
663
|
+
? '<table><thead><tr><th class="mono">#</th><th>관찰 대상</th><th class="mono">옵션</th><th>전환 결과</th></tr></thead><tbody>' + smRows + "</tbody></table>"
|
|
664
|
+
: '<p class="empty">스크롤 연동 상태 전환이 없습니다.</p>') +
|
|
665
|
+
"</details>";
|
|
666
|
+
|
|
667
|
+
// 점수 타일을 다시 그린다 (상태 전환 실패를 반영)
|
|
668
|
+
html = html.replace(/<div class="tile (?:fail|pass)"><b>[^<]*<\/b><span>필수 항목<\/span><\/div>/,
|
|
669
|
+
'<div class="tile ' + (hardFail ? "fail" : "pass") + '"><b>' +
|
|
670
|
+
(hardFail ? hardFail + "건" : "통과") + "</b><span>필수 항목</span></div>");
|
|
671
|
+
|
|
672
|
+
html += '<p class="note"><b>이 도구가 못 보는 것.</b> 모션의 <b>속도와 체감</b>, 그리고 전환이 ' +
|
|
673
|
+
'<b>어느 스크롤 위치에서</b> 일어나는지는 잴 수 없습니다. 상태 기계가 도는 것과 ' +
|
|
674
|
+
'그 타이밍이 자연스러운 것은 다른 문제입니다. ' +
|
|
675
|
+
'측정 전에 전환을 꺼서 목표 값은 정확히 읽으므로, 색과 크기는 여기 결과를 믿어도 됩니다. ' +
|
|
676
|
+
'검수하지 못한 항목은 <b>change-log.md 에 못 했다고 적으세요.</b></p>';
|
|
677
|
+
|
|
678
|
+
out.innerHTML = html;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function esc(s){
|
|
682
|
+
return String(s).replace(/[&<>"]/g, function(c){
|
|
683
|
+
return {"&":"&","<":"<",">":">",'"':"""}[c];
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
})();
|
|
687
|
+
</script>
|
|
688
|
+
</body>
|
|
689
|
+
</html>
|