docutrack 0.1.0 → 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,1411 +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
- </style>
291
- </head>
292
- <body>
293
- <div id="app">
294
-
295
- <header id="header">
296
- <div class="logo" onclick="goHome()">
297
- <div class="logo-icon">📋</div>DocuTrack
298
- </div>
299
- <div class="header-sep"></div>
300
- <span id="project-name">loading…</span>
301
- <div id="search-wrap">
302
- <span class="search-icon">🔍</span>
303
- <input type="text" id="search" placeholder="Search docs…" autocomplete="off">
304
- <span class="search-kbd">⌘K</span>
305
- </div>
306
- <div class="header-right">
307
- <div id="coverage-badge">
308
- <span class="coverage-dot" id="cov-dot"></span>
309
- <span id="cov-pct">–%</span>
310
- <span style="color:var(--text-muted);font-weight:400">coverage</span>
311
- </div>
312
- <button id="theme-toggle" title="Toggle dark mode">🌙</button>
313
- <span class="live-pill"><span class="live-dot"></span>live</span>
314
- </div>
315
- </header>
316
-
317
- <aside id="sidebar">
318
- <div class="nav-section">
319
- <div class="nav-section-title">Overview</div>
320
- <div id="nav-arch"></div>
321
- </div>
322
- <div class="nav-section">
323
- <div class="nav-section-title">Modules</div>
324
- <div id="nav-modules"></div>
325
- </div>
326
- <div class="nav-section">
327
- <div class="nav-section-title">Decisions</div>
328
- <div id="nav-decisions"></div>
329
- </div>
330
- <div class="nav-section">
331
- <div class="nav-section-title">API</div>
332
- <div id="nav-api-explorer"></div>
333
- <div id="nav-api-docs"></div>
334
- </div>
335
- <div class="nav-section">
336
- <div class="nav-section-title">Health</div>
337
- <div id="nav-health"></div>
338
- </div>
339
- <div id="coverage-section">
340
- <div class="coverage-label">
341
- <span>Doc coverage</span>
342
- <span id="cov-label">–%</span>
343
- </div>
344
- <div class="coverage-bar"><div class="coverage-fill" id="cov-bar" style="width:0%"></div></div>
345
- </div>
346
- <div style="padding:12px 16px 8px;border-top:1px solid var(--border);margin-top:8px">
347
- <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">
348
- <span>✨</span><span>Regenerate docs</span>
349
- </button>
350
- </div>
351
- </aside>
352
-
353
- <main id="main">
354
- <div id="doc-wrap">
355
- <div id="pending-banner">
356
- ⚠ <strong id="pending-count">0</strong>&nbsp;file(s) modified without documentation.
357
- Run <code style="margin-left:4px">docutrack status</code> for details.
358
- </div>
359
- <div id="doc-content">
360
- <div class="empty-state">
361
- <div class="empty-icon">📋</div>
362
- <h2>Welcome to DocuTrack</h2>
363
- <p>Living documentation for your project. Select a page from the sidebar.</p>
364
- <span class="spinner"></span>
365
- </div>
366
- </div>
367
- </div>
368
- </main>
369
-
370
- </div>
371
-
372
- <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
373
- <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
374
- <script>
375
- 'use strict'
376
-
377
- // ── State ──────────────────────────────────────────────────────────────────
378
- const state = {
379
- tree: null,
380
- active: null, // file path or 'api-explorer' or 'health-check'
381
- query: '',
382
- view: 'docs', // 'docs' | 'api-explorer' | 'health-check'
383
- staleSet: new Set(),
384
- healthBadge: 0, // number of critical health issues
385
- lang: 'es', // 'es' | 'en' — language for AI doc generation
386
- generating: false,
387
- generationTotal: 0,
388
- generationLog: [], // { icon, color, file } — persisted to replay on panel re-enter
389
- }
390
-
391
- const api = {
392
- spec: null,
393
- baseUrl: localStorage.getItem('dt-baseUrl') || 'http://localhost:3000',
394
- token: localStorage.getItem('dt-token') || '',
395
- showAuth: false,
396
- open: {}, // endpointId -> boolean
397
- tryOpen: {}, // endpointId -> boolean
398
- responses: {}, // endpointId -> response object
399
- }
400
-
401
- // ── Marked ────────────────────────────────────────────────────────────────
402
- marked.use({
403
- gfm: true,
404
- renderer: {
405
- code({ text, lang }) {
406
- if (lang === 'mermaid') {
407
- return `<div class="mermaid-wrap"><div class="mermaid">${esc(text)}</div></div>`
408
- }
409
- return `<pre><code class="language-${esc(lang||'')}">${esc(text)}</code></pre>`
410
- }
411
- }
412
- })
413
- mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' })
414
-
415
- // ── Boot ──────────────────────────────────────────────────────────────────
416
- async function boot() {
417
- setTheme(localStorage.getItem('dt-theme') || 'light')
418
- await refreshTree()
419
- await refreshStatus()
420
- connectSSE()
421
- document.getElementById('search').addEventListener('input', e => {
422
- state.query = e.target.value.trim()
423
- renderNav()
424
- })
425
- document.addEventListener('keydown', e => {
426
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); document.getElementById('search').focus() }
427
- if (e.key === 'Escape') document.getElementById('search').blur()
428
- })
429
- }
430
-
431
- // ── Tree / nav ─────────────────────────────────────────────────────────────
432
- async function refreshTree() {
433
- const tree = await get('/api/tree')
434
- if (!tree) return
435
- state.tree = tree
436
-
437
- // Project name from package.json
438
- try {
439
- const r = await fetch('/api/content?path=package.json')
440
- 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}` } }
441
- } catch { /* ok */ }
442
-
443
- renderNav()
444
- const hasModules = tree.modules?.length > 0
445
- if (!state.active) {
446
- if (hasModules && tree.architecture) loadFile(tree.architecture)
447
- else showWelcome()
448
- } else if (state.active === 'bootstrap' && hasModules) {
449
- // Modules just appeared — navigate to architecture
450
- if (tree.architecture) loadFile(tree.architecture)
451
- }
452
- }
453
-
454
- function renderNav() {
455
- const { tree, query, active } = state
456
- if (!tree) return
457
- const q = query.toLowerCase()
458
-
459
- // Architecture
460
- const archEl = document.getElementById('nav-arch')
461
- if (tree.architecture) {
462
- archEl.innerHTML = matches('architecture', q)
463
- ? navItem(tree.architecture, '🏗', 'Architecture', active === tree.architecture)
464
- : ''
465
- } else {
466
- archEl.innerHTML = '<div class="nav-empty">Not created yet</div>'
467
- }
468
-
469
- renderSection('nav-modules', tree.modules, '📦', q, active)
470
- renderSection('nav-decisions', tree.decisions, '📝', q, active)
471
-
472
- // API Explorer special entry
473
- const explorerEl = document.getElementById('nav-api-explorer')
474
- const epCount = api.spec ? Object.values(api.spec.paths || {}).reduce((n, p) => n + Object.keys(p).length, 0) : 0
475
- const explorerMatch = !q || 'api explorer'.includes(q)
476
- explorerEl.innerHTML = explorerMatch
477
- ? `<div class="nav-item${active === 'api-explorer' ? ' active' : ''}" onclick="showApiExplorer()">
478
- <span class="nav-icon">⚡</span>API Explorer
479
- ${epCount > 0 ? `<span class="nav-badge">${epCount}</span>` : ''}
480
- </div>`
481
- : ''
482
-
483
- // API docs (markdown files)
484
- renderSection('nav-api-docs', tree.api, '📄', q, active)
485
-
486
- // Health Check nav entry
487
- const healthMatch = !q || 'health check'.includes(q)
488
- const healthHasIssue = state.healthBadge
489
- document.getElementById('nav-health').innerHTML = healthMatch
490
- ? `<div class="nav-item${active === 'health-check' ? ' active' : ''}" onclick="showHealthCheck()">
491
- <span class="nav-icon">🩺</span>Health Check
492
- ${healthHasIssue ? `<span class="nav-badge" style="background:var(--red-bg);color:var(--red-text)">${healthHasIssue}</span>` : ''}
493
- </div>` : ''
494
- }
495
-
496
- function renderSection(id, items, icon, q, active) {
497
- const el = document.getElementById(id)
498
- if (!items?.length) { el.innerHTML = '<div class="nav-empty">No docs yet</div>'; return }
499
- const filtered = q ? items.filter(i => i.name.toLowerCase().includes(q)) : items
500
- el.innerHTML = filtered.length
501
- ? filtered.map(i => navItem(i.path, icon, fmtName(i.name), active === i.path)).join('')
502
- : ''
503
- }
504
-
505
- function navItem(p, icon, label, isActive) {
506
- const isStale = state.staleSet.has(p)
507
- const staleTag = isStale ? '<span class="nav-stale">stale</span>' : ''
508
- return `<div class="nav-item${isActive ? ' active' : ''}" onclick="loadFile('${ea(p)}')">`
509
- + `<span class="nav-icon">${icon}</span>${esc(label)}${staleTag}</div>`
510
- }
511
-
512
- function fmtName(n) {
513
- return n.replace(/^ADR-\d+-/, 'ADR: ').replace(/[-_]/g, ' ')
514
- .replace(/\b\w/g, c => c.toUpperCase())
515
- }
516
-
517
- function matches(str, q) { return !q || str.toLowerCase().includes(q) }
518
-
519
- // ── Doc viewer ─────────────────────────────────────────────────────────────
520
- async function loadFile(filePath) {
521
- if (!filePath) return
522
- state.active = filePath
523
- state.view = 'docs'
524
- renderNav()
525
-
526
- const el = document.getElementById('doc-content')
527
- el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
528
-
529
- const r = await fetch(`/api/content?path=${encodeURIComponent(filePath)}`)
530
- if (!r.ok) { el.innerHTML = `<p style="color:var(--red);padding:40px">Could not load: ${esc(filePath)}</p>`; return }
531
-
532
- const text = await r.text()
533
- el.innerHTML = marked.parse(text)
534
- document.getElementById('main').scrollTo({ top: 0 })
535
- await mermaid.run({ nodes: document.querySelectorAll('#doc-content .mermaid') })
536
-
537
- // Show "Generate with AI" banner if ARCHITECTURE.md is still a template
538
- if (filePath === 'ARCHITECTURE.md' && isArchTemplate(text)) {
539
- const banner = document.createElement('div')
540
- banner.id = 'arch-gen-banner'
541
- 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'
542
- banner.innerHTML = `
543
- <span style="font-size:13px;color:var(--text-muted);flex:1">La arquitectura tiene contenido de plantilla sin completar.</span>
544
- <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>`
545
- document.getElementById('doc-wrap').appendChild(banner)
546
- }
547
- }
548
-
549
- function showWelcome() {
550
- const hasModules = state.tree?.modules?.length > 0
551
- if (!hasModules) {
552
- showBootstrap()
553
- } else {
554
- document.getElementById('doc-content').innerHTML = `
555
- <div class="empty-state">
556
- <div class="empty-icon">📋</div>
557
- <h2>Welcome to DocuTrack</h2>
558
- <p>Living documentation for your project. Select a page from the sidebar to get started.</p>
559
- </div>`
560
- }
561
- }
562
-
563
- function showBootstrap() {
564
- state.active = 'bootstrap'
565
- const el = document.getElementById('doc-content')
566
- el.innerHTML = `
567
- <div class="bootstrap-wrap">
568
- <div class="bootstrap-hero">
569
- <div class="bootstrap-hero-icon">🚀</div>
570
- <h1>Set up your docs</h1>
571
- <p>This project doesn't have documentation yet. Let's generate it from your existing code in two steps.</p>
572
- </div>
573
-
574
- <div class="bootstrap-steps">
575
- <div class="bootstrap-step done" id="step-init">
576
- <span class="step-num">1</span>
577
- <div class="step-content">
578
- <div class="step-title">DocuTrack initialized</div>
579
- <div class="step-desc">Hooks, templates, and slash commands are installed.</div>
580
- </div>
581
- <span class="step-check">✅</span>
582
- </div>
583
-
584
- <div class="bootstrap-step active" id="step-scan">
585
- <span class="step-num">2</span>
586
- <div class="step-content">
587
- <div class="step-title">Scan your source files</div>
588
- <div class="step-desc">Queues all existing files so the AI agent knows what to document.</div>
589
- </div>
590
- </div>
591
-
592
- <div class="bootstrap-step" id="step-agent">
593
- <span class="step-num">3</span>
594
- <div class="step-content">
595
- <div class="step-title">Run the documentalista agent</div>
596
- <div class="step-desc">Tell Claude Code to document everything — docs appear here in real-time.</div>
597
- </div>
598
- </div>
599
- </div>
600
-
601
- <button class="bootstrap-btn" id="scan-btn" onclick="runScan()">
602
- <span id="scan-btn-icon">⚡</span>
603
- <span id="scan-btn-label">Scan project files</span>
604
- </button>
605
-
606
- <div id="scan-result" style="display:none"></div>
607
- </div>`
608
- }
609
-
610
- async function runScan() {
611
- const btn = document.getElementById('scan-btn')
612
- const icon = document.getElementById('scan-btn-icon')
613
- const label = document.getElementById('scan-btn-label')
614
- btn.disabled = true
615
- 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>'
616
- label.textContent = 'Scanning…'
617
-
618
- let data
619
- try {
620
- const r = await fetch('/api/scan', { method: 'POST' })
621
- data = await r.json()
622
- } catch {
623
- btn.disabled = false
624
- icon.textContent = '⚡'
625
- label.textContent = 'Scan project files'
626
- return
627
- }
628
-
629
- // Mark step 2 done, activate step 3
630
- document.getElementById('step-scan').classList.remove('active')
631
- document.getElementById('step-scan').classList.add('done')
632
- document.getElementById('step-scan').innerHTML += '<span class="step-check">✅</span>'
633
- document.getElementById('step-agent').classList.add('active')
634
-
635
- // Update button to disabled with success state
636
- btn.style.background = 'var(--green)'
637
- btn.innerHTML = `✓ ${data.queued} file${data.queued !== 1 ? 's' : ''} queued`
638
-
639
- // Show result + Claude command
640
- const preview = data.files?.slice(0, 4).map(f => ` ${f}`).join('\n')
641
- + (data.hasMore ? `\n …and more` : '')
642
-
643
- document.getElementById('scan-result').style.display = 'block'
644
- document.getElementById('scan-result').innerHTML = `
645
- <div class="bootstrap-result">
646
- <div class="bootstrap-result-title">✓ ${data.queued} files queued${data.skipped > 0 ? ` (${data.skipped} already queued)` : ''}</div>
647
- ${preview ? `<div class="bootstrap-result-files">${esc(preview)}</div>` : ''}
648
- </div>
649
- <div id="step3-generate" style="margin-top:12px">
650
- <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">
651
- <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>
652
- <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>
653
- </div>
654
- <button class="bootstrap-btn" id="gen-btn" onclick="runGenerate()">
655
- <span id="gen-btn-icon">✨</span>
656
- <span id="gen-btn-label">Generate docs with AI</span>
657
- </button>
658
- <p style="text-align:center;font-size:12px;color:var(--text-muted);margin-top:8px">
659
- Uses Claude Haiku — reads each file and writes the docs automatically
660
- </p>
661
- </div>`
662
-
663
- // Refresh status badge
664
- await refreshStatus()
665
- }
666
-
667
- function setLang(lang) {
668
- state.lang = lang
669
- const es = document.getElementById('lang-es')
670
- const en = document.getElementById('lang-en')
671
- if (!es || !en) return
672
- if (lang === 'es') {
673
- es.style.background = 'var(--accent)'; es.style.color = 'white'
674
- en.style.background = 'var(--bg-secondary)'; en.style.color = 'var(--text-muted)'
675
- } else {
676
- en.style.background = 'var(--accent)'; en.style.color = 'white'
677
- es.style.background = 'var(--bg-secondary)'; es.style.color = 'var(--text-muted)'
678
- }
679
- }
680
-
681
- async function runGenerate(force = false) {
682
- const lang = state.lang || 'es'
683
- const btn = document.getElementById('gen-btn')
684
- if (!btn) return
685
- btn.disabled = true
686
- 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>'
687
- document.getElementById('gen-btn-label').textContent = 'Starting…'
688
-
689
- let data
690
- try {
691
- const r = await fetch('/api/generate', {
692
- method: 'POST',
693
- headers: { 'Content-Type': 'application/json' },
694
- body: JSON.stringify({ lang, force }),
695
- })
696
- data = await r.json()
697
- } catch (e) {
698
- btn.disabled = false
699
- document.getElementById('gen-btn-icon').textContent = '✨'
700
- document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
701
- return
702
- }
703
-
704
- if (data.error === 'no_api_key') {
705
- btn.disabled = false
706
- document.getElementById('gen-btn-icon').textContent = '✨'
707
- document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
708
- document.getElementById('step3-generate').innerHTML += `
709
- <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)">
710
- <strong>API key not found.</strong> Add to your shell or <code>.env.local</code>:<br>
711
- <code style="display:block;margin-top:8px;font-size:12px;user-select:all">ANTHROPIC_API_KEY=sk-ant-...</code>
712
- Then restart the server and try again.
713
- </div>`
714
- return
715
- }
716
-
717
- if (data.error) {
718
- btn.disabled = false
719
- document.getElementById('gen-btn-icon').textContent = '✨'
720
- document.getElementById('gen-btn-label').textContent = 'Generate docs with AI'
721
- return
722
- }
723
-
724
- // Generation started swap UI to progress view
725
- const total = data.total || 0
726
- const stepAgent = document.getElementById('step-agent')
727
- if (stepAgent) stepAgent.classList.add('done')
728
- btn.style.display = 'none'
729
-
730
- document.getElementById('step3-generate').innerHTML = `
731
- <div id="gen-progress-wrap" style="margin-top:4px">
732
- <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px">
733
- <span style="font-weight:600">Generating documentation…</span>
734
- <span id="gen-counter" style="color:var(--text-muted)">0 / ${total}</span>
735
- </div>
736
- <div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:14px;overflow:hidden">
737
- <div id="gen-bar" style="height:100%;width:0%;background:var(--accent);border-radius:2px;transition:width .3s"></div>
738
- </div>
739
- <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>
740
- </div>`
741
-
742
- state.generating = true
743
- state.generationTotal = total
744
- state.generationDone = 0
745
- state.generationLog = []
746
- }
747
-
748
- function handleProgressEvent(raw) {
749
- // format: "progress:done/total:status:filepath"
750
- if (!raw.startsWith('progress:')) return
751
- const parts = raw.slice(9).split(':')
752
- const counter = parts[0] // "3/31"
753
- const status = parts[1] // done | skip | working | error
754
- const file = parts.slice(2).join(':')
755
-
756
- const [done, total] = counter.split('/').map(Number)
757
- state.generationDone = done
758
-
759
- const icon = status === 'done' ? '✓' : status === 'skip' ? '–' : status === 'error' ? '✗' : '…'
760
- const color = status === 'done' ? 'var(--green-text)' : status === 'error' ? 'var(--red-text)' : status === 'skip' ? 'var(--text-muted)' : 'var(--accent)'
761
-
762
- // Persist completed entries so we can replay when returning to the panel
763
- if (status !== 'working') state.generationLog.push({ icon, color, file })
764
-
765
- const bar = document.getElementById('gen-bar')
766
- const cnt = document.getElementById('gen-counter')
767
- const log = document.getElementById('gen-log')
768
-
769
- if (bar) bar.style.width = `${Math.round((done / total) * 100)}%`
770
- if (cnt) cnt.textContent = `${done} / ${total}`
771
- if (!log) return
772
-
773
- const row = document.createElement('div')
774
- row.style.cssText = `display:flex;gap:8px;align-items:baseline;color:${color}`
775
- row.innerHTML = `<span style="flex-shrink:0;font-weight:700">${icon}</span><span style="color:var(--text-muted)">${esc(file)}</span>`
776
-
777
- if (status === 'working') {
778
- const existing = log.querySelector('[data-working]')
779
- if (existing) existing.remove()
780
- row.setAttribute('data-working', '1')
781
- }
782
- log.appendChild(row)
783
- log.scrollTop = log.scrollHeight
784
- }
785
-
786
- function handleDoneEvent(raw) {
787
- if (!raw.startsWith('done:')) return
788
- state.generating = false
789
- const count = raw.slice(5)
790
- const log = document.getElementById('gen-log')
791
- const bar = document.getElementById('gen-bar')
792
- if (bar) bar.style.background = 'var(--green)'
793
- const cnt = document.getElementById('gen-counter')
794
- if (cnt) cnt.textContent = `${count} files documented`
795
- if (log) {
796
- const row = document.createElement('div')
797
- row.style.cssText = 'margin-top:8px;font-weight:700;color:var(--green-text)'
798
- row.textContent = `✓ Done! Documentation generated for ${count} files.`
799
- log.appendChild(row)
800
- }
801
- // Show regenerate controls
802
- const wrap = document.getElementById('gen-progress-wrap')
803
- if (wrap) {
804
- const lang = state.lang || 'es'
805
- const otherLang = lang === 'es' ? 'en' : 'es'
806
- const otherLabel = otherLang === 'es' ? '🇪🇸 Regenerate in Spanish' : '🇺🇸 Regenerate in English'
807
- const div = document.createElement('div')
808
- div.style.cssText = 'margin-top:16px;display:flex;gap:10px;align-items:center;flex-wrap:wrap'
809
- div.innerHTML = `
810
- <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">
811
- ${otherLabel}
812
- </button>
813
- <span style="font-size:12px;color:var(--text-muted)">Re-generates all docs in the selected language.</span>`
814
- wrap.appendChild(div)
815
- }
816
- }
817
-
818
- function showRegenPanel() {
819
- state.active = 'regen'
820
- renderNav()
821
- const lang = state.lang || 'es'
822
- const el = document.getElementById('doc-content')
823
-
824
- if (state.generating) {
825
- const done = state.generationDone || 0
826
- const total = state.generationTotal || 0
827
- const pct = total > 0 ? Math.round((done / total) * 100) : 0
828
- el.innerHTML = `
829
- <div style="max-width:540px;margin:60px auto;padding:0 24px">
830
- <h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Generando documentación…</h2>
831
- <p style="color:var(--text-muted);font-size:14px;margin-bottom:24px">La generación continúa en segundo plano. Puedes navegar libremente.</p>
832
- <div id="step3-generate">
833
- <div id="gen-progress-wrap">
834
- <div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:8px">
835
- <span style="font-weight:600">Generando documentación…</span>
836
- <span id="gen-counter" style="color:var(--text-muted)">${done} / ${total}</span>
837
- </div>
838
- <div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:14px;overflow:hidden">
839
- <div id="gen-bar" style="height:100%;width:${pct}%;background:var(--accent);border-radius:2px;transition:width .3s"></div>
840
- </div>
841
- <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>
842
- </div>
843
- </div>
844
- </div>`
845
- // Replay saved log entries
846
- const log = el.querySelector('#gen-log')
847
- for (const entry of state.generationLog) {
848
- const row = document.createElement('div')
849
- row.style.cssText = `display:flex;gap:8px;align-items:baseline;color:${entry.color}`
850
- row.innerHTML = `<span style="flex-shrink:0;font-weight:700">${entry.icon}</span><span style="color:var(--text-muted)">${esc(entry.file)}</span>`
851
- log.appendChild(row)
852
- }
853
- log.scrollTop = log.scrollHeight
854
- return
855
- }
856
-
857
- el.innerHTML = `
858
- <div style="max-width:540px;margin:60px auto;padding:0 24px">
859
- <h2 style="font-size:20px;font-weight:700;margin-bottom:8px">Regenerar documentación</h2>
860
- <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>
861
-
862
- <div style="margin-bottom:20px">
863
- <div style="font-size:13px;font-weight:600;margin-bottom:8px">Idioma</div>
864
- <div style="display:flex;gap:0;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;width:fit-content">
865
- <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>
866
- <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>
867
- </div>
868
- </div>
869
-
870
- <button class="bootstrap-btn" id="gen-btn" onclick="runGenerate(true)" style="max-width:300px">
871
- <span id="gen-btn-icon">✨</span>
872
- <span id="gen-btn-label">Regenerar todos los docs</span>
873
- </button>
874
- <p style="font-size:12px;color:var(--text-muted);margin-top:10px">Usa Claude Haiku. Sobreescribe los archivos .md existentes.</p>
875
-
876
- <div id="step3-generate" style="margin-top:16px"></div>
877
- </div>`
878
- }
879
-
880
- function isArchTemplate(text) {
881
- // Solo es plantilla si tiene comentarios HTML placeholder O filas de tabla completamente vacías
882
- const hasComments = /<!--\s*(Describe|describe|TODO|todo)/.test(text)
883
- const emptyRows = (text.match(/^\|(\s*\|){3,}\s*$/mg) || []).length
884
- return hasComments || emptyRows >= 3
885
- }
886
-
887
- async function generateArch() {
888
- const btn = document.getElementById('arch-gen-btn')
889
- if (btn) { btn.disabled = true; btn.textContent = '⏳ Generando…' }
890
-
891
- try {
892
- const r = await fetch('/api/generate-arch', {
893
- method: 'POST',
894
- headers: { 'Content-Type': 'application/json' },
895
- body: JSON.stringify({ lang: state.lang || 'es' }),
896
- })
897
- const data = await r.json()
898
- if (data.error === 'no_api_key') {
899
- if (btn) { btn.disabled = false; btn.textContent = '✨ Generar con IA' }
900
- const banner = document.getElementById('arch-gen-banner')
901
- 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>`)
902
- return
903
- }
904
- // reload will come via SSE — just update button
905
- if (btn) { btn.textContent = '✓ Listo' }
906
- } catch {
907
- if (btn) { btn.disabled = false; btn.textContent = '✨ Generar con IA' }
908
- }
909
- }
910
-
911
- function goHome() {
912
- if (state.tree?.architecture) loadFile(state.tree.architecture)
913
- else showWelcome()
914
- }
915
-
916
- // ── API Explorer ───────────────────────────────────────────────────────────
917
- async function showApiExplorer() {
918
- state.active = 'api-explorer'
919
- state.view = 'api-explorer'
920
- renderNav()
921
-
922
- const el = document.getElementById('doc-content')
923
- el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
924
- document.getElementById('main').scrollTo({ top: 0 })
925
-
926
- if (!api.spec) {
927
- const spec = await get('/api/openapi')
928
- api.spec = spec
929
- }
930
-
931
- renderApiExplorer()
932
- }
933
-
934
- function renderApiExplorer() {
935
- const el = document.getElementById('doc-content')
936
-
937
- if (!api.spec || api.spec.error) {
938
- el.innerHTML = `
939
- <div class="empty-state">
940
- <div class="empty-icon">⚡</div>
941
- <h2>No API spec found</h2>
942
- <p>Run the analyzer to auto-detect routes from your source code.</p>
943
- <code>npx docutrack analyze</code>
944
- <p style="margin-top:12px;font-size:13px">Or create <code>docs/api/openapi.json</code> manually (OpenAPI 3.0).</p>
945
- </div>`
946
- return
947
- }
948
-
949
- const { info, paths } = api.spec
950
- const groups = groupByTag(paths)
951
-
952
- const totalEp = Object.values(paths).reduce((n, p) => n + Object.keys(p).length, 0)
953
-
954
- el.innerHTML = `
955
- <div class="api-explorer">
956
- <div class="api-explorer-header">
957
- <div>
958
- <h1 style="margin-bottom:4px">
959
- ${esc(info.title)}
960
- <span style="font-size:14px;color:var(--text-muted);font-weight:400">v${esc(info.version)}</span>
961
- </h1>
962
- <p style="color:var(--text-muted);font-size:13px;margin:4px 0 0">
963
- ${totalEp} endpoint${totalEp !== 1 ? 's' : ''} detected
964
- </p>
965
- </div>
966
- <div class="base-url-wrap">
967
- <span class="base-url-label">Base URL</span>
968
- <div class="base-url-row">
969
- <input class="base-url-input" id="base-url-input" type="text"
970
- value="${ea(api.baseUrl)}" placeholder="http://localhost:3000"
971
- oninput="api.baseUrl=this.value;localStorage.setItem('dt-baseUrl',this.value)">
972
- <button class="auth-toggle" onclick="toggleAuthRow()">🔑 Auth</button>
973
- </div>
974
- <div class="auth-row${api.showAuth ? ' show' : ''}" id="auth-row">
975
- <span class="auth-label">Bearer</span>
976
- <input class="auth-input" id="auth-input" type="text" placeholder="your-token-here"
977
- value="${ea(api.token)}"
978
- oninput="api.token=this.value;localStorage.setItem('dt-token',this.value)">
979
- </div>
980
- </div>
981
- </div>
982
- ${Object.entries(groups).map(([tag, eps]) => renderApiGroup(tag, eps)).join('')}
983
- </div>`
984
- }
985
-
986
- function groupByTag(paths) {
987
- const groups = {}
988
- if (!paths) return groups
989
- for (const [path, pathItem] of Object.entries(paths)) {
990
- for (const method of ['get','post','put','patch','delete','options','head']) {
991
- if (!pathItem[method]) continue
992
- const op = pathItem[method]
993
- const tag = op.tags?.[0] || 'default'
994
- if (!groups[tag]) groups[tag] = []
995
- groups[tag].push({ path, method: method.toUpperCase(), ...op })
996
- }
997
- }
998
- return groups
999
- }
1000
-
1001
- function renderApiGroup(tag, endpoints) {
1002
- return `
1003
- <div class="api-group">
1004
- <div class="api-group-title">${esc(tag)}</div>
1005
- ${endpoints.map(ep => renderEndpointCard(ep)).join('')}
1006
- </div>`
1007
- }
1008
-
1009
- function renderEndpointCard(ep) {
1010
- const id = epId(ep.method, ep.path)
1011
- const isOpen = api.open[id]
1012
- const params = ep.parameters || []
1013
- const hasBody = !!ep.requestBody
1014
- const pathParams = params.filter(p => p.in === 'path')
1015
- const queryParams = params.filter(p => p.in === 'query')
1016
- const headerParams = params.filter(p => p.in === 'header')
1017
-
1018
- return `
1019
- <div class="endpoint-card${isOpen ? ' open' : ''}" id="card-${id}">
1020
- <div class="endpoint-header" onclick="toggleEndpoint('${id}')">
1021
- <span class="method-badge badge-${ep.method.toLowerCase()}">${ep.method}</span>
1022
- <span class="endpoint-path">${esc(ep.path)}</span>
1023
- ${ep.summary ? `<span class="endpoint-summary">${esc(ep.summary)}</span>` : ''}
1024
- <span class="endpoint-chevron">›</span>
1025
- </div>
1026
- <div class="endpoint-body">
1027
- ${ep.description ? `<div class="endpoint-section"><p style="color:var(--text-muted);font-size:14px;margin:0">${esc(ep.description)}</p></div>` : ''}
1028
- ${params.length ? `
1029
- <div class="endpoint-section">
1030
- <div class="section-title">Parameters</div>
1031
- <table class="params-table">
1032
- <thead><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th></tr></thead>
1033
- <tbody>${params.map(p => `
1034
- <tr>
1035
- <td>${esc(p.name)}</td>
1036
- <td>${esc(p.in)}</td>
1037
- <td>${esc(p.schema?.type || 'string')}</td>
1038
- <td>${p.required ? '<span class="param-required">required</span>' : '<span class="param-optional">optional</span>'}</td>
1039
- </tr>`).join('')}
1040
- </tbody>
1041
- </table>
1042
- </div>` : ''}
1043
- ${hasBody ? `
1044
- <div class="endpoint-section">
1045
- <div class="section-title">Request Body</div>
1046
- <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>
1047
- </div>` : ''}
1048
- ${ep.responses ? `
1049
- <div class="endpoint-section">
1050
- <div class="section-title">Responses</div>
1051
- ${Object.entries(ep.responses).map(([code, r]) => `
1052
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:13px">
1053
- <span class="status-chip ${statusClass(code)}">${code}</span>
1054
- <span style="color:var(--text-muted)">${esc(r.description || '')}</span>
1055
- </div>`).join('')}
1056
- </div>` : ''}
1057
- <div class="try-section">
1058
- ${renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody)}
1059
- </div>
1060
- </div>
1061
- </div>`
1062
- }
1063
-
1064
- function renderTrySection(id, ep, pathParams, queryParams, headerParams, hasBody) {
1065
- const tryOpen = api.tryOpen[id]
1066
- const resp = api.responses[id]
1067
-
1068
- return `
1069
- <button class="try-btn" onclick="toggleTry('${id}',event)">
1070
- ${tryOpen ? '✕ Close' : '▶ Try it out'}
1071
- </button>
1072
- <div class="try-form${tryOpen ? ' show' : ''}" id="try-${id}">
1073
- ${pathParams.map(p => `
1074
- <div class="try-field">
1075
- <label>${esc(p.name)} <span style="color:var(--red-text);font-size:10px">path</span></label>
1076
- <input type="text" id="${id}-path-${esc(p.name)}" placeholder="${esc(p.name)}">
1077
- </div>`).join('')}
1078
- ${queryParams.map(p => `
1079
- <div class="try-field">
1080
- <label>${esc(p.name)} <span style="color:var(--text-muted);font-size:10px">query</span></label>
1081
- <input type="text" id="${id}-query-${esc(p.name)}" placeholder="${esc(p.schema?.type || 'string')}">
1082
- </div>`).join('')}
1083
- ${hasBody ? `
1084
- <div class="try-field">
1085
- <label>Request Body <span style="color:var(--text-muted);font-size:10px">application/json</span></label>
1086
- <textarea id="${id}-body" placeholder="{}"></textarea>
1087
- </div>` : ''}
1088
- <div class="try-actions">
1089
- <button class="execute-btn" id="exec-${id}" onclick="executeRequest('${id}','${esc(ep.method)}','${ea(ep.path)}',${hasBody})">
1090
- Execute
1091
- </button>
1092
- <button class="cancel-try-btn" onclick="toggleTry('${id}',event)">Cancel</button>
1093
- </div>
1094
- ${resp ? renderResponse(resp) : ''}
1095
- </div>`
1096
- }
1097
-
1098
- function renderResponse(resp) {
1099
- const cls = resp.status >= 500 ? 'status-5xx'
1100
- : resp.status >= 400 ? 'status-4xx'
1101
- : resp.status >= 300 ? 'status-3xx'
1102
- : 'status-2xx'
1103
-
1104
- let body = resp.body
1105
- try { body = JSON.stringify(JSON.parse(body), null, 2) } catch { /* raw */ }
1106
-
1107
- return `
1108
- <div class="response-panel">
1109
- <div class="response-bar">
1110
- <span class="status-chip ${cls}">${resp.status} ${resp.statusText}</span>
1111
- <span style="color:var(--text-muted);font-size:12px">${resp.contentType || ''}</span>
1112
- <span class="response-time">${resp.ms}ms</span>
1113
- </div>
1114
- <div class="response-body">${esc(body)}</div>
1115
- </div>
1116
- ${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>` : ''}`
1117
- }
1118
-
1119
- // ── Execute request ────────────────────────────────────────────────────────
1120
- async function executeRequest(id, method, pathTemplate, hasBody) {
1121
- const btn = document.getElementById(`exec-${id}`)
1122
- btn.disabled = true
1123
- btn.textContent = ''
1124
-
1125
- // Build URL
1126
- let url = api.baseUrl.replace(/\/$/, '') + pathTemplate
1127
- // Substitute path params
1128
- url = url.replace(/\{([^}]+)\}/g, (_, name) => {
1129
- const input = document.getElementById(`${id}-path-${name}`)
1130
- return input ? encodeURIComponent(input.value || `:${name}`) : `:${name}`
1131
- })
1132
-
1133
- // Collect query params from spec
1134
- const queryEl = document.querySelectorAll(`[id^="${id}-query-"]`)
1135
- const qs = []
1136
- queryEl.forEach(el => {
1137
- const key = el.id.replace(`${id}-query-`, '')
1138
- if (el.value) qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(el.value)}`)
1139
- })
1140
- if (qs.length) url += '?' + qs.join('&')
1141
-
1142
- const headers = { 'Content-Type': 'application/json' }
1143
- if (api.token) headers['Authorization'] = `Bearer ${api.token}`
1144
-
1145
- let body = undefined
1146
- if (hasBody) {
1147
- const bodyEl = document.getElementById(`${id}-body`)
1148
- body = bodyEl?.value?.trim() || '{}'
1149
- }
1150
-
1151
- const t0 = Date.now()
1152
- let resp
1153
-
1154
- try {
1155
- const fetchResp = await fetch(url, {
1156
- method,
1157
- headers,
1158
- body: body && method !== 'GET' ? body : undefined,
1159
- })
1160
- const text = await fetchResp.text()
1161
- resp = {
1162
- status: fetchResp.status,
1163
- statusText: fetchResp.statusText,
1164
- contentType: fetchResp.headers.get('content-type') || '',
1165
- body: text,
1166
- ms: Date.now() - t0,
1167
- corsError: false,
1168
- }
1169
- } catch (err) {
1170
- const isCors = err.message?.toLowerCase().includes('fetch') || err.name === 'TypeError'
1171
- resp = {
1172
- status: 0,
1173
- statusText: 'Network Error',
1174
- contentType: '',
1175
- body: err.message,
1176
- ms: Date.now() - t0,
1177
- corsError: isCors,
1178
- }
1179
- }
1180
-
1181
- api.responses[id] = resp
1182
- btn.disabled = false
1183
- btn.textContent = ' Execute'
1184
-
1185
- // Re-render just the try section by refreshing the response part
1186
- const tryDiv = document.getElementById(`try-${id}`)
1187
- // Remove old response panel if present
1188
- tryDiv.querySelectorAll('.response-panel, .cors-warn').forEach(e => e.remove())
1189
- tryDiv.insertAdjacentHTML('beforeend', renderResponse(resp))
1190
- }
1191
-
1192
- // ── Interaction helpers ────────────────────────────────────────────────────
1193
- function toggleEndpoint(id) {
1194
- api.open[id] = !api.open[id]
1195
- const card = document.getElementById(`card-${id}`)
1196
- if (api.open[id]) card.classList.add('open')
1197
- else card.classList.remove('open')
1198
- }
1199
-
1200
- function toggleTry(id, e) {
1201
- e.stopPropagation()
1202
- api.tryOpen[id] = !api.tryOpen[id]
1203
- const form = document.getElementById(`try-${id}`)
1204
- if (api.tryOpen[id]) form.classList.add('show')
1205
- else form.classList.remove('show')
1206
- // Update button label
1207
- const btn = form.previousElementSibling
1208
- if (btn?.classList.contains('try-btn')) {
1209
- btn.textContent = api.tryOpen[id] ? '✕ Close' : '▶ Try it out'
1210
- }
1211
- }
1212
-
1213
- function toggleAuthRow() {
1214
- api.showAuth = !api.showAuth
1215
- document.getElementById('auth-row').classList.toggle('show', api.showAuth)
1216
- }
1217
-
1218
- // ── Status ─────────────────────────────────────────────────────────────────
1219
- async function refreshStatus() {
1220
- const s = await get('/api/status')
1221
- if (!s) return
1222
- const pct = s.coverage
1223
- document.getElementById('cov-pct').textContent = `${pct}%`
1224
- document.getElementById('cov-label').textContent = `${pct}%`
1225
- const bar = document.getElementById('cov-bar')
1226
- bar.style.width = `${pct}%`
1227
- bar.className = 'coverage-fill' + (pct < 50 ? ' bad' : pct < 80 ? ' warn' : '')
1228
- document.getElementById('cov-dot').style.background = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--yellow)' : 'var(--red)'
1229
- const banner = document.getElementById('pending-banner')
1230
- if (s.pending > 0) { document.getElementById('pending-count').textContent = s.pending; banner.classList.add('show') }
1231
- else banner.classList.remove('show')
1232
-
1233
- // Store stale map for nav rendering
1234
- state.staleSet = new Set((s.stale || []).map(x => x.doc))
1235
- renderNav()
1236
- }
1237
-
1238
- // ── Health Check ───────────────────────────────────────────────────────────
1239
- async function showHealthCheck() {
1240
- state.active = 'health-check'
1241
- state.view = 'health-check'
1242
- renderNav()
1243
-
1244
- const el = document.getElementById('doc-content')
1245
- el.innerHTML = '<div style="padding:40px;text-align:center"><span class="spinner"></span></div>'
1246
- document.getElementById('main').scrollTo({ top: 0 })
1247
-
1248
- const data = await get('/api/check')
1249
- if (!data) {
1250
- el.innerHTML = `<div class="empty-state"><div class="empty-icon">🩺</div><h2>Health check unavailable</h2><p>Make sure DocuTrack is initialized.</p></div>`
1251
- return
1252
- }
1253
- renderHealthCheck(data)
1254
- }
1255
-
1256
- function renderHealthCheck(data) {
1257
- const el = document.getElementById('doc-content')
1258
- const { pending, stale, drift, complexity, ok } = data
1259
-
1260
- const criticalCount = complexity.critical?.length || 0
1261
- const highDrift = (drift || []).filter(d => d.severity === 'high').length
1262
- const totalIssues = pending + stale + highDrift + criticalCount
1263
- state.healthBadge = totalIssues
1264
- renderNav()
1265
-
1266
- const overallClass = ok ? 'health-ok' : totalIssues > 3 ? 'health-crit' : 'health-warn'
1267
- const overallText = ok ? '✓ Healthy' : `⚠ ${totalIssues} issue${totalIssues !== 1 ? 's' : ''}`
1268
-
1269
- const scoreCard = (val, label, cls) => `
1270
- <div class="health-card ${cls}">
1271
- <div class="health-card-val">${val}</div>
1272
- <div class="health-card-label">${label}</div>
1273
- </div>`
1274
-
1275
- const cards = [
1276
- scoreCard(pending, 'Pending files', pending > 0 ? 'warn-card' : 'ok'),
1277
- scoreCard(stale, 'Stale docs', stale > 0 ? 'warn-card' : 'ok'),
1278
- scoreCard(drift.length, 'Drifted modules', highDrift > 0 ? 'issue' : drift.length > 0 ? 'warn-card' : 'ok'),
1279
- scoreCard(complexity.summary?.critical || 0, 'Complex files', criticalCount > 0 ? 'issue' : 'ok'),
1280
- ].join('')
1281
-
1282
- const driftSection = drift.length > 0 ? `
1283
- <div class="health-section">
1284
- <div class="health-section-title">📐 Documentation Drift</div>
1285
- ${drift.map(d => `
1286
- <div class="drift-row">
1287
- <span class="drift-sev ${d.severity}">${d.severity.toUpperCase()}</span>
1288
- <div>
1289
- <div class="drift-module">${esc(d.module)}</div>
1290
- ${d.undocumented?.length ? `<div class="drift-detail">+undocumented: ${d.undocumented.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
1291
- ${d.orphaned?.length ? `<div class="drift-detail" style="margin-top:3px">−orphaned: ${d.orphaned.map(n => `<span>${esc(n)}</span>`).join('')}</div>` : ''}
1292
- </div>
1293
- </div>`).join('')}
1294
- </div>` : ''
1295
-
1296
- const complexFiles = complexity.critical || []
1297
- const complexSection = complexFiles.length > 0 ? `
1298
- <div class="health-section">
1299
- <div class="health-section-title">📊 Complexity Hotspots</div>
1300
- ${complexFiles.map(f => {
1301
- const scoreClass = f.score < 40 ? 'score-bad' : f.score < 70 ? 'score-warn' : 'score-ok'
1302
- return `
1303
- <div class="complexity-row">
1304
- <div class="complexity-score ${scoreClass}">${f.score}</div>
1305
- <div class="complexity-file" title="${esc(f.file)}">${esc(f.file)}</div>
1306
- <div class="complexity-tags">
1307
- ${f.warnings.map(w => `<span class="complexity-tag${w.level === 'critical' ? ' crit' : ''}">${esc(w.message)}</span>`).join('')}
1308
- </div>
1309
- </div>`}).join('')}
1310
- </div>` : ''
1311
-
1312
- const allGood = drift.length === 0 && complexFiles.length === 0 && pending === 0 && stale === 0
1313
- const emptyState = allGood ? `
1314
- <div class="health-empty" style="padding:40px">
1315
- <div style="font-size:48px;margin-bottom:12px">✅</div>
1316
- <p style="font-size:16px;font-weight:700;color:var(--text)">Documentation is fully healthy.</p>
1317
- <p>No pending files, no stale docs, no drift, no complexity issues.</p>
1318
- </div>` : ''
1319
-
1320
- el.innerHTML = `
1321
- <div style="max-width:800px">
1322
- <div class="health-header">
1323
- <div>
1324
- <div class="health-title">Health Check</div>
1325
- <div class="health-subtitle">Documentation quality analysis</div>
1326
- </div>
1327
- <div style="display:flex;align-items:center;gap:10px">
1328
- <span class="health-overall ${overallClass}">${overallText}</span>
1329
- <button class="health-refresh" onclick="showHealthCheck()">↻ Refresh</button>
1330
- </div>
1331
- </div>
1332
- <div class="health-grid">${cards}</div>
1333
- ${emptyState}
1334
- ${driftSection}
1335
- ${complexSection}
1336
- ${(pending > 0 || stale > 0) ? `
1337
- <div class="health-section">
1338
- <div class="health-section-title">💡 Suggested Actions</div>
1339
- <div style="font-size:13px;color:var(--text-muted);line-height:2">
1340
- ${pending > 0 ? `<div>→ Run the <strong>documentalista</strong> subagent to clear ${pending} pending file${pending !== 1 ? 's' : ''}</div>` : ''}
1341
- ${stale > 0 ? `<div>→ Update ${stale} stale doc${stale !== 1 ? 's' : ''}, then run <code>docutrack clear</code></div>` : ''}
1342
- ${highDrift > 0 ? `<div>→ Add missing exports to docs for high-drift modules above</div>` : ''}
1343
- ${criticalCount > 0 ? `<div>→ Consider splitting the complex files listed above</div>` : ''}
1344
- </div>
1345
- </div>` : ''}
1346
- </div>`
1347
- }
1348
-
1349
- // ── SSE ─────────────────────────────────────────────────────────────────────
1350
- function connectSSE() {
1351
- const es = new EventSource('/events')
1352
- es.onmessage = async (e) => {
1353
- if (e.data.startsWith('progress:')) { handleProgressEvent(e.data); return }
1354
- if (e.data.startsWith('done:')) { handleDoneEvent(e.data); return }
1355
- if (e.data !== 'reload') return
1356
-
1357
- await refreshTree()
1358
- await refreshStatus()
1359
-
1360
- if (state.view === 'api-explorer') {
1361
- api.spec = await get('/api/openapi')
1362
- renderApiExplorer()
1363
- } else if (state.view === 'health-check') {
1364
- const data = await get('/api/check')
1365
- if (data) renderHealthCheck(data)
1366
- } else if (state.active && state.active !== 'bootstrap' && state.active !== 'regen') {
1367
- await loadFile(state.active)
1368
- }
1369
- // If we were on bootstrap and modules now exist → navigate to arch
1370
- if (state.active === 'bootstrap' && state.tree?.modules?.length > 0) {
1371
- if (state.tree.architecture) loadFile(state.tree.architecture)
1372
- }
1373
- }
1374
- es.onerror = () => setTimeout(connectSSE, 3000)
1375
- }
1376
-
1377
- // ── Theme ──────────────────────────────────────────────────────────────────
1378
- document.getElementById('theme-toggle').addEventListener('click', () => {
1379
- setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark')
1380
- })
1381
-
1382
- function setTheme(t) {
1383
- document.documentElement.setAttribute('data-theme', t)
1384
- document.getElementById('theme-toggle').textContent = t === 'dark' ? '☀️' : '🌙'
1385
- localStorage.setItem('dt-theme', t)
1386
- mermaid.initialize({ startOnLoad: false, theme: t === 'dark' ? 'dark' : 'neutral', securityLevel: 'loose' })
1387
- }
1388
-
1389
- // ── Utils ──────────────────────────────────────────────────────────────────
1390
- async function get(url) {
1391
- try { const r = await fetch(url); return r.ok ? r.json() : null } catch { return null }
1392
- }
1393
- function esc(s) {
1394
- return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
1395
- }
1396
- function ea(s) { return String(s||'').replace(/'/g,"\\'") }
1397
- function epId(method, path) {
1398
- return `${method}-${path}`.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
1399
- }
1400
- function statusClass(code) {
1401
- const n = parseInt(code)
1402
- if (n >= 500) return 'status-5xx'
1403
- if (n >= 400) return 'status-4xx'
1404
- if (n >= 300) return 'status-3xx'
1405
- return 'status-2xx'
1406
- }
1407
-
1408
- boot()
1409
- </script>
1410
- </body>
1411
- </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>