htmlboard 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -3,6 +3,7 @@ import net from 'node:net';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { loadDesignsManifest } from './designs.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
@@ -31,210 +32,12 @@ const MIME_TYPES = {
31
32
  '.mp4': 'video/mp4'
32
33
  };
33
34
 
34
- const IGNORED_DIRS = new Set([
35
- 'node_modules',
36
- '.git',
37
- '.github',
38
- 'dist',
39
- 'build',
40
- '.vscode',
41
- '.idea',
42
- '.gemini',
43
- '.agents'
44
- ]);
45
-
46
- /**
47
- * Extract <title> content from HTML text if available
48
- */
49
- function extractTitleFromHtml(content, fallbackName) {
50
- const match = content.match(/<title[^>]*>([^<]+)<\/title>/i);
51
- if (match && match[1]?.trim()) {
52
- return match[1].trim();
53
- }
54
- return fallbackName
55
- .replace(/\.html$/i, '')
56
- .replace(/[-_]/g, ' ')
57
- .replace(/\b\w/g, (c) => c.toUpperCase());
58
- }
59
-
60
- /** Default frame size for each category */
61
- const CATEGORY_SIZES = {
62
- mobile: [390, 844],
63
- tablet: [768, 1024],
64
- components: [720, 600],
65
- desktop: [1280, 800],
66
- responsive: [1200, 800]
67
- };
68
-
69
- /**
70
- * Guess an artboard's category from words in its path, then from its width
71
- */
72
- export function inferCategory(file, width) {
73
- const lower = file.toLowerCase();
74
- if (/mobile|phone|iphone/.test(lower)) return 'mobile';
75
- if (/tablet|ipad/.test(lower)) return 'tablet';
76
- if (/component|\bui\b|atomic/.test(lower)) return 'components';
77
- if (/desktop|laptop|screen/.test(lower)) return 'desktop';
78
- if (width) {
79
- if (width <= 480) return 'mobile';
80
- if (width <= 1024) return 'tablet';
81
- return 'desktop';
82
- }
83
- return 'responsive';
84
- }
85
-
86
- function slugify(text) {
87
- return String(text).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
88
- .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
89
- }
90
-
91
35
  /** True when target is base itself or inside it */
92
36
  function isInside(base, target) {
93
37
  const rel = path.relative(base, target);
94
38
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
95
39
  }
96
40
 
97
- /**
98
- * Scan target directory recursively for HTML files to auto-discover
99
- */
100
- export function discoverDesigns(baseDir) {
101
- const artboards = [];
102
-
103
- function scan(currentDir, relativePath = '') {
104
- let entries;
105
- try {
106
- entries = fs.readdirSync(currentDir, { withFileTypes: true });
107
- } catch {
108
- return;
109
- }
110
-
111
- for (const entry of entries) {
112
- if (entry.name.startsWith('.') && entry.name !== '.') continue;
113
- if (IGNORED_DIRS.has(entry.name)) continue;
114
-
115
- const fullPath = path.join(currentDir, entry.name);
116
- const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
117
-
118
- if (entry.isDirectory()) {
119
- scan(fullPath, relPath);
120
- } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
121
- let title = '';
122
- let fileContent = '';
123
- try {
124
- fileContent = fs.readFileSync(fullPath, 'utf8');
125
- title = extractTitleFromHtml(fileContent, entry.name);
126
- } catch {
127
- title = entry.name;
128
- }
129
-
130
- const category = inferCategory(relPath);
131
- const [width, height] = CATEGORY_SIZES[category];
132
-
133
- const sectionName = relativePath ? relativePath.replace(/[\\/]/g, ' / ') : 'Root Designs';
134
-
135
- artboards.push({
136
- id: slugify(relPath.replace(/\.html$/i, '')),
137
- title,
138
- file: relPath.replace(/\\/g, '/'),
139
- category,
140
- width,
141
- height,
142
- sectionName,
143
- tags: [category]
144
- });
145
- }
146
- }
147
- }
148
-
149
- scan(baseDir);
150
-
151
- // Group into sections
152
- const sectionsMap = new Map();
153
- for (const board of artboards) {
154
- const sName = board.sectionName;
155
- if (!sectionsMap.has(sName)) {
156
- sectionsMap.set(sName, {
157
- id: sName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
158
- name: sName,
159
- description: `HTML artboards discovered in ${sName}`,
160
- artboards: []
161
- });
162
- }
163
- const { sectionName, ...rest } = board;
164
- sectionsMap.get(sName).artboards.push(rest);
165
- }
166
-
167
- const projectName = path.basename(path.resolve(baseDir)) || 'Design Explorer';
168
-
169
- return {
170
- title: projectName,
171
- description: 'Auto-discovered HTML artboards in workspace',
172
- autoDiscovered: true,
173
- sections: Array.from(sectionsMap.values())
174
- };
175
- }
176
-
177
- /**
178
- * Fill in what a manifest may leave out, so the explorer always gets
179
- * `sections[].{id, name, artboards[].{id, title, file, category}}`.
180
- * Accepts the short form too: `sections[].title` and `sections[].designs[]`
181
- * with only `{ file, title, width, height }`.
182
- * `prefix` is prepended to every file (the designs folder relative to the served root).
183
- */
184
- export function normalizeManifest(manifest, prefix = '') {
185
- const usedIds = new Set();
186
- const uniqueId = (wanted) => {
187
- let id = wanted || 'artboard';
188
- for (let n = 2; usedIds.has(id); n++) id = `${wanted}-${n}`;
189
- usedIds.add(id);
190
- return id;
191
- };
192
-
193
- const sections = (Array.isArray(manifest.sections) ? manifest.sections : []).map((section, i) => {
194
- const name = section.name ?? section.title ?? `Section ${i + 1}`;
195
- const boards = section.artboards ?? section.designs ?? [];
196
- return {
197
- ...section,
198
- id: section.id ?? (slugify(name) || `section-${i + 1}`),
199
- name,
200
- artboards: boards.filter((board) => board && board.file).map((board) => {
201
- const file = prefix ? path.posix.join(prefix, board.file) : board.file;
202
- return {
203
- ...board,
204
- id: uniqueId(board.id ?? slugify(board.file.replace(/\.html?(?=$|\?)/i, ''))),
205
- title: board.title ?? board.file,
206
- file,
207
- category: board.category ?? inferCategory(board.file.split('?')[0], board.width)
208
- };
209
- })
210
- };
211
- });
212
-
213
- return { ...manifest, sections };
214
- }
215
-
216
- /**
217
- * Load designs configuration from designs.json or auto-discover.
218
- * With `rootDir` (a folder containing baseDir), artboard paths are given relative to it.
219
- */
220
- export function loadDesignsManifest(baseDir, rootDir = baseDir) {
221
- const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
222
- const configPath = path.join(baseDir, 'designs.json');
223
- if (fs.existsSync(configPath)) {
224
- try {
225
- const raw = fs.readFileSync(configPath, 'utf8');
226
- const data = JSON.parse(raw);
227
- return {
228
- ...normalizeManifest(data, prefix),
229
- autoDiscovered: false
230
- };
231
- } catch (err) {
232
- console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Falling back to auto-discovery.`);
233
- }
234
- }
235
- return normalizeManifest(discoverDesigns(baseDir), prefix);
236
- }
237
-
238
41
  /** Folders the watcher leaves alone: hidden ones (.git, .vscode, …) and dependencies */
239
42
  const skipWatching = (name) => name.startsWith('.') || name === 'node_modules';
240
43
 
@@ -4,30 +4,11 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Desktop Dashboard</title>
7
+ <meta name="description" content="Main analytics dashboard with sidebar, metric cards, and charts">
8
+ <meta name="htmlboard" content="width=1280, height=820">
9
+ <link rel="stylesheet" href="../styles.css">
7
10
  <style>
8
- * {
9
- box-sizing: border-box;
10
- margin: 0;
11
- padding: 0;
12
- -webkit-font-smoothing: antialiased;
13
- }
14
- :root {
15
- --bg: #0b0d13;
16
- --sidebar-bg: #10131d;
17
- --panel-bg: #151824;
18
- --border: #23283c;
19
- --accent: #6366f1;
20
- --accent-light: #818cf8;
21
- --text: #f1f5f9;
22
- --text-muted: #94a3b8;
23
- --emerald: #10b981;
24
- --amber: #f59e0b;
25
- --purple: #a855f7;
26
- }
27
11
  body {
28
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
29
- background: var(--bg);
30
- color: var(--text);
31
12
  display: flex;
32
13
  height: 100vh;
33
14
  overflow: hidden;
@@ -35,7 +16,7 @@
35
16
  /* Sidebar */
36
17
  .sidebar {
37
18
  width: 240px;
38
- background: var(--sidebar-bg);
19
+ background: #10131d;
39
20
  border-right: 1px solid var(--border);
40
21
  display: flex;
41
22
  flex-direction: column;
@@ -43,10 +24,8 @@
43
24
  flex-shrink: 0;
44
25
  }
45
26
  .logo-container {
46
- display: flex;
47
- align-items: center;
48
- gap: 10px;
49
27
  padding: 4px 8px 24px;
28
+ gap: 10px;
50
29
  font-weight: 700;
51
30
  font-size: 17px;
52
31
  letter-spacing: -0.02em;
@@ -54,8 +33,8 @@
54
33
  .logo-badge {
55
34
  width: 32px;
56
35
  height: 32px;
57
- background: linear-gradient(135deg, #6366f1, #3b82f6);
58
- border-radius: 8px;
36
+ background: linear-gradient(135deg, var(--accent), #3b82f6);
37
+ border-radius: var(--radius);
59
38
  display: flex;
60
39
  align-items: center;
61
40
  justify-content: center;
@@ -68,22 +47,14 @@
68
47
  gap: 4px;
69
48
  flex: 1;
70
49
  }
71
- .nav-label {
72
- font-size: 11px;
73
- text-transform: uppercase;
74
- letter-spacing: 0.08em;
75
- color: #64748b;
76
- padding: 8px 12px 4px;
77
- font-weight: 600;
78
- }
50
+ .nav-group .label { padding: 8px 12px 4px; font-size: 11px; }
79
51
  .nav-link {
80
52
  display: flex;
81
53
  align-items: center;
82
54
  gap: 12px;
83
55
  padding: 10px 12px;
84
- border-radius: 8px;
56
+ border-radius: var(--radius);
85
57
  color: var(--text-muted);
86
- text-decoration: none;
87
58
  font-size: 14px;
88
59
  font-weight: 500;
89
60
  transition: all 0.15s ease;
@@ -98,25 +69,12 @@
98
69
  font-weight: 600;
99
70
  }
100
71
  .user-footer {
101
- display: flex;
102
- align-items: center;
103
- gap: 12px;
104
72
  padding: 12px;
105
73
  border-radius: 10px;
106
74
  background: rgba(255, 255, 255, 0.03);
107
75
  border: 1px solid var(--border);
108
76
  }
109
- .user-avatar {
110
- width: 36px;
111
- height: 36px;
112
- border-radius: 50%;
113
- background: linear-gradient(135deg, #ec4899, #8b5cf6);
114
- display: flex;
115
- align-items: center;
116
- justify-content: center;
117
- font-weight: 600;
118
- font-size: 14px;
119
- }
77
+ .user-footer .avatar { background: linear-gradient(135deg, #ec4899, #8b5cf6); font-weight: 600; }
120
78
  /* Main Content */
121
79
  .main {
122
80
  flex: 1;
@@ -124,7 +82,6 @@
124
82
  flex-direction: column;
125
83
  overflow-y: auto;
126
84
  }
127
- /* Top Header */
128
85
  .topbar {
129
86
  height: 64px;
130
87
  border-bottom: 1px solid var(--border);
@@ -135,114 +92,51 @@
135
92
  background: rgba(11, 13, 19, 0.8);
136
93
  backdrop-filter: blur(12px);
137
94
  }
138
- .search-input {
139
- background: var(--panel-bg);
140
- border: 1px solid var(--border);
141
- padding: 8px 16px 8px 36px;
142
- border-radius: 8px;
143
- color: var(--text);
144
- font-size: 13px;
145
- width: 280px;
146
- position: relative;
147
- }
148
- .header-actions {
149
- display: flex;
150
- align-items: center;
151
- gap: 16px;
152
- }
153
- .primary-btn {
154
- background: var(--accent);
155
- color: #fff;
156
- border: none;
157
- padding: 8px 18px;
158
- border-radius: 8px;
159
- font-size: 13px;
160
- font-weight: 600;
161
- cursor: pointer;
162
- box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
163
- }
164
- /* Dashboard Area */
95
+ .topbar .input { width: 280px; padding: 8px 16px; }
96
+ .topbar .row { gap: 16px; font-size: 13px; }
165
97
  .content {
166
98
  padding: 28px 32px;
167
99
  display: flex;
168
100
  flex-direction: column;
169
101
  gap: 24px;
170
102
  }
171
- .page-header {
172
- display: flex;
173
- justify-content: space-between;
174
- align-items: flex-end;
175
- }
176
103
  .page-title {
177
104
  font-size: 24px;
178
105
  font-weight: 700;
179
106
  letter-spacing: -0.02em;
180
107
  }
181
- .page-sub {
182
- color: var(--text-muted);
183
- font-size: 14px;
184
- margin-top: 4px;
185
- }
108
+ .page-sub { font-size: 14px; margin-top: 4px; }
186
109
  /* Stat Cards */
187
110
  .stats-row {
188
111
  display: grid;
189
112
  grid-template-columns: repeat(4, 1fr);
190
113
  gap: 20px;
191
114
  }
192
- .stat-card {
193
- background: var(--panel-bg);
194
- border: 1px solid var(--border);
195
- border-radius: 14px;
196
- padding: 20px;
197
- }
198
- .stat-title {
199
- font-size: 13px;
200
- color: var(--text-muted);
201
- margin-bottom: 8px;
202
- }
115
+ .stat-title { font-size: 13px; margin-bottom: 8px; }
203
116
  .stat-val {
204
117
  font-size: 26px;
205
118
  font-weight: 700;
206
119
  margin-bottom: 8px;
207
120
  }
208
- .stat-badge {
209
- font-size: 12px;
210
- font-weight: 600;
211
- display: inline-flex;
212
- align-items: center;
213
- gap: 4px;
214
- }
121
+ .stat-badge { font-size: 12px; font-weight: 600; }
215
122
  .stat-badge.pos { color: var(--emerald); }
216
123
  .stat-badge.warn { color: var(--amber); }
217
-
218
- /* Visual Grid */
124
+ /* Panels */
219
125
  .grid-2 {
220
126
  display: grid;
221
127
  grid-template-columns: 2fr 1fr;
222
128
  gap: 20px;
223
129
  }
224
- .panel {
225
- background: var(--panel-bg);
226
- border: 1px solid var(--border);
227
- border-radius: 14px;
228
- padding: 22px;
229
- }
130
+ .grid-2 .card { padding: 22px; }
230
131
  .panel-header {
231
132
  display: flex;
232
133
  justify-content: space-between;
233
134
  align-items: center;
234
135
  margin-bottom: 20px;
136
+ font-size: 12px;
235
137
  }
236
- .panel-title {
237
- font-size: 16px;
238
- font-weight: 600;
239
- }
240
- /* Chart SVG */
241
- .chart-container {
242
- width: 100%;
243
- height: 180px;
244
- }
245
- /* Table */
138
+ .panel-title { font-size: 16px; font-weight: 600; }
139
+ .chart-container { width: 100%; height: 180px; }
246
140
  .data-table {
247
141
  width: 100%;
248
142
  border-collapse: collapse;
@@ -250,7 +144,7 @@
250
144
  }
251
145
  .data-table th {
252
146
  text-align: left;
253
- color: #64748b;
147
+ color: var(--text-subtle);
254
148
  font-weight: 500;
255
149
  padding-bottom: 12px;
256
150
  border-bottom: 1px solid var(--border);
@@ -259,90 +153,80 @@
259
153
  padding: 12px 0;
260
154
  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
261
155
  }
262
- .status-pill {
263
- display: inline-block;
264
- padding: 3px 8px;
265
- border-radius: 12px;
266
- font-size: 11px;
267
- font-weight: 600;
268
- }
269
- .status-pill.success { background: rgba(16, 185, 129, 0.15); color: var(--emerald); }
270
- .status-pill.pending { background: rgba(245, 158, 11, 0.15); color: var(--amber); }
156
+ .data-table .badge { padding: 3px 8px; font-size: 11px; }
271
157
  </style>
272
158
  </head>
273
159
  <body>
274
160
  <aside class="sidebar">
275
- <div class="logo-container">
161
+ <div class="logo-container row">
276
162
  <div class="logo-badge">⚡</div>
277
163
  <span>Nexus Platform</span>
278
164
  </div>
279
165
 
280
166
  <nav class="nav-group">
281
- <div class="nav-label">Core</div>
167
+ <div class="label">Core</div>
282
168
  <a href="#" class="nav-link active">📊 Dashboard</a>
283
169
  <a href="#" class="nav-link">👥 Customers</a>
284
170
  <a href="#" class="nav-link">💳 Invoices</a>
285
171
  <a href="#" class="nav-link">📈 Analytics</a>
286
172
 
287
- <div class="nav-label" style="margin-top: 16px;">System</div>
173
+ <div class="label" style="margin-top: 16px">System</div>
288
174
  <a href="#" class="nav-link">⚙️ Settings</a>
289
175
  <a href="#" class="nav-link">🛡️ Security</a>
290
176
  </nav>
291
177
 
292
- <div class="user-footer">
293
- <div class="user-avatar">AD</div>
178
+ <div class="user-footer row">
179
+ <div class="avatar">AD</div>
294
180
  <div>
295
- <div style="font-size: 13px; font-weight: 600;">Alex Morgan</div>
296
- <div style="font-size: 11px; color: var(--text-muted);">alex@nexus.io</div>
181
+ <div style="font-size: 13px; font-weight: 600">Alex Morgan</div>
182
+ <div class="muted" style="font-size: 11px">alex@nexus.io</div>
297
183
  </div>
298
184
  </div>
299
185
  </aside>
300
186
 
301
187
  <main class="main">
302
188
  <header class="topbar">
303
- <input type="text" class="search-input" placeholder="Search resources, clients, reports...">
304
- <div class="header-actions">
305
- <span style="font-size: 13px; color: var(--text-muted);">Last sync: Just now</span>
306
- <button class="primary-btn">+ New Deployment</button>
189
+ <input type="text" class="input" placeholder="Search resources, clients, reports...">
190
+ <div class="row">
191
+ <span class="muted">Last sync: Just now</span>
192
+ <button class="btn btn-primary">+ New Deployment</button>
307
193
  </div>
308
194
  </header>
309
195
 
310
196
  <div class="content">
311
- <div class="page-header">
312
- <div>
313
- <h1 class="page-title">Executive Overview</h1>
314
- <p class="page-sub">Performance metrics and active client deployments</p>
315
- </div>
197
+ <div>
198
+ <h1 class="page-title">Executive Overview</h1>
199
+ <p class="page-sub muted">Performance metrics and active client deployments</p>
316
200
  </div>
317
201
 
318
202
  <div class="stats-row">
319
- <div class="stat-card">
320
- <div class="stat-title">Monthly Recurring Revenue</div>
203
+ <div class="card">
204
+ <div class="stat-title muted">Monthly Recurring Revenue</div>
321
205
  <div class="stat-val">$184,290</div>
322
206
  <div class="stat-badge pos">▲ +12.4% vs last mo</div>
323
207
  </div>
324
- <div class="stat-card">
325
- <div class="stat-title">Active Organizations</div>
208
+ <div class="card">
209
+ <div class="stat-title muted">Active Organizations</div>
326
210
  <div class="stat-val">1,428</div>
327
211
  <div class="stat-badge pos">▲ +48 new</div>
328
212
  </div>
329
- <div class="stat-card">
330
- <div class="stat-title">API Requests (24h)</div>
213
+ <div class="card">
214
+ <div class="stat-title muted">API Requests (24h)</div>
331
215
  <div class="stat-val">42.8M</div>
332
216
  <div class="stat-badge pos">▲ 99.98% uptime</div>
333
217
  </div>
334
- <div class="stat-card">
335
- <div class="stat-title">Average Latency</div>
218
+ <div class="card">
219
+ <div class="stat-title muted">Average Latency</div>
336
220
  <div class="stat-val">34ms</div>
337
221
  <div class="stat-badge warn">▼ -2ms target</div>
338
222
  </div>
339
223
  </div>
340
224
 
341
225
  <div class="grid-2">
342
- <div class="panel">
226
+ <div class="card">
343
227
  <div class="panel-header">
344
228
  <h2 class="panel-title">Throughput & Workload Trends</h2>
345
- <span style="font-size: 12px; color: var(--text-muted);">Trailing 7 Days</span>
229
+ <span class="muted">Trailing 7 Days</span>
346
230
  </div>
347
231
  <div class="chart-container">
348
232
  <svg viewBox="0 0 500 150" width="100%" height="100%" preserveAspectRatio="none">
@@ -359,7 +243,7 @@
359
243
  </div>
360
244
  </div>
361
245
 
362
- <div class="panel">
246
+ <div class="card">
363
247
  <div class="panel-header">
364
248
  <h2 class="panel-title">Recent Audits</h2>
365
249
  </div>
@@ -373,19 +257,19 @@
373
257
  <tbody>
374
258
  <tr>
375
259
  <td>us-east-cluster-01</td>
376
- <td><span class="status-pill success">Optimal</span></td>
260
+ <td><span class="badge badge-emerald">Optimal</span></td>
377
261
  </tr>
378
262
  <tr>
379
263
  <td>auth-gateway-proxy</td>
380
- <td><span class="status-pill success">Optimal</span></td>
264
+ <td><span class="badge badge-emerald">Optimal</span></td>
381
265
  </tr>
382
266
  <tr>
383
267
  <td>edge-cache-frankfurt</td>
384
- <td><span class="status-pill pending">Rebalancing</span></td>
268
+ <td><span class="badge badge-amber">Rebalancing</span></td>
385
269
  </tr>
386
270
  <tr>
387
271
  <td>db-replica-primary</td>
388
- <td><span class="status-pill success">Optimal</span></td>
272
+ <td><span class="badge badge-emerald">Optimal</span></td>
389
273
  </tr>
390
274
  </tbody>
391
275
  </table>