---
name: karma-dashboard-bundle
description: Build a self-contained static dashboard bundle for the Karma admin console's dashboard hosting platform. Use when asked to produce a dashboard, report, or data visualisation that will be uploaded as a .zip to the Karma console. Produces HTML/CSS/JS files with data baked in, ready to zip and upload.
---

# Karma Dashboard Bundle

You are producing a **self-contained static website** that will be uploaded as a
`.zip` to the Karma admin console, validated against a strict contract, and
rendered inside a locked-down sandboxed iframe.

Every rule below is **enforced by a server-side validator**. A bundle that
breaks a HARD rule is rejected on upload with an explicit error. A bundle that
breaks a RUNTIME rule uploads fine and then **fails silently in the browser** —
which is worse. Follow both sets exactly.

---

## 1. The artifact

A folder of plain static files. No build step is run for you, no server, no
database, no API. Whatever you output *is* what runs.

```
manifest.json     ← REQUIRED, at the ROOT of the zip
index.html        ← entry point (or whatever manifest.entry names)
styles.css
app.js
data.js           ← the data, baked in as a JS value
```

When zipped, `manifest.json` and the entry HTML must be at the **top level of
the archive**, not inside a wrapper folder.

---

## 2. HARD constraints (rejected at upload)

Satisfy every one of these:

- [ ] **Allowed file extensions only.** Exactly this list, nothing else:
      `html, css, js, json, png, jpg, jpeg, gif, svg, webp, woff, woff2, ttf, otf, ico`
      → No `.txt`, `.md`, `.map`, `.ts`, `.scss`, `.mp4`, `.zip`, no extensionless files.
      Do not emit source maps. Do not include a README.
- [ ] **≤ 300 files** in the bundle (directories don't count).
- [ ] **≤ 50 MB total uncompressed.** Target **under 2 MB** — see §4.
- [ ] **`manifest.json` at the archive root**, shape:
      `{ "name": string, "version": string, "entry": string, "description"?: string }`
      All three of `name`, `version`, `entry` are required strings.
- [ ] **Entry point is an `.html` file that exists in the bundle**, referenced
      by a relative path. If `manifest.entry` is missing or doesn't resolve, the
      platform falls back to `index.html` at the root; if that's absent too, the
      upload is **rejected**.
- [ ] **All paths relative.** No leading `/`, no `../`, no `C:\`, no backslashes,
      no absolute URLs to your own files, no symlinks.
- [ ] **No duplicate paths** within the archive.

---

## 3. RUNTIME constraints (accepted, then broken)

The bundle runs in an iframe with `sandbox="allow-scripts"` and **no**
`allow-same-origin`, under this exact 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'
```

These are **absolute**. Do not generate code that depends on any of them:

| Never use | Why | Do this instead |
|---|---|---|
| `fetch()`, `XMLHttpRequest`, `WebSocket`, `navigator.sendBeacon` | `connect-src 'none'` blocks every outbound request | Bake the data into a `.js` or `.json` file inside the bundle |
| `localStorage`, `sessionStorage`, `document.cookie`, IndexedDB | Null origin — these **throw a SecurityError**, they don't return empty | Hold state in JS variables for the page's lifetime |
| CDN `<script src="https://…">`, Google Fonts `<link>`, external `<img src="https://…">` | `default-src 'none'` blocks all external loads | Bundle the library/font/image as a file and reference it relatively |
| `window.parent`, `window.top`, `postMessage` to the host | Cross-origin barrier; the host does not listen | Nothing — the dashboard is standalone by design |
| `<form action="…">`, `target="_top"` navigation, `window.open` | `form-action 'none'`, sandbox blocks navigation and popups | Handle interaction in JS within the page |
| `eval()`, `new Function()` | No `'unsafe-eval'` in the CSP | Write ordinary JS |

Inline `<style>` and inline `<script>` **are** allowed (`'unsafe-inline'` is
present for scripts and styles). Inline event handlers work. `data:` URIs are
allowed for images and fonts.

**Assume no network exists.** If the design needs live data, it is the wrong
design for this platform — bake in a snapshot and state the as-of date in the UI.

---

## 4. Size: aim small

| Tier | Budget | What it looks like |
|---|---|---|
| **Ideal** | **< 2 MB** | Pure HTML/CSS/JS. Data as inline JSON/JS. Charts drawn in SVG or canvas. |
| Acceptable | < 10 MB | A few optimised images, one bundled font family. |
| Hard ceiling | 50 MB / 300 files | The wall. Near it means something is wrong. |

Rules of thumb for staying tiny:

- **Draw charts, don't embed them.** Generate SVG or canvas from data in JS. A
  bar chart is a few hundred bytes of markup; a PNG of the same chart is
  hundreds of kilobytes and doesn't scale.
- **No charting library unless genuinely needed.** Most dashboards need bars,
  lines and numbers — hand-rolled SVG beats a 300 KB dependency. If you must,
  bundle one minified copy and reference it once.
- **Prefer system fonts** (`system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`).
  A bundled font family is easily 400 KB. If brand type is required, ship one
  `.woff2` weight, not four formats × six weights.
- **Compress images, prefer `.webp`**, and never base64 a large image into HTML
  (base64 adds ~33% and defeats caching).
- **Never include video.** It isn't an allowed extension anyway.

---

## 5. Responsive

It renders in an iframe from about **360 px wide** upward, and fills the
viewport. Use fluid layout — `max-width` containers, CSS grid with
`auto-fit`/`minmax`, `clamp()` for type. Don't set fixed pixel widths on the
page shell. Include `<meta name="viewport" content="width=device-width, initial-scale=1">`.

Respect `prefers-color-scheme` if practical: the console has light and dark
modes and the dashboard sits inside it.

---

## 6. Worked example — pattern-match this

This exact bundle passes validation (5 files, 4,122 bytes).

**`manifest.json`**
```json
{
  "name": "Quarterly Revenue",
  "version": "1.0.0",
  "entry": "index.html",
  "description": "Revenue and occupancy for the last six months."
}
```

**`index.html`**
```html
<!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 &middot; 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 &middot; 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>
```

**`data.js`**
```js
/* 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 }
  ]
};
```

**`app.js`**
```js
(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;
    var 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>";
})();
```

**`styles.css`** — fluid layout, system fonts, both colour schemes:
```css
: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; }
}
* { box-sizing: border-box; }
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); }
h1 { font-size:clamp(20px,3vw,28px); margin:0 0 4px; }
h2 { font-size:14px; margin:0 0 14px; color:var(--dim); font-weight:600; }
.sub { margin:0 0 24px; color:var(--dim); font-size:13px; }
.tiles { display:grid; gap:12px; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); margin-bottom:20px; }
.tile { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px 16px; }
.tile .k { color:var(--dim); font-size:12px; }
.tile .v { font-size:clamp(18px,2.4vw,24px); font-weight:700; margin-top:4px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; }
.chart svg { display:block; width:100%; height:auto; }
.foot { color:var(--dim); font-size:12px; margin-top:20px; }
```

---

## 7. Output format

Return **every file with its full relative path**, each in its own fenced code
block, with the path stated immediately before the block. Structure it so a
human can save the tree verbatim and zip it.

- `manifest.json` must be at the root of the tree you output.
- Use relative paths throughout (`styles.css`, `assets/logo.svg` — never
  `/styles.css` or `./../styles.css`).
- State the approximate total size at the end.
- Finish with the zip instruction:
  *"Select the files themselves (not the containing folder) and compress —
  `manifest.json` must sit at the top level of the .zip."*

---

## 8. Self-check before returning

Run this list against your own output and fix anything that fails. Do not
return a bundle until every line passes.

1. Is `manifest.json` at the root, with string `name`, `version` and `entry`?
2. Does the file named by `entry` exist, and does it end in `.html`?
3. Is **every** file's extension in the allowed list? (No `.md`, `.txt`, `.map`.)
4. Are there ≤ 300 files and is the total well under 2 MB?
5. Is every `src`/`href` relative — no `http://`, `https://`, `//cdn…`, no leading `/`?
6. Search your output for `fetch(`, `XMLHttpRequest`, `WebSocket`, `sendBeacon`
   — **zero occurrences**.
7. Search for `localStorage`, `sessionStorage`, `document.cookie`, `indexedDB`
   — **zero occurrences**.
8. Search for `eval(`, `new Function(` — **zero occurrences**.
9. Search for `fonts.googleapis`, `cdn.`, `unpkg`, `jsdelivr` — **zero occurrences**.
10. Is all data present as literal values inside the bundle?
11. Does the layout survive a 360 px-wide viewport?
12. Is there a `<meta name="viewport">` and a `<title>`?
13. No `..` path segments, no absolute paths, no duplicate file paths.

If a request genuinely cannot be met under these rules — for example it needs
live data or a login — say so plainly rather than producing a bundle that will
upload cleanly and then fail in the browser.
