A folder of plain static files — HTML, CSS, JavaScript — with the data already inside it, zipped and uploaded.
Think “an interactive PDF that can do maths”, not “a web application”. The dashboard shows a snapshot. To refresh the numbers you export a new bundle and upload it as a new version.
Pure HTML/CSS/JS. Data as inline JSON or JS. Charts drawn in SVG or canvas. Loads instantly.
A few optimised images, one bundled font family. Noticeably slower on a poor connection.
Also max 300 files. Rejected beyond this. If you are near it, something has gone wrong.
The whole bundle is fetched before anything renders, so weight is felt directly by whoever opens it — often an executive on a phone. A well-built dashboard is usually under 500 KB.
| Bloat cause | Typical cost | Fix |
|---|---|---|
| Chart exported as PNG/JPG | 200–800 KB each | Draw it in SVG from the data. A bar chart is a few hundred bytes, and it scales. |
| Uncompressed screenshots | 1–5 MB each | Convert to .webp, resize to the size actually displayed. |
| Embedded video | 10 MB+ | Not possible — video extensions are not allowed. Link out, or use a still. |
| Base64 blobs in HTML | +33% over the file | Ship the file separately and reference it relatively. |
| Whole charting library | 300 KB–1 MB | Hand-rolled SVG covers bars, lines and numbers. Bundle a library only if truly needed — one copy. |
| Full webfont family | 400 KB–2 MB | Use system fonts. If brand type is essential, one .woff2 weight. |
{ "name": "Quarterly Revenue", "version": "1.0.0", "entry": "index.html", "description": "Revenue and occupancy for the last six months." }
name, version and entry are required strings.
description is optional. If the manifest is missing or malformed, the
platform falls back to index.html at the root — and rejects the upload
if that isn't there either.
Exactly these extensions. Anything else is rejected:
html css js json
png jpg jpeg gif svg
webp woff woff2 ttf otf
ico.map source maps (turn them off),
README.md, .DS_Store, .gitignore, and any
extensionless file. All are rejected. Clean the folder before zipping.These are checked server-side before anything is stored. The error text below is what you will actually see.
| Problem | Error you'll see | Fix |
|---|---|---|
| Disallowed extension | Archive contains N file(s) with disallowed extensions: … | Remove the file or convert it. Every offender is listed at once. |
| Too many files | Archive contains N files, which exceeds the limit of 300. | Consolidate assets; drop unused ones. |
| Too large | Archive declares N uncompressed bytes, which exceeds the limit of 52428800. | See §2. Usually images. |
| No entry point | Could not determine an entry point: no manifest.json entry and no index.html at root. | Almost always a wrapper folder — see §9. |
| Path traversal | Archive contains N entries with unsafe paths (absolute, or escaping the bundle root): … | No ../, no leading /, no drive letters, no backslashes. |
| Symlinks | Archive contains symlink entries, which are not allowed: … | Zip real files. Use “compress” rather than a tar-style archiver that preserves links. |
| Duplicate paths | Archive contains duplicate paths: … | Two entries resolved to the same path; rebuild the zip. |
| Corrupt zip | Upload is not a readable zip archive: … | Re-zip. Don't rename a .rar/.7z to .zip. |
This is the part that surprises people. Your dashboard runs in a locked sandbox, and the restrictions are not obvious from the code.
It renders in an iframe with sandbox="allow-scripts" and no
allow-same-origin — a null origin — under this policy:
Content-Security-Policy:
default-src 'none'; script-src 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self';
img-src 'self' data:; font-src 'self' data:; connect-src 'none'; form-action 'none';
base-uri 'none'; frame-ancestors 'self'
fetch('/api/data')
new WebSocket(…)
navigator.sendBeacon(…)<script src="data.js"></script>
window.DASHBOARD_DATA = {…}localStorage.setItem(…)
sessionStorage / document.cookielet state = { tab: 'revenue' };<script src="https://cdn…/chart.js">
<link href="https://fonts.googleapis…"><script src="vendor/chart.min.js">
@font-face { src: url("fonts/x.woff2") }<img src="https://example.com/logo.png"><img src="assets/logo.webp">window.parent.… / window.top.…
<form action="…"> / window.open(…)button.addEventListener('click', render)What does work: inline <script> and
<style>, inline event handlers, data: URIs for
images and fonts, and every ordinary DOM, SVG, canvas and JS API that doesn't
touch the network or storage. eval() and new Function()
are blocked.
fetch rejects a
promise nobody catches; the dashboard just renders empty. Test with the network
disconnected — if it still works, it will work here.It renders in an iframe that fills the console's viewport, from roughly 360 px wide upward — and share-link recipients often open it on a phone.
<meta name="viewport" content="width=device-width, initial-scale=1">.max-width containers, CSS grid with
repeat(auto-fit, minmax(…, 1fr)), clamp() for type.viewBox and width:100%; height:auto
so they scale.prefers-color-scheme; the console
has light and dark modes.Five files, 4,122 bytes total — verified against the real validator. Copy this as a starting point.
{ "name": "Quarterly Revenue", "version": "1.0.0", "entry": "index.html", "description": "Revenue and occupancy for the last six months." }
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quarterly Revenue</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="wrap">
<header>
<h1>Quarterly Revenue</h1>
<p class="sub">Six months to March 2026 · all resorts</p>
</header>
<section class="tiles" id="tiles"></section>
<section class="card">
<h2>Revenue by month</h2>
<div id="chart" class="chart"></div>
</section>
<footer class="foot">Data baked in at export · generated 12 Mar 2026</footer>
</main>
<!-- Data lives IN the bundle. No fetch, no API, no CDN. -->
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>
/* Baked-in data. Replace at export time; never fetched at runtime. */ window.DASHBOARD_DATA = { totals: [ { key: "Revenue", value: "$4.82M" }, { key: "Occupancy", value: "78.4%" }, { key: "ADR", value: "$212" }, { key: "Bookings", value: "9,431" } ], months: [ { label: "Oct", value: 690000 }, { label: "Nov", value: 745000 }, { label: "Dec", value: 910000 }, { label: "Jan", value: 820000 }, { label: "Feb", value: 788000 }, { label: "Mar", value: 867000 } ] };
(function () {
var d = window.DASHBOARD_DATA;
document.getElementById("tiles").innerHTML = d.totals
.map(function (t) {
return '<div class="tile"><div class="k">' + t.key +
'</div><div class="v">' + t.value + "</div></div>";
}).join("");
// Chart drawn as inline SVG — no library, no image, a few hundred bytes.
var W = 720, H = 260, pad = 34;
var max = Math.max.apply(null, d.months.map(function (m) { return m.value; }));
var bw = (W - pad * 2) / d.months.length;
var bars = d.months.map(function (m, i) {
var h = Math.round(((H - pad * 2) * m.value) / max);
var x = pad + i * bw + bw * 0.18, y = H - pad - h;
return '<rect x="' + x + '" y="' + y + '" width="' + bw * 0.64 +
'" height="' + h + '" rx="5" fill="var(--accent)" opacity="0.9"></rect>' +
'<text x="' + (x + bw * 0.32) + '" y="' + (H - pad + 16) +
'" fill="var(--dim)" font-size="11" text-anchor="middle">' + m.label + "</text>";
}).join("");
document.getElementById("chart").innerHTML =
'<svg viewBox="0 0 ' + W + " " + H + '" role="img" aria-label="Revenue by month">' +
'<line x1="' + pad + '" y1="' + (H - pad) + '" x2="' + (W - pad) +
'" y2="' + (H - pad) + '" stroke="var(--line)"></line>' + bars + "</svg>";
})();
/* Fluid layout, system fonts, both colour schemes. Abridged — see the skill file. */ :root { --bg:#0f1420; --panel:#171d2b; --line:#263047; --ink:#e8edf7; --dim:#94a3b8; --accent:#38bdf8; } @media (prefers-color-scheme: light) { :root { --bg:#f6f8fc; --panel:#fff; --line:#e3e8f2; --ink:#0f172a; --dim:#5b6880; } } body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } .wrap { max-width:1100px; margin:0 auto; padding:clamp(16px,3vw,32px); } .tiles { display:grid; gap:12px; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); } .chart svg { display:block; width:100%; height:auto; }
manifest.json is at the zip rootname, version, entry all presententry file exists and ends in .html.map, .md, .DS_Storefetch/XHR/WebSocket anywherelocalStorage/sessionStorage/cookies/, no ../manifest.json and your entry HTML
must sit at the top level of the archive. A wrapper folder is why uploads
fail with “no manifest.json entry and no index.html at root”.bundle.zip
my-dashboard/
manifest.json
index.htmlbundle.zip
manifest.json
index.html
styles.cssmacOS: open the folder, select all the files inside, right-click → Compress N items. Windows: open the folder, select all, right-click → Send to → Compressed (zipped) folder. In both cases you are selecting the files, never the enclosing folder.
Then send the .zip to a dashboard administrator, who uploads it via
Dashboards → Create Dashboard. They'll choose who can see it and whether to
issue a public share link. If validation fails, they will see the exact errors —
ask for them, fix, and resend.