docutrack 0.1.1 → 0.1.6

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,1641 +1,1545 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-theme="light">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>DocuTrack</title>
7
- <style>
8
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
-
10
- :root {
11
- --bg: #ffffff;
12
- --bg-sidebar: #f8fafc;
13
- --bg-hover: #f1f5f9;
14
- --bg-active: #eef2ff;
15
- --border: #e2e8f0;
16
- --text: #0f172a;
17
- --text-muted: #64748b;
18
- --text-sidebar: #374151;
19
- --accent: #6366f1;
20
- --accent-hover: #4f46e5;
21
- --accent-light: #eef2ff;
22
- --code-bg: #f1f5f9;
23
- --code-text: #1e293b;
24
- --green: #10b981; --green-bg: #dcfce7; --green-text: #166534;
25
- --blue-bg: #dbeafe; --blue-text: #1e40af;
26
- --yellow: #f59e0b; --yellow-bg: #fef3c7; --yellow-text: #92400e;
27
- --orange-bg: #ffedd5; --orange-text: #9a3412;
28
- --purple-bg: #ede9fe; --purple-text: #6d28d9;
29
- --red: #ef4444; --red-bg: #fee2e2; --red-text: #991b1b;
30
- --sidebar-w: 272px;
31
- --header-h: 56px;
32
- --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
33
- --font-mono: "Cascadia Code", "Fira Code", Consolas, monospace;
34
- --radius: 6px;
35
- }
36
-
37
- [data-theme="dark"] {
38
- --bg: #0f172a; --bg-sidebar: #1e293b; --bg-hover: #334155; --bg-active: #312e81;
39
- --border: #334155; --text: #f1f5f9; --text-muted: #94a3b8; --text-sidebar: #cbd5e1;
40
- --accent: #818cf8; --accent-hover: #a5b4fc; --accent-light: #1e1b4b;
41
- --code-bg: #1e293b; --code-text: #e2e8f0;
42
- --green-bg: #14532d; --green-text: #86efac;
43
- --blue-bg: #1e3a5f; --blue-text: #93c5fd;
44
- --yellow-bg: #78350f; --yellow-text: #fde68a;
45
- --orange-bg: #7c2d12; --orange-text: #fed7aa;
46
- --purple-bg: #3b0764; --purple-text: #d8b4fe;
47
- --red-bg: #7f1d1d; --red-text: #fca5a5;
48
- }
49
-
50
- html, body { height: 100%; font-family: var(--font); color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.6; }
51
-
52
- /* ── Layout ── */
53
- #app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; grid-template-rows: var(--header-h) 1fr; height: 100vh; }
54
-
55
- /* ── Header ── */
56
- #header { grid-column: 1 / -1; background: var(--bg); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 16px; position: sticky; top: 0; z-index: 100; }
57
- .logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: var(--text); text-decoration: none; white-space: nowrap; cursor: pointer; }
58
- .logo-icon { width: 28px; height: 28px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
59
- .header-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
60
- #project-name { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
61
- .header-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
62
- #coverage-badge { display: flex; align-items: center; gap: 6px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 20px; padding: 3px 10px; font-size: 12px; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
63
- .coverage-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
64
- #theme-toggle { width: 32px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; }
65
- #theme-toggle:hover { background: var(--bg-hover); }
66
- .live-pill { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); }
67
- .live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; flex-shrink: 0; }
68
- @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
69
-
70
- /* ── Search ── */
71
- #search-wrap { flex: 1; max-width: 380px; position: relative; }
72
- #search { width: 100%; padding: 6px 12px 6px 34px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-sidebar); color: var(--text); font-size: 13px; outline: none; }
73
- #search:focus { border-color: var(--accent); background: var(--bg); }
74
- #search::placeholder { color: var(--text-muted); }
75
- .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; pointer-events: none; color: var(--text-muted); }
76
- .search-kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10px; color: var(--text-muted); background: var(--border); padding: 1px 5px; border-radius: 3px; pointer-events: none; }
77
-
78
- /* ── Sidebar ── */
79
- #sidebar { background: var(--bg-sidebar); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0 80px; }
80
- .nav-section { margin-bottom: 4px; }
81
- .nav-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 8px 20px 4px; }
82
- .nav-item { display: flex; align-items: center; gap: 8px; padding: 6px 20px; font-size: 13px; color: var(--text-sidebar); cursor: pointer; border-left: 2px solid transparent; }
83
- .nav-item:hover { background: var(--bg-hover); color: var(--text); }
84
- .nav-item.active { background: var(--bg-active); color: var(--accent); border-left-color: var(--accent); font-weight: 500; }
85
- .nav-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
86
- .nav-empty { font-size: 12px; color: var(--text-muted); padding: 4px 20px 8px; font-style: italic; }
87
- .nav-badge { margin-left: auto; font-size: 11px; background: var(--accent-light); color: var(--accent); border-radius: 10px; padding: 0 6px; font-weight: 600; }
88
- .nav-stale { margin-left: auto; font-size: 10px; background: var(--yellow-bg); color: var(--yellow-text); border-radius: 10px; padding: 0 5px; font-weight: 700; }
89
- .stale-banner { background: var(--yellow-bg); border-top: 1px solid var(--yellow); padding: 8px 20px; font-size: 12px; color: var(--yellow-text); }
90
-
91
- /* Coverage bar */
92
- #coverage-section { margin-top: 16px; padding: 0 16px; }
93
- .coverage-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; display: flex; justify-content: space-between; }
94
- .coverage-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
95
- .coverage-fill { height: 100%; background: var(--green); border-radius: 2px; transition: width .5s; }
96
- .coverage-fill.warn { background: var(--yellow); }
97
- .coverage-fill.bad { background: var(--red); }
98
-
99
- /* ── Main ── */
100
- #main { overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; min-height: 0; }
101
- #doc-wrap { width: 100%; max-width: 800px; padding: 40px 48px 120px; }
102
-
103
- /* Pending banner */
104
- #pending-banner { display: none; background: var(--yellow-bg); border: 1px solid var(--yellow); border-radius: var(--radius); padding: 10px 16px; margin-bottom: 24px; font-size: 13px; color: var(--yellow-text); align-items: center; gap: 8px; }
105
- #pending-banner.show { display: flex; }
106
-
107
- /* ── Markdown ── */
108
- #doc-content h1 { font-size: 28px; font-weight: 800; margin-bottom: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
109
- #doc-content h2 { font-size: 20px; font-weight: 700; margin-top: 36px; margin-bottom: 12px; }
110
- #doc-content h3 { font-size: 16px; font-weight: 600; margin-top: 24px; margin-bottom: 8px; }
111
- #doc-content h4 { font-size: 14px; font-weight: 600; margin-top: 16px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
112
- #doc-content p { margin-bottom: 14px; }
113
- #doc-content ul, #doc-content ol { margin-bottom: 14px; padding-left: 24px; }
114
- #doc-content li { margin-bottom: 4px; }
115
- #doc-content a { color: var(--accent); text-decoration: none; }
116
- #doc-content a:hover { text-decoration: underline; }
117
- #doc-content blockquote { border-left: 3px solid var(--accent); padding: 8px 16px; margin: 16px 0; color: var(--text-muted); background: var(--accent-light); border-radius: 0 var(--radius) var(--radius) 0; }
118
- #doc-content blockquote p { margin: 0; }
119
- #doc-content code { font-family: var(--font-mono); font-size: 13px; background: var(--code-bg); color: var(--code-text); padding: 2px 6px; border-radius: 4px; }
120
- #doc-content pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; overflow-x: auto; margin: 16px 0; }
121
- #doc-content pre code { background: none; padding: 0; font-size: 13px; line-height: 1.6; }
122
- #doc-content table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
123
- #doc-content th { background: var(--bg-sidebar); font-weight: 600; text-align: left; padding: 10px 14px; border: 1px solid var(--border); }
124
- #doc-content td { padding: 9px 14px; border: 1px solid var(--border); }
125
- #doc-content tr:nth-child(even) td { background: var(--bg-sidebar); }
126
- #doc-content hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
127
- .mermaid-wrap { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin: 20px 0; overflow-x: auto; text-align: center; }
128
-
129
- /* ── API Explorer ── */
130
- .api-explorer { width: 100%; }
131
- .api-explorer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
132
- .base-url-wrap { display: flex; flex-direction: column; gap: 4px; min-width: 260px; }
133
- .base-url-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
134
- .base-url-row { display: flex; gap: 6px; align-items: center; }
135
- .base-url-input { padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); width: 260px; outline: none; }
136
- .base-url-input:focus { border-color: var(--accent); }
137
- .auth-toggle { padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 12px; cursor: pointer; white-space: nowrap; }
138
- .auth-toggle:hover { background: var(--bg-hover); }
139
- .auth-row { margin-top: 8px; display: none; gap: 6px; align-items: center; }
140
- .auth-row.show { display: flex; }
141
- .auth-input { flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); outline: none; }
142
- .auth-input:focus { border-color: var(--accent); }
143
- .auth-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
144
-
145
- .api-group { margin-bottom: 24px; }
146
- .api-group-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin-bottom: 8px; padding: 0 2px; }
147
-
148
- .endpoint-card { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; overflow: hidden; transition: box-shadow .15s; }
149
- .endpoint-card.open { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
150
- .endpoint-header { display: flex; align-items: center; gap: 12px; padding: 11px 16px; cursor: pointer; background: var(--bg); user-select: none; }
151
- .endpoint-header:hover { background: var(--bg-hover); }
152
- .endpoint-path { font-family: var(--font-mono); font-size: 13px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
153
- .endpoint-summary { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
154
- .endpoint-chevron { color: var(--text-muted); font-size: 16px; transition: transform .2s; flex-shrink: 0; }
155
- .endpoint-card.open .endpoint-chevron { transform: rotate(90deg); }
156
- .endpoint-body { display: none; border-top: 1px solid var(--border); background: var(--bg); }
157
- .endpoint-card.open .endpoint-body { display: block; }
158
- .endpoint-section { padding: 16px 20px; border-bottom: 1px solid var(--border); }
159
- .endpoint-section:last-child { border-bottom: none; }
160
- .section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: 12px; }
161
-
162
- /* Method badges */
163
- .method-badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; letter-spacing: .05em; font-family: var(--font-mono); flex-shrink: 0; min-width: 54px; justify-content: center; }
164
- .badge-get { background: var(--green-bg); color: var(--green-text); }
165
- .badge-post { background: var(--blue-bg); color: var(--blue-text); }
166
- .badge-put { background: var(--orange-bg); color: var(--orange-text); }
167
- .badge-patch { background: var(--purple-bg); color: var(--purple-text); }
168
- .badge-delete { background: var(--red-bg); color: var(--red-text); }
169
- .badge-options,.badge-head { background: var(--code-bg); color: var(--text-muted); }
170
-
171
- /* Params table */
172
- .params-table { width: 100%; border-collapse: collapse; font-size: 13px; }
173
- .params-table th { font-size: 11px; font-weight: 600; text-align: left; padding: 6px 10px; background: var(--bg-sidebar); border: 1px solid var(--border); color: var(--text-muted); }
174
- .params-table td { padding: 7px 10px; border: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; }
175
- .param-required { background: var(--red-bg); color: var(--red-text); padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; font-family: var(--font); }
176
- .param-optional { background: var(--code-bg); color: var(--text-muted); padding: 1px 6px; border-radius: 3px; font-size: 10px; font-family: var(--font); }
177
-
178
- /* Try it out */
179
- .try-section { padding: 16px 20px; background: var(--bg-sidebar); }
180
- .try-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; }
181
- .try-btn:hover { background: var(--accent-hover); }
182
- .try-btn:disabled { opacity: .5; cursor: not-allowed; }
183
- .try-form { margin-top: 14px; display: none; }
184
- .try-form.show { display: block; }
185
- .try-field { margin-bottom: 10px; }
186
- .try-field label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--text-muted); font-family: var(--font-mono); }
187
- .try-field input, .try-field textarea { width: 100%; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); outline: none; }
188
- .try-field input:focus, .try-field textarea:focus { border-color: var(--accent); }
189
- .try-field textarea { min-height: 90px; resize: vertical; }
190
- .try-actions { display: flex; gap: 8px; margin-top: 12px; }
191
- .execute-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; }
192
- .execute-btn:hover { background: var(--accent-hover); }
193
- .execute-btn:disabled { opacity: .5; cursor: not-allowed; }
194
- .cancel-try-btn { padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 13px; cursor: pointer; }
195
- .cancel-try-btn:hover { background: var(--bg-hover); }
196
-
197
- /* Response panel */
198
- .response-panel { margin-top: 14px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
199
- .response-bar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); font-size: 12px; }
200
- .status-chip { font-size: 12px; font-weight: 700; font-family: var(--font-mono); padding: 2px 8px; border-radius: 4px; }
201
- .status-2xx { background: var(--green-bg); color: var(--green-text); }
202
- .status-3xx { background: var(--blue-bg); color: var(--blue-text); }
203
- .status-4xx { background: var(--yellow-bg); color: var(--yellow-text); }
204
- .status-5xx { background: var(--red-bg); color: var(--red-text); }
205
- .response-time { color: var(--text-muted); margin-left: auto; }
206
- .response-body { padding: 14px; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; overflow-x: auto; max-height: 360px; overflow-y: auto; white-space: pre; background: var(--bg); }
207
- .response-headers { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
208
- .cors-warn { margin-top: 10px; padding: 8px 12px; background: var(--yellow-bg); border-radius: var(--radius); font-size: 12px; color: var(--yellow-text); }
209
-
210
- /* Welcome / empty states */
211
- .empty-state { text-align: center; padding: 60px 40px; }
212
- .empty-icon { font-size: 48px; margin-bottom: 16px; }
213
- .empty-state h2 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
214
- .empty-state p { color: var(--text-muted); max-width: 380px; margin: 0 auto 16px; }
215
- .empty-state code { background: var(--code-bg); padding: 4px 10px; border-radius: var(--radius); font-family: var(--font-mono); font-size: 13px; }
216
-
217
- /* Bootstrap panel */
218
- .bootstrap-wrap { max-width: 580px; margin: 0 auto; padding: 48px 0; }
219
- .bootstrap-hero { text-align: center; margin-bottom: 40px; }
220
- .bootstrap-hero-icon { font-size: 52px; margin-bottom: 16px; }
221
- .bootstrap-hero h1 { font-size: 26px; font-weight: 800; margin-bottom: 8px; }
222
- .bootstrap-hero p { color: var(--text-muted); font-size: 15px; max-width: 420px; margin: 0 auto; }
223
- .bootstrap-steps { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
224
- .bootstrap-step { display: flex; align-items: flex-start; gap: 14px; padding: 16px 18px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); }
225
- .bootstrap-step.done { border-color: var(--green); background: var(--green-bg); }
226
- .bootstrap-step.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
227
- .step-num { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: white; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 1px; }
228
- .bootstrap-step.done .step-num { background: var(--green); }
229
- .step-content { flex: 1; min-width: 0; }
230
- .step-title { font-size: 14px; font-weight: 600; margin-bottom: 3px; }
231
- .step-desc { font-size: 13px; color: var(--text-muted); }
232
- .step-desc code { font-family: var(--font-mono); background: var(--code-bg); padding: 1px 5px; border-radius: 3px; font-size: 12px; }
233
- .step-check { font-size: 18px; flex-shrink: 0; margin-top: 4px; }
234
- .bootstrap-btn { width: 100%; padding: 14px 24px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 15px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; transition: background .15s; }
235
- .bootstrap-btn:hover { background: var(--accent-hover); }
236
- .bootstrap-btn:disabled { opacity: .6; cursor: not-allowed; }
237
- .bootstrap-result { margin-top: 20px; padding: 16px 18px; background: var(--green-bg); border: 1px solid var(--green); border-radius: var(--radius); }
238
- .bootstrap-result-title { font-weight: 700; color: var(--green-text); margin-bottom: 8px; font-size: 14px; }
239
- .bootstrap-result-files { font-family: var(--font-mono); font-size: 12px; color: var(--green-text); line-height: 1.8; }
240
- .bootstrap-claude { margin-top: 12px; padding: 14px 16px; background: var(--accent-light); border: 1px solid var(--accent); border-radius: var(--radius); }
241
- .bootstrap-claude-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); margin-bottom: 8px; }
242
- .bootstrap-claude-cmd { font-family: var(--font-mono); font-size: 13px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; cursor: pointer; user-select: all; color: var(--text); }
243
- .bootstrap-claude-cmd:hover { border-color: var(--accent); }
244
- .copy-hint { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
245
-
246
- /* ── Health Check ── */
247
- .health-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
248
- .health-title { font-size: 26px; font-weight: 800; margin-bottom: 4px; }
249
- .health-subtitle { font-size: 13px; color: var(--text-muted); }
250
- .health-overall { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 700; }
251
- .health-ok { background: var(--green-bg); color: var(--green-text); }
252
- .health-warn { background: var(--yellow-bg); color: var(--yellow-text); }
253
- .health-crit { background: var(--red-bg); color: var(--red-text); }
254
- .health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 32px; }
255
- .health-card { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
256
- .health-card-val { font-size: 32px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
257
- .health-card-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
258
- .health-card.issue .health-card-val { color: var(--red); }
259
- .health-card.warn-card .health-card-val { color: var(--yellow); }
260
- .health-card.ok .health-card-val { color: var(--green); }
261
- .health-section-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
262
- .health-section { margin-bottom: 28px; }
263
- .drift-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; background: var(--bg); }
264
- .drift-sev { font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 3px; flex-shrink: 0; margin-top: 2px; }
265
- .drift-sev.high { background: var(--red-bg); color: var(--red-text); }
266
- .drift-sev.medium { background: var(--yellow-bg); color: var(--yellow-text); }
267
- .drift-sev.low { background: var(--code-bg); color: var(--text-muted); }
268
- .drift-module { font-weight: 600; font-size: 13px; }
269
- .drift-detail { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
270
- .drift-detail span { font-family: var(--font-mono); background: var(--code-bg); padding: 0 4px; border-radius: 3px; font-size: 11px; margin-right: 3px; }
271
- .complexity-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; background: var(--bg); }
272
- .complexity-score { width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800; flex-shrink: 0; }
273
- .score-bad { background: var(--red-bg); color: var(--red-text); }
274
- .score-warn { background: var(--yellow-bg); color: var(--yellow-text); }
275
- .score-ok { background: var(--green-bg); color: var(--green-text); }
276
- .complexity-file { font-family: var(--font-mono); font-size: 12px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
277
- .complexity-tags { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
278
- .complexity-tag { font-size: 11px; background: var(--code-bg); color: var(--text-muted); padding: 1px 7px; border-radius: 3px; }
279
- .complexity-tag.crit { background: var(--red-bg); color: var(--red-text); }
280
- .health-empty { text-align: center; padding: 20px; color: var(--text-muted); font-size: 13px; }
281
- .health-refresh { padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 12px; cursor: pointer; }
282
- .health-refresh:hover { background: var(--bg-hover); }
283
-
284
- /* Misc */
285
- .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .6s linear infinite; }
286
- @keyframes spin { to { transform: rotate(360deg); } }
287
- #sidebar::-webkit-scrollbar, #main::-webkit-scrollbar { width: 5px; }
288
- #sidebar::-webkit-scrollbar-thumb, #main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
289
- @media (max-width: 768px) { #app { grid-template-columns: 1fr; } #sidebar { display: none; } #doc-wrap { padding: 24px 20px 60px; } }
290
-
291
- /* ── Command Palette ── */
292
- .palette-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.48); z-index: 500; display: none; align-items: flex-start; justify-content: center; padding-top: 14vh; }
293
- .palette-overlay.open { display: flex; }
294
- .palette-box { width: 100%; max-width: 560px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 24px 64px rgba(0,0,0,.28); overflow: hidden; }
295
- .palette-input-wrap { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
296
- .palette-search-icon { font-size: 16px; color: var(--text-muted); flex-shrink: 0; }
297
- #palette-input { flex: 1; border: none; outline: none; font-size: 15px; background: transparent; color: var(--text); }
298
- #palette-input::placeholder { color: var(--text-muted); }
299
- .palette-esc { font-size: 11px; background: var(--border); color: var(--text-muted); padding: 2px 7px; border-radius: 4px; flex-shrink: 0; cursor: pointer; }
300
- .palette-results { max-height: 380px; overflow-y: auto; }
301
- .palette-group-header { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 8px 16px 4px; background: var(--bg-sidebar); }
302
- .palette-item { display: flex; align-items: center; gap: 10px; padding: 10px 16px; cursor: pointer; font-size: 13px; border-left: 2px solid transparent; }
303
- .palette-item.selected { background: var(--bg-active); color: var(--accent); border-left-color: var(--accent); }
304
- .palette-item-icon { width: 20px; text-align: center; flex-shrink: 0; }
305
- .palette-item-name { flex: 1; }
306
- .palette-item-section { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
307
- .palette-item-snippet { font-size: 11px; color: var(--text-muted); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
308
- .palette-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13px; }
309
- .palette-footer { padding: 8px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); display: flex; gap: 14px; background: var(--bg-sidebar); }
310
- .palette-footer kbd { background: var(--border); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono); }
311
-
312
- /* ── Copy button ── */
313
- .code-block-wrap { position: relative; }
314
- .copy-btn { position: absolute; top: 8px; right: 8px; padding: 3px 9px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text-muted); font-size: 11px; cursor: pointer; opacity: 0; transition: opacity .15s; z-index: 1; font-family: var(--font); }
315
- .code-block-wrap:hover .copy-btn { opacity: 1; }
316
- .copy-btn.copied { color: var(--green-text); border-color: var(--green); background: var(--green-bg); }
317
-
318
- /* ── Heading anchors ── */
319
- .heading-anchor { opacity: 0; margin-left: 6px; font-size: .72em; color: var(--text-muted); text-decoration: none; transition: opacity .15s; vertical-align: middle; }
320
- h2:hover .heading-anchor, h3:hover .heading-anchor { opacity: 1; }
321
- </style>
322
- </head>
323
- <body>
324
- <div id="app">
325
-
326
- <header id="header">
327
- <div class="logo" onclick="goHome()">
328
- <div class="logo-icon">📋</div>DocuTrack
329
- </div>
330
- <div class="header-sep"></div>
331
- <span id="project-name">loading…</span>
332
- <div id="search-wrap">
333
- <span class="search-icon">🔍</span>
334
- <input type="text" id="search" placeholder="Search docs…" autocomplete="off">
335
- <span class="search-kbd">⌘K</span>
336
- </div>
337
- <div class="header-right">
338
- <div id="coverage-badge">
339
- <span class="coverage-dot" id="cov-dot"></span>
340
- <span id="cov-pct">–%</span>
341
- <span style="color:var(--text-muted);font-weight:400">coverage</span>
342
- </div>
343
- <button id="theme-toggle" title="Toggle dark mode">🌙</button>
344
- <span class="live-pill"><span class="live-dot"></span>live</span>
345
- </div>
346
- </header>
347
-
348
- <aside id="sidebar">
349
- <div class="nav-section">
350
- <div class="nav-section-title">Overview</div>
351
- <div id="nav-arch"></div>
352
- </div>
353
- <div class="nav-section">
354
- <div class="nav-section-title">Modules</div>
355
- <div id="nav-modules"></div>
356
- </div>
357
- <div class="nav-section">
358
- <div class="nav-section-title">Decisions</div>
359
- <div id="nav-decisions"></div>
360
- </div>
361
- <div class="nav-section">
362
- <div class="nav-section-title">API</div>
363
- <div id="nav-api-explorer"></div>
364
- <div id="nav-api-docs"></div>
365
- </div>
366
- <div class="nav-section">
367
- <div class="nav-section-title">Health</div>
368
- <div id="nav-health"></div>
369
- </div>
370
- <div id="coverage-section">
371
- <div class="coverage-label">
372
- <span>Doc coverage</span>
373
- <span id="cov-label">–%</span>
374
- </div>
375
- <div class="coverage-bar"><div class="coverage-fill" id="cov-bar" style="width:0%"></div></div>
376
- </div>
377
- <div style="padding:12px 16px 8px;border-top:1px solid var(--border);margin-top:8px">
378
- <button onclick="showRegenPanel()" style="width:100%;padding:7px 12px;border:1px solid var(--border);background:var(--bg);color:var(--text-muted);border-radius:var(--radius);font-size:12px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:6px">
379
- <span>✨</span><span>Regenerate docs</span>
380
- </button>
381
- </div>
382
- </aside>
383
-
384
- <main id="main">
385
- <div id="doc-wrap">
386
- <div id="pending-banner">
387
- ⚠ <strong id="pending-count">0</strong>&nbsp;file(s) modified without documentation.
388
- Run <code style="margin-left:4px">docutrack status</code> for details.
389
- </div>
390
- <div id="doc-content">
391
- <div class="empty-state">
392
- <div class="empty-icon">📋</div>
393
- <h2>Welcome to DocuTrack</h2>
394
- <p>Living documentation for your project. Select a page from the sidebar.</p>
395
- <span class="spinner"></span>
396
- </div>
397
- </div>
398
- </div>
399
- </main>
400
-
401
- </div>
402
-
403
- <!-- Command Palette -->
404
- <div id="palette-overlay" class="palette-overlay" onclick="onPaletteOverlayClick(event)">
405
- <div class="palette-box">
406
- <div class="palette-input-wrap">
407
- <span class="palette-search-icon">🔍</span>
408
- <input id="palette-input" placeholder="Search docs, jump to…" autocomplete="off" spellcheck="false">
409
- <span class="palette-esc" onclick="closePalette()">ESC</span>
410
- </div>
411
- <div id="palette-results" class="palette-results"></div>
412
- <div class="palette-footer">
413
- <span><kbd>↑↓</kbd> navigate</span>
414
- <span><kbd>↵</kbd> open</span>
415
- <span><kbd>ESC</kbd> close</span>
416
- </div>
417
- </div>
418
- </div>
419
-
420
- <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
421
- <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
422
- <script>
423
- 'use strict'
424
-
425
- // ── State ──────────────────────────────────────────────────────────────────
426
- const state = {
427
- tree: null,
428
- active: null, // file path or 'api-explorer' or 'health-check'
429
- query: '',
430
- view: 'docs', // 'docs' | 'api-explorer' | 'health-check'
431
- staleSet: new Set(),
432
- healthBadge: 0, // number of critical health issues
433
- lang: 'es', // 'es' | 'en' — language for AI doc generation
434
- generating: false,
435
- generationTotal: 0,
436
- generationLog: [], // { icon, color, file } — persisted to replay on panel re-enter
437
- }
438
-
439
- const api = {
440
- spec: null,
441
- baseUrl: localStorage.getItem('dt-baseUrl') || 'http://localhost:3000',
442
- token: localStorage.getItem('dt-token') || '',
443
- showAuth: false,
444
- open: {}, // endpointId -> boolean
445
- tryOpen: {}, // endpointId -> boolean
446
- responses: {}, // endpointId -> response object
447
- }
448
-
449
- // ── Marked ────────────────────────────────────────────────────────────────
450
- marked.use({
451
- gfm: true,
452
- renderer: {
453
- code({ text, lang }) {
454
- if (lang === 'mermaid') {
455
- return `<div class="mermaid-wrap"><div class="mermaid">${esc(text)}</div></div>`
456
- }
457
- return `<div class="code-block-wrap"><pre><code class="language-${esc(lang||'')}">${esc(text)}</code></pre><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>`
458
- },
459
- heading({ text, depth }) {
460
- if (depth === 1) return `<h1>${text}</h1>\n`
461
- const anchor = text.replace(/<[^>]+>/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
462
- return `<h${depth} id="${anchor}">${text}<a class="heading-anchor" href="#${anchor}" onclick="copyAnchor(this,event)">#</a></h${depth}>\n`
463
- }
464
- }
465
- })
466
- mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' })
467
-
468
- // ── Boot ──────────────────────────────────────────────────────────────────
469
- async function boot() {
470
- setTheme(localStorage.getItem('dt-theme') || 'light')
471
- await refreshTree()
472
- await refreshStatus()
473
- connectSSE()
474
- document.getElementById('search').addEventListener('input', e => {
475
- state.query = e.target.value.trim()
476
- renderNav()
477
- })
478
- document.getElementById('palette-input').addEventListener('input', e => {
479
- renderPaletteResults(e.target.value.trim())
480
- })
481
- document.addEventListener('keydown', e => {
482
- const palOpen = document.getElementById('palette-overlay').classList.contains('open')
483
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
484
- e.preventDefault()
485
- palOpen ? closePalette() : openPalette()
486
- return
487
- }
488
- if (e.key === 'Escape') {
489
- if (palOpen) { closePalette(); return }
490
- document.getElementById('search').blur()
491
- }
492
- if (palOpen) {
493
- if (e.key === 'ArrowDown') { e.preventDefault(); paletteIdx = Math.min(paletteIdx + 1, paletteItems.length - 1); updatePaletteSelection(); scrollPaletteItem() }
494
- else if (e.key === 'ArrowUp') { e.preventDefault(); paletteIdx = Math.max(paletteIdx - 1, 0); updatePaletteSelection(); scrollPaletteItem() }
495
- else if (e.key === 'Enter') { e.preventDefault(); selectPaletteItem(paletteIdx) }
496
- }
497
- })
498
- }
499
-
500
- // ── Tree / nav ─────────────────────────────────────────────────────────────
501
- async function refreshTree() {
502
- const tree = await get('/api/tree')
503
- if (!tree) return
504
- state.tree = tree
505
-
506
- // Project name from package.json
507
- try {
508
- const r = await fetch('/api/content?path=package.json')
509
- if (r.ok) { const j = JSON.parse(await r.text()); if (j.name) { document.getElementById('project-name').textContent = j.name; document.title = `DocuTrack — ${j.name}` } }
510
- } catch { /* ok */ }
511
-
512
- renderNav()
513
- const hasModules = tree.modules?.length > 0
514
- if (!state.active) {
515
- if (hasModules && tree.architecture) loadFile(tree.architecture)
516
- else showWelcome()
517
- } else if (state.active === 'bootstrap' && hasModules) {
518
- // Modules just appeared navigate to architecture
519
- if (tree.architecture) loadFile(tree.architecture)
520
- }
521
- }
522
-
523
- function renderNav() {
524
- const { tree, query, active } = state
525
- if (!tree) return
526
- const q = query.toLowerCase()
527
-
528
- // Architecture
529
- const archEl = document.getElementById('nav-arch')
530
- if (tree.architecture) {
531
- archEl.innerHTML = matches('architecture', q)
532
- ? navItem(tree.architecture, '🏗', 'Architecture', active === tree.architecture)
533
- : ''
534
- } else {
535
- archEl.innerHTML = '<div class="nav-empty">Not created yet</div>'
536
- }
537
-
538
- renderSection('nav-modules', tree.modules, '📦', q, active)
539
- renderSection('nav-decisions', tree.decisions, '📝', q, active)
540
-
541
- // API Explorer special entry
542
- const explorerEl = document.getElementById('nav-api-explorer')
543
- const epCount = api.spec ? Object.values(api.spec.paths || {}).reduce((n, p) => n + Object.keys(p).length, 0) : 0
544
- const explorerMatch = !q || 'api explorer'.includes(q)
545
- explorerEl.innerHTML = explorerMatch
546
- ? `<div class="nav-item${active === 'api-explorer' ? ' active' : ''}" onclick="showApiExplorer()">
547
- <span class="nav-icon">⚡</span>API Explorer
548
- ${epCount > 0 ? `<span class="nav-badge">${epCount}</span>` : ''}
549
- </div>`
550
- : ''
551
-
552
- // API docs (markdown files)
553
- renderSection('nav-api-docs', tree.api, '📄', q, active)
554
-
555
- // Health Check nav entry
556
- const healthMatch = !q || 'health check'.includes(q)
557
- const healthHasIssue = state.healthBadge
558
- document.getElementById('nav-health').innerHTML = healthMatch
559
- ? `<div class="nav-item${active === 'health-check' ? ' active' : ''}" onclick="showHealthCheck()">
560
- <span class="nav-icon">🩺</span>Health Check
561
- ${healthHasIssue ? `<span class="nav-badge" style="background:var(--red-bg);color:var(--red-text)">${healthHasIssue}</span>` : ''}
562
- </div>` : ''
563
- }
564
-
565
- function renderSection(id, items, icon, q, active) {
566
- const el = document.getElementById(id)
567
- if (!items?.length) { el.innerHTML = '<div class="nav-empty">No docs yet</div>'; return }
568
- const filtered = q ? items.filter(i => i.name.toLowerCase().includes(q)) : items
569
- el.innerHTML = filtered.length
570
- ? filtered.map(i => navItem(i.path, icon, fmtName(i.name), active === i.path)).join('')
571
- : ''
572
- }
573
-
574
- function navItem(p, icon, label, isActive) {
575
- const isStale = state.staleSet.has(p)
576
- const staleTag = isStale ? '<span class="nav-stale">stale</span>' : ''
577
- return `<div class="nav-item${isActive ? ' active' : ''}" onclick="loadFile('${ea(p)}')">`
578
- + `<span class="nav-icon">${icon}</span>${esc(label)}${staleTag}</div>`
579
- }
580
-
581
- function fmtName(n) {
582
- return n.replace(/^ADR-\d+-/, 'ADR: ').replace(/[-_]/g, ' ')
583
- .replace(/\b\w/g, c => c.toUpperCase())
584
- }
585
-
586
- function matches(str, q) { return !q || str.toLowerCase().includes(q) }
587
-
588
- // ── Doc viewer ─────────────────────────────────────────────────────────────
589
- async function loadFile(filePath) {
590
- if (!filePath) return
591
- state.active = filePath
592
- state.view = 'docs'
593
- renderNav()
594
-
595
- const el = document.getElementById('doc-content')
596
- el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
597
-
598
- const r = await fetch(`/api/content?path=${encodeURIComponent(filePath)}`)
599
- if (!r.ok) { el.innerHTML = `<p style="color:var(--red);padding:40px">Could not load: ${esc(filePath)}</p>`; return }
600
-
601
- const text = await r.text()
602
- el.innerHTML = marked.parse(text)
603
- document.getElementById('main').scrollTo({ top: 0 })
604
- await mermaid.run({ nodes: document.querySelectorAll('#doc-content .mermaid') })
605
-
606
- // Show "Generate with AI" banner if ARCHITECTURE.md is still a template
607
- if (filePath === 'ARCHITECTURE.md' && isArchTemplate(text)) {
608
- const banner = document.createElement('div')
609
- banner.id = 'arch-gen-banner'
610
- banner.style.cssText = 'position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;gap:14px;flex-wrap:wrap'
611
- banner.innerHTML = `
612
- <span style="font-size:13px;color:var(--text-muted);flex:1">La arquitectura tiene contenido de plantilla sin completar.</span>
613
- <button id="arch-gen-btn" onclick="generateArch()" style="padding:8px 18px;background:var(--accent);color:white;border:none;border-radius:var(--radius);font-size:13px;font-weight:600;cursor:pointer">✨ Generar con IA</button>`
614
- document.getElementById('doc-wrap').appendChild(banner)
615
- }
616
- }
617
-
618
- function showWelcome() {
619
- const hasModules = state.tree?.modules?.length > 0
620
- if (!hasModules) {
621
- showBootstrap()
622
- } else {
623
- document.getElementById('doc-content').innerHTML = `
624
- <div class="empty-state">
625
- <div class="empty-icon">📋</div>
626
- <h2>Welcome to DocuTrack</h2>
627
- <p>Living documentation for your project. Select a page from the sidebar to get started.</p>
628
- </div>`
629
- }
630
- }
631
-
632
- function showBootstrap() {
633
- state.active = 'bootstrap'
634
- const el = document.getElementById('doc-content')
635
- el.innerHTML = `
636
- <div class="bootstrap-wrap">
637
- <div class="bootstrap-hero">
638
- <div class="bootstrap-hero-icon">🚀</div>
639
- <h1>Set up your docs</h1>
640
- <p>This project doesn't have documentation yet. Let's generate it from your existing code in two steps.</p>
641
- </div>
642
-
643
- <div class="bootstrap-steps">
644
- <div class="bootstrap-step done" id="step-init">
645
- <span class="step-num">1</span>
646
- <div class="step-content">
647
- <div class="step-title">DocuTrack initialized</div>
648
- <div class="step-desc">Hooks, templates, and slash commands are installed.</div>
649
- </div>
650
- <span class="step-check">✅</span>
651
- </div>
652
-
653
- <div class="bootstrap-step active" id="step-scan">
654
- <span class="step-num">2</span>
655
- <div class="step-content">
656
- <div class="step-title">Scan your source files</div>
657
- <div class="step-desc">Queues all existing files so the AI agent knows what to document.</div>
658
- </div>
659
- </div>
660
-
661
- <div class="bootstrap-step" id="step-agent">
662
- <span class="step-num">3</span>
663
- <div class="step-content">
664
- <div class="step-title">Run the documentalista agent</div>
665
- <div class="step-desc">Tell Claude Code to document everything docs appear here in real-time.</div>
666
- </div>
667
- </div>
668
- </div>
669
-
670
- <button class="bootstrap-btn" id="scan-btn" onclick="runScan()">
671
- <span id="scan-btn-icon">⚡</span>
672
- <span id="scan-btn-label">Scan project files</span>
673
- </button>
674
-
675
- <div id="scan-result" style="display:none"></div>
676
- </div>`
677
- }
678
-
679
- async function runScan() {
680
- const btn = document.getElementById('scan-btn')
681
- const icon = document.getElementById('scan-btn-icon')
682
- const label = document.getElementById('scan-btn-label')
683
- btn.disabled = true
684
- icon.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;border-color:rgba(255,255,255,.3);border-top-color:white"></span>'
685
- label.textContent = 'Scanning…'
686
-
687
- let data
688
- try {
689
- const r = await fetch('/api/scan', { method: 'POST' })
690
- data = await r.json()
691
- } catch {
692
- btn.disabled = false
693
- icon.textContent = ''
694
- label.textContent = 'Scan project files'
695
- return
696
- }
697
-
698
- // Mark step 2 done, activate step 3
699
- document.getElementById('step-scan').classList.remove('active')
700
- document.getElementById('step-scan').classList.add('done')
701
- document.getElementById('step-scan').innerHTML += '<span class="step-check">✅</span>'
702
- document.getElementById('step-agent').classList.add('active')
703
-
704
- // Update button to disabled with success state
705
- btn.style.background = 'var(--green)'
706
- btn.innerHTML = `✓ ${data.queued} file${data.queued !== 1 ? 's' : ''} queued`
707
-
708
- // Show result + Claude command
709
- const preview = data.files?.slice(0, 4).map(f => ` ${f}`).join('\n')
710
- + (data.hasMore ? `\n …and more` : '')
711
-
712
- document.getElementById('scan-result').style.display = 'block'
713
- document.getElementById('scan-result').innerHTML = `
714
- <div class="bootstrap-result">
715
- <div class="bootstrap-result-title">✓ ${data.queued} files queued${data.skipped > 0 ? ` (${data.skipped} already queued)` : ''}</div>
716
- ${preview ? `<div class="bootstrap-result-files">${esc(preview)}</div>` : ''}
717
- </div>
718
- <div id="step3-generate" style="margin-top:12px">
719
- <div style="display:flex;justify-content:center;gap:0;margin-bottom:12px;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content;margin-left:auto;margin-right:auto">
720
- <button id="lang-es" onclick="setLang('es')" style="padding:6px 18px;border:none;background:var(--accent);color:white;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s">🇪🇸 Español</button>
721
- <button id="lang-en" onclick="setLang('en')" style="padding:6px 18px;border:none;background:var(--bg-secondary);color:var(--text-muted);font-size:13px;cursor:pointer;transition:background .15s">🇺🇸 English</button>
722
- </div>
723
- <button class="bootstrap-btn" id="gen-btn" onclick="runGenerate()">
724
- <span id="gen-btn-icon">✨</span>
725
- <span id="gen-btn-label">Generate docs with AI</span>
726
- </button>
727
- <p style="text-align:center;font-size:12px;color:var(--text-muted);margin-top:8px">
728
- Uses Claude Haiku reads each file and writes the docs automatically
729
- </p>
730
- </div>`
731
-
732
- // Refresh status badge
733
- await refreshStatus()
734
- }
735
-
736
- function confirmRegen() {
737
- const total = state.generationTotal || '?'
738
- const lang = state.lang === 'es' ? 'español' : 'inglés'
739
- if (!confirm(`¿Regenerar toda la documentación en ${lang}?\n\nEsto sobreescribirá todos los archivos .md existentes.`)) return
740
- runGenerate(true)
741
- }
742
-
743
- function setLang(lang) {
744
- state.lang = lang
745
- const es = document.getElementById('lang-es')
746
- const en = document.getElementById('lang-en')
747
- if (!es || !en) return
748
- if (lang === 'es') {
749
- es.style.background = 'var(--accent)'; es.style.color = 'white'
750
- en.style.background = 'var(--bg-secondary)'; en.style.color = 'var(--text-muted)'
751
- } else {
752
- en.style.background = 'var(--accent)'; en.style.color = 'white'
753
- es.style.background = 'var(--bg-secondary)'; es.style.color = 'var(--text-muted)'
754
- }
755
- }
756
-
757
- async function runGenerate(force = false) {
758
- const lang = state.lang || 'es'
759
- const btn = document.getElementById('gen-btn')
760
- if (!btn) return
761
- btn.disabled = true
762
- document.getElementById('gen-btn-icon').innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px;border-color:rgba(255,255,255,.3);border-top-color:white;display:inline-block"></span>'
763
- document.getElementById('gen-btn-label').textContent = 'Starting…'
764
-
765
- let data
766
- try {
767
- const r = await fetch('/api/generate', {
768
- method: 'POST',
769
- headers: { 'Content-Type': 'application/json' },
770
- body: JSON.stringify({ lang, force }),
771
- })
772
- data = await r.json()
773
- } catch (e) {
774
- btn.disabled = false
775
- document.getElementById('gen-btn-icon').textContent = ''
776
- document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
777
- return
778
- }
779
-
780
- if (data.error === 'no_api_key') {
781
- btn.disabled = false
782
- document.getElementById('gen-btn-icon').textContent = '✨'
783
- document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
784
- document.getElementById('step3-generate').innerHTML += `
785
- <div style="margin-top:12px;padding:14px 16px;background:var(--yellow-bg);border:1px solid var(--yellow);border-radius:var(--radius);font-size:13px;color:var(--yellow-text)">
786
- <strong>API key not found.</strong> Add to your shell or <code>.env.local</code>:<br>
787
- <code style="display:block;margin-top:8px;font-size:12px;user-select:all">ANTHROPIC_API_KEY=sk-ant-...</code>
788
- Then restart the server and try again.
789
- </div>`
790
- return
791
- }
792
-
793
- if (data.error) {
794
- btn.disabled = false
795
- document.getElementById('gen-btn-icon').textContent = '✨'
796
- document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
797
- return
798
- }
799
-
800
- // Generation started — swap UI to progress view
801
- const total = data.total || 0
802
- const stepAgent = document.getElementById('step-agent')
803
- if (stepAgent) stepAgent.classList.add('done')
804
- btn.style.display = 'none'
805
-
806
- document.getElementById('step3-generate').innerHTML = `
807
- <div id="gen-progress-wrap" style="margin-top:4px">
808
- <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px">
809
- <span style="font-weight:600">Generating documentation…</span>
810
- <span id="gen-counter" style="color:var(--text-muted)">0 / ${total}</span>
811
- </div>
812
- <div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:14px;overflow:hidden">
813
- <div id="gen-bar" style="height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s"></div>
814
- </div>
815
- <div id="gen-log" style="font-family:var(--font-mono);font-size:12px;max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:4px"></div>
816
- </div>`
817
-
818
- state.generating = true
819
- state.generationTotal = total
820
- state.generationDone = 0
821
- state.generationLog = []
822
- state.generationStartMs = Date.now()
823
- }
824
-
825
- function handleProgressEvent(raw) {
826
- // format: "progress:done/total:status:filepath"
827
- if (!raw.startsWith('progress:')) return
828
- const parts = raw.slice(9).split(':')
829
- const counter = parts[0] // "3/31"
830
- const status = parts[1] // done | skip | working | error
831
- const file = parts.slice(2).join(':')
832
-
833
- const [done, total] = counter.split('/').map(Number)
834
- state.generationDone = done
835
-
836
- const icon = status === 'done' ? '✓' : status === 'skip' ? '–' : status === 'error' ? '✗' : '…'
837
- const color = status === 'done' ? 'var(--green-text)' : status === 'error' ? 'var(--red-text)' : status === 'skip' ? 'var(--text-muted)' : 'var(--accent)'
838
-
839
- // Persist completed entries so we can replay when returning to the panel
840
- if (status !== 'working') state.generationLog.push({ icon, color, file })
841
-
842
- const bar = document.getElementById('gen-bar')
843
- const cnt = document.getElementById('gen-counter')
844
- const log = document.getElementById('gen-log')
845
-
846
- if (bar) bar.style.width = `${Math.round((done / total) * 100)}%`
847
- if (cnt) {
848
- let eta = ''
849
- if (done > 2 && state.generationStartMs) {
850
- const elapsed = (Date.now() - state.generationStartMs) / 1000
851
- const secsPerFile = elapsed / done
852
- const remaining = Math.round(secsPerFile * (total - done))
853
- eta = remaining > 5 ? ` — ~${remaining}s restantes` : ''
854
- }
855
- cnt.textContent = `${done} / ${total}${eta}`
856
- }
857
- if (!log) return
858
-
859
- const row = document.createElement('div')
860
- row.style.cssText = `display:flex;gap:8px;align-items:baseline;color:${color}`
861
- row.innerHTML = `<span style="flex-shrink:0;font-weight:700">${icon}</span><span style="color:var(--text-muted)">${esc(file)}</span>`
862
-
863
- if (status === 'working') {
864
- const existing = log.querySelector('[data-working]')
865
- if (existing) existing.remove()
866
- row.setAttribute('data-working', '1')
867
- }
868
- log.appendChild(row)
869
- log.scrollTop = log.scrollHeight
870
- }
871
-
872
- function handleDoneEvent(raw) {
873
- if (!raw.startsWith('done:')) return
874
- state.generating = false
875
- const count = raw.slice(5)
876
- const log = document.getElementById('gen-log')
877
- const bar = document.getElementById('gen-bar')
878
- if (bar) bar.style.background = 'var(--green)'
879
- const cnt = document.getElementById('gen-counter')
880
- if (cnt) cnt.textContent = `${count} files documented`
881
- if (log) {
882
- const row = document.createElement('div')
883
- row.style.cssText = 'margin-top:8px;font-weight:700;color:var(--green-text)'
884
- row.textContent = `✓ Done! Documentation generated for ${count} files.`
885
- log.appendChild(row)
886
- }
887
- // Show regenerate controls
888
- const wrap = document.getElementById('gen-progress-wrap')
889
- if (wrap) {
890
- const lang = state.lang || 'es'
891
- const otherLang = lang === 'es' ? 'en' : 'es'
892
- const otherLabel = otherLang === 'es' ? '🇪🇸 Regenerate in Spanish' : '🇺🇸 Regenerate in English'
893
- const div = document.createElement('div')
894
- div.style.cssText = 'margin-top:16px;display:flex;gap:10px;align-items:center;flex-wrap:wrap'
895
- div.innerHTML = `
896
- <button onclick="setLang('${otherLang}');runGenerate(true)" style="padding:8px 16px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius);font-size:13px;cursor:pointer;flex-shrink:0">
897
- ${otherLabel}
898
- </button>
899
- <span style="font-size:12px;color:var(--text-muted)">Re-generates all docs in the selected language.</span>`
900
- wrap.appendChild(div)
901
- }
902
- }
903
-
904
- function showRegenPanel() {
905
- state.active = 'regen'
906
- renderNav()
907
- const lang = state.lang || 'es'
908
- const el = document.getElementById('doc-content')
909
-
910
- if (state.generating) {
911
- const done = state.generationDone || 0
912
- const total = state.generationTotal || 0
913
- const pct = total > 0 ? Math.round((done / total) * 100) : 0
914
- el.innerHTML = `
915
- <div style="max-width:540px;margin:60px auto;padding:0 24px">
916
- <h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Generando documentación…</h2>
917
- <p style="color:var(--text-muted);font-size:14px;margin-bottom:24px">La generación continúa en segundo plano. Puedes navegar libremente.</p>
918
- <div id="step3-generate">
919
- <div id="gen-progress-wrap">
920
- <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px">
921
- <span style="font-weight:600">Generando documentación…</span>
922
- <span id="gen-counter" style="color:var(--text-muted)">${done} / ${total}</span>
923
- </div>
924
- <div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:14px;overflow:hidden">
925
- <div id="gen-bar" style="height:100%;width:${pct}%;background:var(--accent);border-radius:2px;transition:width .3s"></div>
926
- </div>
927
- <div id="gen-log" style="font-family:var(--font-mono);font-size:12px;max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:4px"></div>
928
- </div>
929
- </div>
930
- </div>`
931
- // Replay saved log entries
932
- const log = el.querySelector('#gen-log')
933
- for (const entry of state.generationLog) {
934
- const row = document.createElement('div')
935
- row.style.cssText = `display:flex;gap:8px;align-items:baseline;color:${entry.color}`
936
- row.innerHTML = `<span style="flex-shrink:0;font-weight:700">${entry.icon}</span><span style="color:var(--text-muted)">${esc(entry.file)}</span>`
937
- log.appendChild(row)
938
- }
939
- log.scrollTop = log.scrollHeight
940
- return
941
- }
942
-
943
- el.innerHTML = `
944
- <div style="max-width:540px;margin:60px auto;padding:0 24px">
945
- <h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Regenerar documentación</h2>
946
- <p style="color:var(--text-muted);font-size:14px;margin-bottom:28px">Re-genera todos los docs desde los archivos fuente. Sobreescribe la documentación existente.</p>
947
-
948
- <div style="margin-bottom:20px">
949
- <div style="font-size:13px;font-weight:600;margin-bottom:8px">Idioma</div>
950
- <div style="display:flex;gap:0;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content">
951
- <button id="lang-es" onclick="setLang('es')" style="padding:8px 20px;border:none;background:${lang==='es'?'var(--accent)':'var(--bg-secondary)'};color:${lang==='es'?'white':'var(--text-muted)'};font-size:13px;font-weight:600;cursor:pointer">🇪🇸 Español</button>
952
- <button id="lang-en" onclick="setLang('en')" style="padding:8px 20px;border:none;background:${lang==='en'?'var(--accent)':'var(--bg-secondary)'};color:${lang==='en'?'white':'var(--text-muted)'};font-size:13px;cursor:pointer">🇺🇸 English</button>
953
- </div>
954
- </div>
955
-
956
- <button class="bootstrap-btn" id="gen-btn" onclick="confirmRegen()" style="max-width:300px">
957
- <span id="gen-btn-icon">✨</span>
958
- <span id="gen-btn-label">Regenerar todos los docs</span>
959
- </button>
960
- <p style="font-size:12px;color:var(--text-muted);margin-top:10px">Usa Claude Haiku. Sobreescribe los archivos .md existentes.</p>
961
-
962
- <div id="step3-generate" style="margin-top:16px"></div>
963
- </div>`
964
- }
965
-
966
- function isArchTemplate(text) {
967
- // Solo es plantilla si tiene comentarios HTML placeholder O filas de tabla completamente vacías
968
- const hasComments = /<!--\s*(Describe|describe|TODO|todo)/.test(text)
969
- const emptyRows = (text.match(/^\|(\s*\|){3,}\s*$/mg) || []).length
970
- return hasComments || emptyRows >= 3
971
- }
972
-
973
- async function generateArch() {
974
- const btn = document.getElementById('arch-gen-btn')
975
- if (btn) { btn.disabled = true; btn.textContent = '⏳ Generando…' }
976
-
977
- try {
978
- const r = await fetch('/api/generate-arch', {
979
- method: 'POST',
980
- headers: { 'Content-Type': 'application/json' },
981
- body: JSON.stringify({ lang: state.lang || 'es' }),
982
- })
983
- const data = await r.json()
984
- if (data.error === 'no_api_key') {
985
- if (btn) { btn.disabled = false; btn.textContent = '✨ Generar con IA' }
986
- const banner = document.getElementById('arch-gen-banner')
987
- if (banner) banner.insertAdjacentHTML('beforeend', `<span style="color:var(--red-text);font-size:12px">API key no encontrada — agrega ANTHROPIC_API_KEY al entorno.</span>`)
988
- return
989
- }
990
- // reload will come via SSE — just update button
991
- if (btn) { btn.textContent = ' Listo' }
992
- } catch {
993
- if (btn) { btn.disabled = false; btn.textContent = '✨ Generar con IA' }
994
- }
995
- }
996
-
997
- function goHome() {
998
- if (state.tree?.architecture) loadFile(state.tree.architecture)
999
- else showWelcome()
1000
- }
1001
-
1002
- // ── API Explorer ───────────────────────────────────────────────────────────
1003
- async function showApiExplorer() {
1004
- state.active = 'api-explorer'
1005
- state.view = 'api-explorer'
1006
- renderNav()
1007
-
1008
- const el = document.getElementById('doc-content')
1009
- el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
1010
- document.getElementById('main').scrollTo({ top: 0 })
1011
-
1012
- if (!api.spec) {
1013
- const spec = await get('/api/openapi')
1014
- api.spec = spec
1015
- }
1016
-
1017
- renderApiExplorer()
1018
- }
1019
-
1020
- function renderApiExplorer() {
1021
- const el = document.getElementById('doc-content')
1022
-
1023
- if (!api.spec || api.spec.error) {
1024
- el.innerHTML = `
1025
- <div class="empty-state">
1026
- <div class="empty-icon">⚡</div>
1027
- <h2>No API spec found</h2>
1028
- <p>Run the analyzer to auto-detect routes from your source code.</p>
1029
- <code>npx docutrack analyze</code>
1030
- <p style="margin-top:12px;font-size:13px">Or create <code>docs/api/openapi.json</code> manually (OpenAPI 3.0).</p>
1031
- </div>`
1032
- return
1033
- }
1034
-
1035
- const { info, paths } = api.spec
1036
- const groups = groupByTag(paths)
1037
-
1038
- const totalEp = Object.values(paths).reduce((n, p) => n + Object.keys(p).length, 0)
1039
-
1040
- el.innerHTML = `
1041
- <div class="api-explorer">
1042
- <div class="api-explorer-header">
1043
- <div>
1044
- <h1 style="margin-bottom:4px">
1045
- ${esc(info.title)}
1046
- <span style="font-size:14px;color:var(--text-muted);font-weight:400">v${esc(info.version)}</span>
1047
- </h1>
1048
- <p style="color:var(--text-muted);font-size:13px;margin:4px 0 0">
1049
- ${totalEp} endpoint${totalEp !== 1 ? 's' : ''} detected
1050
- </p>
1051
- </div>
1052
- <div class="base-url-wrap">
1053
- <span class="base-url-label">Base URL</span>
1054
- <div class="base-url-row">
1055
- <input class="base-url-input" id="base-url-input" type="text"
1056
- value="${ea(api.baseUrl)}" placeholder="http://localhost:3000"
1057
- oninput="api.baseUrl=this.value;localStorage.setItem('dt-baseUrl',this.value)">
1058
- <button class="auth-toggle" onclick="toggleAuthRow()">🔑 Auth</button>
1059
- </div>
1060
- <div class="auth-row${api.showAuth ? ' show' : ''}" id="auth-row">
1061
- <span class="auth-label">Bearer</span>
1062
- <input class="auth-input" id="auth-input" type="text" placeholder="your-token-here"
1063
- value="${ea(api.token)}"
1064
- oninput="api.token=this.value;localStorage.setItem('dt-token',this.value)">
1065
- </div>
1066
- </div>
1067
- </div>
1068
- ${Object.entries(groups).map(([tag, eps]) => renderApiGroup(tag, eps)).join('')}
1069
- </div>`
1070
- }
1071
-
1072
- function groupByTag(paths) {
1073
- const groups = {}
1074
- if (!paths) return groups
1075
- for (const [path, pathItem] of Object.entries(paths)) {
1076
- for (const method of ['get','post','put','patch','delete','options','head']) {
1077
- if (!pathItem[method]) continue
1078
- const op = pathItem[method]
1079
- const tag = op.tags?.[0] || 'default'
1080
- if (!groups[tag]) groups[tag] = []
1081
- groups[tag].push({ path, method: method.toUpperCase(), ...op })
1082
- }
1083
- }
1084
- return groups
1085
- }
1086
-
1087
- function renderApiGroup(tag, endpoints) {
1088
- return `
1089
- <div class="api-group">
1090
- <div class="api-group-title">${esc(tag)}</div>
1091
- ${endpoints.map(ep => renderEndpointCard(ep)).join('')}
1092
- </div>`
1093
- }
1094
-
1095
- function renderEndpointCard(ep) {
1096
- const id = epId(ep.method, ep.path)
1097
- const isOpen = api.open[id]
1098
- const params = ep.parameters || []
1099
- const hasBody = !!ep.requestBody
1100
- const pathParams = params.filter(p => p.in === 'path')
1101
- const queryParams = params.filter(p => p.in === 'query')
1102
- const headerParams = params.filter(p => p.in === 'header')
1103
-
1104
- return `
1105
- <div class="endpoint-card${isOpen ? ' open' : ''}" id="card-${id}">
1106
- <div class="endpoint-header" onclick="toggleEndpoint('${id}')">
1107
- <span class="method-badge badge-${ep.method.toLowerCase()}">${ep.method}</span>
1108
- <span class="endpoint-path">${esc(ep.path)}</span>
1109
- ${ep.summary ? `<span class="endpoint-summary">${esc(ep.summary)}</span>` : ''}
1110
- <span class="endpoint-chevron">›</span>
1111
- </div>
1112
- <div class="endpoint-body">
1113
- ${ep.description ? `<div class="endpoint-section"><p style="color:var(--text-muted);font-size:14px;margin:0">${esc(ep.description)}</p></div>` : ''}
1114
- ${params.length ? `
1115
- <div class="endpoint-section">
1116
- <div class="section-title">Parameters</div>
1117
- <table class="params-table">
1118
- <thead><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th></tr></thead>
1119
- <tbody>${params.map(p => `
1120
- <tr>
1121
- <td>${esc(p.name)}</td>
1122
- <td>${esc(p.in)}</td>
1123
- <td>${esc(p.schema?.type || 'string')}</td>
1124
- <td>${p.required ? '<span class="param-required">required</span>' : '<span class="param-optional">optional</span>'}</td>
1125
- </tr>`).join('')}
1126
- </tbody>
1127
- </table>
1128
- </div>` : ''}
1129
- ${hasBody ? `
1130
- <div class="endpoint-section">
1131
- <div class="section-title">Request Body</div>
1132
- <pre style="background:var(--code-bg);padding:12px;border-radius:var(--radius);font-size:12px;overflow-x:auto">${esc(JSON.stringify(ep.requestBody?.content?.['application/json']?.schema || {}, null, 2))}</pre>
1133
- </div>` : ''}
1134
- ${ep.responses ? `
1135
- <div class="endpoint-section">
1136
- <div class="section-title">Responses</div>
1137
- ${Object.entries(ep.responses).map(([code, r]) => `
1138
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:13px">
1139
- <span class="status-chip ${statusClass(code)}">${code}</span>
1140
- <span style="color:var(--text-muted)">${esc(r.description || '')}</span>
1141
- </div>`).join('')}
1142
- </div>` : ''}
1143
- <div class="try-section">
1144
- ${renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody)}
1145
- </div>
1146
- </div>
1147
- </div>`
1148
- }
1149
-
1150
- function renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody) {
1151
- const tryOpen = api.tryOpen[id]
1152
- const resp = api.responses[id]
1153
-
1154
- return `
1155
- <button class="try-btn" onclick="toggleTry('${id}',event)">
1156
- ${tryOpen ? '✕ Close' : '▶ Try it out'}
1157
- </button>
1158
- <div class="try-form${tryOpen ? ' show' : ''}" id="try-${id}">
1159
- ${pathParams.map(p => `
1160
- <div class="try-field">
1161
- <label>${esc(p.name)} <span style="color:var(--red-text);font-size:10px">path</span></label>
1162
- <input type="text" id="${id}-path-${esc(p.name)}" placeholder="${esc(p.name)}">
1163
- </div>`).join('')}
1164
- ${queryParams.map(p => `
1165
- <div class="try-field">
1166
- <label>${esc(p.name)} <span style="color:var(--text-muted);font-size:10px">query</span></label>
1167
- <input type="text" id="${id}-query-${esc(p.name)}" placeholder="${esc(p.schema?.type || 'string')}">
1168
- </div>`).join('')}
1169
- ${hasBody ? `
1170
- <div class="try-field">
1171
- <label>Request Body <span style="color:var(--text-muted);font-size:10px">application/json</span></label>
1172
- <textarea id="${id}-body" placeholder="{}"></textarea>
1173
- </div>` : ''}
1174
- <div class="try-actions">
1175
- <button class="execute-btn" id="exec-${id}" onclick="executeRequest('${id}','${esc(ep.method)}','${ea(ep.path)}',${hasBody})">
1176
- Execute
1177
- </button>
1178
- <button class="cancel-try-btn" onclick="toggleTry('${id}',event)">Cancel</button>
1179
- </div>
1180
- ${resp ? renderResponse(resp) : ''}
1181
- </div>`
1182
- }
1183
-
1184
- function renderResponse(resp) {
1185
- const cls = resp.status >= 500 ? 'status-5xx'
1186
- : resp.status >= 400 ? 'status-4xx'
1187
- : resp.status >= 300 ? 'status-3xx'
1188
- : 'status-2xx'
1189
-
1190
- let body = resp.body
1191
- try { body = JSON.stringify(JSON.parse(body), null, 2) } catch { /* raw */ }
1192
-
1193
- return `
1194
- <div class="response-panel">
1195
- <div class="response-bar">
1196
- <span class="status-chip ${cls}">${resp.status} ${resp.statusText}</span>
1197
- <span style="color:var(--text-muted);font-size:12px">${resp.contentType || ''}</span>
1198
- <span class="response-time">${resp.ms}ms</span>
1199
- </div>
1200
- <div class="response-body">${esc(body)}</div>
1201
- </div>
1202
- ${resp.corsError ? `<div class="cors-warn">⚠ CORS error — the API server must allow requests from this origin. Add <code>Access-Control-Allow-Origin: *</code> to its headers, or run DocuTrack on the same port.</div>` : ''}`
1203
- }
1204
-
1205
- // ── Execute request ────────────────────────────────────────────────────────
1206
- async function executeRequest(id, method, pathTemplate, hasBody) {
1207
- const btn = document.getElementById(`exec-${id}`)
1208
- btn.disabled = true
1209
- btn.textContent = '…'
1210
-
1211
- // Build URL
1212
- let url = api.baseUrl.replace(/\/$/, '') + pathTemplate
1213
- // Substitute path params
1214
- url = url.replace(/\{([^}]+)\}/g, (_, name) => {
1215
- const input = document.getElementById(`${id}-path-${name}`)
1216
- return input ? encodeURIComponent(input.value || `:${name}`) : `:${name}`
1217
- })
1218
-
1219
- // Collect query params from spec
1220
- const queryEl = document.querySelectorAll(`[id^="${id}-query-"]`)
1221
- const qs = []
1222
- queryEl.forEach(el => {
1223
- const key = el.id.replace(`${id}-query-`, '')
1224
- if (el.value) qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(el.value)}`)
1225
- })
1226
- if (qs.length) url += '?' + qs.join('&')
1227
-
1228
- const headers = { 'Content-Type': 'application/json' }
1229
- if (api.token) headers['Authorization'] = `Bearer ${api.token}`
1230
-
1231
- let body = undefined
1232
- if (hasBody) {
1233
- const bodyEl = document.getElementById(`${id}-body`)
1234
- body = bodyEl?.value?.trim() || '{}'
1235
- }
1236
-
1237
- const t0 = Date.now()
1238
- let resp
1239
-
1240
- try {
1241
- const fetchResp = await fetch(url, {
1242
- method,
1243
- headers,
1244
- body: body && method !== 'GET' ? body : undefined,
1245
- })
1246
- const text = await fetchResp.text()
1247
- resp = {
1248
- status: fetchResp.status,
1249
- statusText: fetchResp.statusText,
1250
- contentType: fetchResp.headers.get('content-type') || '',
1251
- body: text,
1252
- ms: Date.now() - t0,
1253
- corsError: false,
1254
- }
1255
- } catch (err) {
1256
- const isCors = err.message?.toLowerCase().includes('fetch') || err.name === 'TypeError'
1257
- resp = {
1258
- status: 0,
1259
- statusText: 'Network Error',
1260
- contentType: '',
1261
- body: err.message,
1262
- ms: Date.now() - t0,
1263
- corsError: isCors,
1264
- }
1265
- }
1266
-
1267
- api.responses[id] = resp
1268
- btn.disabled = false
1269
- btn.textContent = '▶ Execute'
1270
-
1271
- // Re-render just the try section by refreshing the response part
1272
- const tryDiv = document.getElementById(`try-${id}`)
1273
- // Remove old response panel if present
1274
- tryDiv.querySelectorAll('.response-panel, .cors-warn').forEach(e => e.remove())
1275
- tryDiv.insertAdjacentHTML('beforeend', renderResponse(resp))
1276
- }
1277
-
1278
- // ── Interaction helpers ────────────────────────────────────────────────────
1279
- function toggleEndpoint(id) {
1280
- api.open[id] = !api.open[id]
1281
- const card = document.getElementById(`card-${id}`)
1282
- if (api.open[id]) card.classList.add('open')
1283
- else card.classList.remove('open')
1284
- }
1285
-
1286
- function toggleTry(id, e) {
1287
- e.stopPropagation()
1288
- api.tryOpen[id] = !api.tryOpen[id]
1289
- const form = document.getElementById(`try-${id}`)
1290
- if (api.tryOpen[id]) form.classList.add('show')
1291
- else form.classList.remove('show')
1292
- // Update button label
1293
- const btn = form.previousElementSibling
1294
- if (btn?.classList.contains('try-btn')) {
1295
- btn.textContent = api.tryOpen[id] ? '✕ Close' : ' Try it out'
1296
- }
1297
- }
1298
-
1299
- function toggleAuthRow() {
1300
- api.showAuth = !api.showAuth
1301
- document.getElementById('auth-row').classList.toggle('show', api.showAuth)
1302
- }
1303
-
1304
- // ── Status ─────────────────────────────────────────────────────────────────
1305
- async function refreshStatus() {
1306
- const s = await get('/api/status')
1307
- if (!s) return
1308
- const pct = s.coverage
1309
- document.getElementById('cov-pct').textContent = `${pct}%`
1310
- document.getElementById('cov-label').textContent = `${pct}%`
1311
- const bar = document.getElementById('cov-bar')
1312
- bar.style.width = `${pct}%`
1313
- bar.className = 'coverage-fill' + (pct < 50 ? ' bad' : pct < 80 ? ' warn' : '')
1314
- document.getElementById('cov-dot').style.background = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--yellow)' : 'var(--red)'
1315
- const banner = document.getElementById('pending-banner')
1316
- if (s.pending > 0) { document.getElementById('pending-count').textContent = s.pending; banner.classList.add('show') }
1317
- else banner.classList.remove('show')
1318
-
1319
- // Store stale map for nav rendering
1320
- state.staleSet = new Set((s.stale || []).map(x => x.doc))
1321
- renderNav()
1322
- }
1323
-
1324
- // ── Health Check ───────────────────────────────────────────────────────────
1325
- async function showHealthCheck() {
1326
- state.active = 'health-check'
1327
- state.view = 'health-check'
1328
- renderNav()
1329
-
1330
- const el = document.getElementById('doc-content')
1331
- el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
1332
- document.getElementById('main').scrollTo({ top: 0 })
1333
-
1334
- const data = await get('/api/check')
1335
- if (!data) {
1336
- el.innerHTML = `<div class="empty-state"><div class="empty-icon">🩺</div><h2>Health check unavailable</h2><p>Make sure DocuTrack is initialized.</p></div>`
1337
- return
1338
- }
1339
- renderHealthCheck(data)
1340
- }
1341
-
1342
- function renderHealthCheck(data) {
1343
- const el = document.getElementById('doc-content')
1344
- const { pending, stale, drift, complexity, ok } = data
1345
-
1346
- const criticalCount = complexity.critical?.length || 0
1347
- const highDrift = (drift || []).filter(d => d.severity === 'high').length
1348
- const totalIssues = pending + stale + highDrift + criticalCount
1349
- state.healthBadge = totalIssues
1350
- renderNav()
1351
-
1352
- const overallClass = ok ? 'health-ok' : totalIssues > 3 ? 'health-crit' : 'health-warn'
1353
- const overallText = ok ? '✓ Healthy' : `⚠ ${totalIssues} issue${totalIssues !== 1 ? 's' : ''}`
1354
-
1355
- const scoreCard = (val, label, cls) => `
1356
- <div class="health-card ${cls}">
1357
- <div class="health-card-val">${val}</div>
1358
- <div class="health-card-label">${label}</div>
1359
- </div>`
1360
-
1361
- const cards = [
1362
- scoreCard(pending, 'Pending files', pending > 0 ? 'warn-card' : 'ok'),
1363
- scoreCard(stale, 'Stale docs', stale > 0 ? 'warn-card' : 'ok'),
1364
- scoreCard(drift.length, 'Drifted modules', highDrift > 0 ? 'issue' : drift.length > 0 ? 'warn-card' : 'ok'),
1365
- scoreCard(complexity.summary?.critical || 0, 'Complex files', criticalCount > 0 ? 'issue' : 'ok'),
1366
- ].join('')
1367
-
1368
- const driftSection = drift.length > 0 ? `
1369
- <div class="health-section">
1370
- <div class="health-section-title">📐 Documentation Drift</div>
1371
- ${drift.map(d => `
1372
- <div class="drift-row">
1373
- <span class="drift-sev ${d.severity}">${d.severity.toUpperCase()}</span>
1374
- <div>
1375
- <div class="drift-module">${esc(d.module)}</div>
1376
- ${d.undocumented?.length ? `<div class="drift-detail">+undocumented: ${d.undocumented.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
1377
- ${d.orphaned?.length ? `<div class="drift-detail" style="margin-top:3px">−orphaned: ${d.orphaned.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
1378
- </div>
1379
- </div>`).join('')}
1380
- </div>` : ''
1381
-
1382
- const complexFiles = complexity.critical || []
1383
- const complexSection = complexFiles.length > 0 ? `
1384
- <div class="health-section">
1385
- <div class="health-section-title">📊 Complexity Hotspots</div>
1386
- ${complexFiles.map(f => {
1387
- const scoreClass = f.score < 40 ? 'score-bad' : f.score < 70 ? 'score-warn' : 'score-ok'
1388
- return `
1389
- <div class="complexity-row">
1390
- <div class="complexity-score ${scoreClass}">${f.score}</div>
1391
- <div class="complexity-file" title="${esc(f.file)}">${esc(f.file)}</div>
1392
- <div class="complexity-tags">
1393
- ${f.warnings.map(w => `<span class="complexity-tag${w.level === 'critical' ? ' crit' : ''}">${esc(w.message)}</span>`).join('')}
1394
- </div>
1395
- </div>`}).join('')}
1396
- </div>` : ''
1397
-
1398
- const allGood = drift.length === 0 && complexFiles.length === 0 && pending === 0 && stale === 0
1399
- const emptyState = allGood ? `
1400
- <div class="health-empty" style="padding:40px">
1401
- <div style="font-size:48px;margin-bottom:12px">✅</div>
1402
- <p style="font-size:16px;font-weight:700;color:var(--text)">Documentation is fully healthy.</p>
1403
- <p>No pending files, no stale docs, no drift, no complexity issues.</p>
1404
- </div>` : ''
1405
-
1406
- el.innerHTML = `
1407
- <div style="max-width:800px">
1408
- <div class="health-header">
1409
- <div>
1410
- <div class="health-title">Health Check</div>
1411
- <div class="health-subtitle">Documentation quality analysis</div>
1412
- </div>
1413
- <div style="display:flex;align-items:center;gap:10px">
1414
- <span class="health-overall ${overallClass}">${overallText}</span>
1415
- <button class="health-refresh" onclick="showHealthCheck()">↻ Refresh</button>
1416
- </div>
1417
- </div>
1418
- <div class="health-grid">${cards}</div>
1419
- ${emptyState}
1420
- ${driftSection}
1421
- ${complexSection}
1422
- ${(pending > 0 || stale > 0) ? `
1423
- <div class="health-section">
1424
- <div class="health-section-title">💡 Suggested Actions</div>
1425
- <div style="font-size:13px;color:var(--text-muted);line-height:2">
1426
- ${pending > 0 ? `<div>→ Run the <strong>documentalista</strong> subagent to clear ${pending} pending file${pending !== 1 ? 's' : ''}</div>` : ''}
1427
- ${stale > 0 ? `<div>→ Update ${stale} stale doc${stale !== 1 ? 's' : ''}, then run <code>docutrack clear</code></div>` : ''}
1428
- ${highDrift > 0 ? `<div>→ Add missing exports to docs for high-drift modules above</div>` : ''}
1429
- ${criticalCount > 0 ? `<div>→ Consider splitting the complex files listed above</div>` : ''}
1430
- </div>
1431
- </div>` : ''}
1432
- </div>`
1433
- }
1434
-
1435
- // ── SSE ─────────────────────────────────────────────────────────────────────
1436
- function connectSSE() {
1437
- const es = new EventSource('/events')
1438
- es.onmessage = async (e) => {
1439
- if (e.data.startsWith('progress:')) { handleProgressEvent(e.data); return }
1440
- if (e.data.startsWith('done:')) { handleDoneEvent(e.data); return }
1441
- if (e.data !== 'reload') return
1442
-
1443
- await refreshTree()
1444
- await refreshStatus()
1445
-
1446
- if (state.view === 'api-explorer') {
1447
- api.spec = await get('/api/openapi')
1448
- renderApiExplorer()
1449
- } else if (state.view === 'health-check') {
1450
- const data = await get('/api/check')
1451
- if (data) renderHealthCheck(data)
1452
- } else if (state.active && state.active !== 'bootstrap' && state.active !== 'regen') {
1453
- await loadFile(state.active)
1454
- }
1455
- // If we were on bootstrap and modules now exist → navigate to arch
1456
- if (state.active === 'bootstrap' && state.tree?.modules?.length > 0) {
1457
- if (state.tree.architecture) loadFile(state.tree.architecture)
1458
- }
1459
- }
1460
- es.onerror = () => setTimeout(connectSSE, 3000)
1461
- }
1462
-
1463
- // ── Theme ──────────────────────────────────────────────────────────────────
1464
- document.getElementById('theme-toggle').addEventListener('click', () => {
1465
- setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark')
1466
- })
1467
-
1468
- function setTheme(t) {
1469
- document.documentElement.setAttribute('data-theme', t)
1470
- document.getElementById('theme-toggle').textContent = t === 'dark' ? '☀️' : '🌙'
1471
- localStorage.setItem('dt-theme', t)
1472
- mermaid.initialize({ startOnLoad: false, theme: t === 'dark' ? 'dark' : 'neutral', securityLevel: 'loose' })
1473
- }
1474
-
1475
- // ── Utils ──────────────────────────────────────────────────────────────────
1476
- async function get(url) {
1477
- try { const r = await fetch(url); return r.ok ? r.json() : null } catch { return null }
1478
- }
1479
- function esc(s) {
1480
- return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
1481
- }
1482
- function ea(s) { return String(s||'').replace(/'/g,"\\'") }
1483
- function epId(method, path) {
1484
- return `${method}-${path}`.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
1485
- }
1486
- function statusClass(code) {
1487
- const n = parseInt(code)
1488
- if (n >= 500) return 'status-5xx'
1489
- if (n >= 400) return 'status-4xx'
1490
- if (n >= 300) return 'status-3xx'
1491
- return 'status-2xx'
1492
- }
1493
-
1494
- // ── Command Palette ───────────────────────────────────────────────────────────
1495
- let paletteIdx = 0
1496
- let paletteItems = []
1497
- let paletteSearchTimer = null
1498
-
1499
- function openPalette() {
1500
- document.getElementById('palette-overlay').classList.add('open')
1501
- const inp = document.getElementById('palette-input')
1502
- inp.value = ''
1503
- inp.focus()
1504
- renderPaletteResults('')
1505
- }
1506
-
1507
- function closePalette() {
1508
- document.getElementById('palette-overlay').classList.remove('open')
1509
- }
1510
-
1511
- function onPaletteOverlayClick(e) {
1512
- if (e.target === document.getElementById('palette-overlay')) closePalette()
1513
- }
1514
-
1515
- function renderPaletteResults(q) {
1516
- const { tree } = state
1517
- paletteItems = []
1518
- const lq = q.toLowerCase()
1519
-
1520
- if (tree) {
1521
- if (tree.architecture && (!q || 'architecture'.includes(lq))) {
1522
- paletteItems.push({ icon: '🏗', name: 'Architecture', section: 'Overview', action: () => loadFile(tree.architecture) })
1523
- }
1524
- const addItems = (items, icon, section, fn) => {
1525
- if (!items?.length) return
1526
- for (const item of items) {
1527
- const name = fmtName(item.name)
1528
- if (!q || name.toLowerCase().includes(lq) || item.name.toLowerCase().includes(lq)) {
1529
- paletteItems.push({ icon, name, section, action: () => fn(item) })
1530
- }
1531
- }
1532
- }
1533
- addItems(tree.modules, '📦', 'Modules', m => loadFile(m.path))
1534
- addItems(tree.decisions, '📝', 'Decisions', d => loadFile(d.path))
1535
- addItems(tree.api, '📄', 'API', a => loadFile(a.path))
1536
- }
1537
- if (!q || 'api explorer'.includes(lq)) paletteItems.push({ icon: '⚡', name: 'API Explorer', section: 'Views', action: showApiExplorer })
1538
- if (!q || 'health check'.includes(lq)) paletteItems.push({ icon: '🩺', name: 'Health Check', section: 'Views', action: showHealthCheck })
1539
- if (!q || 'regenerate docs'.includes(lq)) paletteItems.push({ icon: '✨', name: 'Regenerate docs', section: 'Actions', action: showRegenPanel })
1540
-
1541
- paintPalette()
1542
-
1543
- // Debounced content search
1544
- clearTimeout(paletteSearchTimer)
1545
- if (q.length >= 2) {
1546
- paletteSearchTimer = setTimeout(() => fetchContentResults(q), 250)
1547
- }
1548
- }
1549
-
1550
- async function fetchContentResults(q) {
1551
- try {
1552
- const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
1553
- if (!r.ok) return
1554
- const hits = await r.json()
1555
- if (!hits.length) return
1556
- // Append content hits (avoid duplicates with name matches)
1557
- const existing = new Set(paletteItems.map(i => i._path).filter(Boolean))
1558
- for (const hit of hits) {
1559
- if (existing.has(hit.path)) continue
1560
- paletteItems.push({ icon: '🔎', name: hit.title, section: 'Content', snippet: hit.snippet, _path: hit.path, action: () => loadFile(hit.path) })
1561
- }
1562
- paintPalette()
1563
- } catch { /* ok */ }
1564
- }
1565
-
1566
- function paintPalette() {
1567
- const container = document.getElementById('palette-results')
1568
- if (!container) return
1569
-
1570
- if (!paletteItems.length) {
1571
- container.innerHTML = '<div class="palette-empty">No results</div>'
1572
- return
1573
- }
1574
-
1575
- const groups = {}
1576
- for (const item of paletteItems) {
1577
- if (!groups[item.section]) groups[item.section] = []
1578
- groups[item.section].push(item)
1579
- }
1580
-
1581
- let flatIdx = 0
1582
- let html = ''
1583
- for (const [section, items] of Object.entries(groups)) {
1584
- html += `<div class="palette-group-header">${esc(section)}</div>`
1585
- for (const item of items) {
1586
- const i = flatIdx++
1587
- const sel = i === paletteIdx ? ' selected' : ''
1588
- html += `<div class="palette-item${sel}" onmouseenter="paletteIdx=${i};updatePaletteSelection()" onclick="selectPaletteItem(${i})">
1589
- <span class="palette-item-icon">${item.icon}</span>
1590
- <span class="palette-item-name">${esc(item.name)}</span>
1591
- ${item.snippet ? `<span class="palette-item-snippet">${esc(item.snippet)}</span>` : `<span class="palette-item-section">${esc(item.section)}</span>`}
1592
- </div>`
1593
- }
1594
- }
1595
- container.innerHTML = html
1596
- }
1597
-
1598
- function selectPaletteItem(i) {
1599
- closePalette()
1600
- paletteItems[i]?.action()
1601
- }
1602
-
1603
- function updatePaletteSelection() {
1604
- document.querySelectorAll('#palette-results .palette-item').forEach((el, i) => {
1605
- el.classList.toggle('selected', i === paletteIdx)
1606
- })
1607
- }
1608
-
1609
- function scrollPaletteItem() {
1610
- const items = document.querySelectorAll('#palette-results .palette-item')
1611
- items[paletteIdx]?.scrollIntoView({ block: 'nearest' })
1612
- }
1613
-
1614
- // ── Copy code ────────────────────────────────────────────────────────────────
1615
- function copyCode(btn) {
1616
- const code = btn.previousElementSibling?.querySelector('code')
1617
- if (!code) return
1618
- navigator.clipboard.writeText(code.textContent).then(() => {
1619
- btn.textContent = '✓ Copied'
1620
- btn.classList.add('copied')
1621
- setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied') }, 1500)
1622
- }).catch(() => {})
1623
- }
1624
-
1625
- // ── Heading anchors ──────────────────────────────────────────────────────────
1626
- function copyAnchor(a, e) {
1627
- e.preventDefault()
1628
- const url = location.href.split('#')[0] + a.getAttribute('href')
1629
- navigator.clipboard.writeText(url).then(() => {
1630
- const orig = a.textContent
1631
- a.textContent = '✓'
1632
- setTimeout(() => { a.textContent = orig }, 1200)
1633
- }).catch(() => {
1634
- location.hash = a.getAttribute('href')
1635
- })
1636
- }
1637
-
1638
- boot()
1639
- </script>
1640
- </body>
1641
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DocuTrack</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #ffffff;
12
+ --bg-sidebar: #f8fafc;
13
+ --bg-hover: #f1f5f9;
14
+ --bg-active: #eef2ff;
15
+ --border: #e2e8f0;
16
+ --text: #0f172a;
17
+ --text-muted: #64748b;
18
+ --text-sidebar: #374151;
19
+ --accent: #6366f1;
20
+ --accent-hover: #4f46e5;
21
+ --accent-light: #eef2ff;
22
+ --code-bg: #f1f5f9;
23
+ --code-text: #1e293b;
24
+ --green: #10b981; --green-bg: #dcfce7; --green-text: #166534;
25
+ --blue-bg: #dbeafe; --blue-text: #1e40af;
26
+ --yellow: #f59e0b; --yellow-bg: #fef3c7; --yellow-text: #92400e;
27
+ --orange-bg: #ffedd5; --orange-text: #9a3412;
28
+ --purple-bg: #ede9fe; --purple-text: #6d28d9;
29
+ --red: #ef4444; --red-bg: #fee2e2; --red-text: #991b1b;
30
+ --sidebar-w: 272px;
31
+ --header-h: 56px;
32
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
33
+ --font-mono: "Cascadia Code", "Fira Code", Consolas, monospace;
34
+ --radius: 6px;
35
+ }
36
+
37
+ [data-theme="dark"] {
38
+ --bg: #0f172a; --bg-sidebar: #1e293b; --bg-hover: #334155; --bg-active: #312e81;
39
+ --border: #334155; --text: #f1f5f9; --text-muted: #94a3b8; --text-sidebar: #cbd5e1;
40
+ --accent: #818cf8; --accent-hover: #a5b4fc; --accent-light: #1e1b4b;
41
+ --code-bg: #1e293b; --code-text: #e2e8f0;
42
+ --green-bg: #14532d; --green-text: #86efac;
43
+ --blue-bg: #1e3a5f; --blue-text: #93c5fd;
44
+ --yellow-bg: #78350f; --yellow-text: #fde68a;
45
+ --orange-bg: #7c2d12; --orange-text: #fed7aa;
46
+ --purple-bg: #3b0764; --purple-text: #d8b4fe;
47
+ --red-bg: #7f1d1d; --red-text: #fca5a5;
48
+ }
49
+
50
+ html, body { height: 100%; font-family: var(--font); color: var(--text); background: var(--bg); font-size: 15px; line-height: 1.6; }
51
+
52
+ /* ── Layout ── */
53
+ #app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; grid-template-rows: var(--header-h) 1fr; height: 100vh; }
54
+
55
+ /* ── Header ── */
56
+ #header { grid-column: 1 / -1; background: var(--bg); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 16px; position: sticky; top: 0; z-index: 100; }
57
+ .logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; color: var(--text); text-decoration: none; white-space: nowrap; cursor: pointer; }
58
+ .logo-icon { width: 28px; height: 28px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
59
+ .header-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
60
+ #project-name { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
61
+ .header-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
62
+ #coverage-badge { display: flex; align-items: center; gap: 6px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 20px; padding: 3px 10px; font-size: 12px; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
63
+ .coverage-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
64
+ #theme-toggle { width: 32px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; }
65
+ #theme-toggle:hover { background: var(--bg-hover); }
66
+ .live-pill { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); }
67
+ .live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; flex-shrink: 0; }
68
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
69
+
70
+ /* ── Search ── */
71
+ #search-wrap { flex: 1; max-width: 380px; position: relative; }
72
+ #search { width: 100%; padding: 6px 12px 6px 34px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-sidebar); color: var(--text); font-size: 13px; outline: none; }
73
+ #search:focus { border-color: var(--accent); background: var(--bg); }
74
+ #search::placeholder { color: var(--text-muted); }
75
+ .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; pointer-events: none; color: var(--text-muted); }
76
+ .search-kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 10px; color: var(--text-muted); background: var(--border); padding: 1px 5px; border-radius: 3px; pointer-events: none; }
77
+
78
+ /* ── Sidebar ── */
79
+ #sidebar { background: var(--bg-sidebar); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0 80px; }
80
+ .nav-section { margin-bottom: 4px; }
81
+ .nav-section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 8px 20px 4px; }
82
+ .nav-item { display: flex; align-items: center; gap: 8px; padding: 6px 20px; font-size: 13px; color: var(--text-sidebar); cursor: pointer; border-left: 2px solid transparent; }
83
+ .nav-item:hover { background: var(--bg-hover); color: var(--text); }
84
+ .nav-item.active { background: var(--bg-active); color: var(--accent); border-left-color: var(--accent); font-weight: 500; }
85
+ .nav-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
86
+ .nav-empty { font-size: 12px; color: var(--text-muted); padding: 4px 20px 8px; font-style: italic; }
87
+ .nav-badge { margin-left: auto; font-size: 11px; background: var(--accent-light); color: var(--accent); border-radius: 10px; padding: 0 6px; font-weight: 600; }
88
+ .nav-stale { margin-left: auto; font-size: 10px; background: var(--yellow-bg); color: var(--yellow-text); border-radius: 10px; padding: 0 5px; font-weight: 700; }
89
+ .stale-banner { background: var(--yellow-bg); border-top: 1px solid var(--yellow); padding: 8px 20px; font-size: 12px; color: var(--yellow-text); }
90
+
91
+ /* Coverage bar */
92
+ #coverage-section { margin-top: 16px; padding: 0 16px; }
93
+ .coverage-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; display: flex; justify-content: space-between; }
94
+ .coverage-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
95
+ .coverage-fill { height: 100%; background: var(--green); border-radius: 2px; transition: width .5s; }
96
+ .coverage-fill.warn { background: var(--yellow); }
97
+ .coverage-fill.bad { background: var(--red); }
98
+
99
+ /* ── Main ── */
100
+ #main { overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; min-height: 0; }
101
+ #doc-wrap { width: 100%; max-width: 800px; padding: 40px 48px 120px; }
102
+
103
+ /* Pending banner */
104
+ #pending-banner { display: none; background: var(--yellow-bg); border: 1px solid var(--yellow); border-radius: var(--radius); padding: 10px 16px; margin-bottom: 24px; font-size: 13px; color: var(--yellow-text); align-items: center; gap: 8px; }
105
+ #pending-banner.show { display: flex; }
106
+
107
+ /* ── Markdown ── */
108
+ #doc-content h1 { font-size: 28px; font-weight: 800; margin-bottom: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
109
+ #doc-content h2 { font-size: 20px; font-weight: 700; margin-top: 36px; margin-bottom: 12px; }
110
+ #doc-content h3 { font-size: 16px; font-weight: 600; margin-top: 24px; margin-bottom: 8px; }
111
+ #doc-content h4 { font-size: 14px; font-weight: 600; margin-top: 16px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
112
+ #doc-content p { margin-bottom: 14px; }
113
+ #doc-content ul, #doc-content ol { margin-bottom: 14px; padding-left: 24px; }
114
+ #doc-content li { margin-bottom: 4px; }
115
+ #doc-content a { color: var(--accent); text-decoration: none; }
116
+ #doc-content a:hover { text-decoration: underline; }
117
+ #doc-content blockquote { border-left: 3px solid var(--accent); padding: 8px 16px; margin: 16px 0; color: var(--text-muted); background: var(--accent-light); border-radius: 0 var(--radius) var(--radius) 0; }
118
+ #doc-content blockquote p { margin: 0; }
119
+ #doc-content code { font-family: var(--font-mono); font-size: 13px; background: var(--code-bg); color: var(--code-text); padding: 2px 6px; border-radius: 4px; }
120
+ #doc-content pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; overflow-x: auto; margin: 16px 0; }
121
+ #doc-content pre code { background: none; padding: 0; font-size: 13px; line-height: 1.6; }
122
+ #doc-content table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
123
+ #doc-content th { background: var(--bg-sidebar); font-weight: 600; text-align: left; padding: 10px 14px; border: 1px solid var(--border); }
124
+ #doc-content td { padding: 9px 14px; border: 1px solid var(--border); }
125
+ #doc-content tr:nth-child(even) td { background: var(--bg-sidebar); }
126
+ #doc-content hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
127
+ .mermaid-wrap { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin: 20px 0; overflow-x: auto; text-align: center; }
128
+
129
+ /* ── API Explorer ── */
130
+ .api-explorer { width: 100%; }
131
+ .api-explorer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
132
+ .base-url-wrap { display: flex; flex-direction: column; gap: 4px; min-width: 260px; }
133
+ .base-url-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
134
+ .base-url-row { display: flex; gap: 6px; align-items: center; }
135
+ .base-url-input { padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); width: 260px; outline: none; }
136
+ .base-url-input:focus { border-color: var(--accent); }
137
+ .auth-toggle { padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 12px; cursor: pointer; white-space: nowrap; }
138
+ .auth-toggle:hover { background: var(--bg-hover); }
139
+ .auth-row { margin-top: 8px; display: none; gap: 6px; align-items: center; }
140
+ .auth-row.show { display: flex; }
141
+ .auth-input { flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); outline: none; }
142
+ .auth-input:focus { border-color: var(--accent); }
143
+ .auth-label { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
144
+
145
+ .api-group { margin-bottom: 24px; }
146
+ .api-group-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin-bottom: 8px; padding: 0 2px; }
147
+
148
+ .endpoint-card { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; overflow: hidden; transition: box-shadow .15s; }
149
+ .endpoint-card.open { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
150
+ .endpoint-header { display: flex; align-items: center; gap: 12px; padding: 11px 16px; cursor: pointer; background: var(--bg); user-select: none; }
151
+ .endpoint-header:hover { background: var(--bg-hover); }
152
+ .endpoint-path { font-family: var(--font-mono); font-size: 13px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
153
+ .endpoint-summary { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
154
+ .endpoint-chevron { color: var(--text-muted); font-size: 16px; transition: transform .2s; flex-shrink: 0; }
155
+ .endpoint-card.open .endpoint-chevron { transform: rotate(90deg); }
156
+ .endpoint-body { display: none; border-top: 1px solid var(--border); background: var(--bg); }
157
+ .endpoint-card.open .endpoint-body { display: block; }
158
+ .endpoint-section { padding: 16px 20px; border-bottom: 1px solid var(--border); }
159
+ .endpoint-section:last-child { border-bottom: none; }
160
+ .section-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: 12px; }
161
+
162
+ /* Method badges */
163
+ .method-badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; letter-spacing: .05em; font-family: var(--font-mono); flex-shrink: 0; min-width: 54px; justify-content: center; }
164
+ .badge-get { background: var(--green-bg); color: var(--green-text); }
165
+ .badge-post { background: var(--blue-bg); color: var(--blue-text); }
166
+ .badge-put { background: var(--orange-bg); color: var(--orange-text); }
167
+ .badge-patch { background: var(--purple-bg); color: var(--purple-text); }
168
+ .badge-delete { background: var(--red-bg); color: var(--red-text); }
169
+ .badge-options,.badge-head { background: var(--code-bg); color: var(--text-muted); }
170
+
171
+ /* Params table */
172
+ .params-table { width: 100%; border-collapse: collapse; font-size: 13px; }
173
+ .params-table th { font-size: 11px; font-weight: 600; text-align: left; padding: 6px 10px; background: var(--bg-sidebar); border: 1px solid var(--border); color: var(--text-muted); }
174
+ .params-table td { padding: 7px 10px; border: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; }
175
+ .param-required { background: var(--red-bg); color: var(--red-text); padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; font-family: var(--font); }
176
+ .param-optional { background: var(--code-bg); color: var(--text-muted); padding: 1px 6px; border-radius: 3px; font-size: 10px; font-family: var(--font); }
177
+
178
+ /* Try it out */
179
+ .try-section { padding: 16px 20px; background: var(--bg-sidebar); }
180
+ .try-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; }
181
+ .try-btn:hover { background: var(--accent-hover); }
182
+ .try-btn:disabled { opacity: .5; cursor: not-allowed; }
183
+ .try-form { margin-top: 14px; display: none; }
184
+ .try-form.show { display: block; }
185
+ .try-field { margin-bottom: 10px; }
186
+ .try-field label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--text-muted); font-family: var(--font-mono); }
187
+ .try-field input, .try-field textarea { width: 100%; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 13px; font-family: var(--font-mono); outline: none; }
188
+ .try-field input:focus, .try-field textarea:focus { border-color: var(--accent); }
189
+ .try-field textarea { min-height: 90px; resize: vertical; }
190
+ .try-actions { display: flex; gap: 8px; margin-top: 12px; }
191
+ .execute-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 20px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 600; cursor: pointer; }
192
+ .execute-btn:hover { background: var(--accent-hover); }
193
+ .execute-btn:disabled { opacity: .5; cursor: not-allowed; }
194
+ .cancel-try-btn { padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 13px; cursor: pointer; }
195
+ .cancel-try-btn:hover { background: var(--bg-hover); }
196
+
197
+ /* Response panel */
198
+ .response-panel { margin-top: 14px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
199
+ .response-bar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); font-size: 12px; }
200
+ .status-chip { font-size: 12px; font-weight: 700; font-family: var(--font-mono); padding: 2px 8px; border-radius: 4px; }
201
+ .status-2xx { background: var(--green-bg); color: var(--green-text); }
202
+ .status-3xx { background: var(--blue-bg); color: var(--blue-text); }
203
+ .status-4xx { background: var(--yellow-bg); color: var(--yellow-text); }
204
+ .status-5xx { background: var(--red-bg); color: var(--red-text); }
205
+ .response-time { color: var(--text-muted); margin-left: auto; }
206
+ .response-body { padding: 14px; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; overflow-x: auto; max-height: 360px; overflow-y: auto; white-space: pre; background: var(--bg); }
207
+ .response-headers { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
208
+ .cors-warn { margin-top: 10px; padding: 8px 12px; background: var(--yellow-bg); border-radius: var(--radius); font-size: 12px; color: var(--yellow-text); }
209
+
210
+ /* Welcome / empty states */
211
+ .empty-state { text-align: center; padding: 60px 40px; }
212
+ .empty-icon { font-size: 48px; margin-bottom: 16px; }
213
+ .empty-state h2 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
214
+ .empty-state p { color: var(--text-muted); max-width: 380px; margin: 0 auto 16px; }
215
+ .empty-state code { background: var(--code-bg); padding: 4px 10px; border-radius: var(--radius); font-family: var(--font-mono); font-size: 13px; }
216
+
217
+ /* Bootstrap panel */
218
+ .bootstrap-wrap { max-width: 580px; margin: 0 auto; padding: 48px 0; }
219
+ .bootstrap-hero { text-align: center; margin-bottom: 40px; }
220
+ .bootstrap-hero-icon { font-size: 52px; margin-bottom: 16px; }
221
+ .bootstrap-hero h1 { font-size: 26px; font-weight: 800; margin-bottom: 8px; }
222
+ .bootstrap-hero p { color: var(--text-muted); font-size: 15px; max-width: 420px; margin: 0 auto; }
223
+ .bootstrap-steps { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
224
+ .bootstrap-step { display: flex; align-items: flex-start; gap: 14px; padding: 16px 18px; background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); }
225
+ .bootstrap-step.done { border-color: var(--green); background: var(--green-bg); }
226
+ .bootstrap-step.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
227
+ .step-num { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: white; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 1px; }
228
+ .bootstrap-step.done .step-num { background: var(--green); }
229
+ .step-content { flex: 1; min-width: 0; }
230
+ .step-title { font-size: 14px; font-weight: 600; margin-bottom: 3px; }
231
+ .step-desc { font-size: 13px; color: var(--text-muted); }
232
+ .step-desc code { font-family: var(--font-mono); background: var(--code-bg); padding: 1px 5px; border-radius: 3px; font-size: 12px; }
233
+ .step-check { font-size: 18px; flex-shrink: 0; margin-top: 4px; }
234
+ .bootstrap-btn { width: 100%; padding: 14px 24px; background: var(--accent); color: white; border: none; border-radius: var(--radius); font-size: 15px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 10px; transition: background .15s; }
235
+ .bootstrap-btn:hover { background: var(--accent-hover); }
236
+ .bootstrap-btn:disabled { opacity: .6; cursor: not-allowed; }
237
+ .bootstrap-result { margin-top: 20px; padding: 16px 18px; background: var(--green-bg); border: 1px solid var(--green); border-radius: var(--radius); }
238
+ .bootstrap-result-title { font-weight: 700; color: var(--green-text); margin-bottom: 8px; font-size: 14px; }
239
+ .bootstrap-result-files { font-family: var(--font-mono); font-size: 12px; color: var(--green-text); line-height: 1.8; }
240
+ .bootstrap-claude { margin-top: 12px; padding: 14px 16px; background: var(--accent-light); border: 1px solid var(--accent); border-radius: var(--radius); }
241
+ .bootstrap-claude-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); margin-bottom: 8px; }
242
+ .bootstrap-claude-cmd { font-family: var(--font-mono); font-size: 13px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; cursor: pointer; user-select: all; color: var(--text); }
243
+ .bootstrap-claude-cmd:hover { border-color: var(--accent); }
244
+ .copy-hint { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
245
+
246
+ /* ── Health Check ── */
247
+ .health-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
248
+ .health-title { font-size: 26px; font-weight: 800; margin-bottom: 4px; }
249
+ .health-subtitle { font-size: 13px; color: var(--text-muted); }
250
+ .health-overall { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 700; }
251
+ .health-ok { background: var(--green-bg); color: var(--green-text); }
252
+ .health-warn { background: var(--yellow-bg); color: var(--yellow-text); }
253
+ .health-crit { background: var(--red-bg); color: var(--red-text); }
254
+ .health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 32px; }
255
+ .health-card { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
256
+ .health-card-val { font-size: 32px; font-weight: 800; line-height: 1; margin-bottom: 4px; }
257
+ .health-card-label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
258
+ .health-card.issue .health-card-val { color: var(--red); }
259
+ .health-card.warn-card .health-card-val { color: var(--yellow); }
260
+ .health-card.ok .health-card-val { color: var(--green); }
261
+ .health-section-title { font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
262
+ .health-section { margin-bottom: 28px; }
263
+ .drift-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; background: var(--bg); }
264
+ .drift-sev { font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 3px; flex-shrink: 0; margin-top: 2px; }
265
+ .drift-sev.high { background: var(--red-bg); color: var(--red-text); }
266
+ .drift-sev.medium { background: var(--yellow-bg); color: var(--yellow-text); }
267
+ .drift-sev.low { background: var(--code-bg); color: var(--text-muted); }
268
+ .drift-module { font-weight: 600; font-size: 13px; }
269
+ .drift-detail { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
270
+ .drift-detail span { font-family: var(--font-mono); background: var(--code-bg); padding: 0 4px; border-radius: 3px; font-size: 11px; margin-right: 3px; }
271
+ .complexity-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 6px; background: var(--bg); }
272
+ .complexity-score { width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 800; flex-shrink: 0; }
273
+ .score-bad { background: var(--red-bg); color: var(--red-text); }
274
+ .score-warn { background: var(--yellow-bg); color: var(--yellow-text); }
275
+ .score-ok { background: var(--green-bg); color: var(--green-text); }
276
+ .complexity-file { font-family: var(--font-mono); font-size: 12px; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
277
+ .complexity-tags { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
278
+ .complexity-tag { font-size: 11px; background: var(--code-bg); color: var(--text-muted); padding: 1px 7px; border-radius: 3px; }
279
+ .complexity-tag.crit { background: var(--red-bg); color: var(--red-text); }
280
+ .health-empty { text-align: center; padding: 20px; color: var(--text-muted); font-size: 13px; }
281
+ .health-refresh { padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text-muted); font-size: 12px; cursor: pointer; }
282
+ .health-refresh:hover { background: var(--bg-hover); }
283
+
284
+ /* Misc */
285
+ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .6s linear infinite; }
286
+ @keyframes spin { to { transform: rotate(360deg); } }
287
+ #sidebar::-webkit-scrollbar, #main::-webkit-scrollbar { width: 5px; }
288
+ #sidebar::-webkit-scrollbar-thumb, #main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
289
+ @media (max-width: 768px) { #app { grid-template-columns: 1fr; } #sidebar { display: none; } #doc-wrap { padding: 24px 20px 60px; } }
290
+
291
+ /* ── Command Palette ── */
292
+ .palette-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.48); z-index: 500; display: none; align-items: flex-start; justify-content: center; padding-top: 14vh; }
293
+ .palette-overlay.open { display: flex; }
294
+ .palette-box { width: 100%; max-width: 560px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 24px 64px rgba(0,0,0,.28); overflow: hidden; }
295
+ .palette-input-wrap { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
296
+ .palette-search-icon { font-size: 16px; color: var(--text-muted); flex-shrink: 0; }
297
+ #palette-input { flex: 1; border: none; outline: none; font-size: 15px; background: transparent; color: var(--text); }
298
+ #palette-input::placeholder { color: var(--text-muted); }
299
+ .palette-esc { font-size: 11px; background: var(--border); color: var(--text-muted); padding: 2px 7px; border-radius: 4px; flex-shrink: 0; cursor: pointer; }
300
+ .palette-results { max-height: 380px; overflow-y: auto; }
301
+ .palette-group-header { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 8px 16px 4px; background: var(--bg-sidebar); }
302
+ .palette-item { display: flex; align-items: center; gap: 10px; padding: 10px 16px; cursor: pointer; font-size: 13px; border-left: 2px solid transparent; }
303
+ .palette-item.selected { background: var(--bg-active); color: var(--accent); border-left-color: var(--accent); }
304
+ .palette-item-icon { width: 20px; text-align: center; flex-shrink: 0; }
305
+ .palette-item-name { flex: 1; }
306
+ .palette-item-section { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
307
+ .palette-item-snippet { font-size: 11px; color: var(--text-muted); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
308
+ .palette-empty { padding: 32px; text-align: center; color: var(--text-muted); font-size: 13px; }
309
+ .palette-footer { padding: 8px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); display: flex; gap: 14px; background: var(--bg-sidebar); }
310
+ .palette-footer kbd { background: var(--border); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono); }
311
+
312
+ /* ── Copy button ── */
313
+ .code-block-wrap { position: relative; }
314
+ .copy-btn { position: absolute; top: 8px; right: 8px; padding: 3px 9px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text-muted); font-size: 11px; cursor: pointer; opacity: 0; transition: opacity .15s; z-index: 1; font-family: var(--font); }
315
+ .code-block-wrap:hover .copy-btn { opacity: 1; }
316
+ .copy-btn.copied { color: var(--green-text); border-color: var(--green); background: var(--green-bg); }
317
+
318
+ /* ── Heading anchors ── */
319
+ .heading-anchor { opacity: 0; margin-left: 6px; font-size: .72em; color: var(--text-muted); text-decoration: none; transition: opacity .15s; vertical-align: middle; }
320
+ h2:hover .heading-anchor, h3:hover .heading-anchor { opacity: 1; }
321
+ </style>
322
+ </head>
323
+ <body>
324
+ <div id="app">
325
+
326
+ <header id="header">
327
+ <div class="logo" onclick="goHome()">
328
+ <div class="logo-icon">📋</div>DocuTrack
329
+ </div>
330
+ <div class="header-sep"></div>
331
+ <span id="project-name">loading…</span>
332
+ <div id="search-wrap">
333
+ <span class="search-icon">🔍</span>
334
+ <input type="text" id="search" placeholder="Search docs…" autocomplete="off">
335
+ <span class="search-kbd">⌘K</span>
336
+ </div>
337
+ <div class="header-right">
338
+ <div id="coverage-badge">
339
+ <span class="coverage-dot" id="cov-dot"></span>
340
+ <span id="cov-pct">–%</span>
341
+ <span style="color:var(--text-muted);font-weight:400">coverage</span>
342
+ </div>
343
+ <button id="theme-toggle" title="Toggle dark mode">🌙</button>
344
+ <span class="live-pill"><span class="live-dot"></span>live</span>
345
+ </div>
346
+ </header>
347
+
348
+ <aside id="sidebar">
349
+ <div class="nav-section">
350
+ <div class="nav-section-title">Overview</div>
351
+ <div id="nav-arch"></div>
352
+ </div>
353
+ <div class="nav-section">
354
+ <div class="nav-section-title">Modules</div>
355
+ <div id="nav-modules"></div>
356
+ </div>
357
+ <div class="nav-section">
358
+ <div class="nav-section-title">Decisions</div>
359
+ <div id="nav-decisions"></div>
360
+ </div>
361
+ <div class="nav-section">
362
+ <div class="nav-section-title">API</div>
363
+ <div id="nav-api-explorer"></div>
364
+ <div id="nav-api-docs"></div>
365
+ </div>
366
+ <div class="nav-section">
367
+ <div class="nav-section-title">Health</div>
368
+ <div id="nav-health"></div>
369
+ </div>
370
+ <div id="coverage-section">
371
+ <div class="coverage-label">
372
+ <span>Doc coverage</span>
373
+ <span id="cov-label">–%</span>
374
+ </div>
375
+ <div class="coverage-bar"><div class="coverage-fill" id="cov-bar" style="width:0%"></div></div>
376
+ </div>
377
+ <div style="padding:12px 16px 8px;border-top:1px solid var(--border);margin-top:8px">
378
+ <button onclick="showRegenPanel()" style="width:100%;padding:7px 12px;border:1px solid var(--border);background:var(--bg);color:var(--text-muted);border-radius:var(--radius);font-size:12px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:6px">
379
+ <span>✨</span><span>Regenerate docs</span>
380
+ </button>
381
+ </div>
382
+ </aside>
383
+
384
+ <main id="main">
385
+ <div id="doc-wrap">
386
+ <div id="pending-banner">
387
+ ⚠ <strong id="pending-count">0</strong>&nbsp;file(s) modified without documentation.
388
+ Run <code style="margin-left:4px">docutrack status</code> for details.
389
+ </div>
390
+ <div id="doc-content">
391
+ <div class="empty-state">
392
+ <div class="empty-icon">📋</div>
393
+ <h2>Welcome to DocuTrack</h2>
394
+ <p>Living documentation for your project. Select a page from the sidebar.</p>
395
+ <span class="spinner"></span>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </main>
400
+
401
+ </div>
402
+
403
+ <!-- Command Palette -->
404
+ <div id="palette-overlay" class="palette-overlay" onclick="onPaletteOverlayClick(event)">
405
+ <div class="palette-box">
406
+ <div class="palette-input-wrap">
407
+ <span class="palette-search-icon">🔍</span>
408
+ <input id="palette-input" placeholder="Search docs, jump to…" autocomplete="off" spellcheck="false">
409
+ <span class="palette-esc" onclick="closePalette()">ESC</span>
410
+ </div>
411
+ <div id="palette-results" class="palette-results"></div>
412
+ <div class="palette-footer">
413
+ <span><kbd>↑↓</kbd> navigate</span>
414
+ <span><kbd>↵</kbd> open</span>
415
+ <span><kbd>ESC</kbd> close</span>
416
+ </div>
417
+ </div>
418
+ </div>
419
+
420
+ <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
421
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
422
+ <script>
423
+ 'use strict'
424
+
425
+ // ── State ──────────────────────────────────────────────────────────────────
426
+ const state = {
427
+ tree: null,
428
+ active: null, // file path or 'api-explorer' or 'health-check'
429
+ query: '',
430
+ view: 'docs', // 'docs' | 'api-explorer' | 'health-check'
431
+ staleSet: new Set(),
432
+ healthBadge: 0, // number of critical health issues
433
+ lang: 'es', // 'es' | 'en' — language for docs generation
434
+ }
435
+
436
+ const api = {
437
+ spec: null,
438
+ baseUrl: localStorage.getItem('dt-baseUrl') || 'http://localhost:3000',
439
+ token: localStorage.getItem('dt-token') || '',
440
+ showAuth: false,
441
+ open: {}, // endpointId -> boolean
442
+ tryOpen: {}, // endpointId -> boolean
443
+ responses: {}, // endpointId -> response object
444
+ }
445
+
446
+ // ── Marked ────────────────────────────────────────────────────────────────
447
+ marked.use({
448
+ gfm: true,
449
+ renderer: {
450
+ code({ text, lang }) {
451
+ const t = text || ''
452
+ if (lang === 'mermaid') {
453
+ return `<div class="mermaid-wrap"><div class="mermaid">${esc(t)}</div></div>`
454
+ }
455
+ if (!t.trim()) return `<pre><code class="language-${esc(lang||'')}"></code></pre>`
456
+ return `<div class="code-block-wrap"><pre><code class="language-${esc(lang||'')}">${esc(t)}</code></pre><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>`
457
+ },
458
+ heading({ text, depth }) {
459
+ const t = text || ''
460
+ if (depth === 1) return `<h1>${t}</h1>\n`
461
+ const anchor = t.replace(/<[^>]+>/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || `h${depth}`
462
+ return `<h${depth} id="${anchor}">${t}<a class="heading-anchor" href="#${anchor}" onclick="copyAnchor(this,event)">#</a></h${depth}>\n`
463
+ }
464
+ }
465
+ })
466
+ mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' })
467
+
468
+ // ── Boot ──────────────────────────────────────────────────────────────────
469
+ async function boot() {
470
+ setTheme(localStorage.getItem('dt-theme') || 'light')
471
+
472
+ // Pre-set state.active so refreshTree() skips its default auto-navigation
473
+ const _initHash = location.hash.slice(1)
474
+ if (_initHash) state.active = '__hash__'
475
+
476
+ await refreshTree()
477
+ await refreshStatus()
478
+ connectSSE()
479
+
480
+ // Restore the view the user was on before reloading
481
+ if (_initHash) restoreFromHash(_initHash)
482
+ document.getElementById('search').addEventListener('input', e => {
483
+ state.query = e.target.value.trim()
484
+ renderNav()
485
+ })
486
+ document.getElementById('palette-input').addEventListener('input', e => {
487
+ renderPaletteResults(e.target.value.trim())
488
+ })
489
+ document.addEventListener('keydown', e => {
490
+ const palOpen = document.getElementById('palette-overlay').classList.contains('open')
491
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
492
+ e.preventDefault()
493
+ palOpen ? closePalette() : openPalette()
494
+ return
495
+ }
496
+ if (e.key === 'Escape') {
497
+ if (palOpen) { closePalette(); return }
498
+ document.getElementById('search').blur()
499
+ }
500
+ if (palOpen) {
501
+ if (e.key === 'ArrowDown') { e.preventDefault(); paletteIdx = Math.min(paletteIdx + 1, paletteItems.length - 1); updatePaletteSelection(); scrollPaletteItem() }
502
+ else if (e.key === 'ArrowUp') { e.preventDefault(); paletteIdx = Math.max(paletteIdx - 1, 0); updatePaletteSelection(); scrollPaletteItem() }
503
+ else if (e.key === 'Enter') { e.preventDefault(); selectPaletteItem(paletteIdx) }
504
+ }
505
+ })
506
+ }
507
+
508
+ // ── Tree / nav ─────────────────────────────────────────────────────────────
509
+ async function refreshTree() {
510
+ const tree = await get('/api/tree')
511
+ if (!tree) return
512
+ state.tree = tree
513
+
514
+ // Project name from package.json
515
+ try {
516
+ const r = await fetch('/api/content?path=package.json')
517
+ if (r.ok) { const j = JSON.parse(await r.text()); if (j.name) { document.getElementById('project-name').textContent = j.name; document.title = `DocuTrack — ${j.name}` } }
518
+ } catch { /* ok */ }
519
+
520
+ renderNav()
521
+ const hasModules = tree.modules?.length > 0
522
+ if (!state.active) {
523
+ if (hasModules && tree.architecture) loadFile(tree.architecture)
524
+ else showWelcome()
525
+ } else if (state.active === 'bootstrap' && hasModules) {
526
+ // Modules just appeared — navigate to architecture
527
+ if (tree.architecture) loadFile(tree.architecture)
528
+ }
529
+ }
530
+
531
+ function renderNav() {
532
+ const { tree, query, active } = state
533
+ if (!tree) return
534
+ const q = query.toLowerCase()
535
+
536
+ // Architecture
537
+ const archEl = document.getElementById('nav-arch')
538
+ if (tree.architecture) {
539
+ archEl.innerHTML = matches('architecture', q)
540
+ ? navItem(tree.architecture, '🏗', 'Architecture', active === tree.architecture)
541
+ : ''
542
+ } else {
543
+ archEl.innerHTML = '<div class="nav-empty">Not created yet</div>'
544
+ }
545
+
546
+ renderSection('nav-modules', tree.modules, '📦', q, active)
547
+ renderSection('nav-decisions', tree.decisions, '📝', q, active)
548
+
549
+ // API Explorer special entry
550
+ const explorerEl = document.getElementById('nav-api-explorer')
551
+ const epCount = api.spec ? Object.values(api.spec.paths || {}).reduce((n, p) => n + Object.keys(p).length, 0) : 0
552
+ const explorerMatch = !q || 'api explorer'.includes(q)
553
+ explorerEl.innerHTML = explorerMatch
554
+ ? `<div class="nav-item${active === 'api-explorer' ? ' active' : ''}" onclick="showApiExplorer()">
555
+ <span class="nav-icon">⚡</span>API Explorer
556
+ ${epCount > 0 ? `<span class="nav-badge">${epCount}</span>` : ''}
557
+ </div>`
558
+ : ''
559
+
560
+ // API docs (markdown files)
561
+ renderSection('nav-api-docs', tree.api, '📄', q, active)
562
+
563
+ // Health Check nav entry
564
+ const healthMatch = !q || 'health check'.includes(q)
565
+ const healthHasIssue = state.healthBadge
566
+ document.getElementById('nav-health').innerHTML = healthMatch
567
+ ? `<div class="nav-item${active === 'health-check' ? ' active' : ''}" onclick="showHealthCheck()">
568
+ <span class="nav-icon">🩺</span>Health Check
569
+ ${healthHasIssue ? `<span class="nav-badge" style="background:var(--red-bg);color:var(--red-text)">${healthHasIssue}</span>` : ''}
570
+ </div>` : ''
571
+ }
572
+
573
+ function renderSection(id, items, icon, q, active) {
574
+ const el = document.getElementById(id)
575
+ if (!items?.length) { el.innerHTML = '<div class="nav-empty">No docs yet</div>'; return }
576
+ const filtered = q ? items.filter(i => i.name.toLowerCase().includes(q)) : items
577
+ el.innerHTML = filtered.length
578
+ ? filtered.map(i => navItem(i.path, icon, fmtName(i.name), active === i.path)).join('')
579
+ : ''
580
+ }
581
+
582
+ function navItem(p, icon, label, isActive) {
583
+ const isStale = state.staleSet.has(p)
584
+ const staleTag = isStale ? '<span class="nav-stale">stale</span>' : ''
585
+ return `<div class="nav-item${isActive ? ' active' : ''}" onclick="loadFile('${ea(p)}')">`
586
+ + `<span class="nav-icon">${icon}</span>${esc(label)}${staleTag}</div>`
587
+ }
588
+
589
+ function fmtName(n) {
590
+ return n.replace(/^ADR-\d+-/, 'ADR: ').replace(/[-_]/g, ' ')
591
+ .replace(/\b\w/g, c => c.toUpperCase())
592
+ }
593
+
594
+ function matches(str, q) { return !q || str.toLowerCase().includes(q) }
595
+
596
+ // ── Doc viewer ─────────────────────────────────────────────────────────────
597
+ async function loadFile(filePath) {
598
+ if (!filePath) return
599
+ state.active = filePath
600
+ state.view = 'docs'
601
+ setHash('doc/' + filePath)
602
+ renderNav()
603
+
604
+ const el = document.getElementById('doc-content')
605
+ el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
606
+
607
+ const r = await fetch(`/api/content?path=${encodeURIComponent(filePath)}`)
608
+ if (!r.ok) { el.innerHTML = `<p style="color:var(--red);padding:40px">Could not load: ${esc(filePath)}</p>`; return }
609
+
610
+ const text = await r.text()
611
+ el.innerHTML = marked.parse(text)
612
+ document.getElementById('main').scrollTo({ top: 0 })
613
+ await mermaid.run({ nodes: document.querySelectorAll('#doc-content .mermaid') })
614
+
615
+ if (filePath === 'ARCHITECTURE.md' && isArchTemplate(text)) {
616
+ const banner = document.createElement('div')
617
+ banner.id = 'arch-gen-banner'
618
+ banner.style.cssText = 'position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;gap:14px;flex-wrap:wrap'
619
+ banner.innerHTML = `
620
+ <span style="font-size:13px;color:var(--text-muted);flex:1">La arquitectura tiene contenido de plantilla sin completar.</span>
621
+ <button id="arch-gen-btn" onclick="generateArch()" style="padding:8px 18px;background:var(--accent);color:white;border:none;border-radius:var(--radius);font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap">✨ Fill in with AI</button>`
622
+ document.getElementById('doc-wrap').appendChild(banner)
623
+ }
624
+ }
625
+
626
+ function showWelcome() {
627
+ const hasModules = state.tree?.modules?.length > 0
628
+ if (!hasModules) {
629
+ showBootstrap()
630
+ } else {
631
+ document.getElementById('doc-content').innerHTML = `
632
+ <div class="empty-state">
633
+ <div class="empty-icon">📋</div>
634
+ <h2>Welcome to DocuTrack</h2>
635
+ <p>Living documentation for your project. Select a page from the sidebar to get started.</p>
636
+ </div>`
637
+ }
638
+ }
639
+
640
+ function showBootstrap() {
641
+ state.active = 'bootstrap'
642
+ const el = document.getElementById('doc-content')
643
+ el.innerHTML = `
644
+ <div class="bootstrap-wrap">
645
+ <div class="bootstrap-hero">
646
+ <div class="bootstrap-hero-icon">🚀</div>
647
+ <h1>Set up your docs</h1>
648
+ <p>This project doesn't have documentation yet. Let's generate it from your existing code in two steps.</p>
649
+ </div>
650
+
651
+ <div class="bootstrap-steps">
652
+ <div class="bootstrap-step done" id="step-init">
653
+ <span class="step-num">1</span>
654
+ <div class="step-content">
655
+ <div class="step-title">DocuTrack initialized</div>
656
+ <div class="step-desc">Hooks, templates, and slash commands are installed.</div>
657
+ </div>
658
+ <span class="step-check">✅</span>
659
+ </div>
660
+
661
+ <div class="bootstrap-step active" id="step-scan">
662
+ <span class="step-num">2</span>
663
+ <div class="step-content">
664
+ <div class="step-title">Scan your source files</div>
665
+ <div class="step-desc">Queues all existing files so the AI agent knows what to document.</div>
666
+ </div>
667
+ </div>
668
+
669
+ <div class="bootstrap-step" id="step-agent">
670
+ <span class="step-num">3</span>
671
+ <div class="step-content">
672
+ <div class="step-title">Run the documentalista agent</div>
673
+ <div class="step-desc">Tell Claude Code to document everything — docs appear here in real-time.</div>
674
+ </div>
675
+ </div>
676
+ </div>
677
+
678
+ <button class="bootstrap-btn" id="scan-btn" onclick="runScan()">
679
+ <span id="scan-btn-icon">⚡</span>
680
+ <span id="scan-btn-label">Scan project files</span>
681
+ </button>
682
+
683
+ <div id="scan-result" style="display:none"></div>
684
+ </div>`
685
+ }
686
+
687
+ async function runScan() {
688
+ const btn = document.getElementById('scan-btn')
689
+ const icon = document.getElementById('scan-btn-icon')
690
+ const label = document.getElementById('scan-btn-label')
691
+ btn.disabled = true
692
+ icon.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;border-color:rgba(255,255,255,.3);border-top-color:white"></span>'
693
+ label.textContent = 'Scanning…'
694
+
695
+ let data
696
+ try {
697
+ const r = await fetch('/api/scan', { method: 'POST' })
698
+ data = await r.json()
699
+ } catch {
700
+ btn.disabled = false
701
+ icon.textContent = ''
702
+ label.textContent = 'Scan project files'
703
+ return
704
+ }
705
+
706
+ // Mark step 2 done, activate step 3
707
+ document.getElementById('step-scan').classList.remove('active')
708
+ document.getElementById('step-scan').classList.add('done')
709
+ document.getElementById('step-scan').innerHTML += '<span class="step-check">✅</span>'
710
+ document.getElementById('step-agent').classList.add('active')
711
+
712
+ // Update button to disabled with success state
713
+ btn.style.background = 'var(--green)'
714
+ btn.innerHTML = `✓ ${data.queued} file${data.queued !== 1 ? 's' : ''} queued`
715
+
716
+ // Show result + Claude command
717
+ const preview = data.files?.slice(0, 4).map(f => ` ${f}`).join('\n')
718
+ + (data.hasMore ? `\n …and more` : '')
719
+
720
+ document.getElementById('scan-result').style.display = 'block'
721
+ document.getElementById('scan-result').innerHTML = `
722
+ <div class="bootstrap-result">
723
+ <div class="bootstrap-result-title">✓ ${data.queued} files queued${data.skipped > 0 ? ` (${data.skipped} already queued)` : ''}</div>
724
+ ${preview ? `<div class="bootstrap-result-files">${esc(preview)}</div>` : ''}
725
+ </div>
726
+ <div id="step3-generate" style="margin-top:16px">
727
+ <div style="display:flex;justify-content:center;gap:0;margin-bottom:12px;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content;margin-left:auto;margin-right:auto">
728
+ <button id="lang-es" onclick="setLang('es')" style="padding:6px 18px;border:none;background:${state.lang==='es'?'var(--accent)':'var(--bg-sidebar)'};color:${state.lang==='es'?'white':'var(--text-muted)'};font-size:13px;font-weight:600;cursor:pointer;transition:background .15s">🇪🇸 Español</button>
729
+ <button id="lang-en" onclick="setLang('en')" style="padding:6px 18px;border:none;background:${state.lang==='en'?'var(--accent)':'var(--bg-sidebar)'};color:${state.lang==='en'?'white':'var(--text-muted)'};font-size:13px;cursor:pointer;transition:background .15s">🇺🇸 English</button>
730
+ </div>
731
+ <button class="bootstrap-btn" id="gen-btn" onclick="runGenerate()">
732
+ <span id="gen-btn-icon">✨</span>
733
+ <span id="gen-btn-label">Generate docs</span>
734
+ </button>
735
+ </div>`
736
+
737
+ // Refresh status badge
738
+ await refreshStatus()
739
+ }
740
+
741
+ async function runGenerate(force = false) {
742
+ const btn = document.getElementById('gen-btn')
743
+ const icon = document.getElementById('gen-btn-icon')
744
+ const label = document.getElementById('gen-btn-label')
745
+ if (btn) btn.disabled = true
746
+ if (icon) icon.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;border-color:rgba(255,255,255,.3);border-top-color:white"></span>'
747
+ if (label) label.textContent = 'Preparing…'
748
+
749
+ try {
750
+ await fetch('/api/generate', {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({ lang: state.lang || 'es', force }),
754
+ })
755
+ } catch { /* ok — trigger file not critical */ }
756
+
757
+ const langStr = state.lang === 'es' ? 'en español' : 'in English'
758
+ const cmd = force
759
+ ? `Delete all existing docs and run /documentalista to regenerate everything ${langStr}`
760
+ : '/documentalista'
761
+
762
+ let copied = false
763
+ try { await navigator.clipboard.writeText(cmd); copied = true } catch { /* blocked */ }
764
+
765
+ const wrap = document.getElementById('step3-generate')
766
+ if (wrap) wrap.innerHTML = `
767
+ <div style="text-align:center;padding:24px 0 8px">
768
+ <div style="font-size:36px;margin-bottom:10px">${copied ? '' : '📋'}</div>
769
+ <div style="font-size:15px;font-weight:700;margin-bottom:6px;color:var(--green-text)">${copied ? 'Copied to clipboard!' : 'Ready — copy this command'}</div>
770
+ <div style="font-size:13px;color:var(--text-muted);margin-bottom:20px">Switch to Claude Code and press
771
+ <code style="background:var(--border);padding:1px 6px;border-radius:3px;font-size:12px">Ctrl+V</code> then
772
+ <code style="background:var(--border);padding:1px 6px;border-radius:3px;font-size:12px">Enter</code>
773
+ </div>
774
+ <div class="bootstrap-claude">
775
+ <div class="bootstrap-claude-label">${copied ? 'In your clipboard' : 'Command'}</div>
776
+ <div class="bootstrap-claude-cmd" onclick="copyGenCmd(this)">${esc(cmd)}</div>
777
+ <p class="copy-hint">Click to copy again if needed</p>
778
+ </div>
779
+ </div>`
780
+ }
781
+
782
+ function setLang(lang) {
783
+ state.lang = lang
784
+ const es = document.getElementById('lang-es')
785
+ const en = document.getElementById('lang-en')
786
+ if (es && en) {
787
+ es.style.background = lang === 'es' ? 'var(--accent)' : 'var(--bg-sidebar)'
788
+ es.style.color = lang === 'es' ? 'white' : 'var(--text-muted)'
789
+ en.style.background = lang === 'en' ? 'var(--accent)' : 'var(--bg-sidebar)'
790
+ en.style.color = lang === 'en' ? 'white' : 'var(--text-muted)'
791
+ }
792
+ document.querySelectorAll('[data-cmd-type]').forEach(el => {
793
+ el.textContent = genCmdText(el.dataset.cmdType)
794
+ })
795
+ }
796
+
797
+
798
+
799
+
800
+ function showRegenPanel() {
801
+ state.active = 'regen'
802
+ setHash('regen')
803
+ renderNav()
804
+ const lang = state.lang || 'es'
805
+ const el = document.getElementById('doc-content')
806
+ el.innerHTML = `
807
+ <div style="max-width:540px;margin:60px auto;padding:0 24px">
808
+ <h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Regenerar documentación</h2>
809
+ <p style="color:var(--text-muted);font-size:14px;margin-bottom:28px">Dile a Claude Code que regenere todos los docs desde los archivos fuente.</p>
810
+
811
+ <div style="margin-bottom:20px">
812
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">Idioma</div>
813
+ <div style="display:flex;gap:0;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content">
814
+ <button id="lang-es" onclick="setLang('es')" style="padding:8px 20px;border:none;background:${lang==='es'?'var(--accent)':'var(--bg-sidebar)'};color:${lang==='es'?'white':'var(--text-muted)'};font-size:13px;font-weight:600;cursor:pointer">🇪🇸 Español</button>
815
+ <button id="lang-en" onclick="setLang('en')" style="padding:8px 20px;border:none;background:${lang==='en'?'var(--accent)':'var(--bg-sidebar)'};color:${lang==='en'?'white':'var(--text-muted)'};font-size:13px;cursor:pointer">🇺🇸 English</button>
816
+ </div>
817
+ </div>
818
+
819
+ <button class="bootstrap-btn" id="gen-btn" onclick="runGenerate(true)">
820
+ <span id="gen-btn-icon">↻</span>
821
+ <span id="gen-btn-label">Regenerate all docs</span>
822
+ </button>
823
+ <div id="step3-generate" style="margin-top:16px"></div>
824
+ </div>`
825
+ }
826
+
827
+ function isArchTemplate(text) {
828
+ // Solo es plantilla si tiene comentarios HTML placeholder O filas de tabla completamente vacías
829
+ const hasComments = /<!--\s*(Describe|describe|TODO|todo)/.test(text)
830
+ const emptyRows = (text.match(/^\|(\s*\|){3,}\s*$/mg) || []).length
831
+ return hasComments || emptyRows >= 3
832
+ }
833
+
834
+ async function generateArch() {
835
+ const btn = document.getElementById('arch-gen-btn')
836
+ if (btn) { btn.disabled = true; btn.textContent = ' Copying…' }
837
+
838
+ try {
839
+ await fetch('/api/generate', {
840
+ method: 'POST',
841
+ headers: { 'Content-Type': 'application/json' },
842
+ body: JSON.stringify({ lang: state.lang || 'es', type: 'arch' }),
843
+ })
844
+ } catch { /* ok */ }
845
+
846
+ const langStr = state.lang === 'es' ? 'en español' : 'in English'
847
+ const cmd = `Fill in ARCHITECTURE.md for this project based on the actual source code and package.json. Write it ${langStr}.`
848
+
849
+ let copied = false
850
+ try { await navigator.clipboard.writeText(cmd); copied = true } catch { /* ok */ }
851
+
852
+ const banner = document.getElementById('arch-gen-banner')
853
+ if (banner) banner.innerHTML = `
854
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;width:100%">
855
+ <span style="font-size:22px">${copied ? '✓' : '📋'}</span>
856
+ <div style="flex:1;min-width:180px">
857
+ <div style="font-size:13px;font-weight:600;color:var(--green-text);margin-bottom:6px">${copied ? 'Copied! Switch to Claude Code and press Ctrl+V → Enter' : 'Run this in Claude Code:'}</div>
858
+ <div class="bootstrap-claude-cmd" onclick="copyGenCmd(this)" style="font-size:12px">${esc(cmd)}</div>
859
+ </div>
860
+ </div>`
861
+ }
862
+
863
+ function goHome() {
864
+ if (state.tree?.architecture) loadFile(state.tree.architecture)
865
+ else showWelcome()
866
+ }
867
+
868
+ // ── API Explorer ───────────────────────────────────────────────────────────
869
+ async function showApiExplorer() {
870
+ state.active = 'api-explorer'
871
+ state.view = 'api-explorer'
872
+ setHash('api-explorer')
873
+ renderNav()
874
+
875
+ const el = document.getElementById('doc-content')
876
+ el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
877
+ document.getElementById('main').scrollTo({ top: 0 })
878
+
879
+ if (!api.spec) {
880
+ const spec = await get('/api/openapi')
881
+ api.spec = spec
882
+ }
883
+
884
+ renderApiExplorer()
885
+ }
886
+
887
+ function renderApiExplorer() {
888
+ const el = document.getElementById('doc-content')
889
+
890
+ if (!api.spec || api.spec.error) {
891
+ el.innerHTML = `
892
+ <div class="empty-state">
893
+ <div class="empty-icon">⚡</div>
894
+ <h2>No API spec found</h2>
895
+ <p>Run the analyzer to auto-detect routes from your source code.</p>
896
+ <code>npx docutrack analyze</code>
897
+ <p style="margin-top:12px;font-size:13px">Or create <code>docs/api/openapi.json</code> manually (OpenAPI 3.0).</p>
898
+ </div>`
899
+ return
900
+ }
901
+
902
+ const { info, paths } = api.spec
903
+ const groups = groupByTag(paths)
904
+
905
+ const totalEp = Object.values(paths).reduce((n, p) => n + Object.keys(p).length, 0)
906
+
907
+ el.innerHTML = `
908
+ <div class="api-explorer">
909
+ <div class="api-explorer-header">
910
+ <div>
911
+ <h1 style="margin-bottom:4px">
912
+ ${esc(info.title)}
913
+ <span style="font-size:14px;color:var(--text-muted);font-weight:400">v${esc(info.version)}</span>
914
+ </h1>
915
+ <p style="color:var(--text-muted);font-size:13px;margin:4px 0 0">
916
+ ${totalEp} endpoint${totalEp !== 1 ? 's' : ''} detected
917
+ </p>
918
+ </div>
919
+ <div class="base-url-wrap">
920
+ <span class="base-url-label">Base URL</span>
921
+ <div class="base-url-row">
922
+ <input class="base-url-input" id="base-url-input" type="text"
923
+ value="${ea(api.baseUrl)}" placeholder="http://localhost:3000"
924
+ oninput="api.baseUrl=this.value;localStorage.setItem('dt-baseUrl',this.value)">
925
+ <button class="auth-toggle" onclick="toggleAuthRow()">🔑 Auth</button>
926
+ </div>
927
+ <div class="auth-row${api.showAuth ? ' show' : ''}" id="auth-row">
928
+ <span class="auth-label">Bearer</span>
929
+ <input class="auth-input" id="auth-input" type="text" placeholder="your-token-here"
930
+ value="${ea(api.token)}"
931
+ oninput="api.token=this.value;localStorage.setItem('dt-token',this.value)">
932
+ </div>
933
+ </div>
934
+ </div>
935
+ ${Object.entries(groups).map(([tag, eps]) => renderApiGroup(tag, eps)).join('')}
936
+ </div>`
937
+ }
938
+
939
+ function groupByTag(paths) {
940
+ const groups = {}
941
+ if (!paths) return groups
942
+ for (const [path, pathItem] of Object.entries(paths)) {
943
+ for (const method of ['get','post','put','patch','delete','options','head']) {
944
+ if (!pathItem[method]) continue
945
+ const op = pathItem[method]
946
+ const tag = op.tags?.[0] || 'default'
947
+ if (!groups[tag]) groups[tag] = []
948
+ groups[tag].push({ path, method: method.toUpperCase(), ...op })
949
+ }
950
+ }
951
+ return groups
952
+ }
953
+
954
+ function renderApiGroup(tag, endpoints) {
955
+ return `
956
+ <div class="api-group">
957
+ <div class="api-group-title">${esc(tag)}</div>
958
+ ${endpoints.map(ep => renderEndpointCard(ep)).join('')}
959
+ </div>`
960
+ }
961
+
962
+ function renderEndpointCard(ep) {
963
+ const id = epId(ep.method, ep.path)
964
+ const isOpen = api.open[id]
965
+ const params = ep.parameters || []
966
+ const hasBody = !!ep.requestBody
967
+ const pathParams = params.filter(p => p.in === 'path')
968
+ const queryParams = params.filter(p => p.in === 'query')
969
+ const headerParams = params.filter(p => p.in === 'header')
970
+
971
+ return `
972
+ <div class="endpoint-card${isOpen ? ' open' : ''}" id="card-${id}">
973
+ <div class="endpoint-header" onclick="toggleEndpoint('${id}')">
974
+ <span class="method-badge badge-${ep.method.toLowerCase()}">${ep.method}</span>
975
+ <span class="endpoint-path">${esc(ep.path)}</span>
976
+ ${ep.summary ? `<span class="endpoint-summary">${esc(ep.summary)}</span>` : ''}
977
+ <span class="endpoint-chevron">›</span>
978
+ </div>
979
+ <div class="endpoint-body">
980
+ ${ep.description ? `<div class="endpoint-section"><p style="color:var(--text-muted);font-size:14px;margin:0">${esc(ep.description)}</p></div>` : ''}
981
+ ${params.length ? `
982
+ <div class="endpoint-section">
983
+ <div class="section-title">Parameters</div>
984
+ <table class="params-table">
985
+ <thead><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th></tr></thead>
986
+ <tbody>${params.map(p => `
987
+ <tr>
988
+ <td>${esc(p.name)}</td>
989
+ <td>${esc(p.in)}</td>
990
+ <td>${esc(p.schema?.type || 'string')}</td>
991
+ <td>${p.required ? '<span class="param-required">required</span>' : '<span class="param-optional">optional</span>'}</td>
992
+ </tr>`).join('')}
993
+ </tbody>
994
+ </table>
995
+ </div>` : ''}
996
+ ${hasBody ? `
997
+ <div class="endpoint-section">
998
+ <div class="section-title">Request Body</div>
999
+ <pre style="background:var(--code-bg);padding:12px;border-radius:var(--radius);font-size:12px;overflow-x:auto">${esc(JSON.stringify(ep.requestBody?.content?.['application/json']?.schema || {}, null, 2))}</pre>
1000
+ </div>` : ''}
1001
+ ${ep.responses ? `
1002
+ <div class="endpoint-section">
1003
+ <div class="section-title">Responses</div>
1004
+ ${Object.entries(ep.responses).map(([code, r]) => `
1005
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:13px">
1006
+ <span class="status-chip ${statusClass(code)}">${code}</span>
1007
+ <span style="color:var(--text-muted)">${esc(r.description || '')}</span>
1008
+ </div>`).join('')}
1009
+ </div>` : ''}
1010
+ <div class="try-section">
1011
+ ${renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody)}
1012
+ </div>
1013
+ </div>
1014
+ </div>`
1015
+ }
1016
+
1017
+ function renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody) {
1018
+ const tryOpen = api.tryOpen[id]
1019
+ const resp = api.responses[id]
1020
+
1021
+ return `
1022
+ <button class="try-btn" onclick="toggleTry('${id}',event)">
1023
+ ${tryOpen ? '✕ Close' : '▶ Try it out'}
1024
+ </button>
1025
+ <div class="try-form${tryOpen ? ' show' : ''}" id="try-${id}">
1026
+ ${pathParams.map(p => `
1027
+ <div class="try-field">
1028
+ <label>${esc(p.name)} <span style="color:var(--red-text);font-size:10px">path</span></label>
1029
+ <input type="text" id="${id}-path-${esc(p.name)}" placeholder="${esc(p.name)}">
1030
+ </div>`).join('')}
1031
+ ${queryParams.map(p => `
1032
+ <div class="try-field">
1033
+ <label>${esc(p.name)} <span style="color:var(--text-muted);font-size:10px">query</span></label>
1034
+ <input type="text" id="${id}-query-${esc(p.name)}" placeholder="${esc(p.schema?.type || 'string')}">
1035
+ </div>`).join('')}
1036
+ ${hasBody ? `
1037
+ <div class="try-field">
1038
+ <label>Request Body <span style="color:var(--text-muted);font-size:10px">application/json</span></label>
1039
+ <textarea id="${id}-body" placeholder="{}"></textarea>
1040
+ </div>` : ''}
1041
+ <div class="try-actions">
1042
+ <button class="execute-btn" id="exec-${id}" onclick="executeRequest('${id}','${esc(ep.method)}','${ea(ep.path)}',${hasBody})">
1043
+ ▶ Execute
1044
+ </button>
1045
+ <button class="cancel-try-btn" onclick="toggleTry('${id}',event)">Cancel</button>
1046
+ </div>
1047
+ ${resp ? renderResponse(resp) : ''}
1048
+ </div>`
1049
+ }
1050
+
1051
+ function renderResponse(resp) {
1052
+ const cls = resp.status >= 500 ? 'status-5xx'
1053
+ : resp.status >= 400 ? 'status-4xx'
1054
+ : resp.status >= 300 ? 'status-3xx'
1055
+ : 'status-2xx'
1056
+
1057
+ let body = resp.body
1058
+ try { body = JSON.stringify(JSON.parse(body), null, 2) } catch { /* raw */ }
1059
+
1060
+ return `
1061
+ <div class="response-panel">
1062
+ <div class="response-bar">
1063
+ <span class="status-chip ${cls}">${resp.status} ${resp.statusText}</span>
1064
+ <span style="color:var(--text-muted);font-size:12px">${resp.contentType || ''}</span>
1065
+ <span class="response-time">${resp.ms}ms</span>
1066
+ </div>
1067
+ <div class="response-body">${esc(body)}</div>
1068
+ </div>
1069
+ ${resp.corsError ? `<div class="cors-warn">⚠ Could not reach <code>${esc(api.baseUrl)}</code> — either the server is not running, or it is blocking cross-origin requests. Start your app server and make sure it allows <code>Access-Control-Allow-Origin: *</code>.</div>` : ''}`
1070
+ }
1071
+
1072
+ // ── Execute request ────────────────────────────────────────────────────────
1073
+ async function executeRequest(id, method, pathTemplate, hasBody) {
1074
+ const btn = document.getElementById(`exec-${id}`)
1075
+ btn.disabled = true
1076
+ btn.textContent = ''
1077
+
1078
+ // Build URL
1079
+ let url = api.baseUrl.replace(/\/$/, '') + pathTemplate
1080
+ // Substitute path params
1081
+ url = url.replace(/\{([^}]+)\}/g, (_, name) => {
1082
+ const input = document.getElementById(`${id}-path-${name}`)
1083
+ return input ? encodeURIComponent(input.value || `:${name}`) : `:${name}`
1084
+ })
1085
+
1086
+ // Collect query params from spec
1087
+ const queryEl = document.querySelectorAll(`[id^="${id}-query-"]`)
1088
+ const qs = []
1089
+ queryEl.forEach(el => {
1090
+ const key = el.id.replace(`${id}-query-`, '')
1091
+ if (el.value) qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(el.value)}`)
1092
+ })
1093
+ if (qs.length) url += '?' + qs.join('&')
1094
+
1095
+ const headers = { 'Content-Type': 'application/json' }
1096
+ if (api.token) headers['Authorization'] = `Bearer ${api.token}`
1097
+
1098
+ let body = undefined
1099
+ if (hasBody) {
1100
+ const bodyEl = document.getElementById(`${id}-body`)
1101
+ body = bodyEl?.value?.trim() || '{}'
1102
+ }
1103
+
1104
+ const t0 = Date.now()
1105
+ let resp
1106
+
1107
+ try {
1108
+ const fetchResp = await fetch(url, {
1109
+ method,
1110
+ headers,
1111
+ body: body && method !== 'GET' ? body : undefined,
1112
+ })
1113
+ const text = await fetchResp.text()
1114
+ resp = {
1115
+ status: fetchResp.status,
1116
+ statusText: fetchResp.statusText,
1117
+ contentType: fetchResp.headers.get('content-type') || '',
1118
+ body: text,
1119
+ ms: Date.now() - t0,
1120
+ corsError: false,
1121
+ }
1122
+ } catch (err) {
1123
+ const isCors = err.message?.toLowerCase().includes('fetch') || err.name === 'TypeError'
1124
+ resp = {
1125
+ status: 0,
1126
+ statusText: 'Network Error',
1127
+ contentType: '',
1128
+ body: err.message,
1129
+ ms: Date.now() - t0,
1130
+ corsError: isCors,
1131
+ }
1132
+ }
1133
+
1134
+ api.responses[id] = resp
1135
+ btn.disabled = false
1136
+ btn.textContent = '▶ Execute'
1137
+
1138
+ // Re-render just the try section by refreshing the response part
1139
+ const tryDiv = document.getElementById(`try-${id}`)
1140
+ // Remove old response panel if present
1141
+ tryDiv.querySelectorAll('.response-panel, .cors-warn').forEach(e => e.remove())
1142
+ tryDiv.insertAdjacentHTML('beforeend', renderResponse(resp))
1143
+ }
1144
+
1145
+ // ── Interaction helpers ────────────────────────────────────────────────────
1146
+ function toggleEndpoint(id) {
1147
+ api.open[id] = !api.open[id]
1148
+ const card = document.getElementById(`card-${id}`)
1149
+ if (api.open[id]) card.classList.add('open')
1150
+ else card.classList.remove('open')
1151
+ }
1152
+
1153
+ function toggleTry(id, e) {
1154
+ e.stopPropagation()
1155
+ api.tryOpen[id] = !api.tryOpen[id]
1156
+ const form = document.getElementById(`try-${id}`)
1157
+ if (api.tryOpen[id]) form.classList.add('show')
1158
+ else form.classList.remove('show')
1159
+ // Update button label
1160
+ const btn = form.previousElementSibling
1161
+ if (btn?.classList.contains('try-btn')) {
1162
+ btn.textContent = api.tryOpen[id] ? '✕ Close' : '▶ Try it out'
1163
+ }
1164
+ }
1165
+
1166
+ function toggleAuthRow() {
1167
+ api.showAuth = !api.showAuth
1168
+ document.getElementById('auth-row').classList.toggle('show', api.showAuth)
1169
+ }
1170
+
1171
+ // ── Status ─────────────────────────────────────────────────────────────────
1172
+ async function refreshStatus() {
1173
+ const s = await get('/api/status')
1174
+ if (!s) return
1175
+ const pct = s.coverage
1176
+ document.getElementById('cov-pct').textContent = `${pct}%`
1177
+ document.getElementById('cov-label').textContent = `${pct}%`
1178
+ const bar = document.getElementById('cov-bar')
1179
+ bar.style.width = `${pct}%`
1180
+ bar.className = 'coverage-fill' + (pct < 50 ? ' bad' : pct < 80 ? ' warn' : '')
1181
+ document.getElementById('cov-dot').style.background = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--yellow)' : 'var(--red)'
1182
+ const banner = document.getElementById('pending-banner')
1183
+ if (s.pending > 0) { document.getElementById('pending-count').textContent = s.pending; banner.classList.add('show') }
1184
+ else banner.classList.remove('show')
1185
+
1186
+ // Store stale map for nav rendering
1187
+ state.staleSet = new Set((s.stale || []).map(x => x.doc))
1188
+ renderNav()
1189
+ }
1190
+
1191
+ // ── Health Check ───────────────────────────────────────────────────────────
1192
+ async function showHealthCheck() {
1193
+ state.active = 'health-check'
1194
+ state.view = 'health-check'
1195
+ setHash('health-check')
1196
+ renderNav()
1197
+
1198
+ const el = document.getElementById('doc-content')
1199
+ el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
1200
+ document.getElementById('main').scrollTo({ top: 0 })
1201
+
1202
+ const data = await get('/api/check')
1203
+ if (!data) {
1204
+ el.innerHTML = `<div class="empty-state"><div class="empty-icon">🩺</div><h2>Health check unavailable</h2><p>Make sure DocuTrack is initialized.</p></div>`
1205
+ return
1206
+ }
1207
+ renderHealthCheck(data)
1208
+ }
1209
+
1210
+ function renderHealthCheck(data) {
1211
+ const el = document.getElementById('doc-content')
1212
+ const { pending, stale, drift, complexity, ok } = data
1213
+
1214
+ const criticalCount = complexity.critical?.length || 0
1215
+ const highDrift = (drift || []).filter(d => d.severity === 'high').length
1216
+ const totalIssues = pending + stale + highDrift + criticalCount
1217
+ state.healthBadge = totalIssues
1218
+ renderNav()
1219
+
1220
+ const overallClass = ok ? 'health-ok' : totalIssues > 3 ? 'health-crit' : 'health-warn'
1221
+ const overallText = ok ? '✓ Healthy' : `⚠ ${totalIssues} issue${totalIssues !== 1 ? 's' : ''}`
1222
+
1223
+ const scoreCard = (val, label, cls) => `
1224
+ <div class="health-card ${cls}">
1225
+ <div class="health-card-val">${val}</div>
1226
+ <div class="health-card-label">${label}</div>
1227
+ </div>`
1228
+
1229
+ const cards = [
1230
+ scoreCard(pending, 'Pending files', pending > 0 ? 'warn-card' : 'ok'),
1231
+ scoreCard(stale, 'Stale docs', stale > 0 ? 'warn-card' : 'ok'),
1232
+ scoreCard(drift.length, 'Drifted modules', highDrift > 0 ? 'issue' : drift.length > 0 ? 'warn-card' : 'ok'),
1233
+ scoreCard(complexity.summary?.critical || 0, 'Complex files', criticalCount > 0 ? 'issue' : 'ok'),
1234
+ ].join('')
1235
+
1236
+ const driftSection = drift.length > 0 ? `
1237
+ <div class="health-section">
1238
+ <div class="health-section-title">📐 Documentation Drift</div>
1239
+ ${drift.map(d => `
1240
+ <div class="drift-row">
1241
+ <span class="drift-sev ${d.severity}">${d.severity.toUpperCase()}</span>
1242
+ <div>
1243
+ <div class="drift-module">${esc(d.module)}</div>
1244
+ ${d.undocumented?.length ? `<div class="drift-detail">+undocumented: ${d.undocumented.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
1245
+ ${d.orphaned?.length ? `<div class="drift-detail" style="margin-top:3px">−orphaned: ${d.orphaned.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
1246
+ </div>
1247
+ </div>`).join('')}
1248
+ </div>` : ''
1249
+
1250
+ const complexFiles = complexity.critical || []
1251
+ const complexSection = complexFiles.length > 0 ? `
1252
+ <div class="health-section">
1253
+ <div class="health-section-title">📊 Complexity Hotspots</div>
1254
+ ${complexFiles.map(f => {
1255
+ const scoreClass = f.score < 40 ? 'score-bad' : f.score < 70 ? 'score-warn' : 'score-ok'
1256
+ return `
1257
+ <div class="complexity-row">
1258
+ <div class="complexity-score ${scoreClass}">${f.score}</div>
1259
+ <div class="complexity-file" title="${esc(f.file)}">${esc(f.file)}</div>
1260
+ <div class="complexity-tags">
1261
+ ${f.warnings.map(w => `<span class="complexity-tag${w.level === 'critical' ? ' crit' : ''}">${esc(w.message)}</span>`).join('')}
1262
+ </div>
1263
+ </div>`}).join('')}
1264
+ </div>` : ''
1265
+
1266
+ const allGood = drift.length === 0 && complexFiles.length === 0 && pending === 0 && stale === 0
1267
+ const emptyState = allGood ? `
1268
+ <div class="health-empty" style="padding:40px">
1269
+ <div style="font-size:48px;margin-bottom:12px">✅</div>
1270
+ <p style="font-size:16px;font-weight:700;color:var(--text)">Documentation is fully healthy.</p>
1271
+ <p>No pending files, no stale docs, no drift, no complexity issues.</p>
1272
+ </div>` : ''
1273
+
1274
+ el.innerHTML = `
1275
+ <div style="max-width:800px">
1276
+ <div class="health-header">
1277
+ <div>
1278
+ <div class="health-title">Health Check</div>
1279
+ <div class="health-subtitle">Documentation quality analysis</div>
1280
+ </div>
1281
+ <div style="display:flex;align-items:center;gap:10px">
1282
+ <span class="health-overall ${overallClass}">${overallText}</span>
1283
+ <button class="health-refresh" onclick="showHealthCheck()">↻ Refresh</button>
1284
+ </div>
1285
+ </div>
1286
+ <div class="health-grid">${cards}</div>
1287
+ ${emptyState}
1288
+ ${driftSection}
1289
+ ${complexSection}
1290
+ ${(pending > 0 || stale > 0) ? `
1291
+ <div class="health-section">
1292
+ <div class="health-section-title">💡 Suggested Actions</div>
1293
+ <div style="font-size:13px;color:var(--text-muted);line-height:2">
1294
+ ${pending > 0 ? `<div>→ Run the <strong>documentalista</strong> subagent to clear ${pending} pending file${pending !== 1 ? 's' : ''}</div>` : ''}
1295
+ ${stale > 0 ? `<div>→ Update ${stale} stale doc${stale !== 1 ? 's' : ''}, then run <code>docutrack clear</code></div>` : ''}
1296
+ ${highDrift > 0 ? `<div>→ Add missing exports to docs for high-drift modules above</div>` : ''}
1297
+ ${criticalCount > 0 ? `<div>→ Consider splitting the complex files listed above</div>` : ''}
1298
+ </div>
1299
+ </div>` : ''}
1300
+ </div>`
1301
+ }
1302
+
1303
+ // ── SSE ─────────────────────────────────────────────────────────────────────
1304
+ function connectSSE() {
1305
+ const es = new EventSource('/events')
1306
+ es.onmessage = async (e) => {
1307
+ if (e.data !== 'reload') return
1308
+
1309
+ await refreshTree()
1310
+ await refreshStatus()
1311
+
1312
+ if (state.view === 'api-explorer') {
1313
+ api.spec = await get('/api/openapi')
1314
+ renderApiExplorer()
1315
+ } else if (state.view === 'health-check') {
1316
+ const data = await get('/api/check')
1317
+ if (data) renderHealthCheck(data)
1318
+ } else if (state.active && state.active !== 'bootstrap' && state.active !== 'regen') {
1319
+ await loadFile(state.active)
1320
+ }
1321
+ // If we were on bootstrap and modules now exist → navigate to arch
1322
+ if (state.active === 'bootstrap' && state.tree?.modules?.length > 0) {
1323
+ if (state.tree.architecture) loadFile(state.tree.architecture)
1324
+ }
1325
+ }
1326
+ es.onerror = () => setTimeout(connectSSE, 3000)
1327
+ }
1328
+
1329
+ // ── Theme ──────────────────────────────────────────────────────────────────
1330
+ document.getElementById('theme-toggle').addEventListener('click', () => {
1331
+ setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark')
1332
+ })
1333
+
1334
+ function setTheme(t) {
1335
+ document.documentElement.setAttribute('data-theme', t)
1336
+ document.getElementById('theme-toggle').textContent = t === 'dark' ? '☀️' : '🌙'
1337
+ localStorage.setItem('dt-theme', t)
1338
+ mermaid.initialize({ startOnLoad: false, theme: t === 'dark' ? 'dark' : 'neutral', securityLevel: 'loose' })
1339
+ }
1340
+
1341
+ // ── Routing ───────────────────────────────────────────────────────────────
1342
+ function setHash(h) {
1343
+ history.replaceState(null, '', '#' + h)
1344
+ }
1345
+
1346
+ function restoreFromHash(h) {
1347
+ if (h === 'api-explorer') { showApiExplorer(); return }
1348
+ if (h === 'health-check') { showHealthCheck(); return }
1349
+ if (h === 'regen') { showRegenPanel(); return }
1350
+ if (h.startsWith('doc/')) { loadFile(h.slice(4)); return }
1351
+ loadFile(h)
1352
+ }
1353
+
1354
+ // ── Utils ──────────────────────────────────────────────────────────────────
1355
+ async function get(url) {
1356
+ try { const r = await fetch(url); return r.ok ? r.json() : null } catch { return null }
1357
+ }
1358
+ function esc(s) {
1359
+ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
1360
+ }
1361
+ function ea(s) { return String(s||'').replace(/'/g,"\\'") }
1362
+ function epId(method, path) {
1363
+ return `${method}-${path}`.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
1364
+ }
1365
+ function statusClass(code) {
1366
+ const n = parseInt(code)
1367
+ if (n >= 500) return 'status-5xx'
1368
+ if (n >= 400) return 'status-4xx'
1369
+ if (n >= 300) return 'status-3xx'
1370
+ return 'status-2xx'
1371
+ }
1372
+
1373
+ // ── Command Palette ───────────────────────────────────────────────────────────
1374
+ let paletteIdx = 0
1375
+ let paletteItems = []
1376
+ let paletteSearchTimer = null
1377
+
1378
+ function openPalette() {
1379
+ document.getElementById('palette-overlay').classList.add('open')
1380
+ const inp = document.getElementById('palette-input')
1381
+ inp.value = ''
1382
+ inp.focus()
1383
+ renderPaletteResults('')
1384
+ }
1385
+
1386
+ function closePalette() {
1387
+ document.getElementById('palette-overlay').classList.remove('open')
1388
+ }
1389
+
1390
+ function onPaletteOverlayClick(e) {
1391
+ if (e.target === document.getElementById('palette-overlay')) closePalette()
1392
+ }
1393
+
1394
+ function renderPaletteResults(q) {
1395
+ const { tree } = state
1396
+ paletteItems = []
1397
+ const lq = q.toLowerCase()
1398
+
1399
+ if (tree) {
1400
+ if (tree.architecture && (!q || 'architecture'.includes(lq))) {
1401
+ paletteItems.push({ icon: '🏗', name: 'Architecture', section: 'Overview', action: () => loadFile(tree.architecture) })
1402
+ }
1403
+ const addItems = (items, icon, section, fn) => {
1404
+ if (!items?.length) return
1405
+ for (const item of items) {
1406
+ const name = fmtName(item.name)
1407
+ if (!q || name.toLowerCase().includes(lq) || item.name.toLowerCase().includes(lq)) {
1408
+ paletteItems.push({ icon, name, section, action: () => fn(item) })
1409
+ }
1410
+ }
1411
+ }
1412
+ addItems(tree.modules, '📦', 'Modules', m => loadFile(m.path))
1413
+ addItems(tree.decisions, '📝', 'Decisions', d => loadFile(d.path))
1414
+ addItems(tree.api, '📄', 'API', a => loadFile(a.path))
1415
+ }
1416
+ if (!q || 'api explorer'.includes(lq)) paletteItems.push({ icon: '⚡', name: 'API Explorer', section: 'Views', action: showApiExplorer })
1417
+ if (!q || 'health check'.includes(lq)) paletteItems.push({ icon: '🩺', name: 'Health Check', section: 'Views', action: showHealthCheck })
1418
+ if (!q || 'regenerate docs'.includes(lq)) paletteItems.push({ icon: '✨', name: 'Regenerate docs', section: 'Actions', action: showRegenPanel })
1419
+
1420
+ paintPalette()
1421
+
1422
+ // Debounced content search
1423
+ clearTimeout(paletteSearchTimer)
1424
+ if (q.length >= 2) {
1425
+ paletteSearchTimer = setTimeout(() => fetchContentResults(q), 250)
1426
+ }
1427
+ }
1428
+
1429
+ async function fetchContentResults(q) {
1430
+ try {
1431
+ const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
1432
+ if (!r.ok) return
1433
+ const hits = await r.json()
1434
+ if (!hits.length) return
1435
+ // Append content hits (avoid duplicates with name matches)
1436
+ const existing = new Set(paletteItems.map(i => i._path).filter(Boolean))
1437
+ for (const hit of hits) {
1438
+ if (existing.has(hit.path)) continue
1439
+ paletteItems.push({ icon: '🔎', name: hit.title, section: 'Content', snippet: hit.snippet, _path: hit.path, action: () => loadFile(hit.path) })
1440
+ }
1441
+ paintPalette()
1442
+ } catch { /* ok */ }
1443
+ }
1444
+
1445
+ function paintPalette() {
1446
+ const container = document.getElementById('palette-results')
1447
+ if (!container) return
1448
+
1449
+ if (!paletteItems.length) {
1450
+ container.innerHTML = '<div class="palette-empty">No results</div>'
1451
+ return
1452
+ }
1453
+
1454
+ const groups = {}
1455
+ for (const item of paletteItems) {
1456
+ if (!groups[item.section]) groups[item.section] = []
1457
+ groups[item.section].push(item)
1458
+ }
1459
+
1460
+ let flatIdx = 0
1461
+ let html = ''
1462
+ for (const [section, items] of Object.entries(groups)) {
1463
+ html += `<div class="palette-group-header">${esc(section)}</div>`
1464
+ for (const item of items) {
1465
+ const i = flatIdx++
1466
+ const sel = i === paletteIdx ? ' selected' : ''
1467
+ html += `<div class="palette-item${sel}" onmouseenter="paletteIdx=${i};updatePaletteSelection()" onclick="selectPaletteItem(${i})">
1468
+ <span class="palette-item-icon">${item.icon}</span>
1469
+ <span class="palette-item-name">${esc(item.name)}</span>
1470
+ ${item.snippet ? `<span class="palette-item-snippet">${esc(item.snippet)}</span>` : `<span class="palette-item-section">${esc(item.section)}</span>`}
1471
+ </div>`
1472
+ }
1473
+ }
1474
+ container.innerHTML = html
1475
+ }
1476
+
1477
+ function selectPaletteItem(i) {
1478
+ closePalette()
1479
+ paletteItems[i]?.action()
1480
+ }
1481
+
1482
+ function updatePaletteSelection() {
1483
+ document.querySelectorAll('#palette-results .palette-item').forEach((el, i) => {
1484
+ el.classList.toggle('selected', i === paletteIdx)
1485
+ })
1486
+ }
1487
+
1488
+ function scrollPaletteItem() {
1489
+ const items = document.querySelectorAll('#palette-results .palette-item')
1490
+ items[paletteIdx]?.scrollIntoView({ block: 'nearest' })
1491
+ }
1492
+
1493
+ // ── Claude command helpers ───────────────────────────────────────────────────
1494
+ function genCmdText(type) {
1495
+ const lang = state.lang === 'es' ? 'en español' : 'in English'
1496
+ if (type === 'bootstrap') return `Run the documentalista subagent to document all pending files ${lang}`
1497
+ if (type === 'regen') return `Regenerate all documentation ${lang} from scratch — read each source file and overwrite existing docs`
1498
+ if (type === 'arch') return `Fill in ARCHITECTURE.md based on the actual source code and package.json. Write it ${lang}.`
1499
+ return ''
1500
+ }
1501
+
1502
+ function copyGenCmd(el) {
1503
+ navigator.clipboard.writeText(el.textContent).then(() => {
1504
+ const orig = el.textContent
1505
+ const origBg = el.style.background
1506
+ const origColor = el.style.color
1507
+ el.style.background = 'var(--green-bg)'
1508
+ el.style.color = 'var(--green-text)'
1509
+ el.textContent = '✓ Copiado'
1510
+ setTimeout(() => {
1511
+ el.style.background = origBg
1512
+ el.style.color = origColor
1513
+ el.textContent = orig
1514
+ }, 1200)
1515
+ }).catch(() => {})
1516
+ }
1517
+
1518
+ // ── Copy code ────────────────────────────────────────────────────────────────
1519
+ function copyCode(btn) {
1520
+ const code = btn.previousElementSibling?.querySelector('code')
1521
+ if (!code) return
1522
+ navigator.clipboard.writeText(code.textContent).then(() => {
1523
+ btn.textContent = '✓ Copied'
1524
+ btn.classList.add('copied')
1525
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied') }, 1500)
1526
+ }).catch(() => {})
1527
+ }
1528
+
1529
+ // ── Heading anchors ──────────────────────────────────────────────────────────
1530
+ function copyAnchor(a, e) {
1531
+ e.preventDefault()
1532
+ const url = location.href.split('#')[0] + a.getAttribute('href')
1533
+ navigator.clipboard.writeText(url).then(() => {
1534
+ const orig = a.textContent
1535
+ a.textContent = ''
1536
+ setTimeout(() => { a.textContent = orig }, 1200)
1537
+ }).catch(() => {
1538
+ location.hash = a.getAttribute('href')
1539
+ })
1540
+ }
1541
+
1542
+ boot()
1543
+ </script>
1544
+ </body>
1545
+ </html>