Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 130 additions & 130 deletions kits/dashboard/app/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion kits/dashboard/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test src/lib/dashboard.test.mjs src/lib/query.test.mjs src/lib/refresh.test.mjs src/lib/templates.test.mjs"
"test": "node --test src/lib/dashboard.test.mjs src/lib/query.test.mjs src/lib/refresh.test.mjs src/lib/templates.test.mjs",
"capture:thumbs": "node scripts/capture-template-thumbs.mjs"
},
"dependencies": {
"echarts": "^5.5.1",
Expand Down
94 changes: 94 additions & 0 deletions kits/dashboard/app/scripts/capture-template-thumbs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/* Capture one PNG per template, from the template's own preview board.
*
* The card used to draw a grid of grey rectangles — the panel LAYOUT and nothing else. Every
* template has roughly the same layout, so the cards were indistinguishable and told nobody which
* template to pick. A captured board is the honest answer: it IS the thing being chosen, charts
* and all.
*
* Captured rather than rendered live because a board laid out at ~230px is not the same board
* shrunk: the canvas collapses its columns at that width, so a live miniature shows a layout
* nobody will ever get. It also keeps five chart-drawing boards off the landing page's first
* paint.
*
* The figures come from each template's `sample` block, which the copilot cannot reach — the same
* rule that lets the eye-preview show numbers at all.
*
* Usage: node scripts/capture-template-thumbs.mjs [baseUrl]
* HR_USER / HR_PASS log in first, for a console that gates the kit behind a session.
*
* Playwright is deliberately NOT a devDependency: this is a maintenance tool run by hand when a
* template changes, and adding it would make every install of the kit download browsers. Run it
* with `npx playwright@1.62 ...` available, or `npm i -D playwright` temporarily.
*/
import { chromium } from 'playwright';
import { mkdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const KIT = path.resolve(HERE, '..', '..'); // scripts/ -> app/ -> kit root
const OUT = path.join(KIT, 'templates', 'thumbs');
const BASE = process.argv[2] || 'http://localhost:5173/kits/dashboard';
const W = 1200; // board width; the card scales it down with object-fit

const tpl = JSON.parse(await readFile(path.join(KIT, 'templates', 'templates.json'), 'utf8'));
const templates = Array.isArray(tpl) ? tpl : (tpl.templates || []);
await mkdir(OUT, { recursive: true });

const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1600, height: 1200 }, deviceScaleFactor: 2,
ignoreHTTPSErrors: true });
// A self-hosted console gates every page behind a session, so a headless run lands on /login and
// finds no templates at all. Logging in through the same endpoint the login form posts to keeps
// this a normal client rather than a special case in the app.
if (process.env.HR_USER && process.env.HR_PASS) {
const origin = new URL(BASE).origin;
const r = await ctx.request.post(`${origin}/api/selfhost/login`, {
data: { username: process.env.HR_USER, password: process.env.HR_PASS },
});
if (!r.ok()) { console.error(`login failed: HTTP ${r.status()}`); process.exit(1); }
}
const page = await ctx.newPage();
let ok = 0;

for (const [i, t] of templates.entries()) {
await page.goto(BASE, { waitUntil: 'networkidle' });
await page.addStyleTag({ content: '*,*::before,*::after{animation:none!important;transition:none!important}' });
// The eye button is a hover overlay; click it directly rather than simulating the hover.
const opened = await page.evaluate((idx) => {
const eyes = document.querySelectorAll('.db-eye');
if (!eyes[idx]) return false;
eyes[idx].click();
return true;
}, i);
if (!opened) { console.error(`no preview control for ${t.id}`); continue; }
await page.waitForSelector('.uic-modal .db-preview-canvas', { timeout: 15000 });
// Isolate the board so a plain viewport capture IS the thumbnail: element screenshots time out
// waiting for "stable" while the charts settle, and cropping afterwards needs an image library.
const size = await page.evaluate((w) => {
const c = document.querySelector('.uic-modal .db-preview-canvas');
const host = document.createElement('div');
host.id = '__shot';
host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:#fff;overflow:hidden';
c.style.width = `${w}px`;
c.style.maxHeight = 'none';
host.appendChild(c);
document.body.appendChild(host);
for (const el of [...document.body.children]) if (el.id !== '__shot') el.style.display = 'none';
const r = c.getBoundingClientRect();
return { w: Math.round(r.width), h: Math.round(r.height) };
}, W);
await page.setViewportSize({ width: size.w, height: size.h });
await page.waitForTimeout(600); // let the chart library settle
const file = path.join(OUT, `${t.id}.png`);
await page.screenshot({ path: file });
console.log(`${t.id} ${size.w}x${size.h} -> templates/thumbs/${t.id}.png`);
ok += 1;
}

await browser.close();
if (ok !== templates.length) {
console.error(`captured ${ok}/${templates.length}`);
process.exit(1);
}
console.log(`captured ${ok}/${templates.length}`);
48 changes: 48 additions & 0 deletions kits/dashboard/app/src/pages/Landing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,55 @@ function TemplateBoard({ template, rowHeight = 30, gap = 8 }) {
);
}

/** The card thumbnail: the template drawn as the board it becomes, at thumbnail scale.
*
* This used to be a grid of grey rectangles — the panel LAYOUT and nothing else. Every template
* has roughly the same layout (a row of stats, two mid panels, a table), so the cards were
* indistinguishable and told nobody which template to pick. The board with its own illustrative
* figures is the thing being chosen, so that is what the card shows.
*
* It renders at a fixed board width and is scaled into the card with a transform, rather than
* laid out at card size: charts drawn into ~180px behave nothing like charts drawn at 520px and
* then shrunk — axes collapse, labels collide. Scaling a full-size render keeps the miniature
* faithful to the real board.
*
* The same honesty rules as the eye-preview apply, and for the same reason: the figures come from
* `sample`, which the copilot cannot reach, so they can never travel into someone's document.
* If a template ships without `sample`, its panels would draw empty — fall back to the shape. */
/** The card thumbnail: a CAPTURED picture of the board this template becomes.
*
* This used to be a grid of grey rectangles — the panel LAYOUT and nothing else. Every template
* has roughly the same layout (a row of stats, two mid panels, a table), so the cards were
* indistinguishable and told nobody which template to pick. The board is the thing being chosen,
* so the card shows the board.
*
* A picture rather than a live miniature, for two reasons. A board laid out at card width is not
* the same board shrunk — the canvas collapses its columns at ~230px, so a live thumbnail would
* advertise a layout nobody will ever get. And five chart-drawing boards on the landing page cost
* a visibly slower first paint for something nobody interacts with.
*
* The pictures are generated by scripts/capture-template-thumbs.mjs from each template's own
* preview, so they cannot drift into showing a board the template does not build. A template
* whose picture is missing falls back to the shape rather than a broken image.
*/
function TemplateArt({ template }) {
const [failed, setFailed] = useState(false);
if (failed) return <TemplateShape template={template} />;
return (
<img
className="db-tpl-shot"
src={`${import.meta.env.BASE_URL}templates/${template.id}.png`}
alt=""
loading="lazy"
decoding="async"
draggable={false}
onError={() => setFailed(true)}
/>
);
}

/** The layout-only fallback, for a template with no illustrative figures. */
function TemplateShape({ template }) {
const panels = (template.dashboard?.panels || []).slice(0, 10);
return (
<span className="db-tpl-art" aria-hidden="true">
Expand Down
12 changes: 12 additions & 0 deletions kits/dashboard/app/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@
.db-tpl-cell.is-stat { background: var(--brand-50); border-color: var(--brand-soft); }
.db-tpl-cell.is-chart { background: var(--surface-2); }
.db-tpl-cell.is-table { background: var(--surface-2); border-style: dashed; }

/* The card thumbnail is a captured picture of the board (see TemplateArt).
*
* object-fit: cover with a top anchor: the captures are wide boards (~1200x476) and the card art
* box is a different aspect, so anchoring the top keeps the headline stats — the row that actually
* distinguishes one template from another — rather than centring on empty chart whitespace. */
.db-tpl-shot {
display: block; width: 100%; height: 100%;
object-fit: cover; object-position: top center;
background: var(--bg);
pointer-events: none; /* the CARD takes the click */
}
/* The preview control floats over the art, so it needs its own surface to stay legible. */
.db-eye { background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }

Expand Down
8 changes: 7 additions & 1 deletion kits/dashboard/app/vite.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { copyFileSync, mkdirSync } from 'node:fs';
import { copyFileSync, cpSync, existsSync, mkdirSync } from 'node:fs';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

Expand All @@ -10,6 +10,12 @@ const stageTemplates = {
buildStart() {
mkdirSync('public', { recursive: true });
copyFileSync('../templates/templates.json', 'public/templates.json');
// The card thumbnails are kit data too — captured pictures of each template's board, staged
// the same way and for the same reason. Optional: a checkout without them still builds, and
// the card falls back to the layout shape rather than a broken image.
if (existsSync('../templates/thumbs')) {
cpSync('../templates/thumbs', 'public/templates', { recursive: true });
}
},
};

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added kits/dashboard/templates/thumbs/saas-revenue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added kits/dashboard/templates/thumbs/support-ops.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading