Guide

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"] }
  }
}
FieldRequiredNotes
idYesReverse-DNS, equals folder name.
nameYesShown in Admin.
iconOptionalAn emoji shown as the widget's tile icon in Admin (e.g. "🛰️"). Omit for a neutral default.
versionYesSemver string.
entryYesHTML file to load.
sizesYesAllowed footprints on the 8×2 grid. cols 1–8, rows 1–2.
canvasOptionalLogical px the entry renders at; scaled to the slot.
subscriptionsOptionalData channels (sensors, config, …). [] for none.
settingsSchemaOptionalUser-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).
permissionsOptional{ "proxy": ["https://api.example.com/**"], "secrets": ["MY_KEY"] } — see Network below.
refreshIntervalOptionalSeconds; 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.