Build a widget
Widget authoring · updated August 2026
A widget is a folder with a manifest.json and an entry HTML file. Vardek serves the folder in a sandboxed iframe on the dashboard grid — no build step, no framework, no packaging.
Vardek's format traces back to iCUE's original manifest.json + index.html widgets, but Corsair has since moved the Xeneon Edge widget layer onto a CLI-packaged .icuewidget pipeline (icuewidget init/validate/package), a different manifest schema (author, description, preview_icon, min_framework_version, os, supported_devices), HTML config via <meta name="x-icue-property"> tags, and its own onICUEInitialized/onDataUpdated lifecycle — see Elgato's current widget docs. A widget written for one no longer drops straight into the other; porting either direction means adapting the manifest and swapping the bridge calls (Vardek.* here, the iCUE global object there) for the target's own API.
Folder layout
com.yourname.mywidget/
manifest.json # required
index.html # entry (name it whatever manifest.entry says)
... # any JS/CSS/assets, loaded with relative paths
README.md # optional
Use a reverse-DNS id you control (com.yourname.mywidget). It must match the folder name.
manifest.json
{
"id": "com.yourname.mywidget",
"name": "My Widget",
"version": "1.0.0",
"entry": "index.html",
"subscriptions": [],
"sizes": [ { "cols": 2, "rows": 1 }, { "cols": 4, "rows": 2 } ],
"canvas": { "width": 640, "height": 320 },
"settingsSchema": {
"label": { "type": "string", "label": "Label", "default": "Hello" },
"big": { "type": "boolean", "label": "Large text", "default": false },
"mode": { "type": "enum", "label": "Mode", "default": "a", "values": ["a","b"] }
}
}
| Field | Required | Notes |
|---|---|---|
id | Yes | Reverse-DNS, equals folder name. |
name | Yes | Shown in Admin. |
icon | Optional | An emoji shown as the widget's tile icon in Admin (e.g. "🛰️"). Omit for a neutral default. |
version | Yes | Semver string. |
entry | Yes | HTML file to load. |
sizes | Yes | Allowed footprints on the 8×2 grid. cols 1–8, rows 1–2. |
canvas | Optional | Logical px the entry renders at; scaled to the slot. |
subscriptions | Optional | Data channels (sensors, config, …). [] for none. |
settingsSchema | Optional | User-editable settings; Admin builds a form from it. Types: boolean, string, number, enum (with values), color (native color picker, value is a #rrggbb hex string). |
permissions | Optional | { "proxy": ["https://api.example.com/**"], "secrets": ["MY_KEY"] } — see Network below. |
refreshInterval | Optional | Seconds; fires an onRefresh tick. |
The grid is 8 columns × 2 rows. Full panel = { "cols": 8, "rows": 2 } at canvas 2560×720 (the Xeneon Edge). Invalid manifests are skipped with a reason shown in Admin — never fatal.
Runtime bridge
Vardek injects a bridge script at serve time. In your entry file:
<script>
const defaults = { label: "Hello", big: false };
let cfg = defaults;
document.addEventListener("vardek:ready", () => {
cfg = Object.assign({}, defaults, Vardek.settings); // merge user settings
render();
});
render(); // draw immediately too; re-draw on ready with real settings
</script>
Bridge surface (Vardek global): Vardek.settings, Vardek.size, Vardek.subscribe(channel, cb), Vardek.sendCommand(channel, payload), Vardek.onResize(cb). The vardek:ready event fires once the bridge is live.
Hard constraint — no ES modules
Widgets run in a sandbox="allow-scripts" iframe → opaque origin. An external <script type="module" src="..."> is fetched in CORS mode and the daemon does not send CORS headers on widget assets, so it is blocked and your widget renders blank. Use classic scripts:
<script src="helpers.js"></script> <!-- sets globals -->
<script> /* uses those globals */ </script>
Classic scripts run in document order, so a later <script> sees globals set by earlier ones. Expose shared helpers on globalThis. (Inline <script> is fine; just avoid type="module".)
Network
No ambient network. To fetch, declare hosts in permissions.proxy (glob allowlist) and call through the injected proxy; requests to other hosts are blocked. API keys go in permissions.secrets — the user enters them in Admin, they're stored in the macOS Keychain, and injected server-side (never exposed to widget JS). A CSP restricts the widget document. Keep allowlists tight.
Gotcha: the API host and its CDN host can be different domains. The widget document's CSP (img-src/connect-src/…) is derived straight from permissions.proxy, so every host your widget's markup loads from must be listed — not just the one you call for JSON. A widget calling *.wikipedia.org for data but loading images from upload.wikimedia.org is a real example of this — two different subdomains, easy to miss one. Forgetting the second host doesn't error on the fetch; the <img> just gets silently blocked by CSP, which looks exactly like a network/proxy failure. Check every host your rendered HTML actually loads from (fonts, images, iframes), not just the ones your JS calls directly.
Test locally
./install-addon.sh com.yourname.mywidget # copy to user dir + rescan
Edit files, re-run to reinstall, then reload the widget in Admin (toggle it off/on or refresh the dashboard) to repaint. After editing a widget already installed, the daemon needs a rescan to pick up manifest changes.
Contributing
Open a PR against vardekapp/vardek-widgets adding your com.yourname.widget/ folder and a row in the README table. Keep widgets self-contained and dependency-free where possible.