@opensip-tools/contracts 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/LICENSE +21 -0
  4. package/dist/__tests__/dashboard.test.d.ts +2 -0
  5. package/dist/__tests__/dashboard.test.d.ts.map +1 -0
  6. package/dist/__tests__/dashboard.test.js +85 -0
  7. package/dist/__tests__/dashboard.test.js.map +1 -0
  8. package/dist/__tests__/exit-codes.test.d.ts +2 -0
  9. package/dist/__tests__/exit-codes.test.d.ts.map +1 -0
  10. package/dist/__tests__/exit-codes.test.js +73 -0
  11. package/dist/__tests__/exit-codes.test.js.map +1 -0
  12. package/dist/__tests__/store.test.d.ts +2 -0
  13. package/dist/__tests__/store.test.d.ts.map +1 -0
  14. package/dist/__tests__/store.test.js +169 -0
  15. package/dist/__tests__/store.test.js.map +1 -0
  16. package/dist/exit-codes.d.ts +14 -0
  17. package/dist/exit-codes.d.ts.map +1 -0
  18. package/dist/exit-codes.js +61 -0
  19. package/dist/exit-codes.js.map +1 -0
  20. package/dist/index.d.ts +21 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +20 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/persistence/dashboard/checks.d.ts +7 -0
  25. package/dist/persistence/dashboard/checks.d.ts.map +1 -0
  26. package/dist/persistence/dashboard/checks.js +279 -0
  27. package/dist/persistence/dashboard/checks.js.map +1 -0
  28. package/dist/persistence/dashboard/css.d.ts +6 -0
  29. package/dist/persistence/dashboard/css.d.ts.map +1 -0
  30. package/dist/persistence/dashboard/css.js +141 -0
  31. package/dist/persistence/dashboard/css.js.map +1 -0
  32. package/dist/persistence/dashboard/generator.d.ts +9 -0
  33. package/dist/persistence/dashboard/generator.d.ts.map +1 -0
  34. package/dist/persistence/dashboard/generator.js +79 -0
  35. package/dist/persistence/dashboard/generator.js.map +1 -0
  36. package/dist/persistence/dashboard/index.d.ts +5 -0
  37. package/dist/persistence/dashboard/index.d.ts.map +1 -0
  38. package/dist/persistence/dashboard/index.js +5 -0
  39. package/dist/persistence/dashboard/index.js.map +1 -0
  40. package/dist/persistence/dashboard/overview.d.ts +6 -0
  41. package/dist/persistence/dashboard/overview.d.ts.map +1 -0
  42. package/dist/persistence/dashboard/overview.js +65 -0
  43. package/dist/persistence/dashboard/overview.js.map +1 -0
  44. package/dist/persistence/dashboard/recipes.d.ts +6 -0
  45. package/dist/persistence/dashboard/recipes.d.ts.map +1 -0
  46. package/dist/persistence/dashboard/recipes.js +68 -0
  47. package/dist/persistence/dashboard/recipes.js.map +1 -0
  48. package/dist/persistence/dashboard/sessions.d.ts +6 -0
  49. package/dist/persistence/dashboard/sessions.d.ts.map +1 -0
  50. package/dist/persistence/dashboard/sessions.js +205 -0
  51. package/dist/persistence/dashboard/sessions.js.map +1 -0
  52. package/dist/persistence/dashboard/shared.d.ts +6 -0
  53. package/dist/persistence/dashboard/shared.d.ts.map +1 -0
  54. package/dist/persistence/dashboard/shared.js +211 -0
  55. package/dist/persistence/dashboard/shared.js.map +1 -0
  56. package/dist/persistence/dashboard/tool-tabs.d.ts +6 -0
  57. package/dist/persistence/dashboard/tool-tabs.d.ts.map +1 -0
  58. package/dist/persistence/dashboard/tool-tabs.js +102 -0
  59. package/dist/persistence/dashboard/tool-tabs.js.map +1 -0
  60. package/dist/persistence/store.d.ts +103 -0
  61. package/dist/persistence/store.d.ts.map +1 -0
  62. package/dist/persistence/store.js +156 -0
  63. package/dist/persistence/store.js.map +1 -0
  64. package/dist/types.d.ts +279 -0
  65. package/dist/types.d.ts.map +1 -0
  66. package/dist/types.js +2 -0
  67. package/dist/types.js.map +1 -0
  68. package/package.json +35 -0
  69. package/src/__tests__/dashboard.test.ts +102 -0
  70. package/src/__tests__/exit-codes.test.ts +87 -0
  71. package/src/__tests__/store.test.ts +213 -0
  72. package/src/exit-codes.ts +74 -0
  73. package/src/index.ts +71 -0
  74. package/src/persistence/dashboard/checks.ts +279 -0
  75. package/src/persistence/dashboard/css.ts +141 -0
  76. package/src/persistence/dashboard/generator.ts +89 -0
  77. package/src/persistence/dashboard/index.ts +5 -0
  78. package/src/persistence/dashboard/overview.ts +65 -0
  79. package/src/persistence/dashboard/recipes.ts +68 -0
  80. package/src/persistence/dashboard/sessions.ts +205 -0
  81. package/src/persistence/dashboard/shared.ts +211 -0
  82. package/src/persistence/dashboard/tool-tabs.ts +102 -0
  83. package/src/persistence/store.ts +233 -0
  84. package/src/types.ts +306 -0
  85. package/tsconfig.json +8 -0
  86. package/vitest.config.ts +16 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Checks catalog rendering — browsable catalog of checks with run stats.
3
+ * Reusable: renderChecksCatalog(container, catalogData) can be called from any panel.
4
+ * Returns JS code as a string.
5
+ */
6
+
7
+ export function dashboardChecksJs(): string {
8
+ return `
9
+ // =======================================================
10
+ // CHECKS CATALOG
11
+ // =======================================================
12
+
13
+ function computeCheckStats() {
14
+ const stats = {};
15
+ for (const s of sessions) {
16
+ for (const ch of s.checks) {
17
+ if (!stats[ch.checkSlug]) stats[ch.checkSlug] = { runs: 0, passed: 0, failed: 0, lastRun: null };
18
+ const st = stats[ch.checkSlug];
19
+ st.runs++;
20
+ if (ch.passed) st.passed++; else st.failed++;
21
+ if (!st.lastRun || s.timestamp > st.lastRun) st.lastRun = s.timestamp;
22
+ }
23
+ }
24
+ return stats;
25
+ }
26
+ const checkStats = computeCheckStats();
27
+
28
+ /** Render longDescription as DOM nodes with bold and code formatting. Safe — no innerHTML. */
29
+ function renderLongDesc(text) {
30
+ const container = document.createElement('div');
31
+ container.className = 'check-long-desc';
32
+ if (!text) return container;
33
+ const parts = text.split(/(\\*\\*[^*]+\\*\\*|\\\`[^\\\`]+\\\`|\\n)/g);
34
+ parts.forEach(part => {
35
+ if (part === '\\n') {
36
+ container.appendChild(document.createElement('br'));
37
+ } else if (part.startsWith('**') && part.endsWith('**')) {
38
+ const strong = document.createElement('strong');
39
+ strong.textContent = part.slice(2, -2);
40
+ container.appendChild(strong);
41
+ } else if (part.startsWith('\\\`') && part.endsWith('\\\`')) {
42
+ const code = document.createElement('code');
43
+ code.textContent = part.slice(1, -1);
44
+ container.appendChild(code);
45
+ } else {
46
+ container.appendChild(document.createTextNode(part));
47
+ }
48
+ });
49
+ return container;
50
+ }
51
+
52
+ /**
53
+ * Render a check catalog table into any container element.
54
+ * @param panel - DOM element to render into
55
+ * @param catalogData - array of check catalog entries
56
+ */
57
+ function renderChecksCatalog(panel, catalogData) {
58
+ if (!catalogData.length) {
59
+ panel.appendChild(el('div', {class:'empty', text:'No checks registered.'}));
60
+ return;
61
+ }
62
+
63
+ const allTags = new Set();
64
+ catalogData.forEach(c => (c.tags || []).forEach(t => allTags.add(t)));
65
+ const sortedTags = Array.from(allTags).sort();
66
+
67
+ // Filter bar
68
+ const filterBar = el('div', {class:'filter-bar'});
69
+ const searchInput = el('input', {class:'search-input', type:'text', placeholder:'Search checks...'});
70
+ const tagSelect = el('select', {class:'filter-select'});
71
+ tagSelect.appendChild(el('option', {value:'', text:'All tags'}));
72
+ sortedTags.forEach(t => tagSelect.appendChild(el('option', {value:t, text:t})));
73
+ const sourceSelect = el('select', {class:'filter-select'});
74
+ ['', 'built-in', 'community'].forEach(v => {
75
+ sourceSelect.appendChild(el('option', {value:v, text: v || 'All sources'}));
76
+ });
77
+ filterBar.appendChild(searchInput);
78
+ filterBar.appendChild(tagSelect);
79
+ filterBar.appendChild(sourceSelect);
80
+ panel.appendChild(filterBar);
81
+
82
+ // Stats summary
83
+ const totalChecks = catalogData.length;
84
+ const builtinCount = catalogData.filter(c => c.source === 'built-in').length;
85
+ const communityCount = catalogData.filter(c => c.source === 'community').length;
86
+ const statsRow = el('div', {style:'display:flex;gap:16px;margin-bottom:16px;font-size:13px;color:var(--text-muted)'});
87
+ statsRow.appendChild(el('span', {text: totalChecks + ' total checks'}));
88
+ statsRow.appendChild(el('span', {text: builtinCount + ' built-in', style:'color:var(--accent)'}));
89
+ if (communityCount > 0) statsRow.appendChild(el('span', {text: communityCount + ' community', style:'color:var(--accent-sim)'}));
90
+ panel.appendChild(statsRow);
91
+
92
+ // Table
93
+ const table = el('table', {class:'data-table sortable'});
94
+ const thead = el('thead');
95
+ const headerRow = el('tr');
96
+ ['', 'Check', 'Tags', 'Confidence', 'Source', 'Runs', 'Pass Rate', 'Last Run'].forEach(h => {
97
+ headerRow.appendChild(el('th', {text: h}));
98
+ });
99
+ thead.appendChild(headerRow);
100
+ table.appendChild(thead);
101
+
102
+ const tbody = el('tbody');
103
+ const sorted = [...catalogData].sort((a, b) => a.slug.localeCompare(b.slug));
104
+ const uid = 'cc-' + Math.random().toString(36).slice(2, 8);
105
+
106
+ sorted.forEach((check, i) => {
107
+ const st = checkStats[check.slug] || { runs: 0, passed: 0, failed: 0, lastRun: null };
108
+ const rate = st.runs > 0 ? Math.round((st.passed / st.runs) * 100) : -1;
109
+ const hasDesc = !!check.longDescription;
110
+ const expanderId = uid + '-exp-' + i;
111
+
112
+ const arrowCell = el('td', {style:'width:24px;text-align:center;color:var(--text-dim);font-size:12px'});
113
+ if (hasDesc) arrowCell.textContent = '\\u25B6';
114
+
115
+ const row = el('tr', {
116
+ class: hasDesc ? 'clickable' : '',
117
+ 'data-slug': check.slug,
118
+ 'data-tags': (check.tags || []).join(','),
119
+ 'data-source': check.source,
120
+ 'data-name': check.name.toLowerCase(),
121
+ onclick: hasDesc ? () => {
122
+ const exp = document.getElementById(expanderId);
123
+ if (exp) {
124
+ const isOpen = exp.classList.toggle('open');
125
+ exp.style.display = isOpen ? 'table-row' : 'none';
126
+ arrowCell.textContent = isOpen ? '\\u25BC' : '\\u25B6';
127
+ }
128
+ row.classList.toggle('expanded');
129
+ } : undefined
130
+ });
131
+ row.appendChild(arrowCell);
132
+
133
+ const nameCell = el('td', {style:'font-weight:500'});
134
+ nameCell.appendChild(document.createTextNode(check.slug));
135
+ row.appendChild(nameCell);
136
+
137
+ const tagsCell = el('td');
138
+ (check.tags || []).slice(0, 4).forEach(t => {
139
+ tagsCell.appendChild(el('span', {class:'tag-badge', text:t}));
140
+ });
141
+ if ((check.tags || []).length > 4) {
142
+ tagsCell.appendChild(el('span', {class:'tag-badge', text:'+' + ((check.tags || []).length - 4)}));
143
+ }
144
+ row.appendChild(tagsCell);
145
+
146
+ const confCell = el('td');
147
+ confCell.appendChild(el('span', {class:'badge badge-' + check.confidence, text: check.confidence}));
148
+ row.appendChild(confCell);
149
+
150
+ const sourceCell = el('td');
151
+ const sourceStyle = check.source === 'built-in' ? 'color:var(--accent)' : 'color:var(--accent-sim)';
152
+ sourceCell.appendChild(el('span', {text: check.source, style: sourceStyle + ';font-size:12px'}));
153
+ row.appendChild(sourceCell);
154
+
155
+ row.appendChild(el('td', {text: st.runs > 0 ? '' + st.runs : '\\u2014', style:'color:var(--text-dim)'}));
156
+
157
+ const rateCell = el('td');
158
+ if (rate >= 0) {
159
+ const rateColor = rate >= 90 ? 'var(--success)' : rate >= 70 ? 'var(--warning)' : 'var(--error)';
160
+ const bar = el('span', {class:'pass-rate-bar'});
161
+ const track = el('span', {class:'pass-rate-track'});
162
+ track.appendChild(el('span', {class:'pass-rate-fill', style:'width:' + rate + '%;background:' + rateColor}));
163
+ bar.appendChild(track);
164
+ bar.appendChild(el('span', {text: rate + '%', style:'font-size:12px;color:' + rateColor}));
165
+ rateCell.appendChild(bar);
166
+ } else {
167
+ rateCell.textContent = '\\u2014';
168
+ rateCell.style.color = 'var(--text-dim)';
169
+ }
170
+ row.appendChild(rateCell);
171
+
172
+ row.appendChild(el('td', {
173
+ text: st.lastRun ? new Date(st.lastRun).toLocaleDateString() : '\\u2014',
174
+ style:'color:var(--text-dim);font-size:12px'
175
+ }));
176
+
177
+ tbody.appendChild(row);
178
+
179
+ if (hasDesc) {
180
+ const expRow = el('tr', {id: expanderId, class:'expander-row', 'data-slug': check.slug, 'data-tags': (check.tags || []).join(','), 'data-source': check.source, 'data-name': check.name.toLowerCase()});
181
+ const expCell = el('td', {colspan:'8', style:'padding:0'});
182
+ const expContent = el('div', {class:'expander-content'});
183
+ expContent.appendChild(renderLongDesc(check.longDescription));
184
+ expCell.appendChild(expContent);
185
+ expRow.appendChild(expCell);
186
+ tbody.appendChild(expRow);
187
+ }
188
+ });
189
+
190
+ table.appendChild(tbody);
191
+ const pag = el('div', {class:'pagination'});
192
+ const card = el('div', {class:'card'}, [table, pag]);
193
+ panel.appendChild(card);
194
+
195
+ const emptyMsg = el('div', {class:'empty', style:'display:none', text:'No checks match your filters.'});
196
+ card.insertBefore(emptyMsg, pag);
197
+ paginateGroupedRows(tbody, pag, 10);
198
+
199
+ function applyFilters() {
200
+ const search = searchInput.value.toLowerCase();
201
+ const tag = tagSelect.value;
202
+ const source = sourceSelect.value;
203
+ const allRows = Array.from(tbody.children);
204
+ let visibleCount = 0;
205
+
206
+ // First pass: mark rows visible/hidden and collapse expanders
207
+ for (let i = 0; i < allRows.length; i++) {
208
+ const row = allRows[i];
209
+ if (row.classList.contains('expander-row')) continue;
210
+ const slug = row.getAttribute('data-slug') || '';
211
+ const name = row.getAttribute('data-name') || '';
212
+ const rowTags = row.getAttribute('data-tags') || '';
213
+ const rowSource = row.getAttribute('data-source') || '';
214
+ const matchSearch = !search || slug.includes(search) || name.includes(search);
215
+ const matchTag = !tag || rowTags.split(',').includes(tag);
216
+ const matchSource = !source || rowSource === source;
217
+ const visible = matchSearch && matchTag && matchSource;
218
+ row.style.display = visible ? '' : 'none';
219
+ row._filterVisible = visible;
220
+ if (visible) visibleCount++;
221
+ if (i + 1 < allRows.length && allRows[i + 1].classList.contains('expander-row')) {
222
+ allRows[i + 1].style.display = 'none';
223
+ allRows[i + 1].classList.remove('open');
224
+ if (row.classList.contains('expanded')) {
225
+ row.classList.remove('expanded');
226
+ const arrowTd = row.children[0];
227
+ if (arrowTd) arrowTd.textContent = '\\u25B6';
228
+ }
229
+ }
230
+ }
231
+ emptyMsg.style.display = visibleCount === 0 ? '' : 'none';
232
+
233
+ // Re-paginate only visible rows
234
+ const hasFilters = search || tag || source;
235
+ if (hasFilters) {
236
+ // Hide all first, then paginate only matching groups
237
+ const groups = [];
238
+ for (let i = 0; i < allRows.length; i++) {
239
+ const row = allRows[i];
240
+ if (row.classList.contains('expander-row')) continue;
241
+ if (!row._filterVisible) continue;
242
+ const group = [row];
243
+ if (i + 1 < allRows.length && allRows[i+1].classList.contains('expander-row')) {
244
+ group.push(allRows[i+1]);
245
+ }
246
+ groups.push(group);
247
+ }
248
+
249
+ // Custom pagination for filtered results
250
+ let currentPage = 0;
251
+ const pageSize = 10;
252
+ const totalPages = Math.max(1, Math.ceil(groups.length / pageSize));
253
+
254
+ function renderFilteredPage() {
255
+ // Hide all filtered rows first
256
+ groups.forEach(g => g.forEach(r => { r.style.display = 'none'; }));
257
+ const start = currentPage * pageSize;
258
+ const end = start + pageSize;
259
+ groups.slice(start, end).forEach(g => { g[0].style.display = ''; });
260
+
261
+ while (pag.firstChild) pag.removeChild(pag.firstChild);
262
+ if (groups.length <= pageSize) return;
263
+ pag.appendChild(el('div', {class:'pagination-info', text: 'Showing ' + (start+1) + '-' + Math.min(end, groups.length) + ' of ' + groups.length + ' checks'}));
264
+ const btns = el('div', {class:'pagination-btns'});
265
+ renderPageButtons(btns, currentPage, totalPages, (p) => { currentPage = p; renderFilteredPage(); });
266
+ pag.appendChild(btns);
267
+ }
268
+ renderFilteredPage();
269
+ } else {
270
+ paginateGroupedRows(tbody, pag, 10);
271
+ }
272
+ }
273
+
274
+ searchInput.addEventListener('input', applyFilters);
275
+ tagSelect.addEventListener('change', applyFilters);
276
+ sourceSelect.addEventListener('change', applyFilters);
277
+ }
278
+ `;
279
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Dashboard CSS — all styles for the self-contained HTML dashboard.
3
+ * Returns the contents of the <style> block.
4
+ */
5
+
6
+ export function dashboardCss(): string {
7
+ return String.raw`
8
+ :root {
9
+ --bg: #1a1210; --bg-surface: #231a16; --bg-card: #231a16;
10
+ --bg-hover: #3a2e27; --text: #f4ede5; --text-secondary: #e6ddd2;
11
+ --text-muted: #c0b2a2; --text-dim: #958474; --accent: #c49a6c;
12
+ --accent-fitness: #7ca068; --accent-sim: #9b8aa5;
13
+ --success: #8fbc8f; --success-light: rgba(143,188,143,0.2);
14
+ --warning: #d4a574; --warning-light: rgba(212,165,116,0.2);
15
+ --error: #c75b4a; --error-light: rgba(199,91,74,0.2);
16
+ --border: #3a2e27; --border-light: #483a31;
17
+ --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
+ --font-display: "Fraunces", Georgia, "Times New Roman", serif;
19
+ --radius: 8px; --radius-sm: 4px;
20
+ }
21
+ * { margin: 0; padding: 0; box-sizing: border-box; }
22
+ body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; padding: 24px; max-width: 1200px; margin: 0 auto; }
23
+ h1 { font-family: var(--font-display); font-size: 22px; font-weight: 500; margin-bottom: 4px; }
24
+ h1 .brand-open { color: var(--accent); }
25
+ h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
26
+ .header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
27
+ .header-icon { color: var(--accent); display: flex; align-items: center; }
28
+ .header-brand { color: var(--accent); font-size: 13px; font-weight: 500; }
29
+
30
+ /* Tabs */
31
+ .tab-bar { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
32
+ .tab { padding: 10px 20px; cursor: pointer; color: var(--text-dim); font-size: 13px; font-weight: 500; border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s; display: flex; align-items: center; gap: 6px; }
33
+ .tab svg { vertical-align: middle; }
34
+ .tab:hover { color: var(--text-secondary); }
35
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
36
+ .tab-panel { display: none; }
37
+ .tab-panel.active { display: block; }
38
+
39
+ /* Subtabs (within a tab panel) */
40
+ .subtab-bar { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
41
+ .subtab { padding: 8px 16px; cursor: pointer; color: var(--text-dim); font-size: 13px; font-weight: 500; border-bottom: 2px solid transparent; transition: color 0.15s; }
42
+ .subtab:hover { color: var(--text-secondary); }
43
+ .subtab.active { color: var(--text); border-bottom-color: var(--accent); }
44
+ .subtab-panel { display: none; }
45
+ .subtab-panel.active { display: block; }
46
+
47
+ /* Cards and stats */
48
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
49
+ .stat-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
50
+ .stat-label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
51
+ .stat-value { font-size: 28px; font-weight: 700; }
52
+ .score-good { color: var(--success); } .score-warn { color: var(--warning); } .score-bad { color: var(--error); }
53
+ .card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
54
+ .section { margin-bottom: 32px; }
55
+ .empty { color: var(--text-dim); font-style: italic; padding: 24px; text-align: center; }
56
+
57
+ /* Trend chart */
58
+ .trend-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; padding: 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 24px; }
59
+ .trend-bar { flex: 1; border-radius: 2px 2px 0 0; min-width: 8px; max-width: 40px; position: relative; cursor: pointer; }
60
+ .trend-bar:hover::after { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg-hover); color: var(--text); padding: 4px 8px; border-radius: var(--radius-sm); font-size: 11px; white-space: nowrap; border: 1px solid var(--border); }
61
+
62
+ /* Table */
63
+ .data-table { width: 100%; border-collapse: collapse; }
64
+ .data-table td, .data-table th { white-space: nowrap; }
65
+ .data-table th { text-align: left; padding: 8px 12px; font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); font-weight: 600; cursor: pointer; }
66
+ .data-table th:hover { color: var(--text-muted); }
67
+ .data-table th[data-sort="asc"]::after { content: ' \25B2'; font-size: 10px; }
68
+ .data-table th[data-sort="desc"]::after { content: ' \25BC'; font-size: 10px; }
69
+ .data-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
70
+ .data-table tr:hover { background: var(--bg-hover); }
71
+ .data-table tr.clickable { cursor: pointer; }
72
+ .data-table tr.selected { background: var(--bg-hover); border-left: 2px solid var(--accent); }
73
+
74
+ /* Check rows and findings */
75
+ .check-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
76
+ .check-row:last-child { border-bottom: none; }
77
+ .check-icon { width: 20px; text-align: center; font-size: 14px; }
78
+ .check-icon.pass { color: var(--success); } .check-icon.fail { color: var(--error); }
79
+ .check-slug { font-weight: 500; flex: 1; }
80
+ .check-duration { color: var(--text-dim); font-size: 12px; min-width: 60px; text-align: right; }
81
+ .findings-toggle { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 8px; border-radius: var(--radius-sm); }
82
+ .findings-toggle:hover { background: var(--bg-hover); }
83
+ .findings-list { display: none; padding: 8px 0 8px 32px; }
84
+ .findings-list.open { display: block; }
85
+ .finding-item { padding: 4px 0; font-size: 13px; color: var(--text-muted); border-left: 2px solid var(--border); padding-left: 12px; margin-bottom: 4px; }
86
+ .finding-file { color: var(--text-dim); font-size: 11px; }
87
+ .finding-sev { font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; }
88
+ .finding-sev.error { background: var(--error-light); color: var(--error); }
89
+ .finding-sev.warning { background: var(--warning-light); color: var(--warning); }
90
+
91
+ /* Expander rows */
92
+ .expander-row { display: none; }
93
+ .expander-row.open { display: table-row; }
94
+ .expander-row td { white-space: normal; }
95
+ .expander-content { padding: 8px 12px 16px 36px; background: var(--bg); border-left: 2px solid var(--accent); margin-left: 12px; }
96
+ .data-table tr.expanded td:first-child { color: var(--accent); }
97
+ .data-table tr.clickable:hover td:first-child { color: var(--accent); }
98
+
99
+ .badge { font-size: 11px; padding: 2px 8px; border-radius: 3px; font-weight: 500; display: inline-block; }
100
+ .badge-pass { background: var(--success-light); color: var(--success); }
101
+ .badge-fail { background: var(--error-light); color: var(--error); }
102
+ .badge-warn { background: var(--warning-light); color: var(--warning); }
103
+
104
+ /* Pagination */
105
+ .pagination { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; margin-top: 8px; }
106
+ .pagination-info { font-size: 12px; color: var(--text-dim); }
107
+ .pagination-btns { display: flex; gap: 4px; }
108
+ .pagination-btn { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 12px; color: var(--text-muted); font-size: 12px; cursor: pointer; }
109
+ .pagination-btn:hover { background: var(--bg-hover); color: var(--text); }
110
+ .pagination-btn.disabled { opacity: 0.3; cursor: default; pointer-events: none; }
111
+ .pagination-btn.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
112
+
113
+ .footer { color: var(--text-dim); font-size: 12px; text-align: center; padding: 24px 0; border-top: 1px solid var(--border); margin-top: 32px; }
114
+ .footer a { color: var(--accent); text-decoration: none; }
115
+
116
+ /* Tag badges */
117
+ .tag-badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--bg-hover); color: var(--text-muted); display: inline-block; margin-right: 3px; margin-bottom: 2px; white-space: nowrap; }
118
+
119
+ /* Confidence badges */
120
+ .badge-high { background: rgba(143,188,143,0.2); color: var(--success); }
121
+ .badge-medium { background: rgba(212,165,116,0.2); color: var(--warning); }
122
+ .badge-low { background: rgba(199,91,74,0.15); color: var(--text-dim); }
123
+
124
+ /* Search & filter bar */
125
+ .filter-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
126
+ .search-input { background: var(--bg-surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 10px; font-size: 13px; font-family: var(--font); width: 240px; }
127
+ .search-input::placeholder { color: var(--text-dim); }
128
+ .search-input:focus { outline: none; border-color: var(--accent); }
129
+ .filter-select { background: var(--bg-surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: 12px; cursor: pointer; font-family: var(--font); }
130
+
131
+ /* Check long description */
132
+ .check-long-desc { padding: 12px 16px; color: var(--text-muted); font-size: 13px; line-height: 1.7; max-width: 800px; }
133
+ .check-long-desc strong { color: var(--text); font-weight: 600; }
134
+ .check-long-desc code { background: var(--bg-hover); padding: 1px 4px; border-radius: 2px; font-size: 12px; }
135
+
136
+ /* Pass rate bar */
137
+ .pass-rate-bar { display: inline-flex; align-items: center; gap: 6px; }
138
+ .pass-rate-track { width: 48px; height: 6px; border-radius: 3px; background: var(--bg-hover); overflow: hidden; display: inline-block; vertical-align: middle; }
139
+ .pass-rate-fill { height: 6px; border-radius: 3px; display: block; }
140
+ `;
141
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Dashboard HTML generator — orchestrates all modules into a single self-contained HTML file.
3
+ *
4
+ * Composes CSS, shared JS, and tab-specific JS into one HTML string with all data inlined.
5
+ * Each tool tab (Fitness, Simulation) has subtabs: Overview, Catalog, Recipes.
6
+ */
7
+
8
+
9
+ import { dashboardChecksJs } from './checks.js'
10
+ import { dashboardCss } from './css.js'
11
+ import { dashboardOverviewJs } from './overview.js'
12
+ import { dashboardRecipesJs } from './recipes.js'
13
+ import { dashboardSessionsJs } from './sessions.js'
14
+ import { dashboardSharedJs } from './shared.js'
15
+ import { dashboardToolTabsJs } from './tool-tabs.js'
16
+
17
+ import type { StoredSession, CheckCatalogEntry, RecipeCatalogEntry } from '../store.js'
18
+
19
+ // Escape all < and > to prevent script injection in HTML <script> context
20
+ function escapeForScriptContext(json: string): string {
21
+ return json.replaceAll('<', String.raw`\u003c`).replaceAll('>', String.raw`\u003e`)
22
+ }
23
+
24
+ export function generateDashboardHtml(
25
+ sessions: StoredSession[],
26
+ checkCatalog: CheckCatalogEntry[] = [],
27
+ recipeCatalog: RecipeCatalogEntry[] = [],
28
+ ): string {
29
+ const latest = sessions[0]
30
+ const safeDataJson = escapeForScriptContext(JSON.stringify(sessions))
31
+ const safeCatalogJson = escapeForScriptContext(JSON.stringify(checkCatalog))
32
+ const safeRecipeJson = escapeForScriptContext(JSON.stringify(recipeCatalog))
33
+
34
+ return `<!DOCTYPE html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <title>OpenSIP Tools${latest ? ` — Pass Rate: ${latest.score}%` : ''}</title>
40
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='M4 8h12a4 4 0 0 1 0 8h-1M4 8v8a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V8M4 8H2M6 4c0 1 .5 2 1 2.5M10 3c0 1.5.5 2.5 1 3M14 4c0 1 .5 2 1 2.5' stroke='%23c4956a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
41
+ <link rel="preconnect" href="https://fonts.googleapis.com">
42
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&display=swap" rel="stylesheet">
43
+ <style>
44
+ ${dashboardCss()}
45
+ </style>
46
+ </head>
47
+ <body>
48
+
49
+ <div class="header">
50
+ <span class="header-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none"><path d="M4 8h12a4 4 0 0 1 0 8h-1M4 8v8a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V8M4 8H2M6 4c0 1 .5 2 1 2.5M10 3c0 1.5.5 2.5 1 3M14 4c0 1 .5 2 1 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
51
+ <div><h1><span class="brand-open">Open</span>SIP Tools</h1></div>
52
+ </div>
53
+
54
+ <div class="tab-bar" id="tab-bar">
55
+ <div class="tab active" data-tab="overview"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> Overview</div>
56
+ <div class="tab" data-tab="fitness"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.596 12.768a2 2 0 1 0 2.829-2.829l-1.768-1.767a2 2 0 0 0 2.828-2.829l-2.828-2.828a2 2 0 0 0-2.829 2.828l-1.767-1.768a2 2 0 1 0-2.829 2.829z"/><path d="m2.5 21.5 1.4-1.4"/><path d="m20.1 3.9 1.4-1.4"/><path d="M5.343 21.485a2 2 0 1 0 2.829-2.828l1.767 1.768a2 2 0 1 0 2.829-2.829l-6.364-6.364a2 2 0 1 0-2.829 2.829l1.768 1.767a2 2 0 0 0-2.828 2.829z"/><path d="m9.6 14.4 4.8-4.8"/></svg> Fitness</div>
57
+ <div class="tab" data-tab="simulation"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> Simulation</div>
58
+ </div>
59
+
60
+ <div id="panel-overview" class="tab-panel active"></div>
61
+ <div id="panel-fitness" class="tab-panel"></div>
62
+ <div id="panel-simulation" class="tab-panel"></div>
63
+
64
+ <div class="footer">Generated by <strong>opensip-tools</strong> &mdash; <a href="https://opensip.ai">opensip.ai</a></div>
65
+
66
+ <script>
67
+ const sessions = ${safeDataJson};
68
+ const checkCatalog = ${safeCatalogJson};
69
+ const recipeCatalog = ${safeRecipeJson};
70
+ const fitSessions = sessions.filter(s => s.tool === 'fit');
71
+ const simSessions = sessions.filter(s => s.tool === 'sim');
72
+
73
+ ${dashboardSharedJs()}
74
+ ${dashboardOverviewJs()}
75
+ ${dashboardSessionsJs()}
76
+ ${dashboardChecksJs()}
77
+ ${dashboardRecipesJs()}
78
+ ${dashboardToolTabsJs()}
79
+
80
+ // =======================================================
81
+ // RENDER TABS
82
+ // =======================================================
83
+ renderOverview();
84
+ renderFitnessTab();
85
+ renderSimulationTab();
86
+ </script>
87
+ </body>
88
+ </html>`
89
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Dashboard module barrel — re-exports the generator function.
3
+ */
4
+
5
+ export { generateDashboardHtml } from './generator.js'
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Overview tab — cross-tool recent activity table.
3
+ * Returns JS code as a string.
4
+ */
5
+
6
+ export function dashboardOverviewJs(): string {
7
+ return `
8
+ // =======================================================
9
+ // OVERVIEW TAB
10
+ // =======================================================
11
+ function renderOverview() {
12
+ const panel = document.getElementById('panel-overview');
13
+ if (!sessions.length) { panel.appendChild(el('div', {class:'empty', text:'No sessions yet. Run opensip-tools fit to generate data.'})); return; }
14
+
15
+ const sec = el('div', {class:'section'}, [el('h3', {text:'Recent Activity'})]);
16
+ const table = el('table', {class:'data-table sortable'});
17
+ const thead = el('thead');
18
+ const headerRow = el('tr');
19
+ ['Timestamp', 'Tool', 'Recipe', 'Pass Rate', 'Status', 'Checks', 'Findings', 'Duration'].forEach(h => {
20
+ headerRow.appendChild(el('th', {text: h}));
21
+ });
22
+ thead.appendChild(headerRow);
23
+ table.appendChild(thead);
24
+
25
+ const tbody = el('tbody');
26
+ const toolBadgeStyles = {
27
+ fit: 'background:rgba(124,160,104,0.15);color:var(--accent-fitness)',
28
+ sim: 'background:rgba(155,138,165,0.15);color:var(--accent-sim)',
29
+ };
30
+ const tabMap = { fit: 'fitness', sim: 'simulation' };
31
+
32
+ sessions.forEach(s => {
33
+ const sc2 = s.score >= 90 ? 'color:var(--success)' : s.score >= 70 ? 'color:var(--warning)' : 'color:var(--error)';
34
+ const row = el('tr', {class:'clickable', onclick: () => {
35
+ // Navigate to the tool's tab
36
+ const tabName = tabMap[s.tool] || s.tool;
37
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
38
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
39
+ const tab = document.querySelector('.tab[data-tab="' + tabName + '"]');
40
+ if (tab) tab.classList.add('active');
41
+ const panel = document.getElementById('panel-' + tabName);
42
+ if (panel) panel.classList.add('active');
43
+ }});
44
+ row.appendChild(el('td', {text: new Date(s.timestamp).toLocaleString(), style:'color:var(--text-dim)'}));
45
+ const toolCell = el('td');
46
+ toolCell.appendChild(el('span', {class:'badge', style: toolBadgeStyles[s.tool] || '', text: s.tool.toUpperCase()}));
47
+ row.appendChild(toolCell);
48
+ row.appendChild(el('td', {text: s.recipe || 'default', style:'color:var(--text-muted)'}));
49
+ row.appendChild(el('td', {text: s.score+'%', style:'font-weight:600;'+sc2}));
50
+ const statusCell = el('td');
51
+ statusCell.appendChild(statusBadge(sessionStatus(s)));
52
+ row.appendChild(statusCell);
53
+ row.appendChild(el('td', {text: s.summary.passed+'/'+s.summary.total}));
54
+ row.appendChild(el('td', {text: ''+(s.summary.errors + (s.summary.warnings || 0))}));
55
+ row.appendChild(el('td', {text: (s.durationMs/1000).toFixed(1)+'s', style:'color:var(--text-dim)'}));
56
+ tbody.appendChild(row);
57
+ });
58
+ table.appendChild(tbody);
59
+ const pag = el('div', {class:'pagination'});
60
+ sec.appendChild(el('div', {class:'card'}, [table, pag]));
61
+ panel.appendChild(sec);
62
+ paginateTable(tbody, pag, 10);
63
+ }
64
+ `;
65
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Recipes catalog rendering — shows available recipes with their configuration.
3
+ * Returns JS code as a string.
4
+ */
5
+
6
+ export function dashboardRecipesJs(): string {
7
+ return `
8
+ // =======================================================
9
+ // RECIPES CATALOG
10
+ // =======================================================
11
+
12
+ function renderRecipesPanel(container, recipesData) {
13
+ if (!recipesData || !recipesData.length) {
14
+ container.appendChild(el('div', {class:'empty', text:'No recipes available.'}));
15
+ return;
16
+ }
17
+
18
+ const table = el('table', {class:'data-table'});
19
+ const thead = el('thead');
20
+ const headerRow = el('tr');
21
+ ['Recipe', 'Description', 'Selector', 'Mode', 'Timeout', 'Tags'].forEach(h => {
22
+ headerRow.appendChild(el('th', {text: h}));
23
+ });
24
+ thead.appendChild(headerRow);
25
+ table.appendChild(thead);
26
+
27
+ const tbody = el('tbody');
28
+ recipesData.forEach(recipe => {
29
+ const row = el('tr');
30
+
31
+ // Name
32
+ const nameCell = el('td', {style:'font-weight:500'});
33
+ nameCell.appendChild(el('div', {text: recipe.displayName}));
34
+ nameCell.appendChild(el('div', {text: recipe.name, style:'font-size:11px;color:var(--text-dim);font-weight:400'}));
35
+ row.appendChild(nameCell);
36
+
37
+ // Description
38
+ row.appendChild(el('td', {text: recipe.description, style:'color:var(--text-muted)'}));
39
+
40
+ // Selector type
41
+ const selCell = el('td');
42
+ selCell.appendChild(el('span', {class:'badge', style:'background:var(--bg-hover);color:var(--text-muted)', text: recipe.selectorType}));
43
+ row.appendChild(selCell);
44
+
45
+ // Mode
46
+ const modeCell = el('td');
47
+ const modeColor = recipe.mode === 'parallel' ? 'color:var(--success)' : 'color:var(--warning)';
48
+ modeCell.appendChild(el('span', {text: recipe.mode, style: modeColor + ';font-size:12px'}));
49
+ row.appendChild(modeCell);
50
+
51
+ // Timeout
52
+ row.appendChild(el('td', {text: (recipe.timeout / 1000) + 's', style:'color:var(--text-dim);font-size:12px'}));
53
+
54
+ // Tags
55
+ const tagsCell = el('td');
56
+ (recipe.tags || []).forEach(t => {
57
+ tagsCell.appendChild(el('span', {class:'tag-badge', text: t}));
58
+ });
59
+ row.appendChild(tagsCell);
60
+
61
+ tbody.appendChild(row);
62
+ });
63
+
64
+ table.appendChild(tbody);
65
+ container.appendChild(el('div', {class:'card'}, [table]));
66
+ }
67
+ `;
68
+ }