@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/bin/cli.mjs +331 -0
  4. package/package.json +53 -0
  5. package/skills/erd-visual/SKILL.md +294 -0
  6. package/skills/erd-visual/build.mjs +404 -0
  7. package/skills/erd-visual/engine/dbml.mjs +133 -0
  8. package/skills/erd-visual/engine/ingest.mjs +273 -0
  9. package/skills/erd-visual/engine/layout.mjs +165 -0
  10. package/skills/erd-visual/engine/overview.mjs +314 -0
  11. package/skills/erd-visual/engine/render.mjs +144 -0
  12. package/skills/erd-visual/engine/router.mjs +401 -0
  13. package/skills/erd-visual/engine/verify.mjs +138 -0
  14. package/skills/erd-visual/engine/wire.mjs +115 -0
  15. package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
  16. package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
  17. package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
  18. package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
  19. package/skills/erd-visual/fixtures/src-json/api.json +38 -0
  20. package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
  21. package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
  22. package/skills/erd-visual/readers/csv.mjs +81 -0
  23. package/skills/erd-visual/readers/index.mjs +55 -0
  24. package/skills/erd-visual/readers/jsonschema.mjs +86 -0
  25. package/skills/erd-visual/readers/prisma.mjs +102 -0
  26. package/skills/erd-visual/readers/sql.mjs +205 -0
  27. package/skills/erd-visual/readers/xlsx.mjs +220 -0
  28. package/skills/erd-visual/readers/xml.mjs +130 -0
  29. package/skills/erd-visual/readers/zip.mjs +74 -0
  30. package/skills/erd-visual/viewer/index.html +539 -0
  31. package/skills/project-build/SKILL.md +171 -0
  32. package/skills/project-build/templates/build-rules.md +88 -0
  33. package/skills/project-build/templates/change-log.md +29 -0
  34. package/skills/project-build/templates/coverage.md +49 -0
  35. package/skills/project-build/templates/parity.html +375 -0
  36. package/skills/project-build/templates/theme.css +45 -0
  37. package/skills/project-design/MEDIUMS.md +104 -0
  38. package/skills/project-design/QUESTIONS.md +126 -0
  39. package/skills/project-design/SKILL.md +365 -0
  40. package/skills/project-design/templates/audit.html +689 -0
  41. package/skills/project-design/templates/change-log.md +53 -0
  42. package/skills/project-design/templates/design-rules.md +118 -0
  43. package/skills/project-design/templates/mockup-app.html +96 -0
  44. package/skills/project-design/templates/mockup-slides.html +82 -0
  45. package/skills/project-design/templates/mockup-web.html +45 -0
  46. package/skills/project-design/templates/styleguide.html +436 -0
  47. package/skills/project-design/templates/tokens.css +69 -0
  48. package/skills/project-design/templates/tone-options.html +138 -0
  49. package/skills/project-init/GUIDE.md +370 -0
  50. package/skills/project-init/RUNBOOK.md +192 -0
  51. package/skills/project-init/SKILL.md +382 -0
  52. package/skills/project-init/build.mjs +2898 -0
  53. package/skills/project-init/evals/RUBRIC.md +81 -0
  54. package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
  55. package/skills/project-init/evals/cases/conflicting.md +9 -0
  56. package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
  57. package/skills/project-init/evals/cases/vague-idea.md +7 -0
  58. package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
  59. package/skills/project-init/evals/cases/well-formed.md +28 -0
  60. package/skills/project-init/markdown.mjs +0 -0
  61. package/skills/project-init/modules/a11y.json +46 -0
  62. package/skills/project-init/modules/ai.json +77 -0
  63. package/skills/project-init/modules/audience.json +48 -0
  64. package/skills/project-init/modules/backend.json +129 -0
  65. package/skills/project-init/modules/brand.json +78 -0
  66. package/skills/project-init/modules/core.json +132 -0
  67. package/skills/project-init/modules/design.json +72 -0
  68. package/skills/project-init/modules/engineering.json +86 -0
  69. package/skills/project-init/modules/mobile.json +22 -0
  70. package/skills/project-init/modules/ops.json +122 -0
  71. package/skills/project-init/modules/process.json +104 -0
  72. package/skills/project-init/modules/product.json +37 -0
  73. package/skills/project-init/modules/ux.json +52 -0
  74. package/skills/project-init/modules/web.json +129 -0
  75. package/skills/project-init/presets/ai-product.json +12 -0
  76. package/skills/project-init/presets/internal-system.json +15 -0
  77. package/skills/project-init/presets/mobile-app.json +20 -0
  78. package/skills/project-init/presets/web-corporate.json +116 -0
  79. package/skills/project-init/schema.json +80 -0
  80. package/skills/project-init/templates/log.md +54 -0
  81. package/skills/project-init/templates/readme.md +62 -0
  82. package/skills/project-init/templates/reference.md +34 -0
  83. package/skills/project-init/templates/register.md +63 -0
  84. package/skills/project-init/templates/spec.md +46 -0
  85. package/skills/project-interview/INTERVIEW.md +221 -0
  86. package/skills/project-interview/SKILL.md +156 -0
  87. package/skills/project-interview/fixtures/brief.md +49 -0
  88. package/skills/project-interview/fixtures/decisions.md +13 -0
  89. package/skills/project-interview/fixtures/open-questions.md +9 -0
  90. package/skills/project-interview/templates/brief.md +179 -0
  91. package/skills/project-interview/templates/decisions.md +55 -0
  92. package/skills/project-interview/templates/open-questions.md +40 -0
  93. package/skills/project-interview/validate.mjs +49 -0
@@ -0,0 +1,539 @@
1
+ <!doctype html>
2
+ <html lang="ko"><head><meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <title>ERD</title>
5
+ <style>
6
+ :root{--bg:#FFFFFF;--pnl:#FFFFFF;--pnl2:#FAFAFA;--ink:#09090B;--ink2:#52525B;--ink3:#A1A1AA;
7
+ --line:#E4E4E7;--line2:#D4D4D8;--bad:#B42318;--ok:#0A7B4B}
8
+ *{box-sizing:border-box}
9
+ body{margin:0;background:var(--bg);color:var(--ink);word-break:keep-all;-webkit-font-smoothing:antialiased;
10
+ font-family:Pretendard,-apple-system,"Apple SD Gothic Neo","Noto Sans KR",system-ui,sans-serif}
11
+ header{position:sticky;top:0;z-index:20;background:rgba(255,255,255,.94);backdrop-filter:blur(10px);
12
+ border-bottom:1.5px solid var(--ink);padding:14px 26px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
13
+ h1{font-size:17px;margin:0;font-weight:800;letter-spacing:-.02em}
14
+ .sub{font-size:12px;color:var(--ink2);margin-top:2px}
15
+ .sp{flex:1}
16
+ .seg{display:flex;border:1.5px solid var(--ink);border-radius:9px;overflow:hidden}
17
+ .seg button{border:0;background:var(--pnl);padding:7px 14px;font-size:13px;font-weight:700;
18
+ color:var(--ink2);cursor:pointer;font-family:inherit;white-space:nowrap;transition:background .12s,color .12s}
19
+ .seg button+button{border-left:1.5px solid var(--ink)}
20
+ .seg button.on{background:var(--ink);color:#fff}
21
+ .lab{font-size:11px;color:var(--ink3);font-weight:800;letter-spacing:.12em}
22
+ #find{border:1.5px solid var(--ink);border-radius:8px;padding:6px 11px;font-size:13px;
23
+ font-family:inherit;width:190px;outline:0}
24
+ #find:focus{box-shadow:0 0 0 3px rgba(9,9,11,.12)}
25
+ #fstat{font-size:12px;color:var(--ink2);min-width:52px}
26
+ .zoom{display:flex;align-items:center;gap:6px}
27
+ .zoom button{border:1.5px solid var(--ink);background:var(--pnl);border-radius:7px;width:28px;height:28px;
28
+ font-size:14px;font-weight:700;cursor:pointer;line-height:1;font-family:inherit}
29
+ #zval{font-size:12px;color:var(--ink2);min-width:38px;text-align:center;font-variant-numeric:tabular-nums}
30
+ /* 검색 결과 강조 — 나머지를 접지 않고 찾은 것만 띄운다 */
31
+ .seek .cd:not(.found){opacity:.12}
32
+ .seek .ed,.seek .bd{opacity:.05!important}
33
+ .cd.found .bd0{stroke-width:3.4}
34
+ #vpanel{position:fixed;right:18px;bottom:18px;width:330px;background:var(--pnl);
35
+ border:1.5px solid var(--ink);border-radius:12px;padding:16px 18px;z-index:35;display:none;
36
+ box-shadow:0 8px 28px rgba(0,0,0,.14)}
37
+ #vpanel.on{display:block}
38
+ #vpanel h3{margin:0 0 10px;font-size:14px;font-weight:800}
39
+ #vpanel .r{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;
40
+ border-bottom:1px solid var(--line)}
41
+ #vpanel .r b{font-variant-numeric:tabular-nums}
42
+ #vpanel .pass b{color:var(--ok)} #vpanel .fail b{color:var(--bad)}
43
+ #vpanel .sum{margin-top:11px;font-size:12.5px;font-weight:800}
44
+ #vpanel .note{margin-top:9px;font-size:11.5px;color:var(--ink2);line-height:1.5}
45
+ #vpanel .cl2{position:absolute;right:12px;top:12px;border:0;background:none;cursor:pointer;
46
+ font-size:15px;color:var(--ink2)}
47
+ .hint{font-size:12.5px;color:var(--ink2);padding:9px 26px;border-bottom:1px solid var(--line);background:var(--pnl2)}
48
+ .hint b{color:var(--ink);font-weight:700}
49
+ .scroll{overflow:auto;padding:22px}
50
+ .view{display:none}.view.on{display:block}
51
+ #ovstage svg{width:100%;height:auto;max-width:1720px;margin:0 auto;display:block}
52
+ #ov svg.dim .ove:not(.hot){opacity:.06}
53
+ #ov svg.dim .ovn:not(.hot):not(.src){opacity:.16}
54
+ .ove{transition:opacity .14s}
55
+ .ove.noev{opacity:.95!important}
56
+ #ovwarn{background:#FEF3F2;border-bottom:1.5px solid var(--bad);color:var(--bad);
57
+ padding:10px 26px;font-size:12.5px;line-height:1.6}
58
+ #ovwarn.info{background:var(--pnl2);border-bottom-color:var(--ink);color:var(--ink2)}
59
+ #ovwarn b{font-weight:800}
60
+ #ovwarn.info b{color:var(--ink)}
61
+ .ovn{transition:opacity .14s;cursor:pointer}
62
+ .ovn .hit{fill:transparent}
63
+ .ove.hot{opacity:1!important;stroke:#09090B;stroke-width:3.4;
64
+ stroke-dasharray:11 5;animation:flow .75s linear infinite}
65
+ .ove.hot.gap{stroke-width:3;animation:flow .9s linear infinite}
66
+ .ovn.hot .bd0,.ovn.src .bd0{stroke-width:3.4}
67
+ .ovn.src .bd0{animation:pulse 1.15s ease-in-out infinite}
68
+ @media (prefers-reduced-motion:reduce){
69
+ .ove.hot,.ovn.src .bd0{animation:none!important}.ove.hot{stroke-dasharray:none}}
70
+ #stage svg{display:block;max-width:none}
71
+ .ed,.bd{transition:opacity .12s,stroke-width .12s}
72
+ .cd .hit{fill:transparent}
73
+ .cd{cursor:pointer}
74
+ .m-bundle .ed{display:none}
75
+ .m-biz .bd,.m-biz .ed.sys{display:none}
76
+ .m-all .bd{display:none}
77
+ .dim .ed:not(.hot),.dim .bd:not(.hot){opacity:.04!important}
78
+ .dim .cd:not(.hot){opacity:.16}
79
+ .ed.hot,.bd.hot{opacity:1!important;stroke:#09090B}
80
+ .ed.hot{stroke-width:2.4}
81
+ .cd.hot .bd0{stroke-width:2.6}
82
+ .cd.src .bd0{stroke-width:3.4}
83
+ @keyframes flow{to{stroke-dashoffset:-32}}
84
+ @keyframes pulse{0%,100%{stroke-width:2}50%{stroke-width:4.6}}
85
+ .ed.hot,.bd.hot{stroke-dasharray:11 5;animation:flow .75s linear infinite}
86
+ .cd.src .bd0{animation:pulse 1.15s ease-in-out infinite}
87
+ @media (prefers-reduced-motion:reduce){
88
+ .ed.hot,.bd.hot,.cd.src .bd0{animation:none!important}
89
+ .ed.hot,.bd.hot{stroke-dasharray:none}}
90
+
91
+ aside{position:fixed;right:0;top:0;bottom:0;width:344px;background:var(--pnl);border-left:1.5px solid var(--ink);
92
+ padding:24px;overflow:auto;transform:translateX(100%);transition:transform .18s;z-index:30}
93
+ aside.on{transform:none}
94
+ aside h2{margin:0 0 3px;font-size:20px;font-weight:800;letter-spacing:-.02em}
95
+ aside .ln{font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:600;color:var(--ink2);margin-bottom:16px}
96
+ aside .kv{display:flex;justify-content:space-between;font-size:13px;padding:7px 0;border-bottom:1px solid var(--line)}
97
+ aside .kv span:first-child{color:var(--ink2)}
98
+ aside .kv span:last-child{font-weight:700}
99
+ aside h3{font-size:11px;color:var(--ink3);margin:22px 0 8px;letter-spacing:.1em;font-weight:800}
100
+ aside ul{list-style:none;margin:0;padding:0}
101
+ aside li{font-size:12.5px;padding:7px 0;border-bottom:1px solid var(--line);line-height:1.45}
102
+ aside li a{color:var(--ink);text-decoration:none;font-weight:700;border-bottom:1.5px solid var(--line2);cursor:pointer}
103
+ aside li b{font-family:ui-monospace,Menlo,monospace;font-weight:500;color:var(--ink3);font-size:11.5px}
104
+ aside .cl{position:absolute;right:18px;top:18px;border:1.5px solid var(--ink);background:var(--pnl);
105
+ width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:15px;line-height:1}
106
+ .tagx{display:inline-block;padding:2px 8px;border-radius:5px;font-size:10.5px;font-weight:800;border:1.2px solid var(--ink)}
107
+
108
+ /* ── DBML 편집 ── */
109
+ #editor{position:fixed;left:0;right:0;bottom:0;height:52vh;background:var(--pnl);
110
+ border-top:2px solid var(--ink);z-index:40;transform:translateY(100%);transition:transform .2s;
111
+ display:flex;flex-direction:column}
112
+ #editor.on{transform:none}
113
+ #editor .bar{display:flex;gap:10px;align-items:center;padding:11px 20px;border-bottom:1px solid var(--line)}
114
+ #editor .bar strong{font-size:14px;font-weight:800}
115
+ #editor textarea{flex:1;border:0;outline:0;resize:none;padding:16px 20px;font-size:12.5px;line-height:1.6;
116
+ font-family:ui-monospace,Menlo,monospace;color:var(--ink);background:var(--pnl2)}
117
+ .btn{border:1.5px solid var(--ink);background:var(--pnl);color:var(--ink);border-radius:8px;
118
+ padding:6px 13px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}
119
+ .btn.pri{background:var(--ink);color:#fff}
120
+ .btn:disabled{opacity:.4;cursor:default}
121
+ #stat{font-size:12px;font-weight:700}
122
+ #stat.ok{color:var(--ok)} #stat.bad{color:var(--bad)}
123
+ .busy{position:fixed;inset:0;background:rgba(255,255,255,.72);z-index:50;display:none;
124
+ align-items:center;justify-content:center;font-weight:800;font-size:15px}
125
+ .busy.on{display:flex}
126
+ @media print{header,aside,#editor,.hint{display:none}.scroll{overflow:visible;padding:0}}
127
+ </style></head><body>
128
+
129
+ <header>
130
+ <div><h1 id="ttl">ERD</h1><div class="sub" id="sub"></div></div>
131
+ <div class="sp"></div>
132
+ <div class="seg" id="views" hidden>
133
+ <button data-v="ov">업무 흐름</button>
134
+ <button class="on" data-v="dt">전체 관계도</button>
135
+ </div>
136
+ <span class="lab" id="wlab">배선</span>
137
+ <div class="seg" id="modes">
138
+ <button class="on" data-m="bundle">그룹 흐름</button>
139
+ <button data-m="biz">업무</button>
140
+ <button data-m="all">전체</button>
141
+ </div>
142
+ <input id="find" placeholder="테이블 검색" autocomplete="off">
143
+ <span id="fstat"></span>
144
+ <div class="zoom">
145
+ <button id="zout" title="축소">−</button>
146
+ <span id="zval">100%</span>
147
+ <button id="zin" title="확대">+</button>
148
+ <button id="zfit" title="화면 맞춤" style="width:auto;padding:0 9px;font-size:12px">맞춤</button>
149
+ </div>
150
+ <button class="btn" id="verify">검증</button>
151
+ <button class="btn" id="edit">DBML 편집</button>
152
+ </header>
153
+ <div class="hint" id="hint"></div>
154
+ <div class="view" id="ov">
155
+ <div id="ovwarn" style="display:none"></div>
156
+ <div class="scroll" id="ovstage">__OVSVG__</div>
157
+ </div>
158
+ <div class="view on" id="dtview"><div class="scroll" id="stage">__SVG__</div></div>
159
+
160
+ <aside id="pn"><button class="cl" onclick="closePn()">×</button>
161
+ <h2 id="pk"></h2><div class="ln" id="pl"></div><div id="pm"></div><div id="po"></div><div id="pi"></div></aside>
162
+
163
+ <div id="editor">
164
+ <div class="bar">
165
+ <strong>DBML</strong>
166
+ <span id="stat"></span>
167
+ <div class="sp"></div>
168
+ <button class="btn pri" id="apply">적용 · 재배선</button>
169
+ <button class="btn" id="copy">복사</button>
170
+ <button class="btn" id="down">내려받기</button>
171
+ <button class="btn" id="reset">되돌리기</button>
172
+ <button class="btn" id="close">닫기</button>
173
+ </div>
174
+ <textarea id="src" spellcheck="false"></textarea>
175
+ </div>
176
+ <div class="busy" id="busy">재배선 중…</div>
177
+ <div id="vpanel"><button class="cl2" onclick="document.querySelector('#vpanel').classList.remove('on')">×</button>
178
+ <h3>검증</h3><div id="vbody"></div></div>
179
+
180
+ <script>
181
+ /*__ENGINE__*/
182
+
183
+ const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
184
+ const ORIGINAL=__DBML__;
185
+ let META=__META__, NBR=__NBR__, TMETA=__TMETA__;
186
+ const OVSPEC=__OVSPEC__;
187
+ let mode='bundle', locked=false, dbmlText=ORIGINAL;
188
+
189
+ const HINTS={
190
+ bundle:'<b>그룹 흐름</b> — 영역 사이 외래키를 묶은 굵은 선입니다. <b>굵기 = 참조 수.</b> 선에 마우스를 올리면 건수가 나옵니다.',
191
+ biz:'<b>업무 관계</b> — 담당자·통화·감사 같은 시스템 배선을 뺀 외래키입니다. 카드에 <b>마우스를 올리면</b> 미리보기, <b>클릭</b>하면 고정됩니다.',
192
+ all:'<b>전체 관계</b> — 시스템 배선을 포함한 모든 외래키입니다. 밀도가 높으니 카드를 클릭해서 보세요.'};
193
+
194
+ function head(){
195
+ $('#ttl').textContent=META.title;
196
+ $('#sub').textContent='테이블 '+META.tables+' · 관계 '+META.refs+' (업무 '+META.biz+' · 시스템 '+META.sys+')'
197
+ +' · 그룹 '+META.groups+(META.check&&!META.check.ok?' ⚠ 검증 실패':'');
198
+ $('#hint').innerHTML=HINTS[mode];
199
+ }
200
+ function setMode(m){
201
+ mode=m;
202
+ $$('#modes button').forEach(b=>b.classList.toggle('on',b.dataset.m===m));
203
+ $('#stage').className='scroll m-'+m;
204
+ closePn(); head();
205
+ }
206
+ $$('#modes button').forEach(b=>b.onclick=()=>setMode(b.dataset.m));
207
+
208
+ /* ── 강조 ── */
209
+ function clearHot(){ const s=$('#detail'); if(!s)return;
210
+ s.classList.remove('dim'); $$('#detail .hot,#detail .src').forEach(x=>x.classList.remove('hot','src')); }
211
+ function closePn(){ locked=false; $('#pn').classList.remove('on'); clearHot(); }
212
+ function hot(t){
213
+ const s=$('#detail'); s.classList.add('dim');
214
+ $$('#detail .hot,#detail .src').forEach(x=>x.classList.remove('hot','src'));
215
+ const keep=new Set([t]);
216
+ $$('#detail .ed').forEach(p=>{
217
+ if(p.classList.contains('sys')&&mode!=='all')return;
218
+ if(p.dataset.a===t||p.dataset.b===t){p.classList.add('hot');keep.add(p.dataset.a);keep.add(p.dataset.b);
219
+ p.parentNode.appendChild(p);}});
220
+ $$('#detail .cd').forEach(c=>{ if(c.dataset.t===t)c.classList.add('hot','src');
221
+ else if(keep.has(c.dataset.t))c.classList.add('hot'); });
222
+ }
223
+ function bindCards(){
224
+ $$('#detail .cd').forEach(g=>{
225
+ g.addEventListener('mouseenter',()=>{ if(!locked) hot(g.dataset.t); });
226
+ g.addEventListener('mouseleave',()=>{ if(!locked) clearHot(); });
227
+ g.addEventListener('click',ev=>{ ev.stopPropagation(); focusOn(g.dataset.t); });
228
+ });
229
+ const s=$('#detail');
230
+ if(s) s.addEventListener('click',ev=>{ if(!ev.target.closest('.cd')) closePn(); });
231
+ }
232
+ function focusOn(t){
233
+ locked=true;
234
+ if(mode==='bundle') setMode('biz');
235
+ locked=true; hot(t); panel(t);
236
+ }
237
+ const kv=(k,v)=>'<div class="kv"><span>'+k+'</span><span>'+v+'</span></div>';
238
+ function sec(title,arr){
239
+ if(!arr.length) return '<h3>'+title+'</h3><div style="font-size:12.5px;color:var(--ink3);padding:6px 0">없음</div>';
240
+ return '<h3>'+title+' · '+arr.length+'</h3><ul>'+arr.map(x=>
241
+ '<li><a onclick="focusOn(\''+x.o+'\')">'+((TMETA[x.o]||{}).ko||x.o)+'</a> <b>'+x.col+'</b></li>').join('')+'</ul>';
242
+ }
243
+ function panel(t){
244
+ const m=TMETA[t]; if(!m) return;
245
+ const c=META.palette[m.dom]||['#eee','#666'];
246
+ $('#pk').textContent=m.ko; $('#pl').textContent=t;
247
+ const ls=(NBR[t]||[]).filter(x=>mode==='all'||!isSys({to:x.dir==='out'?x.o:t,col:x.col}));
248
+ $('#pm').innerHTML='<div style="margin-bottom:14px;display:flex;gap:6px;flex-wrap:wrap">'
249
+ +'<span class="tagx" style="background:'+c[0]+'">'+m.dom+'</span>'
250
+ +(m.custom?'<span class="tagx" style="background:#fff">커스텀</span>':'')
251
+ +(m.stub?'<span class="tagx" style="background:#09090B;color:#fff;border-color:#09090B">정의 없음</span>':'')
252
+ +'</div>'+kv('총 속성',m.stub?'알 수 없음':m.total+'개')
253
+ +kv('참조함',ls.filter(x=>x.dir==='out').length+'건')
254
+ +kv('참조받음',ls.filter(x=>x.dir==='in').length+'건');
255
+ $('#po').innerHTML=sec('참조함 — 이 테이블의 외래키',ls.filter(x=>x.dir==='out'));
256
+ $('#pi').innerHTML=sec('참조받음 — 여기를 가리키는 것',ls.filter(x=>x.dir==='in'));
257
+ $('#pn').classList.add('on');
258
+ }
259
+
260
+ /* ── DBML 편집 → 재배선 ── */
261
+ const ed=$('#editor'), ta=$('#src'), stat=$('#stat');
262
+ $('#edit').onclick=()=>{ ta.value=dbmlText; ed.classList.add('on'); stat.textContent=''; ta.focus(); };
263
+ $('#close').onclick=()=>ed.classList.remove('on');
264
+ $('#reset').onclick=()=>{ ta.value=ORIGINAL; stat.textContent='원본으로 되돌렸습니다. 적용을 누르세요.'; stat.className=''; };
265
+ $('#copy').onclick=async()=>{
266
+ try{ await navigator.clipboard.writeText(ta.value); stat.textContent='복사했습니다'; stat.className='ok'; }
267
+ catch(e){ ta.select(); document.execCommand('copy'); stat.textContent='복사했습니다'; stat.className='ok'; }
268
+ };
269
+ $('#down').onclick=()=>{
270
+ const b=new Blob([ta.value],{type:'text/plain'});
271
+ const a=document.createElement('a');
272
+ a.href=URL.createObjectURL(b); a.download='schema.dbml'; a.click();
273
+ setTimeout(()=>URL.revokeObjectURL(a.href),1000);
274
+ stat.textContent='내려받았습니다'; stat.className='ok';
275
+ };
276
+ $('#apply').onclick=()=>{
277
+ const busy=$('#busy'); busy.classList.add('on');
278
+ setTimeout(()=>{
279
+ try{
280
+ const errs=findBlockErrors(ta.value);
281
+ if(errs.length) throw Object.assign(new Error(errs[0].msg),{line:errs[0].line});
282
+ const warn=rebuild(ta.value);
283
+ stat.textContent='적용됨 · 테이블 '+META.tables+' · 관계 '+META.refs
284
+ +(ovWarn?' ⚠ 조감도 '+ovWarn+'건 어긋남':'')
285
+ +(warn.length?' (경고 '+warn.length+'건: '+warn[0]+')':'');
286
+ stat.className=ovWarn?'bad':(warn.length?'':'ok');
287
+ }
288
+ catch(e){
289
+ stat.textContent=(e.line?e.line+'행 — ':'')+'실패: '+e.message;
290
+ stat.className='bad';
291
+ if(e.line){ // 해당 줄로 커서를 옮긴다
292
+ const upto=ta.value.split('\n').slice(0,e.line-1).join('\n').length+1;
293
+ ta.focus(); ta.setSelectionRange(upto, upto+(ta.value.split('\n')[e.line-1]||'').length);
294
+ }
295
+ }
296
+ busy.classList.remove('on');
297
+ },30);
298
+ };
299
+
300
+ function rebuild(src){
301
+ const model=parseDbml(src);
302
+ if(!model.tables.size) throw new Error('Table 을 하나도 읽지 못했습니다');
303
+ const lay=layout(model);
304
+ const w=wire(model,lay);
305
+ const pal=palette(lay.order);
306
+ const svg=renderDetail(model,lay,w,{
307
+ title:META.title,
308
+ subtitle:'테이블 '+model.tables.size+' · 관계 '+model.refs.length+' · 그룹 '+lay.order.length+' · 직각 배선',
309
+ palette:pal});
310
+
311
+ // 검증 — 편집 결과도 같은 기준으로 판정한다
312
+ const chk={pierce:checkPierce(w.paths,w.RECT,w.skips).length,
313
+ cardOverlap:checkCardOverlap(w.RECT,w.names).length,
314
+ overlap:measureOverlap(w.paths)};
315
+
316
+ dbmlText=src;
317
+ $('#stage').innerHTML=svg;
318
+ META={...META,tables:model.tables.size,refs:model.refs.length,groups:lay.order.length,
319
+ biz:w.isSysRef.filter(x=>!x).length,sys:w.isSysRef.filter(x=>x).length,
320
+ bundles:w.bundleKeys.length,order:lay.order,
321
+ palette:Object.fromEntries(lay.order.map(d=>[d,pal.get(d)])),
322
+ check:{ok:chk.pierce===0&&chk.cardOverlap===0}};
323
+ NBR={};
324
+ for(const r of model.refs){
325
+ (NBR[r.from]=NBR[r.from]||[]).push({o:r.to,col:r.col,dir:'out'});
326
+ (NBR[r.to]=NBR[r.to]||[]).push({o:r.from,col:r.col,dir:'in'});
327
+ }
328
+ TMETA={};
329
+ for(const [t,p] of lay.pos){
330
+ const m=model.tables.get(t);
331
+ TMETA[t]={ko:m.label,dom:p.dom,total:m.total,stub:m.stub,custom:m.custom};
332
+ }
333
+ $('#stage').className='scroll m-'+mode;
334
+ bindCards(); head(); applyZoom(); runFind();
335
+
336
+ // 조감도 — 손으로 그린 명세가 '이 스키마에 해당하는지' 먼저 본다.
337
+ // 해당함 → 배치는 그대로 두고 근거만 다시 대조 (붉은 점선으로 표시)
338
+ // 해당없음 → 다른 스키마용 그림이므로 DBML 에서 자동 생성으로 갈아끼운다
339
+ {
340
+ const fit = OVSPEC ? overviewFit(OVSPEC, model) : null;
341
+ const useCurated = fit && fit.applicable;
342
+ const spec = useCurated ? OVSPEC : autoOverview(model, lay);
343
+ const ovc = verifyOverview(spec, model);
344
+ $('#ov .scroll').innerHTML = renderOverview(spec, model);
345
+ bindOverview();
346
+ META.overviewCheck = ovc;
347
+ META.overviewAuto = !useCurated;
348
+
349
+ const n = ovc.edgeNoEvidence.length + ovc.gapContradicted.length + ovc.missingTables.length;
350
+ let msg = '';
351
+ if (!useCurated && OVSPEC) {
352
+ msg = '<b>이 스키마에는 준비된 조감도가 없어 자동 생성했습니다.</b> '
353
+ + 'overview.json 의 노드 ' + fit.total + '개 중 ' + fit.alive + '개만 현재 DBML 에 있습니다 — '
354
+ + '다른 스키마용 그림이라 쓰지 않았습니다. 모든 선은 실제 외래키입니다.';
355
+ } else if (n) {
356
+ msg = '<b>조감도 ' + n + '건이 편집된 DBML 과 어긋납니다.</b> '
357
+ + (ovc.edgeNoEvidence.length ? '근거 잃은 연결 ' + ovc.edgeNoEvidence.length + '건(붉은 점선) ' : '')
358
+ + (ovc.gapContradicted.length ? '실제로는 있는 관계 ' + ovc.gapContradicted.length + '건 ' : '')
359
+ + (ovc.missingTables.length ? '없는 테이블 ' + ovc.missingTables.length + '건' : '');
360
+ }
361
+ $('#ovwarn').innerHTML = msg;
362
+ $('#ovwarn').style.display = msg ? '' : 'none';
363
+ $('#ovwarn').className = (!useCurated && OVSPEC) ? 'info' : '';
364
+ ovWarn = useCurated ? n : 0;
365
+ }
366
+ if(chk.pierce||chk.cardOverlap) console.warn('검증 실패', chk);
367
+ return model.warnings;
368
+ }
369
+
370
+ /* ── 검색 ─────────────────────────────────────────────── */
371
+ const findEl=$('#find');
372
+ function runFind(){
373
+ const q=findEl.value.trim().toLowerCase();
374
+ const stage=$('#stage');
375
+ $$('#detail .found').forEach(c=>c.classList.remove('found'));
376
+ if(!q){ stage.classList.remove('seek'); $('#fstat').textContent=''; return; }
377
+ let n=0, first=null;
378
+ $$('#detail .cd').forEach(c=>{
379
+ const t=c.dataset.t, m=TMETA[t]||{};
380
+ if(t.toLowerCase().includes(q)||String(m.ko||'').toLowerCase().includes(q)){
381
+ c.classList.add('found'); n++; if(!first) first=c;
382
+ }
383
+ });
384
+ stage.classList.toggle('seek', n>0);
385
+ $('#fstat').textContent = n ? n+'개' : '없음';
386
+ if(first) first.scrollIntoView({block:'center',inline:'center'});
387
+ }
388
+ findEl.addEventListener('input',runFind);
389
+ findEl.addEventListener('keydown',e=>{
390
+ if(e.key==='Escape'){ findEl.value=''; runFind(); findEl.blur(); }
391
+ if(e.key==='Enter'){ const f=$('#detail .cd.found'); if(f) focusOn(f.dataset.t); }
392
+ });
393
+
394
+ /* ── 확대·축소 ─────────────────────────────────────────── */
395
+ let zoom=1;
396
+ function applyZoom(){
397
+ const svg=$('#detail'); if(!svg) return;
398
+ const vb=svg.getAttribute('viewBox').split(' ').map(Number);
399
+ svg.setAttribute('width', Math.round(vb[2]*zoom));
400
+ svg.setAttribute('height', Math.round(vb[3]*zoom));
401
+ $('#zval').textContent=Math.round(zoom*100)+'%';
402
+ }
403
+ function fitZoom(){
404
+ const svg=$('#detail'); if(!svg) return;
405
+ const vb=svg.getAttribute('viewBox').split(' ').map(Number);
406
+ const box=$('#stage').clientWidth-48;
407
+ zoom=Math.min(1, Math.max(0.1, box/vb[2]));
408
+ applyZoom();
409
+ }
410
+ $('#zin').onclick=()=>{ zoom=Math.min(3,zoom*1.25); applyZoom(); };
411
+ $('#zout').onclick=()=>{ zoom=Math.max(0.1,zoom/1.25); applyZoom(); };
412
+ $('#zfit').onclick=fitZoom;
413
+
414
+ /* ── 검증 — 브라우저 실측 ───────────────────────────────
415
+ Node 검사는 텍스트 폭이 추정치다. 실제 폰트 메트릭은 브라우저만 안다.
416
+ 함정 둘을 코드로 막는다:
417
+ · getBBox() 는 숨겨진 요소에서 0 을 반환한다 → 보이는지 먼저 확인
418
+ · transform 그룹 안에서는 로컬 좌표를 준다 → getScreenCTM 으로 변환 */
419
+ function userBox(el, svg){
420
+ const b=el.getBBox();
421
+ const toRoot=svg.getScreenCTM().inverse();
422
+ const m=el.getScreenCTM();
423
+ if(!m) return null;
424
+ const pts=[[b.x,b.y],[b.x+b.width,b.y],[b.x,b.y+b.height],[b.x+b.width,b.y+b.height]]
425
+ .map(([x,y])=>{ const p=svg.createSVGPoint(); p.x=x; p.y=y;
426
+ const q=p.matrixTransform(m).matrixTransform(toRoot); return [q.x,q.y]; });
427
+ const xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
428
+ return {x:Math.min(...xs),y:Math.min(...ys),x2:Math.max(...xs),y2:Math.max(...ys)};
429
+ }
430
+ function runVerify(){
431
+ const svg=$('#detail');
432
+ const panel=$('#vpanel'), body=$('#vbody');
433
+ panel.classList.add('on');
434
+ if(!svg || !svg.getBoundingClientRect().width){
435
+ body.innerHTML='<div class="note">도면이 화면에 보이지 않아 잴 수 없습니다. '
436
+ +'숨겨진 요소에서는 getBBox 가 0 을 반환해 <b>거짓 통과</b>가 납니다.</div>';
437
+ return;
438
+ }
439
+ const model=parseDbml(dbmlText);
440
+ const lay=layout(model), w=wire(model,lay);
441
+ const geo={pierce:checkPierce(w.paths,w.RECT,w.skips).length,
442
+ bundle:checkPierce(w.bundlePaths,w.bundleRect,w.bundleSkips).length,
443
+ card:checkCardOverlap(w.RECT,w.names).length,
444
+ ov:measureOverlap(w.paths)};
445
+
446
+ // 실제 렌더 텍스트가 카드를 넘는가 (추정이 아니라 실측)
447
+ let over=0, sample='';
448
+ $$('#detail .cd').forEach(g=>{
449
+ const r=g.querySelector('.bd0'); if(!r) return;
450
+ const rx=+r.getAttribute('x'), rw=+r.getAttribute('width');
451
+ const ry=+r.getAttribute('y'), rh=+r.getAttribute('height');
452
+ g.querySelectorAll('text').forEach(t=>{
453
+ if(t.getAttribute('text-anchor')==='end') return;
454
+ const b=userBox(t,svg); if(!b) return;
455
+ if(b.x2>rx+rw-4||b.y2>ry+rh-1){ over++; if(!sample) sample=g.dataset.t+' "'+t.textContent+'"'; }
456
+ });
457
+ });
458
+ // 캔버스 이탈
459
+ const vb=svg.getAttribute('viewBox').split(' ').map(Number);
460
+ let esc=0;
461
+ $$('#detail text').forEach(t=>{ const b=userBox(t,svg);
462
+ if(b&&(b.x<0||b.y<0||b.x2>vb[2]||b.y2>vb[3])) esc++; });
463
+
464
+ const rows=[['카드 관통',geo.pierce],['번들 관통',geo.bundle],['카드 겹침',geo.card],
465
+ ['텍스트 넘침 (실측)',over],['캔버스 이탈 (실측)',esc]];
466
+ const ok=rows.every(r=>r[1]===0);
467
+ body.innerHTML=rows.map(([k,v])=>
468
+ '<div class="r '+(v===0?'pass':'fail')+'"><span>'+k+'</span><b>'+v+'</b></div>').join('')
469
+ +'<div class="r"><span>선 겹침</span><b>'+geo.ov.overlapPairs+'쌍 / '+geo.ov.overlapLen+'px</b></div>'
470
+ +'<div class="sum" style="color:'+(ok?'var(--ok)':'var(--bad)')+'">'
471
+ +(ok?'통과 — 필수 항목 0건':'실패'+(sample?' · 예: '+sample:''))+'</div>'
472
+ +(META.overviewCheck ? '<div class="r '+(META.overviewCheck.ok?'pass':'fail')+'">'
473
+ +'<span>조감도 ↔ DBML 대조</span><b>'
474
+ +(META.overviewCheck.ok?'통과':'실패 '+(META.overviewCheck.edgeNoEvidence.length
475
+ +META.overviewCheck.gapContradicted.length)+'건')+'</b></div>' : '')
476
+ +'<div class="note">텍스트는 <b>추정 폭이 아니라 브라우저 실측</b>입니다. '
477
+ +'선 겹침은 실패 항목이 아닙니다 — 허브 앞에서 모이는 건 자연스럽습니다.</div>';
478
+ }
479
+ $('#verify').onclick=runVerify;
480
+
481
+ /* 단축키 */
482
+ addEventListener('keydown',e=>{
483
+ if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT') return;
484
+ if(e.key==='/'){ e.preventDefault(); findEl.focus(); }
485
+ if(e.key==='0'){ fitZoom(); }
486
+ });
487
+
488
+ /* ── 뷰 전환 (조감도 / 전체 관계도) ─────────────────────── */
489
+ const HINT_OV='<b>업무 흐름 조감도</b> — 고객 설명용 한 장입니다. 카드에 <b>마우스를 올리면</b> '
490
+ +'연결된 곳과 선이 흐르며 강조됩니다. 모든 연결선은 DBML 과 대조된 실제 외래키이고, '
491
+ +'검정 점선 <b>①②</b>는 <b>있어야 할 것 같으나 실제로는 없는 연결</b>입니다.';
492
+ function setView(v){
493
+ $$('#views button').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
494
+ $('#ov').classList.toggle('on',v==='ov');
495
+ $('#dtview').classList.toggle('on',v==='dt');
496
+ const dt = v==='dt';
497
+ $('#modes').style.display = dt?'':'none';
498
+ $('#wlab').style.display = dt?'':'none';
499
+ $('#find').style.display = dt?'':'none';
500
+ $('#fstat').style.display = dt?'':'none';
501
+ $('.zoom').style.display = dt?'':'none';
502
+ closePn(); ovClear();
503
+ $('#hint').innerHTML = dt ? HINTS[mode] : HINT_OV;
504
+ }
505
+ if($('#ov svg')){
506
+ $('#views').hidden=false;
507
+ $$('#views button').forEach(b=>b.onclick=()=>setView(b.dataset.v));
508
+ }
509
+
510
+ /* ── 조감도 hover — 재렌더 후에도 다시 걸 수 있게 함수로 둔다 ── */
511
+ const ovSvg=()=>$('#ov svg');
512
+ function ovClear(){ const s=ovSvg(); if(!s)return; s.classList.remove('dim');
513
+ s.querySelectorAll('.ove,.ovn').forEach(x=>x.classList.remove('hot','src')); }
514
+ function ovHover(id){
515
+ const s=ovSvg(); if(!s)return;
516
+ s.classList.add('dim');
517
+ s.querySelectorAll('.ove,.ovn').forEach(x=>x.classList.remove('hot','src'));
518
+ const keep=new Set([id]);
519
+ s.querySelectorAll('.ove').forEach(p=>{ if(p.dataset.a===id||p.dataset.b===id){
520
+ p.classList.add('hot'); keep.add(p.dataset.a); keep.add(p.dataset.b);
521
+ p.parentNode.appendChild(p); }});
522
+ s.querySelectorAll('.ovn').forEach(n=>{ if(n.dataset.id===id) n.classList.add('src','hot');
523
+ else if(keep.has(n.dataset.id)) n.classList.add('hot'); });
524
+ }
525
+ function bindOverview(){
526
+ const s=ovSvg(); if(!s)return;
527
+ s.querySelectorAll('.ovn').forEach(n=>{
528
+ n.addEventListener('mouseenter',()=>ovHover(n.dataset.id));
529
+ n.addEventListener('mouseleave',e=>{
530
+ if(!e.relatedTarget||!e.relatedTarget.closest||!e.relatedTarget.closest('.ovn')) ovClear(); });
531
+ });
532
+ s.addEventListener('mouseleave',ovClear);
533
+ }
534
+ bindOverview();
535
+
536
+ head(); setMode('bundle'); bindCards(); applyZoom();
537
+ if($('#ov svg')) setView('dt');
538
+ window.focusOn=focusOn; window.closePn=closePn;
539
+ </script></body></html>