@commersive/xnd
v0.2.0
Published
Push custom components to Xanadu — a built web app becomes a placeable layer on a retail wall.
Maintainers
Readme
xnd — custom components for Xanadu
Build a web app, push it, and an author places it as a layer in the editor like any other. Your settings become their form; your events become their triggers.
npx @commersive/xnd login # email + six-digit code, same as the CMS
npx @commersive/xnd init my-component
cd my-component # build your app into ./dist
npx @commersive/xnd pushOr install it, which gives you the xnd command:
npm i -g @commersive/xnd
xnd pushThe scope is not optional. Unscoped xnd on npm is an unrelated package
somebody else published in 2021, so npx xnd runs a stranger's code. The
command you get is still plain xnd — that comes from the bin field.
From a checkout of this repo instead:
pnpm run cli:build # produces apps/cli/dist/xnd.mjs
node apps/cli/dist/xnd.mjs loginLet Claude build it
npx @commersive/xnd skill installWrites a skill into ~/.claude/skills/xanadu-component/ — the schema, the
postMessage contract, the limits, the push flow, and the wall-specific design
notes. Open a new session, ask for a Xanadu component, and it is picked up
automatically. --dir .claude/skills installs it project-scoped instead;
--force overwrites an older copy after a CLI upgrade.
The skill ships inside the CLI rather than being downloaded, so it works on a plane and can promise that what it describes is what your version does.
What a component is
A built web app — html, css, js and images — that runs inside a sandboxed
iframe in an authored experience, served from its own hostname
(lyrs.xnd.media). It is a layer on the canvas: an author positions it,
animates it, shows and hides it, and wires what it emits.
It is deliberately not trusted. The frame has no allow-same-origin, so it runs
on an opaque origin: no cookies, no access to the page around it, and no
storage APIs at all. If you need to persist something, emit an event and let the
experience decide.
xnd.json
Lives beside your package.json. Everything except dist, slug and
organizationId is the schema the editor reads.
{
"organizationId": "…",
"slug": "spin-to-win",
"name": "Spin to win",
"version": "1.0.0",
"entry": "index.html",
"dist": "dist",
"settings": [
{ "id": "headline", "type": "text", "label": "Headline", "default": "Spin to win" },
{ "id": "accent", "type": "color", "label": "Accent", "default": "#22D3EE" },
{ "id": "prizes", "type": "number","label": "Prizes", "default": 6, "min": 2, "max": 12 },
{ "id": "hero", "type": "asset", "label": "Backdrop" }
],
"events": [
{ "id": "win", "label": "Player wins", "payload": { "prizeId": "string" } },
{ "id": "lose", "label": "Player loses" }
],
"commands": [{ "id": "reset", "label": "Start again" }]
}Setting types: text, textarea, number, boolean, color, select
(with options), asset (an image from the account's library), product.
A version is immutable. Pushing one that already exists is a 409 — bump it.
That is what lets a published experience pin component@version and know that
what is on a wall today is what was on it yesterday.
Talking to the experience
const V = 1;
const send = (msg) => parent.postMessage({ v: V, ...msg }, "*");
addEventListener("message", (e) => {
const m = e.data;
if (!m || m.v !== V) return;
if (m.type === "init") {
// m.mode — "run" on a wall, "edit" while somebody is positioning you
// m.settings — the author's values, by setting id
// m.assets — resolved urls for `asset` settings, by setting id
// m.size — the layer's authored width and height
}
if (m.type === "command") {
// m.id — one of your declared commands, e.g. "reset"
}
});
send({ type: "ready" }); // ask for init
send({ type: "event", id: "win", payload: { prizeId: "p-1" } });Two rules worth knowing rather than discovering:
- Only declared events get through. The host drops anything not in your
eventslist — the schema is the allowlist, not documentation. initarrives afterready, and again on every settings change, so an author dragging a colour picker sees it live. Re-render; don't assume once.
Limits
| | |
|---|---|
| Images | downscaled to 1920×1080, 5 MB each (install sharp and the CLI does it for you) |
| Video and audio | cannot be bundled, in any container |
| Other files | 5 MB each, 40 MB and 250 files per bundle |
| Paths | relative only — build with base: './' |
Video is refused because the platform already has a video layer the timeline can seek, the scheduler can pre-cache and the kiosk agent can take offline. A clip inside an iframe is none of those. Need moving pictures? Place a video layer behind or over your component, or point at an asset-library url — those are served from the CMS and cached on the wall like any other asset.
Absolute paths (/app.js) are refused too: your files are served from a
versioned folder, so a leading slash points outside it. It works in your dev
server and 404s on the wall, which is the worst kind of bug to ship.
Where things end up
Files land in R2 under components/<componentId>/<version>/… and are served by
the lyrs worker with a one-year immutable cache. A kiosk mirrors the whole
folder to disk on sync, preserving your relative layout, so a component keeps
working with the store's network down.
