Building
Authoring a widget
The four non-negotiables
| Rule | Why | Enforced by |
|---|---|---|
| Zero external requests | The sandbox has no network. A CDN link is a blank block, and a webfont is a layout shift on every load. | Inline everything: CSS in <style>, JS in <script>, images as data: URIs. |
| Read config from two sources | The /embed path has a URL; the downloaded path does not. One file must serve both. | The bk-config loader, in the same order, in every widget. |
| Implement theme and accent | A dashboard is re-skinned as a whole. Six widgets with six hardcoded palettes cannot be. | <code>data-theme</code> on <code><html></code> plus <code>--accent</code>. |
| Namespace persisted state | Two widgets on one page must not collide, and a schema change must not corrupt stored data. | <code>notion-arena.<slug>.v1</code> |
The file skeleton
Copy this and fill in the middle. Everything outside <!-- widget --> is boilerplate that should be identical across the catalog.
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Widget</title>
<style>
:root {
--accent: #2383e2;
--bg: #ffffff; --fg: #37352f; --muted: #787774;
--line: rgba(55,53,47,.14); --raised: #f7f7f5;
}
html[data-theme="dark"] {
--bg: #191919; --fg: #e6e6e4; --muted: #9b9a97;
--line: rgba(255,255,255,.14); --raised: #202020;
}
@media (prefers-color-scheme: dark) {
html[data-theme="auto"] {
--bg: #191919; --fg: #e6e6e4; --muted: #9b9a97;
--line: rgba(255,255,255,.14); --raised: #202020;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { overflow: hidden; background: var(--bg); color: var(--fg);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI",
Helvetica, Arial, sans-serif; }
.wrap { height: 100%; display: flex; flex-direction: column; padding: 14px; gap: 10px; }
</style>
</head>
<body>
<div class="wrap">
<!-- widget -->
</div>
<script>
(function () {
"use strict";
/* ---- 1. configuration ------------------------------------- */
var baked = {};
try {
baked = JSON.parse(
(document.getElementById("bk-config") || {}).textContent || "{}"
);
} catch (e) { baked = {}; }
var qs = new URLSearchParams(location.search);
var cfg = Object.assign({}, baked);
qs.forEach(function (v, k) { cfg[k] = v; });
var num = function (k, d) { var n = parseFloat(cfg[k]); return isFinite(n) ? n : d; };
var bool = function (k, d) {
return cfg[k] === undefined ? d : ["true", "1"].indexOf(String(cfg[k])) > -1;
};
var str = function (k, d) { return cfg[k] === undefined || cfg[k] === "" ? d : String(cfg[k]); };
document.documentElement.setAttribute("data-theme", str("theme", "auto"));
document.documentElement.style.setProperty("--accent", str("accent", "#2383e2"));
/* ---- 2. state --------------------------------------------- */
var KEY = "notion-arena.my-widget.v1";
var load = function () {
try { return JSON.parse(localStorage.getItem(KEY) || "null") || {}; }
catch (e) { return {}; }
};
var save = function (s) {
try { localStorage.setItem(KEY, JSON.stringify(s)); } catch (e) { /* private mode */ }
};
/* ---- 3. widget -------------------------------------------- */
})();
</script>
</body>
</html>setItem), and never let one bad parameter throw during initialisation.Sizing and layout
A Notion block can be resized by dragging its edge to anything between roughly 100px and 900px tall and a full page column wide. Your widget has to survive all of it.
body { overflow: hidden }and.wrap { height: 100% }. A scrollbar inside a Notion block is a bad experience: the page already scrolls.- Declare
height,minHeightandmaxHeightin the catalog entry. The configurator uses them to bound the resize slider, and the card preview usesheight. - Prefer fluid units and
clamp()over breakpoints. There is no reliable viewport width to design against, because the same file renders in a full-width page, a narrow column and a mobile screen. - Test at 240px wide and at 120px tall. If it is unusable there, the catalog entry's
minHeightshould say so honestly.
Export, and the clipboard caveat
A sandboxed block cannot send anything anywhere. If a widget produces something worth keeping (a whiteboard drawing, an org chart, a calculator result) it has to hand it back through the clipboard or the console.
async function copy(payload, label) {
try {
await navigator.clipboard.writeText(payload);
flash("Copied " + label);
} catch (err) {
// Clipboard writes need a user gesture and are blocked in some WebViews.
// Always leave the payload in the console as a fallback.
console.log("[notion-arena] " + label + "\n" + payload);
flash("Copy blocked. Payload logged to the console");
}
}- Offer the format the destination actually wants: JSON for re-importing, CSV for a Notion database import, SVG for a cover image.
- A
Downloadbutton using ablob:URL and an<a download>works in the desktop app and in browsers, and fails silently in some mobile WebViews. Pair it with the copy path. - Never rely on the clipboard alone. Every export in this catalog also logs, and says so in the toast.
Adding it to the catalog
One file, one entry. Nothing else needs to change: the embed route, the download route, the registry manifest, the JSON Schema endpoint, the sitemap and the catalog UI all read from the same array.
{
slug: "my-widget",
name: "My Widget",
kind: "widget",
emoji: "🧩",
category: "visuals",
tier: "free",
plan: "Free",
badge: "new",
version: "1.0.0",
updatedAt: "2026-10-11",
tagline: "One line that says what it is for.",
description:
"Two or three sentences: what it shows, what it remembers, and the thing it cannot do.",
tags: ["visual", "canvas"],
features: [
"Does the thing",
"Remembers your last session per browser",
"Exports SVG",
],
useCases: [
"Top of a project dashboard",
"Inside a weekly review page",
],
capabilities: {
interactive: true,
offlineSafe: true,
persistsLocally: true,
writesToNotion: false, // no widget may ever set this true
needsNetwork: false, // false for widgets: the file is self-contained
},
delivery: ["embed", "html-block", "download"],
file: "/widgets/my-widget.html",
height: 260,
minHeight: 160,
maxHeight: 620,
params: [
{ key: "label", type: "string", label: "Label", default: "My Widget" },
{ key: "count", type: "number", label: "Count", default: 10, min: 1, max: 100, step: 1 },
{ key: "compact",type: "boolean", label: "Compact",default: false },
{ key: "mode", type: "select", label: "Mode", default: "a",
options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
{ key: "accent", type: "color", label: "Accent", default: "#2383e2" },
{ key: "theme", type: "select", label: "Theme", default: "auto",
options: [{ value: "auto", label: "Match Notion" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" }] },
],
}buildConfig() drops any key that is not declared here before it reaches the file. So an undeclared parameter is not merely undocumented: it is silently unreachable through the embed URL. If a widget reads cfg.something, it must appear in params.Pre-merge checklist
Testing without Notion
You do not need a Notion workspace to develop a widget. Three URLs cover almost everything:
| URL | What it exercises |
|---|---|
/widgets/my-widget.html | The raw file. No config injected, no CSP. Fastest iteration loop, and the closest thing to what an HTML block receives. |
/embed/my-widget?count=5&theme=dark | The hosted path: parameter coercion, the whitelist, the CSP header, and framing. |
/api/widget/my-widget?count=5 | The download path: config baked into the bk-config tag, served with a Content-Disposition attachment header. |
Diffing the two paths
Download the configured file and open it directly. It must render identically to the embed URL with the same parameters. If it does not, the widget is reading something from location that the sandbox will not have, usually location.search or location.href.
# same config, two channels, byte-comparable output
curl -s "localhost:3000/api/widget/my-widget?count=5&theme=dark" > baked.html
open baked.html # must match /embed/my-widget?count=5&theme=dark