htmlboard 0.4.0 → 0.5.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/designs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { COMPONENTS_FOLDER, PREVIEW_WIDTH, isPage, isTagName } from './components.js';
3
4
 
4
5
  // Which designs a folder holds, and how the canvas shows them.
5
6
  //
@@ -16,6 +17,9 @@ import path from 'node:path';
16
17
  // A designs.json beside them is optional and only curates: sections with their titles, the order,
17
18
  // overrides for a design's values. Designs it doesn't list come after the ones it does (unless it
18
19
  // says "unlisted": "hide").
20
+ //
21
+ // The components in components/ (pieces of pages, see components.js) aren't designs. A designs.json
22
+ // can show them anyway, each in a frame of its own: "components": "show", or listing them.
19
23
 
20
24
  /** Folders discovery leaves alone (hidden ones too) */
21
25
  const IGNORED_DIRS = new Set(['node_modules', 'dist', 'build']);
@@ -93,7 +97,8 @@ function decode(text) {
93
97
  });
94
98
  }
95
99
 
96
- function attributes(tag) {
100
+ /** The attributes of an HTML tag (`<meta name="x" content="y">`), names in lowercase, values decoded */
101
+ export function attributes(tag) {
97
102
  const attrs = {};
98
103
  for (const m of tag.matchAll(/([^\s"'<>/=]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
99
104
  attrs[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? '');
@@ -127,7 +132,7 @@ function parseVariant(content) {
127
132
  export function parseDesignMeta(html) {
128
133
  const end = html.search(/<\/head\s*>|<body[\s>]/i);
129
134
  const head = end >= 0 ? html.slice(0, end) : html.slice(0, 65536);
130
- const meta = { variants: [] };
135
+ const meta = { variants: [], page: isPage(html) };
131
136
  const title = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
132
137
  if (title) {
133
138
  const text = decode(title[1]).replace(/\s+/g, ' ').trim();
@@ -203,7 +208,8 @@ function variantDesign(base, { query, label }) {
203
208
 
204
209
  /**
205
210
  * Every design in the folder, in order: the files of a folder by name (number prefixes order
206
- * them), each followed by its variants, then its subfolders by name.
211
+ * them), each followed by its variants, then its subfolders by name. The components in
212
+ * components/ are left out (a whole page there is still a design).
207
213
  */
208
214
  export function scanDesigns(baseDir) {
209
215
  const designs = [];
@@ -219,7 +225,7 @@ export function scanDesigns(baseDir) {
219
225
  if (!entry.isFile() || !/\.html?$/i.test(entry.name)) continue;
220
226
  const file = folder ? `${folder}/${entry.name}` : entry.name;
221
227
  const meta = readDesignMeta(path.join(dir, entry.name));
222
- if (!meta || meta.skip) continue;
228
+ if (!meta || meta.skip || (folder === COMPONENTS_FOLDER && !meta.page)) continue;
223
229
  const base = fileDesign(file, folder, meta);
224
230
  designs.push(base);
225
231
  for (const variant of meta.variants) designs.push(variantDesign(base, variant));
@@ -232,6 +238,43 @@ export function scanDesigns(baseDir) {
232
238
  return designs;
233
239
  }
234
240
 
241
+ /**
242
+ * The components in the folder's components/, by name, as designs that show each one alone (its
243
+ * file's example, at components/<tag>.html?preview). For designs.json, which can show them.
244
+ * A component's file can give its preview's size with the same tag as a design.
245
+ */
246
+ export function scanComponents(baseDir) {
247
+ const dir = path.join(baseDir, COMPONENTS_FOLDER);
248
+ let entries;
249
+ try {
250
+ entries = fs.readdirSync(dir, { withFileTypes: true });
251
+ } catch {
252
+ return [];
253
+ }
254
+ return entries
255
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.html') && isTagName(entry.name.slice(0, -5)))
256
+ .sort((a, b) => byName(a.name, b.name))
257
+ .flatMap((entry) => {
258
+ const meta = readDesignMeta(path.join(dir, entry.name));
259
+ if (!meta || meta.skip || meta.page) return [];
260
+ const tag = entry.name.slice(0, -5);
261
+ const file = `${COMPONENTS_FOLDER}/${entry.name}`;
262
+ return [compact({
263
+ key: file,
264
+ file: `${file}?preview`,
265
+ folder: COMPONENTS_FOLDER,
266
+ id: designId(file),
267
+ title: `<${tag}>`,
268
+ description: meta.description,
269
+ width: meta.width || PREVIEW_WIDTH,
270
+ height: meta.height,
271
+ fit: !meta.height || undefined, // the frame is as tall as what it shows, however short
272
+ category: 'components',
273
+ component: true
274
+ })];
275
+ });
276
+ }
277
+
235
278
  /** Designs grouped into one section per folder, in order (`rootName` for the files at the top) */
236
279
  function groupByFolder(designs, rootName = '') {
237
280
  const groups = new Map();
@@ -274,8 +317,13 @@ function resolveEntry(entry, byKey) {
274
317
  return design;
275
318
  }
276
319
 
277
- /** designs.json's sections filled from the files, then the designs it doesn't list */
278
- function curate(config, found) {
320
+ /**
321
+ * designs.json's sections filled from the files, then the designs it doesn't list. Components
322
+ * (see scanComponents) show where it lists them, and all of them with "components": "show".
323
+ */
324
+ function curate(config, designs, components = []) {
325
+ const showComponents = config.components === 'show' || config.components === true;
326
+ const found = [...designs, ...components];
279
327
  const byKey = new Map(found.map((d) => [d.key, d]));
280
328
  const sources = (Array.isArray(config.sections) ? config.sections : []).map((section) => ({
281
329
  section,
@@ -313,14 +361,16 @@ function curate(config, found) {
313
361
  owners.set(folders[0], owners.has(folders[0]) && owners.get(folders[0]) !== i ? -1 : i);
314
362
  });
315
363
 
316
- const unlisted = found.filter((d) => !used.has(d.key));
364
+ const unlisted = found.filter((d) => !used.has(d.key) && (!d.component || showComponents));
317
365
  const rest = [];
318
366
  for (const design of unlisted) {
319
367
  const owner = owners.get(design.folder ?? '');
320
368
  if (owner !== undefined && owner >= 0) sections[owner].artboards.push(design);
321
- else rest.push(design);
369
+ else if (design.component || config.unlisted !== 'hide') rest.push(design);
322
370
  }
323
- if (config.unlisted !== 'hide') sections.push(...groupByFolder(rest, 'Unlisted'));
371
+ // Without sections of its own, designs.json only sets things: the files at the top keep a first
372
+ // row without a title, as without designs.json.
373
+ sections.push(...groupByFolder(rest, sources.length ? 'Unlisted' : ''));
324
374
 
325
375
  const { sections: _sections, ...top } = config;
326
376
  return { ...top, sections };
@@ -343,7 +393,7 @@ function finalize(manifest, prefix = '') {
343
393
  id: section.id ?? (slugify(name) || (folder ? slugify(folder.split('/').map(stripPrefix).join('-')) : `section-${i + 1}`)),
344
394
  name,
345
395
  artboards: artboards.map((design) => {
346
- const { key, folder: _folder, baseTitle, variantOf, ...board } = design;
396
+ const { key, folder: _folder, baseTitle, variantOf, component, ...board } = design;
347
397
  const category = board.category ?? inferCategory(design.file.split('?')[0], board.width, board.height);
348
398
  return compact({
349
399
  ...board,
@@ -381,18 +431,16 @@ export function normalizeManifest(manifest, prefix = '') {
381
431
  */
382
432
  export function loadDesignsManifest(baseDir, rootDir = baseDir) {
383
433
  const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
434
+ const title = path.basename(path.resolve(baseDir)) || 'Designs';
384
435
  const found = scanDesigns(baseDir);
385
436
  const configPath = path.join(baseDir, 'designs.json');
386
437
  if (fs.existsSync(configPath)) {
387
438
  try {
388
439
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
389
- return { ...finalize(curate(config, found), prefix), autoDiscovered: false };
440
+ return { ...finalize(curate({ title, ...config }, found, scanComponents(baseDir)), prefix), autoDiscovered: false };
390
441
  } catch (err) {
391
442
  console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Showing the files without it.`);
392
443
  }
393
444
  }
394
- return {
395
- ...finalize({ title: path.basename(path.resolve(baseDir)) || 'Designs', sections: groupByFolder(found) }, prefix),
396
- autoDiscovered: true
397
- };
445
+ return { ...finalize({ title, sections: groupByFolder(found) }, prefix), autoDiscovered: true };
398
446
  }
package/src/index.js CHANGED
@@ -15,9 +15,27 @@ export {
15
15
  normalizeManifest,
16
16
  inferCategory,
17
17
  parseDesignMeta,
18
+ scanComponents,
18
19
  scanDesigns
19
20
  } from './designs.js';
20
21
 
22
+ export {
23
+ installComponents,
24
+ componentPreview,
25
+ COMPONENTS_FOLDER,
26
+ COMPONENTS_SCRIPT
27
+ } from './components.js';
28
+
29
+ export {
30
+ buildCatalog,
31
+ formatCatalog
32
+ } from './catalog.js';
33
+
34
+ export {
35
+ runCheck,
36
+ formatReport
37
+ } from './check.js';
38
+
21
39
  export {
22
40
  installSkills,
23
41
  uninstallSkills,
package/src/server.js CHANGED
@@ -2,8 +2,10 @@ import http from 'node:http';
2
2
  import net from 'node:net';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { loadDesignsManifest } from './designs.js';
8
+ import { componentPreview } from './components.js';
7
9
 
8
10
  const __filename = fileURLToPath(import.meta.url);
9
11
  const __dirname = path.dirname(__filename);
@@ -32,6 +34,50 @@ const MIME_TYPES = {
32
34
  '.mp4': 'video/mp4'
33
35
  };
34
36
 
37
+ // The probe (client/probe.js) notes what goes wrong while a page builds, for `htmlboard check`. It
38
+ // goes first in the pages the canvas shows in frames, before anything that could go wrong.
39
+ const PROBE = Buffer.from('<script src="/__explorer/probe.js"></script>');
40
+
41
+ /** A page with the probe first in it: in its <head>, else after <html> or <!DOCTYPE>, else first */
42
+ export function withProbe(page) {
43
+ const text = page.toString('latin1'); // a byte per character: positions in it are positions in page
44
+ for (const tag of [/<head\b[^>]*>/i, /<html\b[^>]*>/i, /<!doctype\b[^>]*>/i]) {
45
+ const match = tag.exec(text);
46
+ if (match) {
47
+ const at = match.index + match[0].length;
48
+ return Buffer.concat([page.subarray(0, at), PROBE, page.subarray(at)]);
49
+ }
50
+ }
51
+ return Buffer.concat([PROBE, page]);
52
+ }
53
+
54
+ function json(res, status, body) {
55
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
56
+ res.end(JSON.stringify(body));
57
+ }
58
+
59
+ function readBody(req, limit = 16 * 1024 * 1024) {
60
+ return new Promise((resolve, reject) => {
61
+ const chunks = [];
62
+ let size = 0;
63
+ req.on('data', (chunk) => {
64
+ size += chunk.length;
65
+ if (size > limit) {
66
+ reject(new Error('Too large'));
67
+ req.destroy();
68
+ } else {
69
+ chunks.push(chunk);
70
+ }
71
+ });
72
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
73
+ req.on('error', reject);
74
+ });
75
+ }
76
+
77
+ /** How long a canvas has to take a check (htmlboard check), then to do it */
78
+ const CLAIM_MS = 15000;
79
+ const CHECK_MS = 180000;
80
+
35
81
  /** True when target is base itself or inside it */
36
82
  function isInside(base, target) {
37
83
  const rel = path.relative(base, target);
@@ -152,13 +198,106 @@ export function createServer(options = {}) {
152
198
  if (!isInside(rootDir, targetDir)) {
153
199
  throw new Error(`The designs folder (${targetDir}) must be inside the root (${rootDir})`);
154
200
  }
155
- const manifestFile = path.posix.join(path.relative(rootDir, targetDir).split(path.sep).join('/'), 'designs.json');
201
+ const designsPath = path.relative(rootDir, targetDir).split(path.sep).join('/'); // '' when they're the same
202
+ const manifestFile = path.posix.join(designsPath, 'designs.json');
156
203
  const clients = new Set();
157
204
 
205
+ const send = (event, data) => {
206
+ for (const client of clients) client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
207
+ };
208
+
209
+ // Checks (htmlboard check). GET /api/check asks the canvases open in a browser (over the live
210
+ // reload events) to check the designs; the first to claim the check does it, and what it posts
211
+ // back answers the request.
212
+ const checks = new Map(); // id → { res, claimed, timer }
213
+
214
+ function endCheck(id, status, body) {
215
+ const check = checks.get(id);
216
+ if (!check) return;
217
+ clearTimeout(check.timer);
218
+ checks.delete(id);
219
+ json(check.res, status, body);
220
+ }
221
+
222
+ // only: files or folders (from the designs folder) to check; all the designs when there's none
223
+ function startCheck(res, only) {
224
+ const wanted = only.map((item) => item.replace(/\\/g, '/').replace(/^\.?\/+|\/+$/g, ''));
225
+ const designs = loadDesignsManifest(targetDir, rootDir).sections
226
+ .flatMap((section) => section.artboards)
227
+ .map(({ file, title, width, height, fit }) => ({ file, title, width, height, fit }))
228
+ .filter(({ file }) => {
229
+ const own = (designsPath ? file.slice(designsPath.length + 1) : file).split('?')[0];
230
+ return !wanted.length || wanted.some((item) => !item || own === item || own.startsWith(`${item}/`));
231
+ });
232
+ if (!designs.length) {
233
+ json(res, 404, { error: wanted.some(Boolean) ? `No design on the canvas is in ${only.join(', ')}` : 'There are no designs to check' });
234
+ return;
235
+ }
236
+ if (!clients.size) {
237
+ json(res, 409, { error: "The canvas isn't open in a browser" });
238
+ return;
239
+ }
240
+ const id = randomUUID();
241
+ const check = {
242
+ res,
243
+ claimed: false,
244
+ timer: setTimeout(() => endCheck(id, 504, { error: "The canvas didn't take the check (reload it in the browser)" }), CLAIM_MS)
245
+ };
246
+ checks.set(id, check);
247
+ res.on('close', () => {
248
+ if (checks.get(id) !== check) return; // answered
249
+ clearTimeout(check.timer);
250
+ checks.delete(id);
251
+ });
252
+ send('check', { id, designs });
253
+ }
254
+
255
+ function claimCheck(id, res) {
256
+ const check = checks.get(id);
257
+ if (!check || check.claimed) {
258
+ json(res, 409, { error: 'Taken' });
259
+ return;
260
+ }
261
+ check.claimed = true;
262
+ clearTimeout(check.timer);
263
+ check.timer = setTimeout(() => endCheck(id, 504, { error: `The canvas didn't finish the check in ${CHECK_MS / 1000} s` }), CHECK_MS);
264
+ json(res, 200, { ok: true });
265
+ }
266
+
267
+ async function finishCheck(id, req, res) {
268
+ let report;
269
+ try {
270
+ report = JSON.parse(await readBody(req));
271
+ } catch {
272
+ json(res, 400, { error: 'Not a report' });
273
+ return;
274
+ }
275
+ if (!checks.get(id)?.claimed) {
276
+ json(res, 404, { error: 'No such check' });
277
+ return;
278
+ }
279
+ if (report.error) endCheck(id, 500, { error: `The canvas couldn't check the designs: ${report.error}` });
280
+ else endCheck(id, 200, { dir: targetDir, root: rootDir, designs: Array.isArray(report.designs) ? report.designs : [] });
281
+ res.writeHead(204).end();
282
+ }
283
+
158
284
  const server = http.createServer((req, res) => {
159
285
  const parsedUrl = new URL(req.url, 'http://localhost');
160
286
  let pathname = decodeURIComponent(parsedUrl.pathname);
161
287
 
288
+ // API: checking the designs (htmlboard check), which the canvas does
289
+ if (pathname === '/api/check' && req.method === 'GET') {
290
+ startCheck(res, parsedUrl.searchParams.getAll('only'));
291
+ return;
292
+ }
293
+ if (pathname.startsWith('/api/check/') && req.method === 'POST') {
294
+ const [id, action, ...rest] = pathname.slice('/api/check/'.length).split('/');
295
+ if (action === 'claim' && !rest.length) claimCheck(id, res);
296
+ else if (action === undefined) finishCheck(id, req, res);
297
+ else json(res, 404, { error: 'Not found' });
298
+ return;
299
+ }
300
+
162
301
  // API: Designs Manifest
163
302
  if (pathname === '/api/designs') {
164
303
  const manifest = loadDesignsManifest(targetDir, rootDir);
@@ -202,7 +341,7 @@ export function createServer(options = {}) {
202
341
 
203
342
  // Serve HtmlBoard Explorer client files
204
343
  if (pathname === '/' || pathname === '/index.html') {
205
- serveStaticFile(path.join(CLIENT_DIR, 'index.html'), res);
344
+ serveStaticFile(path.join(CLIENT_DIR, 'index.html'), req, res);
206
345
  return;
207
346
  }
208
347
 
@@ -214,7 +353,7 @@ export function createServer(options = {}) {
214
353
  res.writeHead(403).end('Forbidden');
215
354
  return;
216
355
  }
217
- serveStaticFile(clientFilePath, res);
356
+ serveStaticFile(clientFilePath, req, res);
218
357
  return;
219
358
  }
220
359
 
@@ -232,24 +371,43 @@ export function createServer(options = {}) {
232
371
  return;
233
372
  }
234
373
 
374
+ // The pages the canvas shows in frames get the probe (htmlboard check); a page opened on its own doesn't
375
+ const framed = req.headers['sec-fetch-dest'] === 'iframe';
376
+
377
+ // A component alone, as the canvas shows it (components/<tag>.html?preview): its example in a page
378
+ if (parsedUrl.searchParams.has('preview')) {
379
+ const preview = componentPreview(targetDir, filePath);
380
+ if (preview !== null) {
381
+ res.writeHead(200, {
382
+ 'Content-Type': MIME_TYPES['.html'],
383
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
384
+ });
385
+ res.end(framed ? withProbe(Buffer.from(preview)) : preview);
386
+ return;
387
+ }
388
+ }
389
+
235
390
  if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
236
- serveStaticFile(filePath, res);
391
+ serveStaticFile(filePath, req, res, { probe: framed });
237
392
  } else {
238
393
  res.writeHead(404, { 'Content-Type': 'text/plain' });
239
394
  res.end(`File not found: ${pathname}`);
240
395
  }
241
396
  });
242
397
 
243
- function serveStaticFile(filePath, res) {
398
+ function serveStaticFile(filePath, req, res, { probe = false } = {}) {
244
399
  try {
245
400
  const ext = path.extname(filePath).toLowerCase();
246
401
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
247
- const content = fs.readFileSync(filePath);
402
+ // HEAD (htmlboard check asks which files are there) needs no reading
403
+ let content = req.method === 'HEAD' ? null : fs.readFileSync(filePath);
404
+ if (content && probe && (ext === '.html' || ext === '.htm')) content = withProbe(content);
248
405
  res.writeHead(200, {
249
406
  'Content-Type': contentType,
250
407
  'Cache-Control': 'no-cache, no-store, must-revalidate'
251
408
  });
252
- res.end(content);
409
+ if (content) res.end(content);
410
+ else res.end();
253
411
  } catch {
254
412
  res.writeHead(500, { 'Content-Type': 'text/plain' });
255
413
  res.end('Error reading file');
@@ -271,15 +429,12 @@ export function createServer(options = {}) {
271
429
  const files = [...changed];
272
430
  changed.clear();
273
431
  for (const [file, isFolder] of files) {
274
- const eventData = JSON.stringify({
432
+ send('change', {
275
433
  type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
276
434
  file,
277
435
  isHtml: !isFolder && /\.html?$/i.test(file),
278
436
  timestamp: Date.now()
279
437
  });
280
- for (const client of clients) {
281
- client.write(`event: change\ndata: ${eventData}\n\n`);
282
- }
283
438
  }
284
439
  }, 100);
285
440
  });
@@ -294,6 +449,7 @@ export function createServer(options = {}) {
294
449
  close: () => {
295
450
  clearTimeout(watchDebounceTimer);
296
451
  if (stopWatching) stopWatching();
452
+ for (const id of [...checks.keys()]) endCheck(id, 503, { error: 'htmlboard stopped' });
297
453
  for (const client of clients) {
298
454
  client.end();
299
455
  }
@@ -0,0 +1,112 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Dashboard</title>
7
+ <meta name="description" content="Analytics dashboard with sidebar, metric cards, and charts">
8
+ <meta name="htmlboard" content="width=1280, height=820">
9
+ <link rel="stylesheet" href="../styles.css">
10
+ <script type="module" src="../components.js"></script>
11
+ <style>
12
+ .stats-row {
13
+ display: grid;
14
+ grid-template-columns: repeat(4, 1fr);
15
+ gap: 20px;
16
+ }
17
+ .grid-2 {
18
+ display: grid;
19
+ grid-template-columns: 2fr 1fr;
20
+ gap: 20px;
21
+ }
22
+ .grid-2 .card { padding: 22px; }
23
+ .panel-header {
24
+ display: flex;
25
+ justify-content: space-between;
26
+ align-items: center;
27
+ margin-bottom: 20px;
28
+ font-size: 12px;
29
+ }
30
+ .panel-title { font-size: 16px; font-weight: 600; }
31
+ .chart-container { width: 100%; height: 180px; }
32
+ </style>
33
+ </head>
34
+ <body class="app">
35
+ <app-sidebar active="Dashboard"></app-sidebar>
36
+
37
+ <main class="app-main">
38
+ <app-topbar search="Search resources, clients, reports...">
39
+ <span class="muted">Last sync: Just now</span>
40
+ <button class="btn btn-primary">+ New Deployment</button>
41
+ </app-topbar>
42
+
43
+ <div class="app-content">
44
+ <div>
45
+ <h1 class="page-title">Executive Overview</h1>
46
+ <p class="page-sub">Performance metrics and active client deployments</p>
47
+ </div>
48
+
49
+ <div class="stats-row">
50
+ <stat-card label="Monthly Recurring Revenue" value="$184,290" trend="▲ +12.4% vs last mo"></stat-card>
51
+ <stat-card label="Active Organizations" value="1,428" trend="▲ +48 new"></stat-card>
52
+ <stat-card label="API Requests (24h)" value="42.8M" trend="▲ 99.98% uptime"></stat-card>
53
+ <stat-card label="Average Latency" value="34ms" trend="▼ -2ms target" warn></stat-card>
54
+ </div>
55
+
56
+ <div class="grid-2">
57
+ <div class="card">
58
+ <div class="panel-header">
59
+ <h2 class="panel-title">Throughput & Workload Trends</h2>
60
+ <span class="muted">Trailing 7 Days</span>
61
+ </div>
62
+ <div class="chart-container">
63
+ <svg viewBox="0 0 500 150" width="100%" height="100%" preserveAspectRatio="none">
64
+ <defs>
65
+ <linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
66
+ <stop offset="0%" stop-color="#6366f1" stop-opacity="0.5"/>
67
+ <stop offset="100%" stop-color="#6366f1" stop-opacity="0.0"/>
68
+ </linearGradient>
69
+ </defs>
70
+ <path d="M 0 130 Q 80 70, 150 90 T 300 40 T 420 50 T 500 20 L 500 150 L 0 150 Z" fill="url(#chartGrad)"/>
71
+ <path d="M 0 130 Q 80 70, 150 90 T 300 40 T 420 50 T 500 20" fill="none" stroke="#6366f1" stroke-width="3"/>
72
+ <circle cx="500" cy="20" r="4" fill="#a855f7" stroke="#fff" stroke-width="2"/>
73
+ </svg>
74
+ </div>
75
+ </div>
76
+
77
+ <div class="card">
78
+ <div class="panel-header">
79
+ <h2 class="panel-title">Recent Audits</h2>
80
+ </div>
81
+ <table class="table">
82
+ <thead>
83
+ <tr>
84
+ <th>Service</th>
85
+ <th>Status</th>
86
+ </tr>
87
+ </thead>
88
+ <tbody>
89
+ <tr>
90
+ <td>us-east-cluster-01</td>
91
+ <td><span class="badge badge-emerald">Optimal</span></td>
92
+ </tr>
93
+ <tr>
94
+ <td>auth-gateway-proxy</td>
95
+ <td><span class="badge badge-emerald">Optimal</span></td>
96
+ </tr>
97
+ <tr>
98
+ <td>edge-cache-frankfurt</td>
99
+ <td><span class="badge badge-amber">Rebalancing</span></td>
100
+ </tr>
101
+ <tr>
102
+ <td>db-replica-primary</td>
103
+ <td><span class="badge badge-emerald">Optimal</span></td>
104
+ </tr>
105
+ </tbody>
106
+ </table>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </main>
111
+ </body>
112
+ </html>
@@ -0,0 +1,77 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Customers</title>
7
+ <meta name="description" content="Customer list with plans and status, in the same sidebar and top bar as the dashboard">
8
+ <meta name="htmlboard" content="width=1280, height=820">
9
+ <link rel="stylesheet" href="../styles.css">
10
+ <script type="module" src="../components.js"></script>
11
+ <style>
12
+ .header { display: flex; justify-content: space-between; align-items: flex-end; }
13
+ .filters { display: flex; gap: 8px; }
14
+ .table .row { gap: 10px; }
15
+ .table .avatar { width: 30px; height: 30px; font-size: 11px; }
16
+ .org { font-weight: 600; }
17
+ .table td:last-child, .table th:last-child { text-align: right; padding-right: 0; }
18
+ </style>
19
+ </head>
20
+ <body class="app">
21
+ <app-sidebar active="Customers"></app-sidebar>
22
+
23
+ <main class="app-main">
24
+ <app-topbar search="Search customers...">
25
+ <button class="btn btn-secondary">Export CSV</button>
26
+ <button class="btn btn-primary">+ Add Customer</button>
27
+ </app-topbar>
28
+
29
+ <div class="app-content">
30
+ <div class="header">
31
+ <div>
32
+ <h1 class="page-title">Customers</h1>
33
+ <p class="page-sub">1,428 organizations on 3 plans</p>
34
+ </div>
35
+ <div class="filters">
36
+ <span class="badge">All plans</span>
37
+ <span class="badge badge-emerald">Active</span>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="card">
42
+ <table class="table">
43
+ <thead>
44
+ <tr><th>Organization</th><th>Plan</th><th>Seats</th><th>MRR</th><th>Status</th></tr>
45
+ </thead>
46
+ <tbody>
47
+ <tr>
48
+ <td><div class="row"><div class="avatar">AC</div><div><div class="org">Acme Cloud</div><div class="muted">acme.cloud</div></div></div></td>
49
+ <td>Enterprise</td><td>240</td><td>$12,400</td><td><span class="badge badge-emerald">Active</span></td>
50
+ </tr>
51
+ <tr>
52
+ <td><div class="row"><div class="avatar">LX</div><div><div class="org">Lumen Labs</div><div class="muted">lumenlabs.io</div></div></div></td>
53
+ <td>Growth</td><td>58</td><td>$2,320</td><td><span class="badge badge-emerald">Active</span></td>
54
+ </tr>
55
+ <tr>
56
+ <td><div class="row"><div class="avatar">NB</div><div><div class="org">Northbeam</div><div class="muted">northbeam.co</div></div></div></td>
57
+ <td>Growth</td><td>34</td><td>$1,360</td><td><span class="badge badge-amber">Trial ends in 3 days</span></td>
58
+ </tr>
59
+ <tr>
60
+ <td><div class="row"><div class="avatar">QS</div><div><div class="org">Quarry Studio</div><div class="muted">quarry.studio</div></div></div></td>
61
+ <td>Starter</td><td>6</td><td>$90</td><td><span class="badge badge-emerald">Active</span></td>
62
+ </tr>
63
+ <tr>
64
+ <td><div class="row"><div class="avatar">FW</div><div><div class="org">Fieldwise</div><div class="muted">fieldwise.app</div></div></div></td>
65
+ <td>Enterprise</td><td>410</td><td>$19,850</td><td><span class="badge badge-rose">Payment failed</span></td>
66
+ </tr>
67
+ <tr>
68
+ <td><div class="row"><div class="avatar">OT</div><div><div class="org">Orbit Travel</div><div class="muted">orbit.travel</div></div></div></td>
69
+ <td>Growth</td><td>72</td><td>$2,880</td><td><span class="badge badge-emerald">Active</span></td>
70
+ </tr>
71
+ </tbody>
72
+ </table>
73
+ </div>
74
+ </div>
75
+ </main>
76
+ </body>
77
+ </html>