openvibers/openvibe.sdk Commits

SDK 0.152.0: release train for #227 and #226 (#230)

OP OpenVibers committed 1 days ago · e96dba022b · 23 files, +2456 −85

* ui-layout: validate, applyDefaults and merge for people's own controls (0.151.0)

* ui-layout: the stored active flag is part of a layout

* Add openvibe-sdk/elements: validate, run and the state reducer (0.151.0)

* ui-layout: declare the new entry and count it in the package checks

* Elements: match the robot action fields to Bot's head preset and headlight kinds

* SDK 0.152.0: elements, with 0.151.0 reserved for ui-layout (#226)

* Merge main; ui-layout moves to 0.152.0 after the 0.151.0 release

* ui-layout moves to 0.153.0 after the open elements release

* Merge branch 'pr227' into release/sdk-152

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	package-lock.json
#	package.json
#	src/core/index.js

* Merge branch 'pr226' into release/sdk-152

# Conflicts:
#	esm/index.mjs
#	src/core/index.js
#	test/pack.test.js

* SDK 0.152.0: release train for #227 and #226

openvibe-sdk/elements (#227: validate, run, the state reducer) and
openvibe-sdk/ui-layout (#226: validate, applyDefaults, merge) in one release.
The Contracts pin stays v0.408.0 (#227 had an older base). Suite: 135 passed.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f9698dc..ad4dc99 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,20 @@
# Changelog
++## 0.152.0 — 2026-11-11
++
++A train of two pull requests: `openvibe-sdk/elements` (#227) and `openvibe-sdk/ui-layout` (#226).
++
++- Reserved for `openvibe-sdk/ui-layout` in OpenVibers/OpenVibe.SDK#226. That module is not released by this entry; it
++ keeps the changelog's minor sequence while that pull request is open, and its own entry replaces this one at merge. (#227)
++- Add `openvibe-sdk/ui-layout` (also `sdk.uiLayout` in both entries): `validate(catalog, layout)` is the one
++ check the server, the editor and the AI answer use, returning `{ ok, reasons[] }` in plain words. It refuses a
++ missing, hidden or undersized required part, anything pressed under 44 px (and any part under the catalog's own
++ pixel minimum, e.g. Stop at 64), a part covering a required part or breaking a `never_covers` rule, a tint that
++ is not a catalog theme token or a style/binding the part does not allow, a part the catalog does not have, and
++ any URL, markup or code. `applyDefaults(catalog, device?)` builds the valid default arrangement (required parts
++ first, every part visible, 44 px met) and `merge(layout, patch)` applies a patch by part id without mutating
++ the layout. Browser-safe, no dependencies; exhaustive tests. (#226)
++
## 0.151.0 — 2026-11-11
- Add related-origin passkey sign-in to `openvibe-sdk/sso` (opt-in `relatedPasskeys: true`): `POST /auth/passkey/options`
ddiff --git a/README.md b/README.md
iindex 1fb7046..34f1cc5 100644
---- a/README.md
++++ b/README.md
@@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.151.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.152.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex 673229f..e09699d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,18 +3,19 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.151.0",
++ "version": "0.152.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"contracts": "openvibe-contracts v0.408.0",
"features": [
++ "ui-layout (openvibe-sdk/ui-layout, also sdk.uiLayout): validate(catalog, layout) returns { ok, reasons[] } in plain words for required parts, 44 px pressed targets, coverage, theme-token tints, styles, bindings and data-only content; applyDefaults(catalog, device?) and merge(layout, patch) build and patch an arrangement without mutating it",
"menu (openvibe-sdk/menu): menuCard() builds one validated frame.menu-card@1 (plain text, the contract caps, https links on the site own origin or an openvibe.* host, an optional item picture) and menuRoute() is the Express/node:http GET /.well-known/openvibe-menu.json handler (public cache, CORS *, [] and one log line when the cards cannot be built)",
"cam (openvibe-sdk/cam): defineOvCamera({ src }) injects the <ov-camera> element from openvibe.cam once and resolves to its class, with typed attributes, events and methods (start/stop/capture/flip); browser-only, upload=\"off\" by default",
"pics (openvibe-sdk/pics): the public gallery and one picture's reads, private uploads with resumable chunks, the owner's explicit publication, likes and albums on OpenVibe.Pics; reads are public, writes take a person's Network token",
"streams (openvibe-sdk/streams, S7): schedule(), listSchedules(), cancelSchedule() and remindSchedule() for scheduled public agent streams; postStep() and steps.post() from any harness; digest.read() and poll.open()/poll.result() for a Co-pilot service token; onControl() for pause, curtain, mute and end",
"reports (openvibe-sdk/reports, S7): write() a session report with the person's own token, private by default and idempotent per source and task ref, and read() one the caller may see; examples/cli-agent-stream.js streams any CLI command's run with steps",
-- "chat-apps (openvibe-sdk/chat-apps): the three chat apps (Telegram, Discord, Slack) in one place — the delivery checks (Telegram's secret token, Slack's v0 signing, Discord's Ed25519), the button callback (signed once, used once, expiring), each app's payload as the same shape and splitText() for a long answer",
++ "chat-apps (openvibe-sdk/chat-apps): the three chat apps (Telegram, Discord, Slack) in one place \u2014 the delivery checks (Telegram's secret token, Slack's v0 signing, Discord's Ed25519), the button callback (signed once, used once, expiring), each app's payload as the same shape and splitText() for a long answer",
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, endpointOffers, removeEndpointOffer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check; an offer is a Link machine's Network report or a standalone OpenAI-compatible endpoint registered on the public seller API",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",
@@@ -38,7 +39,7 @@
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
-- "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
++ "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter \u2014 validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
@@@ -47,8 +48,8 @@
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
-- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps",
-- "streams (openvibe-sdk/streams): the Agent Streams plan S1 — start an agent stream on OpenVibe.Live (live.agent-stream@1) with its publish ticket, renew and revoke that ticket (the kill switch), poll state, post the last frame, set the curtain, switch visibility, end it and mint the view ticket; the publish ticket is a bearer of its own on the desk routes"
++ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, \u00b1300 s) for developer apps",
++ "streams (openvibe-sdk/streams): the Agent Streams plan S1 \u2014 start an agent stream on OpenVibe.Live (live.agent-stream@1) with its publish ticket, renew and revoke that ticket (the kill switch), poll state, post the last frame, set the curtain, switch visibility, end it and mint the view ticket; the publish ticket is a bearer of its own on the desk routes"
],
"updated": "2026-11-11"
}
ddiff --git a/browser.js b/browser.js
iindex 5dd99b5..e66da33 100644
---- a/browser.js
++++ b/browser.js
@@@ -20,6 +20,7 @@ module.exports = {
cam: require('./src/cam'),
mediaUpload: require('./src/media-upload'),
ui: require('./src/ui'),
++ uiLayout: require('./src/ui-layout'),
community: require('./src/community'),
homeFeed: require('./src/home-feed'),
feed: require('./src/feed'),
@@@ -29,6 +30,7 @@ module.exports = {
meme: require('./src/meme'),
vision: require('./src/vision'),
themes: require('./src/themes'),
++ elements: require('./src/elements'),
design: require('./src/design'),
redact: require('./src/redact'),
chat: require('./src/chat'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex a894f94..5fa479e 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.151.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.152.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, community, homeFeed, feed, bot, search, bench, meme, vision, themes, design, redact, chat, ai, credentials, jobs, agents, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -27,29 +27,31 @@ module.exports = {
cam: __load(29),
mediaUpload: __load(30),
ui: __load(31),
-- community: __load(32),
-- homeFeed: __load(33),
-- feed: __load(34),
-- bot: __load(35),
-- search: __load(36),
-- bench: __load(37),
-- meme: __load(38),
-- vision: __load(39),
-- themes: __load(40),
-- design: __load(41),
-- redact: __load(42),
-- chat: __load(43),
-- ai: __load(45),
-- credentials: __load(47),
-- jobs: __load(48),
-- agents: __load(50),
-- tools: __load(51),
-- projects: __load(52),
-- geo: __load(53),
-- history: __load(54),
-- userCard: __load(55),
-- player: __load(56),
-- agentSheet: __load(57),
++ uiLayout: __load(32),
++ community: __load(33),
++ homeFeed: __load(34),
++ feed: __load(35),
++ bot: __load(36),
++ search: __load(37),
++ bench: __load(38),
++ meme: __load(39),
++ vision: __load(40),
++ themes: __load(41),
++ elements: __load(42),
++ design: __load(43),
++ redact: __load(44),
++ chat: __load(45),
++ ai: __load(47),
++ credentials: __load(49),
++ jobs: __load(50),
++ agents: __load(52),
++ tools: __load(53),
++ projects: __load(54),
++ geo: __load(55),
++ history: __load(56),
++ userCard: __load(57),
++ player: __load(58),
++ agentSheet: __load(59),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -64,7 +66,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.151.0';
++const SDK_VERSION = '0.152.0';
module.exports = {
createClient,
@@@ -3624,7 +3626,313 @@ function progress(el, options) {
module.exports = { progress };
},
--// ── 32: src/community.js ────────────────────────────────────────────
++// ── 32: src/ui-layout.js ────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * ui.layout validation and editing (make your own controls design, 2026-11-11, pieces 2-3).
++ *
++ * One function is the authority for a person's arrangement of a surface: the server uses it before
++ * a layout is stored, the editor uses it before Save, and OpenVibe.AI uses it on every answer. It is
++ * plain code with no browser or Node dependency, so the same check runs everywhere.
++ *
++ * validate(catalog, layout) -> { ok, reasons[] } the catalog is ui.surface@1, the layout ui.layout@1
++ * applyDefaults(catalog) -> a valid layout for the default (or named) device
++ * merge(layout, patch) -> a new layout, patched by part id
++ *
++ * The rules, in the words a person sees (the design note's "Can't do that: <reason>"):
++ * - required parts present, visible and at their minimum size;
++ * - anything pressed is at least 44 px, and a part above that gets its own minimum (Stop 64 px);
++ * - nothing covers a required part, and a catalog's never_covers rules hold;
++ * - every tint is a theme token the catalog lists, and every style and binding is one the part allows;
++ * - only parts the catalog has;
++ * - no URL, markup or code anywhere: a layout is data.
++ *
++ * The validator never changes a layout and never fills a missing value in: a caller that wants the
++ * default asks applyDefaults. Reasons are unique and in a stable order, so two runs and two callers
++ * agree on the refusal.
++ */
++
++const DEVICE_PX = { phone: 44, tablet: 44, computer: 44 };
++const DEVICE_COLUMNS = { phone: 4, tablet: 8, computer: 12 };
++const PRESSED_PX = 44;
++const MIN_OPACITY = 0.4;
++const MAX_OPACITY = 1;
++const URL_RE = /(?:https?:\/\/|www\.)/i;
++const SCHEME_RE = /(?:^|[\s"'(])(?:javascript|data|vbscript|file):/i;
++const CODE_RE = /(?:<\s*\/?\s*(?:script|iframe|img|svg|style|a|object|embed)\b|\bon[a-z]{3,}\s*=|\bfunction\s*\(|=>|\beval\s*\(|\brequire\s*\(|\bimport\s*\()/i;
++
++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
++const nameOf = (part) => String((part && part.name) || (part && part.id) || 'that part');
++const unique = (list) => [...new Set(list)];
++
++/** Every string (and object key) in a value, for the no-URL/no-code rule. */
++function stringsIn(value, out = []) {
++ if (typeof value === 'string') { out.push(value); return out; }
++ if (Array.isArray(value)) { for (const v of value) stringsIn(v, out); return out; }
++ if (!isObject(value)) return out;
++ for (const [k, v] of Object.entries(value)) { out.push(k); stringsIn(v, out); }
++ return out;
++}
++
++/** A part's size in cells: grid place (w/h) or dock place (w/h); null when the place is unusable. */
++function sizeOf(place) {
++ if (!isObject(place)) return null;
++ if (place.dock !== undefined) {
++ return Number.isInteger(place.w) && Number.isInteger(place.h) && place.w >= 1 && place.h >= 1 ? { w: place.w, h: place.h } : null;
++ }
++ return Number.isInteger(place.w) && Number.isInteger(place.h) && place.w >= 1 && place.h >= 1 ? { w: place.w, h: place.h } : null;
++}
++
++/** The rectangular cells a visible part occupies, or null for a dock (which covers along its edge). */
++function boxOf(part) {
++ const p = part && part.place;
++ if (!isObject(p) || p.dock !== undefined) return null;
++ if (![p.x, p.y, p.w, p.h].every(Number.isInteger)) return null;
++ if (p.x < 0 || p.y < 0 || p.w < 1 || p.h < 1) return null;
++ return { x: p.x, y: p.y, w: p.w, h: p.h };
++}
++
++function overlaps(a, b) {
++ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
++}
++
++/** Two docked parts cover each other only when they claim the same edge and order. */
++function dockOverlaps(a, b) {
++ const pa = a && a.place;
++ const pb = b && b.place;
++ return isObject(pa) && isObject(pb) && pa.dock !== undefined && pb.dock !== undefined
++ && pa.dock === pb.dock && pa.order === pb.order;
++}
++
++/**
++ * Check one layout against its surface catalog.
++ * @returns {{ ok: boolean, reasons: string[] }}
++ */
++function validate(catalog, layout) {
++ const reasons = [];
++ const fail = (reason) => { if (!reasons.includes(reason)) reasons.push(reason); };
++ if (!isObject(catalog) || !Array.isArray(catalog.parts)) {
++ return { ok: false, reasons: ['The surface catalog is missing.'] };
++ }
++ if (!isObject(layout)) return { ok: false, reasons: ['A layout has to be an object.'] };
++
++ // A layout is data: never a URL, markup or code, anywhere in a name, a value or a key.
++ if (stringsIn(layout).some((s) => URL_RE.test(s) || SCHEME_RE.test(s) || CODE_RE.test(s))) {
++ fail("A layout is data only: it can't carry a URL or code.");
++ }
++
++ if (layout.surface !== catalog.id) fail(`This layout is not for ${catalog.id || 'this surface'}.`);
++ if (layout.surface_version !== catalog.version) {
++ fail(`${catalog.name || 'This surface'} has changed since this layout was made; reset it to the default.`);
++ }
++ const device = layout.device;
++ const devices = Array.isArray(catalog.devices) ? catalog.devices : ['phone', 'tablet', 'computer'];
++ if (!devices.includes(device)) fail(`This surface doesn't come in ${device || 'that device'}.`);
++
++ const columns = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || DEVICE_COLUMNS[device] || 4;
++ const cellPx = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[device]) || DEVICE_PX[device] || 44;
++ const tokens = Array.isArray(catalog.theme_tokens) ? catalog.theme_tokens : [];
++ const byId = new Map(catalog.parts.filter((p) => p && p.id).map((p) => [p.id, p]));
++ const parts = Array.isArray(layout.parts) ? layout.parts : [];
++ const placed = new Map();
++
++ if (!Array.isArray(layout.parts) || layout.parts.length === 0) fail('A layout needs at least one part.');
++
++ for (const placedPart of parts) {
++ if (!isObject(placedPart) || typeof placedPart.id !== 'string') { fail('Every part in a layout needs an id.'); continue; }
++ const source = byId.get(placedPart.id);
++ if (!source) { fail(`${placedPart.id} is not a part of ${catalog.name || 'this surface'}.`); continue; }
++ if (placed.has(placedPart.id)) { fail(`${nameOf(source)} appears more than once.`); continue; }
++ placed.set(placedPart.id, placedPart);
++
++ const name = nameOf(source);
++ if (source.required && placedPart.visible !== true) fail(`${name} has to stay visible.`);
++
++ const place = placedPart.place;
++ if (!isObject(place)) { fail(`${name} needs a place on the surface.`); continue; }
++ const size = sizeOf(place);
++ if (!size) { fail(`${name} needs a size, in cells.`); continue; }
++ if (place.dock !== undefined) {
++ if (!['left', 'right', 'top', 'bottom'].includes(place.dock)) fail(`${name} can only dock to an edge.`);
++ if (!Number.isInteger(place.order) || place.order < 0) fail(`${name} needs a place in the dock order.`);
++ } else {
++ if (!Number.isInteger(place.x) || place.x < 0 || !Number.isInteger(place.y) || place.y < 0) fail(`${name} needs a place on the grid.`);
++ else if (place.x + place.w > columns) fail(`${name} runs past the edge of the ${device} grid.`);
++ }
++ const min = source.min || {};
++ if (size.w < (min.w || 1) || size.h < (min.h || 1)) {
++ fail(`${name} has to be at least ${Math.max(min.w || 1, 1)} × ${Math.max(min.h || 1, 1)} cells.`);
++ }
++ const needPx = Math.max(source.pressed ? PRESSED_PX : 0, Number(min.px) || 0);
++ if (needPx && (size.w * cellPx < needPx || size.h * cellPx < needPx)) fail(`${name} has to be at least ${needPx} px.`);
++
++ const style = isObject(placedPart.style) ? placedPart.style : {};
++ const allowedStyles = isObject(source.styles) ? source.styles : {};
++ if (typeof style.tint !== 'string' || !tokens.includes(style.tint)) {
++ fail(`${name}'s tint has to be one of the theme's tokens${tokens.length ? ` (${tokens.join(', ')})` : ''}.`);
++ } else if (Array.isArray(allowedStyles.tints) && !allowedStyles.tints.includes(style.tint)) {
++ fail(`${name} can only be tinted ${allowedStyles.tints.join(', ')}.`);
++ }
++ if (!['round', 'pill', 'square'].includes(style.shape)) fail(`${name} needs a shape: round, pill or square.`);
++ else if (Array.isArray(allowedStyles.shapes) && !allowedStyles.shapes.includes(style.shape)) {
++ fail(`${name} can only be ${allowedStyles.shapes.join(', ')}.`);
++ }
++ const low = Math.max(MIN_OPACITY, Number(allowedStyles.opacity && allowedStyles.opacity.min) || MIN_OPACITY);
++ const high = Math.min(MAX_OPACITY, Number(allowedStyles.opacity && allowedStyles.opacity.max) || MAX_OPACITY);
++ if (typeof style.opacity !== 'number' || style.opacity < low || style.opacity > high) {
++ fail(`${name}'s opacity has to be between ${low} and ${high}.`);
++ }
++ if (style.label !== true && style.label !== false) fail(`${name} says whether its label is on.`);
++ else if (style.label && allowedStyles.label === false) fail(`${name} can't show a label.`);
++
++ const bindings = isObject(placedPart.bindings) ? placedPart.bindings : {};
++ const allowed = isObject(source.bindings) ? source.bindings : {};
++ for (const kind of ['keys', 'gamepad']) {
++ const list = Array.isArray(bindings[kind]) ? bindings[kind] : [];
++ const ok = Array.isArray(allowed[kind]) ? allowed[kind] : [];
++ const bad = list.filter((b) => !ok.includes(b));
++ if (bad.length) fail(`${name} can't be bound to ${bad.join(', ')}.`);
++ if (new Set(list).size !== list.length) fail(`${name} binds ${kind} more than once.`);
++ }
++ }
++
++ for (const source of catalog.parts) {
++ if (source && source.required && !placed.has(source.id)) fail(`${nameOf(source)} has to stay on the surface.`);
++ }
++
++ // Nothing covers a required part. Only a visible part can cover anything, and a required part
++ // that is itself hidden is already refused above; its neighbours still do not cover it.
++ const visible = parts.filter((p) => p && p.visible === true && placed.has(p.id));
++ for (const required of visible) {
++ const source = byId.get(required.id);
++ if (!source || !source.required) continue;
++ for (const other of visible) {
++ if (other === required || other.id === required.id) continue;
++ const covers = overlaps(boxOf(required) || { x: 0, y: 0, w: 0, h: 0 }, boxOf(other) || { x: 0, y: 0, w: 0, h: 0 })
++ || dockOverlaps(required, other);
++ if (covers) fail(`${nameOf(byId.get(other.id))} covers ${nameOf(source)}.`);
++ }
++ }
++ // A catalog's own never_covers rules, in either direction of the relation it states.
++ for (const [id, source] of byId) {
++ const own = placed.get(id);
++ if (!own || own.visible !== true) continue;
++ for (const targetId of Array.isArray(source.never_covers) ? source.never_covers : []) {
++ const target = placed.get(targetId);
++ if (!target || target.visible !== true) continue;
++ const targetSource = byId.get(targetId);
++ if (overlaps(boxOf(own) || { x: 0, y: 0, w: 0, h: 0 }, boxOf(target) || { x: 0, y: 0, w: 0, h: 0 }) || dockOverlaps(own, target)) {
++ fail(`${nameOf(source)} covers ${nameOf(targetSource || { id: targetId })}.`);
++ }
++ }
++ }
++
++ return { ok: reasons.length === 0, reasons };
++}
++
++/** The cells a part needs at the default size: its own minimum, and at least 44 px when pressed. */
++function cellsFor(part, cellPx) {
++ const min = part.min || {};
++ const px = Math.max(part.pressed ? PRESSED_PX : 0, Number(min.px) || 0);
++ return {
++ w: Math.max(1, Number(min.w) || 1, px ? Math.ceil(px / cellPx) : 0),
++ h: Math.max(1, Number(min.h) || 1, px ? Math.ceil(px / cellPx) : 0),
++ };
++}
++
++/**
++ * The default arrangement of a surface: every catalog part, visible, at its minimum (or a size that
++ * meets the 44 px rule), required parts first, laid out left to right and then down the grid.
++ * @param {object} catalog ui.surface@1
++ * @param {'phone'|'tablet'|'computer'} [device] defaults to the catalog's first device
++ */
++function applyDefaults(catalog, device) {
++ if (!isObject(catalog) || !Array.isArray(catalog.parts)) throw new TypeError('applyDefaults: a ui.surface@1 catalog is required');
++ const chosen = (Array.isArray(catalog.devices) && catalog.devices.includes(device)) ? device : (catalog.devices || ['phone'])[0];
++ const columns = (catalog.grid && catalog.grid.columns && catalog.grid.columns[chosen]) || DEVICE_COLUMNS[chosen] || 4;
++ const cellPx = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[chosen]) || DEVICE_PX[chosen] || 44;
++ const ordered = [...catalog.parts].sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0));
++ let x = 0; let y = 0; let rowHeight = 0;
++ const parts = ordered.map((part) => {
++ const size = cellsFor(part, cellPx);
++ const w = Math.min(columns, size.w);
++ const h = size.h;
++ if (x + w > columns) { x = 0; y += rowHeight; rowHeight = 0; }
++ const place = { x, y, w, h };
++ x += w;
++ rowHeight = Math.max(rowHeight, h);
++ const styles = isObject(part.styles) ? part.styles : {};
++ const bindings = isObject(part.bindings) ? part.bindings : {};
++ const tokens = Array.isArray(catalog.theme_tokens) ? catalog.theme_tokens : [];
++ const tints = Array.isArray(styles.tints) ? styles.tints : [];
++ return {
++ id: part.id,
++ visible: true,
++ place,
++ style: {
++ tint: tints.find((t) => tokens.includes(t)) || tints[0] || tokens[0] || 'surface',
++ shape: (Array.isArray(styles.shapes) && styles.shapes[0]) || 'square',
++ opacity: 1,
++ label: styles.label !== false,
++ },
++ bindings: {
++ keys: Array.isArray(bindings.keys) ? [...bindings.keys] : [],
++ gamepad: Array.isArray(bindings.gamepad) ? [...bindings.gamepad] : [],
++ },
++ };
++ });
++ return {
++ surface: catalog.id,
++ surface_version: catalog.version,
++ device: chosen,
++ name: 'Default',
++ author: { name: 'OpenVibe' },
++ sharing: 'private',
++ parts,
++ };
++}
++
++function mergeInto(base, patch) {
++ if (!isObject(base)) return clone(patch);
++ if (!isObject(patch)) return clone(patch);
++ const out = clone(base);
++ for (const [key, value] of Object.entries(patch)) {
++ if (isObject(value) && isObject(out[key])) out[key] = mergeInto(out[key], value);
++ else out[key] = clone(value);
++ }
++ return out;
++}
++
++/**
++ * A new layout with `patch` applied. `parts` are merged by id: a patch part changes only the fields it
++ * names, and a part the layout does not have is appended. Nothing is mutated, and the result is not
++ * validated (the caller validates before storing or showing it).
++ */
++function merge(layout, patch) {
++ if (!isObject(layout)) throw new TypeError('merge: a layout is required');
++ if (!isObject(patch)) return clone(layout);
++ const out = clone(layout);
++ for (const [key, value] of Object.entries(patch)) {
++ if (key === 'parts' && Array.isArray(value)) {
++ const parts = Array.isArray(out.parts) ? out.parts.map(clone) : [];
++ const index = new Map(parts.map((p, i) => [p && p.id, i]));
++ for (const incoming of value) {
++ if (!isObject(incoming) || typeof incoming.id !== 'string') { parts.push(clone(incoming)); continue; }
++ if (index.has(incoming.id)) parts[index.get(incoming.id)] = mergeInto(parts[index.get(incoming.id)], incoming);
++ else { index.set(incoming.id, parts.length); parts.push(clone(incoming)); }
++ }
++ out.parts = parts;
++ } else if (isObject(value) && isObject(out[key])) out[key] = mergeInto(out[key], value);
++ else out[key] = clone(value);
++ }
++ return out;
++}
++
++module.exports = { validate, applyDefaults, merge, PRESSED_PX };
++},
++// ── 33: src/community.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3787,7 +4095,7 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
},
--// ── 33: src/home-feed.js ────────────────────────────────────────────
++// ── 34: src/home-feed.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/** The menu's portable Home layout, network day counts, what people do now and personal wall. Pass a user-token core
@@@ -3819,7 +4127,7 @@ function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {})
module.exports = { createHomeFeedClient };
},
--// ── 34: src/feed.js ─────────────────────────────────────────────────
++// ── 35: src/feed.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -3837,7 +4145,7 @@ function createFeedClient(client, { communityBaseUrl } = {}) {
module.exports = { createFeedClient };
},
--// ── 35: src/bot.js ──────────────────────────────────────────────────
++// ── 36: src/bot.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4078,7 +4386,7 @@ function createBotClient(client, defaults = {}) {
module.exports = { createBotClient, actingHeaders };
},
--// ── 36: src/search.js ───────────────────────────────────────────────
++// ── 37: src/search.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4224,7 +4532,7 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf, fabricOf, codeOf };
},
--// ── 37: src/bench.js ────────────────────────────────────────────────
++// ── 38: src/bench.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/** openvibe-sdk/bench: OpenVibe.Bench's JSON API and streamed open dataset. Browser-safe. */
@@@ -4332,7 +4640,7 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
},
--// ── 38: src/meme.js ─────────────────────────────────────────────────
++// ── 39: src/meme.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4415,7 +4723,7 @@ function createMemeClient(client, defaults = {}) {
module.exports = { createMemeClient };
},
--// ── 39: src/vision.js ───────────────────────────────────────────────
++// ── 40: src/vision.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4573,7 +4881,7 @@ function pick(o, keys) {
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
},
--// ── 40: src/themes.js ───────────────────────────────────────────────
++// ── 41: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4723,7 +5031,435 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
--// ── 41: src/design.js ───────────────────────────────────────────────
++// ── 42: src/elements.js ─────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/elements — the element runtime every product uses.
++ *
++ * An Element is a look, a recipe (triggers → conditions → actions) and, only when a recipe is not
++ * enough, a mod. This module is the one place that decides whether an element is well formed and
++ * whether a press runs:
++ *
++ * validate(element) → { valid, errors: [{ path, message }] }, plain reasons
++ * run(recipe, trigger, ctx) → the host's decision and the action requests, in order
++ * reduce(state, actions) → the element's own store after screen.counter/screen.state
++ *
++ * `run` is deliberately not a command path of its own. It checks the element's declared
++ * capabilities, the presser's role, the live and robot-turn conditions, the chance, the cooldowns,
++ * the daily caps, then asks the owner (channel points, `ctx.spend`) and only then sends each action
++ * to `ctx.send`. The product that owns an action authorises it again; Bot checks turns, Stop, the
++ * e-stop and the owner's limits, Live checks points and its own route, Chat checks its content rules.
++ * An element never adds a power.
++ *
++ * The host contract (`ctx`) is small on purpose:
++ *
++ * { now?, random?, isLive?, robotInTurn?, subject?, role?,
++ * element: { id?, name?, capabilities: [...] },
++ * actor: { subject?, role?, name? },
++ * limits: { cooldown({scope, elementId, actorId}) → ms remaining,
++ * dailyCount({scope, elementId, actorId, day}) → count,
++ * hit({elementId, actorId, at, day}) → void },
++ * spend({points, elementId, actorId, idempotencyKey, at}) → {ok, reason?, message?},
++ * refund?({...same}) → void,
++ * send(action, {element, actor, trigger, product}) → {ok, result?, reason?, message?} }
++ *
++ * A product that sends an action to a machine it does not own must keep that product's own checks:
++ * this module refuses early where it can, and refuses to send an action the element did not declare.
++ */
++const ROLES = ['everyone', 'follower', 'subscriber', 'vip', 'operator', 'owner'];
++const ROLE_RANK = { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 };
++
++const PRODUCTS = {
++ 'robot.drive_pulse': 'bot', 'robot.head_preset': 'bot', 'robot.headlight': 'bot', 'robot.say': 'bot', 'robot.macro': 'bot',
++ 'chat.message': 'chat', 'chat.pin': 'chat', 'chat.poll': 'chat',
++ 'screen.alert': 'live', 'screen.animation': 'live', 'screen.confetti': 'live', 'screen.sound': 'live',
++ 'screen.tts': 'live', 'screen.counter': 'live', 'screen.state': 'live', 'screen.visibility': 'live',
++ 'screen.scene': 'live', 'stream.clip': 'live', 'stream.moment': 'live', 'stream.title': 'live',
++ 'stream.category': 'live', 'webhook.call': 'live',
++ 'ai.ask': 'ai', 'actor.task': 'actor', 'money.tip_goal': 'billing',
++};
++const ACTION_KINDS = Object.keys(PRODUCTS);
++const TRIGGER_KINDS = [
++ 'press', 'hold', 'double_press', 'key', 'gamepad', 'chat_command', 'chat_text', 'follow', 'tip',
++ 'ai_moment', 'rundown_topic', 'timer', 'schedule', 'robot_telemetry', 'robot_event', 'stream_start',
++ 'stream_end', 'viewer_count', 'element_event',
++];
++const ACTION_SET = new Set(ACTION_KINDS);
++const TRIGGER_SET = new Set(TRIGGER_KINDS);
++const ROBOT_ACTIONS = ACTION_KINDS.filter((k) => k.startsWith('robot.'));
++const ROBOT_SET = new Set(ROBOT_ACTIONS);
++
++const ID_RE = {
++ element: /^el_[0-9A-HJKMNP-TV-Z]{26}$/,
++ mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,
++ key: /^[a-z][a-z0-9_]{0,31}$/,
++ token: /^[a-z][a-z0-9-]{0,39}$/,
++ component: /^[a-z][a-z0-9-]{0,63}$/,
++ icon: /^[a-z][a-z0-9-]{1,31}$/,
++ command: /^![a-z0-9_-]{1,32}$/,
++ hex64: /^[0-9a-f]{64}$/,
++ semver: /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/,
++};
++
++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++const isText = (v, max = 400) => typeof v === 'string' && v.length > 0 && v.length <= max;
++const isInt = (v, min = -Infinity, max = Infinity) => Number.isInteger(v) && v >= min && v <= max;
++const isNum = (v, min = -Infinity, max = Infinity) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
++
++// ── Action and trigger checks ──────────────────────────────────────────────
++// One small check per kind, the same fields ui.action@1 and ui.trigger@1 declare. It is duplicated
++// on purpose: the contract is the wire shape, and this is the host's own last look before it calls
++// a product. A drift is a bug found here or in a consumer's contract check, never a power.
++const ACTIONS = {
++ 'robot.drive_pulse': (a) => (['forward', 'back', 'left', 'right', 'spin_left', 'spin_right'].includes(a.direction) && isInt(a.ms, 50, 3000) && (a.speed === undefined || isNum(a.speed, 0.1, 1))) || 'direction and ms (50–3000) are required',
++ 'robot.head_preset': (a) => ['up', 'center', 'down'].includes(a.preset) || 'preset is up, center or down',
++ 'robot.headlight': (a) => typeof a.on === 'boolean' || 'on is required (true or false)',
++ 'robot.say': (a) => isText(a.text, 300) || 'text (300 characters at most) is required',
++ 'robot.macro': (a) => isText(a.macro, 40) || 'macro is required',
++ 'chat.message': (a) => isText(a.text, 400) || 'text (400 characters at most) is required',
++ 'chat.pin': (a) => ['trigger', 'latest'].includes(a.source) || 'source is trigger or latest',
++ 'chat.poll': (a) => (isText(a.question, 200) && Array.isArray(a.options) && a.options.length >= 2 && a.options.length <= 10 && new Set(a.options).size === a.options.length && a.options.every((o) => isText(o, 80))) || 'question and 2–10 distinct options are required',
++ 'screen.alert': (a) => (isText(a.title, 80) && (a.text === undefined || isText(a.text, 200))) || 'title is required',
++ 'screen.animation': (a) => isText(a.name, 40) || 'name is required',
++ 'screen.confetti': (a) => (a.intensity === undefined || ['low', 'medium', 'high'].includes(a.intensity)) || 'intensity is low, medium or high',
++ 'screen.sound': (a) => (isText(a.sound, 80) && (a.volume === undefined || isNum(a.volume, 0, 1))) || 'sound is required',
++ 'screen.tts': (a) => (isText(a.text, 300) && isText(a.voice, 40)) || 'text and voice are required',
++ 'screen.counter': (a) => (ID_RE.key.test(String(a.key)) && isInt(a.by, -1000, 1000)) || 'key and by (-1000–1000) are required',
++ 'screen.state': (a) => (ID_RE.key.test(String(a.key)) && isValidValue(a.value)) || 'key and a value (a boolean, a number, a short string or a list of those) are required',
++ 'screen.visibility': (a) => (ID_RE.element.test(String(a.element)) && typeof a.visible === 'boolean') || 'element and visible are required',
++ 'screen.scene': (a) => isText(a.scene, 40) || 'scene is required',
++ 'stream.clip': (a) => (a.seconds === undefined || isInt(a.seconds, 5, 30)) || 'seconds is 5–30',
++ 'stream.moment': (a) => (a.label === undefined || isText(a.label, 120)) || 'label is 120 characters at most',
++ 'stream.title': (a) => isText(a.title, 140) || 'title is required',
++ 'stream.category': (a) => isText(a.category, 80) || 'category is required',
++ 'ai.ask': (a) => (isText(a.prompt, 500) && (a.model === undefined || isText(a.model, 80)) && (a.max_cost_usd === undefined || isNum(a.max_cost_usd, 0, 1))) || 'prompt is required',
++ 'actor.task': (a) => isText(a.task, 8000) || 'task is required',
++ 'webhook.call': (a) => /^[a-z][a-z0-9_-]{0,31}$/.test(String(a.endpoint)) || 'endpoint is required (a name, never a URL)',
++ 'money.tip_goal': (a) => isText(a.goal, 40) || 'goal is required',
++};
++
++const TRIGGERS = {
++ press: () => true,
++ hold: (t) => isInt(t.ms, 300, 10000) || 'ms is 300–10000',
++ double_press: (t) => (t.within_ms === undefined || isInt(t.within_ms, 100, 2000)) || 'within_ms is 100–2000',
++ key: (t) => (/^[A-Za-z0-9+_.-]{1,40}$/.test(String(t.key)) && (t.modifiers === undefined || (Array.isArray(t.modifiers) && t.modifiers.length <= 4 && t.modifiers.every((m) => ['ctrl', 'alt', 'shift', 'meta'].includes(m))))) || 'key is required',
++ gamepad: (t) => (/^[a-z][a-z0-9_.-]{0,39}$/.test(String(t.button)) && (t.index === undefined || isInt(t.index, 0, 7))) || 'button is required',
++ chat_command: (t) => ID_RE.command.test(String(t.command)) || 'command is a lowercase !command',
++ chat_text: (t) => (Array.isArray(t.words) && t.words.length >= 1 && t.words.length <= 16 && t.words.every((w) => isText(w, 40))) || 'words (1–16) are required',
++ follow: () => true,
++ tip: (t) => (t.min === undefined || isInt(t.min, 0)) || 'min is a whole number',
++ ai_moment: () => true,
++ rundown_topic: (t) => (t.words === undefined || (Array.isArray(t.words) && t.words.length <= 8 && t.words.every((w) => isText(w, 40)))) || 'words (at most 8) must be text',
++ timer: (t) => (isInt(t.every_seconds, 5, 86400) && (t.start === undefined || ['live', 'always'].includes(t.start))) || 'every_seconds (5–86400) is required',
++ schedule: (t) => (/^[0-9*/, -]{9,80}$/.test(String(t.cron)) && isText(t.timezone, 64)) || 'cron and timezone are required',
++ robot_telemetry: (t) => (['battery_percent', 'latency_ms', 'stuck', 'temperature_c'].includes(t.metric) && ['lt', 'gt'].includes(t.op) && isNum(t.value) && (t.for_seconds === undefined || isInt(t.for_seconds, 0, 3600))) || 'metric, op and value are required',
++ robot_event: (t) => ['estop', 'stopped', 'disconnected', 'command_refused', 'button'].includes(t.event) || 'event is required',
++ stream_start: () => true,
++ stream_end: () => true,
++ viewer_count: (t) => (['above', 'below'].includes(t.op) && isInt(t.count, 0, 100000000)) || 'op and count are required',
++ element_event: (t) => (ID_RE.element.test(String(t.element_id)) && (t.event === undefined || ['pressed', 'state', 'shown'].includes(t.event))) || 'element_id is required',
++};
++
++function isValidValue(v) {
++ if (typeof v === 'boolean') return true;
++ if (typeof v === 'number') return Number.isFinite(v);
++ if (typeof v === 'string') return v.length <= 200;
++ if (Array.isArray(v)) return v.length <= 64 && v.every((x) => (typeof x === 'number' && Number.isFinite(x)) || (typeof x === 'string' && x.length <= 200));
++ return false;
++}
++
++// ── validate ──────────────────────────────────────────────────────────────
++/**
++ * Validate one element (a draft or a stored one) against the catalogs and the element's own
++ * declarations. → { valid, errors: [{ path, message }] }. The messages are plain words the editor
++ * can show after "Can't do that: ".
++ */
++function validate(element) {
++ const errors = [];
++ const bad = (path, message) => { errors.push({ path, message }); };
++ if (!isObject(element)) return { valid: false, errors: [{ path: '', message: 'An element is an object.' }] };
++ if (element.id !== undefined && !ID_RE.element.test(String(element.id))) bad('/id', 'The element id is not an el_ id.');
++ if (!isText(element.name, 80)) bad('/name', 'An element needs a name of 80 characters at most.');
++ if (element.description !== undefined && !isText(element.description, 500)) bad('/description', 'The description is 500 characters at most.');
++ if (!isObject(element.author) || !isText(element.author.name, 80)) bad('/author', 'An element needs an author name.');
++ if (!['private', 'link', 'gallery'].includes(element.sharing)) bad('/sharing', 'Sharing is private, link or gallery.');
++
++ const look = element.look;
++ if (!isObject(look)) bad('/look', 'An element needs a look.');
++ else {
++ if (!ID_RE.component.test(String(look.component))) bad('/look/component', 'The look names a design component.');
++ if (!isText(look.label, 40)) bad('/look/label', 'The label is 1–40 characters.');
++ if (look.icon !== undefined && !ID_RE.icon.test(String(look.icon))) bad('/look/icon', 'The icon names a glyph.');
++ if (look.props !== undefined) {
++ if (!isObject(look.props) || Object.keys(look.props).length > 20) bad('/look/props', 'Props are an object of at most 20 values.');
++ else for (const [k, v] of Object.entries(look.props)) {
++ if (!/^[a-z][a-zA-Z0-9]{0,31}$/.test(k)) bad(`/look/props/${k}`, 'A prop name starts lower-case.');
++ if (!isValidValue(v) || Array.isArray(v)) bad(`/look/props/${k}`, 'A prop is a number, a string (200 characters) or a boolean.');
++ }
++ }
++ const style = look.style;
++ if (style !== undefined) {
++ if (!isObject(style)) bad('/look/style', 'The style is an object.');
++ else {
++ if (style.tint !== undefined && !ID_RE.token.test(String(style.tint))) bad('/look/style/tint', 'The tint is a theme token.');
++ if (style.shape !== undefined && !['round', 'pill', 'square'].includes(style.shape)) bad('/look/style/shape', 'The shape is round, pill or square.');
++ if (style.opacity !== undefined && !isNum(style.opacity, 0.4, 1)) bad('/look/style/opacity', 'The opacity is 0.4–1.');
++ if (style.show_label !== undefined && typeof style.show_label !== 'boolean') bad('/look/style/show_label', 'show_label is a boolean.');
++ }
++ }
++ }
++
++ const recipe = element.recipe;
++ if (!isObject(recipe)) bad('/recipe', 'An element needs a recipe.');
++ else {
++ if (!Array.isArray(recipe.triggers) || recipe.triggers.length < 1 || recipe.triggers.length > 8) bad('/recipe/triggers', 'A recipe has 1–8 triggers.');
++ else recipe.triggers.forEach((t, i) => {
++ if (!isObject(t)) return bad(`/recipe/triggers/${i}`, 'A trigger is an object.');
++ if (!TRIGGER_SET.has(t.kind)) return bad(`/recipe/triggers/${i}/kind`, `"${t.kind}" is not a trigger.`);
++ const r = TRIGGERS[t.kind](t);
++ if (r !== true) bad(`/recipe/triggers/${i}`, r);
++ });
++ if (recipe.conditions !== undefined) {
++ const c = recipe.conditions;
++ if (!isObject(c)) bad('/recipe/conditions', 'Conditions are an object.');
++ else {
++ if (c.role !== undefined && !ROLES.includes(c.role)) bad('/recipe/conditions/role', 'The role is everyone, follower, subscriber, vip, operator or owner.');
++ if (c.cost !== undefined && (!isObject(c.cost) || !isInt(c.cost.points, 1, 1000000))) bad('/recipe/conditions/cost', 'A cost is channel points, 1–1000000.');
++ if (c.cooldown !== undefined) {
++ if (!isObject(c.cooldown)) bad('/recipe/conditions/cooldown', 'Cooldowns are an object.');
++ else {
++ if (c.cooldown.per_person_seconds !== undefined && !isInt(c.cooldown.per_person_seconds, 0, 86400)) bad('/recipe/conditions/cooldown', 'The per-person cooldown is 0–86400 seconds.');
++ if (c.cooldown.overall_seconds !== undefined && !isInt(c.cooldown.overall_seconds, 0, 86400)) bad('/recipe/conditions/cooldown', 'The overall cooldown is 0–86400 seconds.');
++ }
++ }
++ if (c.daily_cap !== undefined) {
++ if (!isObject(c.daily_cap)) bad('/recipe/conditions/daily_cap', 'A daily cap is an object.');
++ else {
++ if (c.daily_cap.per_person !== undefined && !isInt(c.daily_cap.per_person, 1, 10000)) bad('/recipe/conditions/daily_cap', 'The per-person daily cap is 1–10000.');
++ if (c.daily_cap.overall !== undefined && !isInt(c.daily_cap.overall, 1, 1000000)) bad('/recipe/conditions/daily_cap', 'The overall daily cap is 1–1000000.');
++ }
++ }
++ if (c.live_only !== undefined && typeof c.live_only !== 'boolean') bad('/recipe/conditions/live_only', 'live_only is a boolean.');
++ if (c.robot_turn_only !== undefined && typeof c.robot_turn_only !== 'boolean') bad('/recipe/conditions/robot_turn_only', 'robot_turn_only is a boolean.');
++ if (c.chance !== undefined && (!isObject(c.chance) || !isInt(c.chance.one_in, 1, 1000000))) bad('/recipe/conditions/chance', 'A chance is one_in, 1–1000000.');
++ }
++ }
++ if (!Array.isArray(recipe.actions) || recipe.actions.length < 1 || recipe.actions.length > 16) bad('/recipe/actions', 'A recipe has 1–16 actions.');
++ else recipe.actions.forEach((a, i) => {
++ if (!isObject(a)) return bad(`/recipe/actions/${i}`, 'An action is an object.');
++ if (!ACTION_SET.has(a.kind)) return bad(`/recipe/actions/${i}/kind`, `"${a.kind}" is not an action.`);
++ if (a.product !== PRODUCTS[a.kind]) return bad(`/recipe/actions/${i}/product`, `${a.kind} is authorised by ${PRODUCTS[a.kind]} only.`);
++ const r = ACTIONS[a.kind](a);
++ if (r !== true) bad(`/recipe/actions/${i}`, r);
++ });
++ }
++
++ if (!Array.isArray(element.capabilities) || element.capabilities.length < 1 || element.capabilities.length > 32) bad('/capabilities', 'An element declares 1–32 capabilities.');
++ else {
++ if (new Set(element.capabilities).size !== element.capabilities.length) bad('/capabilities', 'A capability is declared once.');
++ for (const c of element.capabilities) if (!ACTION_SET.has(c)) bad('/capabilities', `"${c}" is not an action an element can declare.`);
++ if (isObject(recipe) && Array.isArray(recipe.actions)) {
++ const declared = new Set(element.capabilities);
++ for (const a of recipe.actions) if (isObject(a) && ACTION_SET.has(a.kind) && !declared.has(a.kind)) bad('/recipe/actions', `The recipe uses ${a.kind}, which the element did not declare.`);
++ }
++ }
++
++ if (element.initial_state !== undefined) {
++ if (!isObject(element.initial_state)) bad('/initial_state', 'The initial state is an object.');
++ else {
++ if (Object.keys(element.initial_state).length > 32) bad('/initial_state', 'The state holds at most 32 keys.');
++ for (const [k, v] of Object.entries(element.initial_state)) {
++ if (!ID_RE.key.test(k)) bad(`/initial_state/${k}`, 'A state key is lower-case and 32 characters at most.');
++ if (!isValidValue(v)) bad(`/initial_state/${k}`, 'A state value is a boolean, a number, a short string or a list of those.');
++ }
++ }
++ }
++
++ if (element.mod !== undefined) {
++ const m = element.mod;
++ if (!isObject(m)) bad('/mod', 'The mod reference is an object.');
++ else {
++ if (!ID_RE.mod.test(String(m.id))) bad('/mod/id', 'The mod id is a mod_ id.');
++ if (!ID_RE.semver.test(String(m.version))) bad('/mod/version', 'The mod version is semver.');
++ if (!ID_RE.hex64.test(String(m.sha256))) bad('/mod/sha256', 'The mod digest is a sha256 hex string.');
++ if (m.capabilities !== undefined) {
++ if (!Array.isArray(m.capabilities) || m.capabilities.length > 32) bad('/mod/capabilities', 'The mod capabilities are an array of at most 32.');
++ else {
++ const declared = new Set(Array.isArray(element.capabilities) ? element.capabilities : []);
++ for (const c of m.capabilities) {
++ if (!ACTION_SET.has(c)) bad('/mod/capabilities', `"${c}" is not a capability.`);
++ else if (!declared.has(c)) bad('/mod/capabilities', `The mod asks for ${c}, which the element did not declare.`);
++ }
++ }
++ }
++ }
++ }
++ return { valid: errors.length === 0, errors };
++}
++
++// ── run ───────────────────────────────────────────────────────────────────
++function refuse(code, message, extra = {}) {
++ return { ok: false, code, message, ...extra };
++}
++
++/** Whether the fired trigger is one the recipe listens for. chat_command compares its command. */
++function triggerMatches(recipe, trigger) {
++ if (!isObject(trigger) || !TRIGGER_SET.has(trigger.kind)) return false;
++ return (recipe.triggers || []).some((t) => {
++ if (!isObject(t) || t.kind !== trigger.kind) return false;
++ if (t.kind === 'chat_command') return String(t.command) === String(trigger.command);
++ if (t.kind === 'chat_text') return Array.isArray(t.words) && (!Array.isArray(trigger.words) || trigger.words.some((w) => t.words.includes(w)));
++ if (t.kind === 'element_event') return String(t.element_id) === String(trigger.element_id);
++ if (t.kind === 'viewer_count') return t.op === trigger.op && t.count === trigger.count;
++ if (t.kind === 'robot_event') return t.event === trigger.event;
++ return true;
++ });
++}
++
++/**
++ * Run one fired trigger of one element in the host.
++ *
++ * Returns { ok: true, actions: [{action, result}], pointsSpent } or
++ * { ok: false, code, message, retryAfterMs?, refusedAction?, refunded? }. Codes are:
++ * invalid, trigger, capability, role, not_live, robot_turn, chance, cooldown, daily_cap, points,
++ * limits, action. It never charges money: ctx.spend is the channel-points owner.
++ */
++async function run(recipe, trigger, ctx = {}) {
++ const now = Number.isFinite(ctx.now) ? ctx.now : Date.now();
++ const day = new Date(now).toISOString().slice(0, 10);
++ const element = isObject(ctx.element) ? ctx.element : {};
++ const actor = isObject(ctx.actor) ? ctx.actor : {};
++ const elementId = element.id || '';
++ const actorId = actor.subject || actor.name || '';
++
++ if (!isObject(recipe) || !Array.isArray(recipe.actions) || recipe.actions.length < 1) return refuse('invalid', 'This element has nothing to do.');
++ if (!triggerMatches(recipe, trigger)) return refuse('trigger', 'This element does not listen for that.');
++
++ const declared = new Set(Array.isArray(element.capabilities) ? element.capabilities : []);
++ if (!declared.size) return refuse('capability', 'This element has no declared capabilities.');
++ for (const action of recipe.actions) {
++ if (!isObject(action) || !ACTION_SET.has(action.kind)) return refuse('invalid', 'This element has an action I do not know.');
++ if (!declared.has(action.kind)) return refuse('capability', `This element did not declare ${action.kind}.`, { refusedAction: action.kind });
++ if (action.product !== PRODUCTS[action.kind]) return refuse('capability', `${action.kind} is authorised by ${PRODUCTS[action.kind]} only.`, { refusedAction: action.kind });
++ const bad = ACTIONS[action.kind](action);
++ if (bad !== true) return refuse('invalid', `The ${action.kind} action is not complete: ${bad}.`);
++ }
++
++ const conditions = isObject(recipe.conditions) ? recipe.conditions : {};
++ const role = conditions.role || 'everyone';
++ const actorRole = ROLES.includes(actor.role) ? actor.role : 'everyone';
++ if ((ROLE_RANK[actorRole] || 0) < (ROLE_RANK[role] || 0)) {
++ return refuse('role', role === 'owner' ? 'Only the channel owner can use this.' : `Only ${role}s can use this.`);
++ }
++ if (conditions.live_only && ctx.isLive !== true) return refuse('not_live', 'This element works only while the channel is live.');
++ if (recipe.actions.some((a) => ROBOT_SET.has(a.kind))) {
++ if (ctx.robotInTurn === false) return refuse('robot_turn', 'It is not this person\'s turn with the robot.');
++ if (conditions.robot_turn_only && ctx.robotInTurn !== true) return refuse('robot_turn', 'This element works only during the presser\'s robot turn.');
++ }
++ if (conditions.chance && conditions.chance.one_in > 1) {
++ const random = typeof ctx.random === 'function' ? ctx.random : Math.random;
++ if (Math.floor(random() * conditions.chance.one_in) !== 0) return refuse('chance', 'The odds did not come up this time.');
++ }
++
++ const lim = isObject(ctx.limits) ? ctx.limits : null;
++ const cooldown = conditions.cooldown || {};
++ if (lim && typeof lim.cooldown === 'function') {
++ for (const scope of ['person', 'overall']) {
++ const seconds = scope === 'person' ? cooldown.per_person_seconds : cooldown.overall_seconds;
++ if (!seconds) continue;
++ const left = Number(await lim.cooldown({ scope, elementId, actorId, at: now })) || 0;
++ if (left > 0) return refuse('cooldown', `This is on cooldown; try again in ${Math.ceil(left / 1000)} seconds.`, { retryAfterMs: left, scope });
++ }
++ }
++ const cap = conditions.daily_cap || {};
++ if (lim && typeof lim.dailyCount === 'function') {
++ for (const scope of ['person', 'overall']) {
++ const max = scope === 'person' ? cap.per_person : cap.overall;
++ if (!max) continue;
++ const used = Number(await lim.dailyCount({ scope, elementId, actorId, day })) || 0;
++ if (used >= max) return refuse('daily_cap', 'The daily limit for this element has been reached.', { scope });
++ }
++ }
++
++ const points = isObject(conditions.cost) ? conditions.cost.points : 0;
++ let charged = false;
++ if (points) {
++ if (typeof ctx.spend !== 'function') return refuse('points', `This press costs ${points} channel points, which I cannot check here.`);
++ let paid;
++ try { paid = await ctx.spend({ points, elementId, actorId, subject: actor.subject || null, at: now }); }
++ catch (e) { return refuse('points', (e && e.message) || 'The channel points could not be checked.'); }
++ if (!paid || paid.ok !== true) return refuse('points', (paid && (paid.message || paid.reason)) || `Not enough channel points for this (it needs ${points}).`);
++ charged = true;
++ }
++ if (lim && typeof lim.hit === 'function') {
++ try { await lim.hit({ elementId, actorId, at: now, day }); }
++ catch (e) {
++ if (charged && typeof ctx.refund === 'function') { try { await ctx.refund({ points, elementId, actorId, subject: actor.subject || null, at: now }); } catch { /* the owner's ledger decides */ } }
++ return refuse('limits', 'The element\'s limits could not be recorded, so nothing ran.');
++ }
++ }
++
++ const done = [];
++ for (const action of recipe.actions) {
++ let result;
++ try { result = await ctx.send(action, { element, actor, trigger, product: PRODUCTS[action.kind] }); }
++ catch (e) { result = { ok: false, message: (e && e.message) || 'The product did not answer.' }; }
++ if (!result || result.ok !== true) {
++ let refunded = false;
++ if (charged && typeof ctx.refund === 'function') {
++ try { await ctx.refund({ points, elementId, actorId, subject: actor.subject || null, at: now }); refunded = true; } catch { /* the ledger decides */ }
++ }
++ const why = (result && (result.message || result.reason)) || 'refused';
++ return refuse('action', `${PRODUCTS[action.kind]} refused ${action.kind}: ${why}`, { refusedAction: action.kind, actions: done, refunded, pointsSpent: refunded ? 0 : points });
++ }
++ done.push({ action, result: result.result === undefined ? null : result.result });
++ }
++ return { ok: true, actions: done, pointsSpent: points };
++}
++
++// ── reduce: the element's own store ───────────────────────────────────────
++/**
++ * Apply the state actions among `actions` to an element's store. Returns
++ * { values, revision, changed, errors }: revision grows by one when anything changed, and an
++ * invalid change is reported without touching the store. Only screen.counter and screen.state
++ * touch state; every other action is the product's business.
++ */
++function reduce(state, actions, { maxKeys = 32 } = {}) {
++ const base = isObject(state) ? state : {};
++ const values = isObject(base.values) ? { ...base.values } : {};
++ let revision = isInt(base.revision, 0) ? base.revision : 0;
++ let changed = false;
++ const errors = [];
++ for (const action of Array.isArray(actions) ? actions : []) {
++ if (!isObject(action)) continue;
++ if (action.kind !== 'screen.counter' && action.kind !== 'screen.state') continue;
++ if (!ID_RE.key.test(String(action.key))) { errors.push({ path: `/values/${action.key}`, message: 'A state key is lower-case and 32 characters at most.' }); continue; }
++ if (action.kind === 'screen.counter') {
++ if (!isInt(action.by, -1000, 1000)) { errors.push({ path: `/values/${action.key}`, message: 'A counter moves by a whole number from -1000 to 1000.' }); continue; }
++ const prev = values[action.key] === undefined ? 0 : values[action.key];
++ if (typeof prev !== 'number' || !Number.isFinite(prev)) { errors.push({ path: `/values/${action.key}`, message: 'That counter is not a number.' }); continue; }
++ if (!(action.key in values) && Object.keys(values).length >= maxKeys) { errors.push({ path: `/values/${action.key}`, message: `The state holds at most ${maxKeys} keys.` }); continue; }
++ const next = prev + action.by;
++ if (!Number.isFinite(next)) { errors.push({ path: `/values/${action.key}`, message: 'That counter cannot go further.' }); continue; }
++ if (next !== prev) { values[action.key] = next; changed = true; }
++ } else {
++ if (!isValidValue(action.value)) { errors.push({ path: `/values/${action.key}`, message: 'A state value is a boolean, a number, a short string or a list of those.' }); continue; }
++ if (!(action.key in values) && Object.keys(values).length >= maxKeys) { errors.push({ path: `/values/${action.key}`, message: `The state holds at most ${maxKeys} keys.` }); continue; }
++ if (JSON.stringify(values[action.key]) !== JSON.stringify(action.value)) { values[action.key] = action.value; changed = true; }
++ }
++ }
++ if (changed) revision += 1;
++ return { values, revision, changed, errors };
++}
++
++module.exports = {
++ validate, run, reduce,
++ isValidValue,
++ PRODUCTS, ACTION_KINDS, ACTION_SET, TRIGGER_KINDS, TRIGGER_SET, ROBOT_ACTIONS, ROLES, ROLE_RANK,
++};
++},
++// ── 43: src/design.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -4790,7 +5526,7 @@ function createDesignClient({ accessToken = null, baseUrl = DEFAULT_DESIGN_URL,
module.exports = { createDesignClient, DEFAULT_DESIGN_URL };
},
--// ── 42: src/redact.js ───────────────────────────────────────────────
++// ── 44: src/redact.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5037,7 +5773,7 @@ function onlySecret(text) {
module.exports = { redact, onlySecret };
},
--// ── 43: src/chat.js ─────────────────────────────────────────────────
++// ── 45: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5070,7 +5806,7 @@ function (module, exports) {
const { isOpenVibeError } = __load(3);
const enc = encodeURIComponent;
--const history = __load(44);
++const history = __load(46);
/** The kinds chat.embed@1 names; anything else is not a preview this SDK returns. */
const EMBED_KINDS = new Set(['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game']);
@@@ -5359,7 +6095,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher, history, readEmbeds };
},
--// ── 44: src/chat-history.js ─────────────────────────────────────────
++// ── 46: src/chat-history.js ─────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -5438,7 +6174,7 @@ function attach({ feed, status = null, fetchPage, render, hasMore = true, thresh
module.exports = { pages, attach };
},
--// ── 45: src/ai.js ───────────────────────────────────────────────────
++// ── 47: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5618,7 +6354,7 @@ function createAiClient(client, defaults = {}) {
remove(id) { requireFiles('remove'); return fileObjects.delete(id); },
};
-- const metrics = __load(46).createMetricsClient(call);
++ const metrics = __load(48).createMetricsClient(call);
const api = { tasks, creations, choices, run, turn, admin, runs, batches, files, metrics, TERMINAL, CLASSES };
// Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
for (const op of OPS) {
@@@ -5631,7 +6367,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
},
--// ── 46: src/ai-metrics.js ───────────────────────────────────────────
++// ── 48: src/ai-metrics.js ───────────────────────────────────────────
function (module, exports) {
'use strict';
// Public measured reads: no bearer token, prompt, person or site identifiers.
@@@ -5660,7 +6396,7 @@ function createMetricsClient(call) {
}
module.exports = { createMetricsClient };
},
--// ── 47: src/credentials.js ──────────────────────────────────────────
++// ── 49: src/credentials.js ──────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -5678,7 +6414,7 @@ function createCredentialsClient(client, defaults = {}) {
module.exports = { createCredentialsClient };
},
--// ── 48: src/jobs.js ─────────────────────────────────────────────────
++// ── 50: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5716,7 +6452,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(49);
++const { appendFiles } = __load(51);
const { parseSSE } = __load(25);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -5880,7 +6616,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 49: src/core/form.js ────────────────────────────────────────────
++// ── 51: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5914,7 +6650,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 50: src/agents.js ───────────────────────────────────────────────
++// ── 52: src/agents.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/** Agent run snapshots, AG-UI event streams and approval responses. */
@@@ -5969,7 +6705,7 @@ function createAgentsClient(client, { baseUrl, service = 'network', audience = '
}
module.exports = { createAgentsClient };
},
--// ── 51: src/tools.js ────────────────────────────────────────────────
++// ── 53: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -6003,8 +6739,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(49);
--const { createJobsClient } = __load(48);
++const { isUpload, appendFiles } = __load(51);
++const { createJobsClient } = __load(50);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -6266,7 +7002,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 52: src/projects.js ─────────────────────────────────────────────
++// ── 54: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -6382,7 +7118,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 53: src/geo.js ──────────────────────────────────────────────────
++// ── 55: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -6446,7 +7182,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
module.exports = { createGeoClient };
},
--// ── 54: src/history.js ──────────────────────────────────────────────
++// ── 56: src/history.js ──────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -6474,7 +7210,7 @@ function createHistoryClient(client, { baseUrl } = {}) {
module.exports = { createHistoryClient };
},
--// ── 55: src/user-card.js ────────────────────────────────────────────
++// ── 57: src/user-card.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/** One public Network aggregate. Hidden/unavailable fields stay absent; no credentials are needed. */
@@@ -6490,7 +7226,7 @@ function createUserCardClient(client, { baseUrl } = {}) {
}
module.exports = { createUserCardClient };
},
--// ── 56: src/player.js ───────────────────────────────────────────────
++// ── 58: src/player.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/** Browser facade for Shared's portable live player. */
@@@ -6506,7 +7242,7 @@ function on(listener) {
}
module.exports = { current, handoff, on };
},
--// ── 57: src/agent-sheet.js ──────────────────────────────────────────
++// ── 59: src/agent-sheet.js ──────────────────────────────────────────
function (module, exports) {
'use strict';
/** Owner-only agent sheets through Network's fixed cross-site relay. The caller supplies their own Bearer. */
@@@ -6554,9 +7290,10 @@ export const {
isActingSubjectId, satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
auth, locker, registry, trust, levels, modules,
realtime, media, composer, cam, mediaUpload, ui,
-- community, homeFeed, feed, bot, search, bench,
-- meme, vision, themes, design, redact, chat,
-- ai, credentials, jobs, agents, tools, projects,
-- geo, history, userCard, player, agentSheet,
++ uiLayout, community, homeFeed, feed, bot, search,
++ bench, meme, vision, themes, elements, design,
++ redact, chat, ai, credentials, jobs, agents,
++ tools, projects, geo, history, userCard, player,
++ agentSheet,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 289abdf..9a80bb2 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -169,6 +169,22 @@
"createDownloadClient"
]
},
++ "elements": {
++ "object": [
++ "ACTION_KINDS",
++ "ACTION_SET",
++ "PRODUCTS",
++ "ROBOT_ACTIONS",
++ "ROLES",
++ "ROLE_RANK",
++ "TRIGGER_KINDS",
++ "TRIGGER_SET",
++ "isValidValue",
++ "reduce",
++ "run",
++ "validate"
++ ]
++ },
"events": {
"object": [
"appSource",
@@@ -492,6 +508,14 @@
"progress"
]
},
++ "uiLayout": {
++ "object": [
++ "PRESSED_PX",
++ "applyDefaults",
++ "merge",
++ "validate"
++ ]
++ },
"ulid": "function",
"usage": {
"object": [
@@@ -802,6 +826,57 @@
"contentTypeOf": "function",
"createDownloadClient": "function"
},
++ "openvibe-sdk/elements": {
++ "ACTION_KINDS": "array",
++ "ACTION_SET": "object",
++ "PRODUCTS": {
++ "object": [
++ "actor.task",
++ "ai.ask",
++ "chat.message",
++ "chat.pin",
++ "chat.poll",
++ "money.tip_goal",
++ "robot.drive_pulse",
++ "robot.head_preset",
++ "robot.headlight",
++ "robot.macro",
++ "robot.say",
++ "screen.alert",
++ "screen.animation",
++ "screen.confetti",
++ "screen.counter",
++ "screen.scene",
++ "screen.sound",
++ "screen.state",
++ "screen.tts",
++ "screen.visibility",
++ "stream.category",
++ "stream.clip",
++ "stream.moment",
++ "stream.title",
++ "webhook.call"
++ ]
++ },
++ "ROBOT_ACTIONS": "array",
++ "ROLES": "array",
++ "ROLE_RANK": {
++ "object": [
++ "everyone",
++ "follower",
++ "operator",
++ "owner",
++ "subscriber",
++ "vip"
++ ]
++ },
++ "TRIGGER_KINDS": "array",
++ "TRIGGER_SET": "object",
++ "isValidValue": "function",
++ "reduce": "function",
++ "run": "function",
++ "validate": "function"
++ },
"openvibe-sdk/events": {
"appSource": "function",
"createAppEvents": "function",
@@@ -1317,6 +1392,12 @@
"openvibe-sdk/ui": {
"progress": "function"
},
++ "openvibe-sdk/ui-layout": {
++ "PRESSED_PX": "number",
++ "applyDefaults": "function",
++ "merge": "function",
++ "validate": "function"
++ },
"openvibe-sdk/usage": {
"createUsageReporter": "function",
"usageKey": "function",
ddiff --git a/esm/elements.mjs b/esm/elements.mjs
nnew file mode 100644
iindex 0000000..2fc20f9
---- /dev/null
++++ b/esm/elements.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/elements.js. Do not edit.
++import cjs from '../src/elements.js';
++
++export const {
++ validate, run, reduce, isValidValue, PRODUCTS, ACTION_KINDS,
++ ACTION_SET, TRIGGER_KINDS, TRIGGER_SET, ROBOT_ACTIONS, ROLES, ROLE_RANK,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 214aa9f..4ff6c95 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,15 +7,16 @@ export const {
isActingSubjectId, satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
report, share, locker, auth, registry, identity,
trust, levels, modules, events, realtime, media,
-- composer, cam, mediaUpload, ui, community, homeFeed,
-- feed, bot, search, bench, meme, vision,
-- themes, design, redact, chat, chatApps, ai,
-- credentials, jobs, agents, tools, projects, resources,
-- vip, quest, live, inventory, tips, blog,
-- reviews, wiki, coupons, deals, shoes, trade,
-- space, host, news, food, actor, codes,
-- run, video, pics, work, zone, git,
-- play, download, openre, geo, history, usage,
-- commerce, moneyReasons, telemetry, userCard, player, agentSheet,
++ composer, cam, mediaUpload, ui, uiLayout, community,
++ homeFeed, feed, bot, search, bench, meme,
++ vision, themes, elements, design, redact, chat,
++ chatApps, ai, credentials, jobs, agents, tools,
++ projects, resources, vip, quest, live, inventory,
++ tips, blog, reviews, wiki, coupons, deals,
++ shoes, trade, space, host, news, food,
++ actor, codes, run, video, pics, work,
++ zone, git, play, download, openre, geo,
++ history, usage, commerce, moneyReasons, telemetry, userCard,
++ player, agentSheet,
} = cjs;
export default cjs;
ddiff --git a/esm/ui-layout.mjs b/esm/ui-layout.mjs
nnew file mode 100644
iindex 0000000..ce087ca
---- /dev/null
++++ b/esm/ui-layout.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/ui-layout.js. Do not edit.
++import cjs from '../src/ui-layout.js';
++
++export const {
++ validate, applyDefaults, merge, PRESSED_PX,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 5f3e950..c9de97b 100644
---- a/index.js
++++ b/index.js
@@@ -25,6 +25,7 @@ module.exports = {
cam: require('./src/cam'),
mediaUpload: require('./src/media-upload'),
ui: require('./src/ui'),
++ uiLayout: require('./src/ui-layout'),
community: require('./src/community'),
homeFeed: require('./src/home-feed'),
feed: require('./src/feed'),
@@@ -34,6 +35,7 @@ module.exports = {
meme: require('./src/meme'),
vision: require('./src/vision'),
themes: require('./src/themes'),
++ elements: require('./src/elements'),
design: require('./src/design'),
redact: require('./src/redact'),
chat: require('./src/chat'),
ddiff --git a/package-lock.json b/package-lock.json
iindex cdba301..9757dce 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.151.0",
++ "version": "0.152.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.151.0",
++ "version": "0.152.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 99e38ff..0a92ea0 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.151.0",
++ "version": "0.152.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -34,6 +34,11 @@
"import": "./esm/assertions.mjs",
"default": "./src/assertions.js"
},
++ "./elements": {
++ "types": "./types/elements.d.ts",
++ "import": "./esm/elements.mjs",
++ "default": "./src/elements.js"
++ },
"./registry": {
"types": "./types/registry.d.ts",
"import": "./esm/registry.mjs",
@@@ -99,6 +104,11 @@
"import": "./esm/ui.mjs",
"default": "./src/ui.js"
},
++ "./ui-layout": {
++ "types": "./types/ui-layout.d.ts",
++ "import": "./esm/ui-layout.mjs",
++ "default": "./src/ui-layout.js"
++ },
"./meme": {
"types": "./types/meme.d.ts",
"browser": "./src/meme.js",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex d8ca272..6d30f97 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -40,6 +40,7 @@ const ENTRIES = {
'composer.mjs': '../src/composer.js',
'media-upload.mjs': '../src/media-upload.js',
'ui.mjs': '../src/ui.js',
++ 'ui-layout.mjs': '../src/ui-layout.js',
'community.mjs': '../src/community.js',
'bot.mjs': '../src/bot.js',
'machines.mjs': '../src/machines.js',
@@@ -47,6 +48,7 @@ const ENTRIES = {
'bench.mjs': '../src/bench.js',
'meme.mjs': '../src/meme.js',
'themes.mjs': '../src/themes.js',
++ 'elements.mjs': '../src/elements.js',
'design.mjs': '../src/design.js',
'limits.mjs': '../src/limits.js',
'account-data.mjs': '../src/account-data.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 281df7d..f32a040 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.151.0';
++const SDK_VERSION = '0.152.0';
module.exports = {
createClient,
ddiff --git a/src/elements.js b/src/elements.js
nnew file mode 100644
iindex 0000000..d28bc47
---- /dev/null
++++ b/src/elements.js
@@@ -0,0 +1,425 @@
++'use strict';
++/**
++ * openvibe-sdk/elements — the element runtime every product uses.
++ *
++ * An Element is a look, a recipe (triggers → conditions → actions) and, only when a recipe is not
++ * enough, a mod. This module is the one place that decides whether an element is well formed and
++ * whether a press runs:
++ *
++ * validate(element) → { valid, errors: [{ path, message }] }, plain reasons
++ * run(recipe, trigger, ctx) → the host's decision and the action requests, in order
++ * reduce(state, actions) → the element's own store after screen.counter/screen.state
++ *
++ * `run` is deliberately not a command path of its own. It checks the element's declared
++ * capabilities, the presser's role, the live and robot-turn conditions, the chance, the cooldowns,
++ * the daily caps, then asks the owner (channel points, `ctx.spend`) and only then sends each action
++ * to `ctx.send`. The product that owns an action authorises it again; Bot checks turns, Stop, the
++ * e-stop and the owner's limits, Live checks points and its own route, Chat checks its content rules.
++ * An element never adds a power.
++ *
++ * The host contract (`ctx`) is small on purpose:
++ *
++ * { now?, random?, isLive?, robotInTurn?, subject?, role?,
++ * element: { id?, name?, capabilities: [...] },
++ * actor: { subject?, role?, name? },
++ * limits: { cooldown({scope, elementId, actorId}) → ms remaining,
++ * dailyCount({scope, elementId, actorId, day}) → count,
++ * hit({elementId, actorId, at, day}) → void },
++ * spend({points, elementId, actorId, idempotencyKey, at}) → {ok, reason?, message?},
++ * refund?({...same}) → void,
++ * send(action, {element, actor, trigger, product}) → {ok, result?, reason?, message?} }
++ *
++ * A product that sends an action to a machine it does not own must keep that product's own checks:
++ * this module refuses early where it can, and refuses to send an action the element did not declare.
++ */
++const ROLES = ['everyone', 'follower', 'subscriber', 'vip', 'operator', 'owner'];
++const ROLE_RANK = { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 };
++
++const PRODUCTS = {
++ 'robot.drive_pulse': 'bot', 'robot.head_preset': 'bot', 'robot.headlight': 'bot', 'robot.say': 'bot', 'robot.macro': 'bot',
++ 'chat.message': 'chat', 'chat.pin': 'chat', 'chat.poll': 'chat',
++ 'screen.alert': 'live', 'screen.animation': 'live', 'screen.confetti': 'live', 'screen.sound': 'live',
++ 'screen.tts': 'live', 'screen.counter': 'live', 'screen.state': 'live', 'screen.visibility': 'live',
++ 'screen.scene': 'live', 'stream.clip': 'live', 'stream.moment': 'live', 'stream.title': 'live',
++ 'stream.category': 'live', 'webhook.call': 'live',
++ 'ai.ask': 'ai', 'actor.task': 'actor', 'money.tip_goal': 'billing',
++};
++const ACTION_KINDS = Object.keys(PRODUCTS);
++const TRIGGER_KINDS = [
++ 'press', 'hold', 'double_press', 'key', 'gamepad', 'chat_command', 'chat_text', 'follow', 'tip',
++ 'ai_moment', 'rundown_topic', 'timer', 'schedule', 'robot_telemetry', 'robot_event', 'stream_start',
++ 'stream_end', 'viewer_count', 'element_event',
++];
++const ACTION_SET = new Set(ACTION_KINDS);
++const TRIGGER_SET = new Set(TRIGGER_KINDS);
++const ROBOT_ACTIONS = ACTION_KINDS.filter((k) => k.startsWith('robot.'));
++const ROBOT_SET = new Set(ROBOT_ACTIONS);
++
++const ID_RE = {
++ element: /^el_[0-9A-HJKMNP-TV-Z]{26}$/,
++ mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,
++ key: /^[a-z][a-z0-9_]{0,31}$/,
++ token: /^[a-z][a-z0-9-]{0,39}$/,
++ component: /^[a-z][a-z0-9-]{0,63}$/,
++ icon: /^[a-z][a-z0-9-]{1,31}$/,
++ command: /^![a-z0-9_-]{1,32}$/,
++ hex64: /^[0-9a-f]{64}$/,
++ semver: /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/,
++};
++
++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++const isText = (v, max = 400) => typeof v === 'string' && v.length > 0 && v.length <= max;
++const isInt = (v, min = -Infinity, max = Infinity) => Number.isInteger(v) && v >= min && v <= max;
++const isNum = (v, min = -Infinity, max = Infinity) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
++
++// ── Action and trigger checks ──────────────────────────────────────────────
++// One small check per kind, the same fields ui.action@1 and ui.trigger@1 declare. It is duplicated
++// on purpose: the contract is the wire shape, and this is the host's own last look before it calls
++// a product. A drift is a bug found here or in a consumer's contract check, never a power.
++const ACTIONS = {
++ 'robot.drive_pulse': (a) => (['forward', 'back', 'left', 'right', 'spin_left', 'spin_right'].includes(a.direction) && isInt(a.ms, 50, 3000) && (a.speed === undefined || isNum(a.speed, 0.1, 1))) || 'direction and ms (50–3000) are required',
++ 'robot.head_preset': (a) => ['up', 'center', 'down'].includes(a.preset) || 'preset is up, center or down',
++ 'robot.headlight': (a) => typeof a.on === 'boolean' || 'on is required (true or false)',
++ 'robot.say': (a) => isText(a.text, 300) || 'text (300 characters at most) is required',
++ 'robot.macro': (a) => isText(a.macro, 40) || 'macro is required',
++ 'chat.message': (a) => isText(a.text, 400) || 'text (400 characters at most) is required',
++ 'chat.pin': (a) => ['trigger', 'latest'].includes(a.source) || 'source is trigger or latest',
++ 'chat.poll': (a) => (isText(a.question, 200) && Array.isArray(a.options) && a.options.length >= 2 && a.options.length <= 10 && new Set(a.options).size === a.options.length && a.options.every((o) => isText(o, 80))) || 'question and 2–10 distinct options are required',
++ 'screen.alert': (a) => (isText(a.title, 80) && (a.text === undefined || isText(a.text, 200))) || 'title is required',
++ 'screen.animation': (a) => isText(a.name, 40) || 'name is required',
++ 'screen.confetti': (a) => (a.intensity === undefined || ['low', 'medium', 'high'].includes(a.intensity)) || 'intensity is low, medium or high',
++ 'screen.sound': (a) => (isText(a.sound, 80) && (a.volume === undefined || isNum(a.volume, 0, 1))) || 'sound is required',
++ 'screen.tts': (a) => (isText(a.text, 300) && isText(a.voice, 40)) || 'text and voice are required',
++ 'screen.counter': (a) => (ID_RE.key.test(String(a.key)) && isInt(a.by, -1000, 1000)) || 'key and by (-1000–1000) are required',
++ 'screen.state': (a) => (ID_RE.key.test(String(a.key)) && isValidValue(a.value)) || 'key and a value (a boolean, a number, a short string or a list of those) are required',
++ 'screen.visibility': (a) => (ID_RE.element.test(String(a.element)) && typeof a.visible === 'boolean') || 'element and visible are required',
++ 'screen.scene': (a) => isText(a.scene, 40) || 'scene is required',
++ 'stream.clip': (a) => (a.seconds === undefined || isInt(a.seconds, 5, 30)) || 'seconds is 5–30',
++ 'stream.moment': (a) => (a.label === undefined || isText(a.label, 120)) || 'label is 120 characters at most',
++ 'stream.title': (a) => isText(a.title, 140) || 'title is required',
++ 'stream.category': (a) => isText(a.category, 80) || 'category is required',
++ 'ai.ask': (a) => (isText(a.prompt, 500) && (a.model === undefined || isText(a.model, 80)) && (a.max_cost_usd === undefined || isNum(a.max_cost_usd, 0, 1))) || 'prompt is required',
++ 'actor.task': (a) => isText(a.task, 8000) || 'task is required',
++ 'webhook.call': (a) => /^[a-z][a-z0-9_-]{0,31}$/.test(String(a.endpoint)) || 'endpoint is required (a name, never a URL)',
++ 'money.tip_goal': (a) => isText(a.goal, 40) || 'goal is required',
++};
++
++const TRIGGERS = {
++ press: () => true,
++ hold: (t) => isInt(t.ms, 300, 10000) || 'ms is 300–10000',
++ double_press: (t) => (t.within_ms === undefined || isInt(t.within_ms, 100, 2000)) || 'within_ms is 100–2000',
++ key: (t) => (/^[A-Za-z0-9+_.-]{1,40}$/.test(String(t.key)) && (t.modifiers === undefined || (Array.isArray(t.modifiers) && t.modifiers.length <= 4 && t.modifiers.every((m) => ['ctrl', 'alt', 'shift', 'meta'].includes(m))))) || 'key is required',
++ gamepad: (t) => (/^[a-z][a-z0-9_.-]{0,39}$/.test(String(t.button)) && (t.index === undefined || isInt(t.index, 0, 7))) || 'button is required',
++ chat_command: (t) => ID_RE.command.test(String(t.command)) || 'command is a lowercase !command',
++ chat_text: (t) => (Array.isArray(t.words) && t.words.length >= 1 && t.words.length <= 16 && t.words.every((w) => isText(w, 40))) || 'words (1–16) are required',
++ follow: () => true,
++ tip: (t) => (t.min === undefined || isInt(t.min, 0)) || 'min is a whole number',
++ ai_moment: () => true,
++ rundown_topic: (t) => (t.words === undefined || (Array.isArray(t.words) && t.words.length <= 8 && t.words.every((w) => isText(w, 40)))) || 'words (at most 8) must be text',
++ timer: (t) => (isInt(t.every_seconds, 5, 86400) && (t.start === undefined || ['live', 'always'].includes(t.start))) || 'every_seconds (5–86400) is required',
++ schedule: (t) => (/^[0-9*/, -]{9,80}$/.test(String(t.cron)) && isText(t.timezone, 64)) || 'cron and timezone are required',
++ robot_telemetry: (t) => (['battery_percent', 'latency_ms', 'stuck', 'temperature_c'].includes(t.metric) && ['lt', 'gt'].includes(t.op) && isNum(t.value) && (t.for_seconds === undefined || isInt(t.for_seconds, 0, 3600))) || 'metric, op and value are required',
++ robot_event: (t) => ['estop', 'stopped', 'disconnected', 'command_refused', 'button'].includes(t.event) || 'event is required',
++ stream_start: () => true,
++ stream_end: () => true,
++ viewer_count: (t) => (['above', 'below'].includes(t.op) && isInt(t.count, 0, 100000000)) || 'op and count are required',
++ element_event: (t) => (ID_RE.element.test(String(t.element_id)) && (t.event === undefined || ['pressed', 'state', 'shown'].includes(t.event))) || 'element_id is required',
++};
++
++function isValidValue(v) {
++ if (typeof v === 'boolean') return true;
++ if (typeof v === 'number') return Number.isFinite(v);
++ if (typeof v === 'string') return v.length <= 200;
++ if (Array.isArray(v)) return v.length <= 64 && v.every((x) => (typeof x === 'number' && Number.isFinite(x)) || (typeof x === 'string' && x.length <= 200));
++ return false;
++}
++
++// ── validate ──────────────────────────────────────────────────────────────
++/**
++ * Validate one element (a draft or a stored one) against the catalogs and the element's own
++ * declarations. → { valid, errors: [{ path, message }] }. The messages are plain words the editor
++ * can show after "Can't do that: ".
++ */
++function validate(element) {
++ const errors = [];
++ const bad = (path, message) => { errors.push({ path, message }); };
++ if (!isObject(element)) return { valid: false, errors: [{ path: '', message: 'An element is an object.' }] };
++ if (element.id !== undefined && !ID_RE.element.test(String(element.id))) bad('/id', 'The element id is not an el_ id.');
++ if (!isText(element.name, 80)) bad('/name', 'An element needs a name of 80 characters at most.');
++ if (element.description !== undefined && !isText(element.description, 500)) bad('/description', 'The description is 500 characters at most.');
++ if (!isObject(element.author) || !isText(element.author.name, 80)) bad('/author', 'An element needs an author name.');
++ if (!['private', 'link', 'gallery'].includes(element.sharing)) bad('/sharing', 'Sharing is private, link or gallery.');
++
++ const look = element.look;
++ if (!isObject(look)) bad('/look', 'An element needs a look.');
++ else {
++ if (!ID_RE.component.test(String(look.component))) bad('/look/component', 'The look names a design component.');
++ if (!isText(look.label, 40)) bad('/look/label', 'The label is 1–40 characters.');
++ if (look.icon !== undefined && !ID_RE.icon.test(String(look.icon))) bad('/look/icon', 'The icon names a glyph.');
++ if (look.props !== undefined) {
++ if (!isObject(look.props) || Object.keys(look.props).length > 20) bad('/look/props', 'Props are an object of at most 20 values.');
++ else for (const [k, v] of Object.entries(look.props)) {
++ if (!/^[a-z][a-zA-Z0-9]{0,31}$/.test(k)) bad(`/look/props/${k}`, 'A prop name starts lower-case.');
++ if (!isValidValue(v) || Array.isArray(v)) bad(`/look/props/${k}`, 'A prop is a number, a string (200 characters) or a boolean.');
++ }
++ }
++ const style = look.style;
++ if (style !== undefined) {
++ if (!isObject(style)) bad('/look/style', 'The style is an object.');
++ else {
++ if (style.tint !== undefined && !ID_RE.token.test(String(style.tint))) bad('/look/style/tint', 'The tint is a theme token.');
++ if (style.shape !== undefined && !['round', 'pill', 'square'].includes(style.shape)) bad('/look/style/shape', 'The shape is round, pill or square.');
++ if (style.opacity !== undefined && !isNum(style.opacity, 0.4, 1)) bad('/look/style/opacity', 'The opacity is 0.4–1.');
++ if (style.show_label !== undefined && typeof style.show_label !== 'boolean') bad('/look/style/show_label', 'show_label is a boolean.');
++ }
++ }
++ }
++
++ const recipe = element.recipe;
++ if (!isObject(recipe)) bad('/recipe', 'An element needs a recipe.');
++ else {
++ if (!Array.isArray(recipe.triggers) || recipe.triggers.length < 1 || recipe.triggers.length > 8) bad('/recipe/triggers', 'A recipe has 1–8 triggers.');
++ else recipe.triggers.forEach((t, i) => {
++ if (!isObject(t)) return bad(`/recipe/triggers/${i}`, 'A trigger is an object.');
++ if (!TRIGGER_SET.has(t.kind)) return bad(`/recipe/triggers/${i}/kind`, `"${t.kind}" is not a trigger.`);
++ const r = TRIGGERS[t.kind](t);
++ if (r !== true) bad(`/recipe/triggers/${i}`, r);
++ });
++ if (recipe.conditions !== undefined) {
++ const c = recipe.conditions;
++ if (!isObject(c)) bad('/recipe/conditions', 'Conditions are an object.');
++ else {
++ if (c.role !== undefined && !ROLES.includes(c.role)) bad('/recipe/conditions/role', 'The role is everyone, follower, subscriber, vip, operator or owner.');
++ if (c.cost !== undefined && (!isObject(c.cost) || !isInt(c.cost.points, 1, 1000000))) bad('/recipe/conditions/cost', 'A cost is channel points, 1–1000000.');
++ if (c.cooldown !== undefined) {
++ if (!isObject(c.cooldown)) bad('/recipe/conditions/cooldown', 'Cooldowns are an object.');
++ else {
++ if (c.cooldown.per_person_seconds !== undefined && !isInt(c.cooldown.per_person_seconds, 0, 86400)) bad('/recipe/conditions/cooldown', 'The per-person cooldown is 0–86400 seconds.');
++ if (c.cooldown.overall_seconds !== undefined && !isInt(c.cooldown.overall_seconds, 0, 86400)) bad('/recipe/conditions/cooldown', 'The overall cooldown is 0–86400 seconds.');
++ }
++ }
++ if (c.daily_cap !== undefined) {
++ if (!isObject(c.daily_cap)) bad('/recipe/conditions/daily_cap', 'A daily cap is an object.');
++ else {
++ if (c.daily_cap.per_person !== undefined && !isInt(c.daily_cap.per_person, 1, 10000)) bad('/recipe/conditions/daily_cap', 'The per-person daily cap is 1–10000.');
++ if (c.daily_cap.overall !== undefined && !isInt(c.daily_cap.overall, 1, 1000000)) bad('/recipe/conditions/daily_cap', 'The overall daily cap is 1–1000000.');
++ }
++ }
++ if (c.live_only !== undefined && typeof c.live_only !== 'boolean') bad('/recipe/conditions/live_only', 'live_only is a boolean.');
++ if (c.robot_turn_only !== undefined && typeof c.robot_turn_only !== 'boolean') bad('/recipe/conditions/robot_turn_only', 'robot_turn_only is a boolean.');
++ if (c.chance !== undefined && (!isObject(c.chance) || !isInt(c.chance.one_in, 1, 1000000))) bad('/recipe/conditions/chance', 'A chance is one_in, 1–1000000.');
++ }
++ }
++ if (!Array.isArray(recipe.actions) || recipe.actions.length < 1 || recipe.actions.length > 16) bad('/recipe/actions', 'A recipe has 1–16 actions.');
++ else recipe.actions.forEach((a, i) => {
++ if (!isObject(a)) return bad(`/recipe/actions/${i}`, 'An action is an object.');
++ if (!ACTION_SET.has(a.kind)) return bad(`/recipe/actions/${i}/kind`, `"${a.kind}" is not an action.`);
++ if (a.product !== PRODUCTS[a.kind]) return bad(`/recipe/actions/${i}/product`, `${a.kind} is authorised by ${PRODUCTS[a.kind]} only.`);
++ const r = ACTIONS[a.kind](a);
++ if (r !== true) bad(`/recipe/actions/${i}`, r);
++ });
++ }
++
++ if (!Array.isArray(element.capabilities) || element.capabilities.length < 1 || element.capabilities.length > 32) bad('/capabilities', 'An element declares 1–32 capabilities.');
++ else {
++ if (new Set(element.capabilities).size !== element.capabilities.length) bad('/capabilities', 'A capability is declared once.');
++ for (const c of element.capabilities) if (!ACTION_SET.has(c)) bad('/capabilities', `"${c}" is not an action an element can declare.`);
++ if (isObject(recipe) && Array.isArray(recipe.actions)) {
++ const declared = new Set(element.capabilities);
++ for (const a of recipe.actions) if (isObject(a) && ACTION_SET.has(a.kind) && !declared.has(a.kind)) bad('/recipe/actions', `The recipe uses ${a.kind}, which the element did not declare.`);
++ }
++ }
++
++ if (element.initial_state !== undefined) {
++ if (!isObject(element.initial_state)) bad('/initial_state', 'The initial state is an object.');
++ else {
++ if (Object.keys(element.initial_state).length > 32) bad('/initial_state', 'The state holds at most 32 keys.');
++ for (const [k, v] of Object.entries(element.initial_state)) {
++ if (!ID_RE.key.test(k)) bad(`/initial_state/${k}`, 'A state key is lower-case and 32 characters at most.');
++ if (!isValidValue(v)) bad(`/initial_state/${k}`, 'A state value is a boolean, a number, a short string or a list of those.');
++ }
++ }
++ }
++
++ if (element.mod !== undefined) {
++ const m = element.mod;
++ if (!isObject(m)) bad('/mod', 'The mod reference is an object.');
++ else {
++ if (!ID_RE.mod.test(String(m.id))) bad('/mod/id', 'The mod id is a mod_ id.');
++ if (!ID_RE.semver.test(String(m.version))) bad('/mod/version', 'The mod version is semver.');
++ if (!ID_RE.hex64.test(String(m.sha256))) bad('/mod/sha256', 'The mod digest is a sha256 hex string.');
++ if (m.capabilities !== undefined) {
++ if (!Array.isArray(m.capabilities) || m.capabilities.length > 32) bad('/mod/capabilities', 'The mod capabilities are an array of at most 32.');
++ else {
++ const declared = new Set(Array.isArray(element.capabilities) ? element.capabilities : []);
++ for (const c of m.capabilities) {
++ if (!ACTION_SET.has(c)) bad('/mod/capabilities', `"${c}" is not a capability.`);
++ else if (!declared.has(c)) bad('/mod/capabilities', `The mod asks for ${c}, which the element did not declare.`);
++ }
++ }
++ }
++ }
++ }
++ return { valid: errors.length === 0, errors };
++}
++
++// ── run ───────────────────────────────────────────────────────────────────
++function refuse(code, message, extra = {}) {
++ return { ok: false, code, message, ...extra };
++}
++
++/** Whether the fired trigger is one the recipe listens for. chat_command compares its command. */
++function triggerMatches(recipe, trigger) {
++ if (!isObject(trigger) || !TRIGGER_SET.has(trigger.kind)) return false;
++ return (recipe.triggers || []).some((t) => {
++ if (!isObject(t) || t.kind !== trigger.kind) return false;
++ if (t.kind === 'chat_command') return String(t.command) === String(trigger.command);
++ if (t.kind === 'chat_text') return Array.isArray(t.words) && (!Array.isArray(trigger.words) || trigger.words.some((w) => t.words.includes(w)));
++ if (t.kind === 'element_event') return String(t.element_id) === String(trigger.element_id);
++ if (t.kind === 'viewer_count') return t.op === trigger.op && t.count === trigger.count;
++ if (t.kind === 'robot_event') return t.event === trigger.event;
++ return true;
++ });
++}
++
++/**
++ * Run one fired trigger of one element in the host.
++ *
++ * Returns { ok: true, actions: [{action, result}], pointsSpent } or
++ * { ok: false, code, message, retryAfterMs?, refusedAction?, refunded? }. Codes are:
++ * invalid, trigger, capability, role, not_live, robot_turn, chance, cooldown, daily_cap, points,
++ * limits, action. It never charges money: ctx.spend is the channel-points owner.
++ */
++async function run(recipe, trigger, ctx = {}) {
++ const now = Number.isFinite(ctx.now) ? ctx.now : Date.now();
++ const day = new Date(now).toISOString().slice(0, 10);
++ const element = isObject(ctx.element) ? ctx.element : {};
++ const actor = isObject(ctx.actor) ? ctx.actor : {};
++ const elementId = element.id || '';
++ const actorId = actor.subject || actor.name || '';
++
++ if (!isObject(recipe) || !Array.isArray(recipe.actions) || recipe.actions.length < 1) return refuse('invalid', 'This element has nothing to do.');
++ if (!triggerMatches(recipe, trigger)) return refuse('trigger', 'This element does not listen for that.');
++
++ const declared = new Set(Array.isArray(element.capabilities) ? element.capabilities : []);
++ if (!declared.size) return refuse('capability', 'This element has no declared capabilities.');
++ for (const action of recipe.actions) {
++ if (!isObject(action) || !ACTION_SET.has(action.kind)) return refuse('invalid', 'This element has an action I do not know.');
++ if (!declared.has(action.kind)) return refuse('capability', `This element did not declare ${action.kind}.`, { refusedAction: action.kind });
++ if (action.product !== PRODUCTS[action.kind]) return refuse('capability', `${action.kind} is authorised by ${PRODUCTS[action.kind]} only.`, { refusedAction: action.kind });
++ const bad = ACTIONS[action.kind](action);
++ if (bad !== true) return refuse('invalid', `The ${action.kind} action is not complete: ${bad}.`);
++ }
++
++ const conditions = isObject(recipe.conditions) ? recipe.conditions : {};
++ const role = conditions.role || 'everyone';
++ const actorRole = ROLES.includes(actor.role) ? actor.role : 'everyone';
++ if ((ROLE_RANK[actorRole] || 0) < (ROLE_RANK[role] || 0)) {
++ return refuse('role', role === 'owner' ? 'Only the channel owner can use this.' : `Only ${role}s can use this.`);
++ }
++ if (conditions.live_only && ctx.isLive !== true) return refuse('not_live', 'This element works only while the channel is live.');
++ if (recipe.actions.some((a) => ROBOT_SET.has(a.kind))) {
++ if (ctx.robotInTurn === false) return refuse('robot_turn', 'It is not this person\'s turn with the robot.');
++ if (conditions.robot_turn_only && ctx.robotInTurn !== true) return refuse('robot_turn', 'This element works only during the presser\'s robot turn.');
++ }
++ if (conditions.chance && conditions.chance.one_in > 1) {
++ const random = typeof ctx.random === 'function' ? ctx.random : Math.random;
++ if (Math.floor(random() * conditions.chance.one_in) !== 0) return refuse('chance', 'The odds did not come up this time.');
++ }
++
++ const lim = isObject(ctx.limits) ? ctx.limits : null;
++ const cooldown = conditions.cooldown || {};
++ if (lim && typeof lim.cooldown === 'function') {
++ for (const scope of ['person', 'overall']) {
++ const seconds = scope === 'person' ? cooldown.per_person_seconds : cooldown.overall_seconds;
++ if (!seconds) continue;
++ const left = Number(await lim.cooldown({ scope, elementId, actorId, at: now })) || 0;
++ if (left > 0) return refuse('cooldown', `This is on cooldown; try again in ${Math.ceil(left / 1000)} seconds.`, { retryAfterMs: left, scope });
++ }
++ }
++ const cap = conditions.daily_cap || {};
++ if (lim && typeof lim.dailyCount === 'function') {
++ for (const scope of ['person', 'overall']) {
++ const max = scope === 'person' ? cap.per_person : cap.overall;
++ if (!max) continue;
++ const used = Number(await lim.dailyCount({ scope, elementId, actorId, day })) || 0;
++ if (used >= max) return refuse('daily_cap', 'The daily limit for this element has been reached.', { scope });
++ }
++ }
++
++ const points = isObject(conditions.cost) ? conditions.cost.points : 0;
++ let charged = false;
++ if (points) {
++ if (typeof ctx.spend !== 'function') return refuse('points', `This press costs ${points} channel points, which I cannot check here.`);
++ let paid;
++ try { paid = await ctx.spend({ points, elementId, actorId, subject: actor.subject || null, at: now }); }
++ catch (e) { return refuse('points', (e && e.message) || 'The channel points could not be checked.'); }
++ if (!paid || paid.ok !== true) return refuse('points', (paid && (paid.message || paid.reason)) || `Not enough channel points for this (it needs ${points}).`);
++ charged = true;
++ }
++ if (lim && typeof lim.hit === 'function') {
++ try { await lim.hit({ elementId, actorId, at: now, day }); }
++ catch (e) {
++ if (charged && typeof ctx.refund === 'function') { try { await ctx.refund({ points, elementId, actorId, subject: actor.subject || null, at: now }); } catch { /* the owner's ledger decides */ } }
++ return refuse('limits', 'The element\'s limits could not be recorded, so nothing ran.');
++ }
++ }
++
++ const done = [];
++ for (const action of recipe.actions) {
++ let result;
++ try { result = await ctx.send(action, { element, actor, trigger, product: PRODUCTS[action.kind] }); }
++ catch (e) { result = { ok: false, message: (e && e.message) || 'The product did not answer.' }; }
++ if (!result || result.ok !== true) {
++ let refunded = false;
++ if (charged && typeof ctx.refund === 'function') {
++ try { await ctx.refund({ points, elementId, actorId, subject: actor.subject || null, at: now }); refunded = true; } catch { /* the ledger decides */ }
++ }
++ const why = (result && (result.message || result.reason)) || 'refused';
++ return refuse('action', `${PRODUCTS[action.kind]} refused ${action.kind}: ${why}`, { refusedAction: action.kind, actions: done, refunded, pointsSpent: refunded ? 0 : points });
++ }
++ done.push({ action, result: result.result === undefined ? null : result.result });
++ }
++ return { ok: true, actions: done, pointsSpent: points };
++}
++
++// ── reduce: the element's own store ───────────────────────────────────────
++/**
++ * Apply the state actions among `actions` to an element's store. Returns
++ * { values, revision, changed, errors }: revision grows by one when anything changed, and an
++ * invalid change is reported without touching the store. Only screen.counter and screen.state
++ * touch state; every other action is the product's business.
++ */
++function reduce(state, actions, { maxKeys = 32 } = {}) {
++ const base = isObject(state) ? state : {};
++ const values = isObject(base.values) ? { ...base.values } : {};
++ let revision = isInt(base.revision, 0) ? base.revision : 0;
++ let changed = false;
++ const errors = [];
++ for (const action of Array.isArray(actions) ? actions : []) {
++ if (!isObject(action)) continue;
++ if (action.kind !== 'screen.counter' && action.kind !== 'screen.state') continue;
++ if (!ID_RE.key.test(String(action.key))) { errors.push({ path: `/values/${action.key}`, message: 'A state key is lower-case and 32 characters at most.' }); continue; }
++ if (action.kind === 'screen.counter') {
++ if (!isInt(action.by, -1000, 1000)) { errors.push({ path: `/values/${action.key}`, message: 'A counter moves by a whole number from -1000 to 1000.' }); continue; }
++ const prev = values[action.key] === undefined ? 0 : values[action.key];
++ if (typeof prev !== 'number' || !Number.isFinite(prev)) { errors.push({ path: `/values/${action.key}`, message: 'That counter is not a number.' }); continue; }
++ if (!(action.key in values) && Object.keys(values).length >= maxKeys) { errors.push({ path: `/values/${action.key}`, message: `The state holds at most ${maxKeys} keys.` }); continue; }
++ const next = prev + action.by;
++ if (!Number.isFinite(next)) { errors.push({ path: `/values/${action.key}`, message: 'That counter cannot go further.' }); continue; }
++ if (next !== prev) { values[action.key] = next; changed = true; }
++ } else {
++ if (!isValidValue(action.value)) { errors.push({ path: `/values/${action.key}`, message: 'A state value is a boolean, a number, a short string or a list of those.' }); continue; }
++ if (!(action.key in values) && Object.keys(values).length >= maxKeys) { errors.push({ path: `/values/${action.key}`, message: `The state holds at most ${maxKeys} keys.` }); continue; }
++ if (JSON.stringify(values[action.key]) !== JSON.stringify(action.value)) { values[action.key] = action.value; changed = true; }
++ }
++ }
++ if (changed) revision += 1;
++ return { values, revision, changed, errors };
++}
++
++module.exports = {
++ validate, run, reduce,
++ isValidValue,
++ PRODUCTS, ACTION_KINDS, ACTION_SET, TRIGGER_KINDS, TRIGGER_SET, ROBOT_ACTIONS, ROLES, ROLE_RANK,
++};
ddiff --git a/src/ui-layout.js b/src/ui-layout.js
nnew file mode 100644
iindex 0000000..3d30a9b
---- /dev/null
++++ b/src/ui-layout.js
@@@ -0,0 +1,303 @@
++'use strict';
++/**
++ * ui.layout validation and editing (make your own controls design, 2026-11-11, pieces 2-3).
++ *
++ * One function is the authority for a person's arrangement of a surface: the server uses it before
++ * a layout is stored, the editor uses it before Save, and OpenVibe.AI uses it on every answer. It is
++ * plain code with no browser or Node dependency, so the same check runs everywhere.
++ *
++ * validate(catalog, layout) -> { ok, reasons[] } the catalog is ui.surface@1, the layout ui.layout@1
++ * applyDefaults(catalog) -> a valid layout for the default (or named) device
++ * merge(layout, patch) -> a new layout, patched by part id
++ *
++ * The rules, in the words a person sees (the design note's "Can't do that: <reason>"):
++ * - required parts present, visible and at their minimum size;
++ * - anything pressed is at least 44 px, and a part above that gets its own minimum (Stop 64 px);
++ * - nothing covers a required part, and a catalog's never_covers rules hold;
++ * - every tint is a theme token the catalog lists, and every style and binding is one the part allows;
++ * - only parts the catalog has;
++ * - no URL, markup or code anywhere: a layout is data.
++ *
++ * The validator never changes a layout and never fills a missing value in: a caller that wants the
++ * default asks applyDefaults. Reasons are unique and in a stable order, so two runs and two callers
++ * agree on the refusal.
++ */
++
++const DEVICE_PX = { phone: 44, tablet: 44, computer: 44 };
++const DEVICE_COLUMNS = { phone: 4, tablet: 8, computer: 12 };
++const PRESSED_PX = 44;
++const MIN_OPACITY = 0.4;
++const MAX_OPACITY = 1;
++const URL_RE = /(?:https?:\/\/|www\.)/i;
++const SCHEME_RE = /(?:^|[\s"'(])(?:javascript|data|vbscript|file):/i;
++const CODE_RE = /(?:<\s*\/?\s*(?:script|iframe|img|svg|style|a|object|embed)\b|\bon[a-z]{3,}\s*=|\bfunction\s*\(|=>|\beval\s*\(|\brequire\s*\(|\bimport\s*\()/i;
++
++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
++const nameOf = (part) => String((part && part.name) || (part && part.id) || 'that part');
++const unique = (list) => [...new Set(list)];
++
++/** Every string (and object key) in a value, for the no-URL/no-code rule. */
++function stringsIn(value, out = []) {
++ if (typeof value === 'string') { out.push(value); return out; }
++ if (Array.isArray(value)) { for (const v of value) stringsIn(v, out); return out; }
++ if (!isObject(value)) return out;
++ for (const [k, v] of Object.entries(value)) { out.push(k); stringsIn(v, out); }
++ return out;
++}
++
++/** A part's size in cells: grid place (w/h) or dock place (w/h); null when the place is unusable. */
++function sizeOf(place) {
++ if (!isObject(place)) return null;
++ if (place.dock !== undefined) {
++ return Number.isInteger(place.w) && Number.isInteger(place.h) && place.w >= 1 && place.h >= 1 ? { w: place.w, h: place.h } : null;
++ }
++ return Number.isInteger(place.w) && Number.isInteger(place.h) && place.w >= 1 && place.h >= 1 ? { w: place.w, h: place.h } : null;
++}
++
++/** The rectangular cells a visible part occupies, or null for a dock (which covers along its edge). */
++function boxOf(part) {
++ const p = part && part.place;
++ if (!isObject(p) || p.dock !== undefined) return null;
++ if (![p.x, p.y, p.w, p.h].every(Number.isInteger)) return null;
++ if (p.x < 0 || p.y < 0 || p.w < 1 || p.h < 1) return null;
++ return { x: p.x, y: p.y, w: p.w, h: p.h };
++}
++
++function overlaps(a, b) {
++ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
++}
++
++/** Two docked parts cover each other only when they claim the same edge and order. */
++function dockOverlaps(a, b) {
++ const pa = a && a.place;
++ const pb = b && b.place;
++ return isObject(pa) && isObject(pb) && pa.dock !== undefined && pb.dock !== undefined
++ && pa.dock === pb.dock && pa.order === pb.order;
++}
++
++/**
++ * Check one layout against its surface catalog.
++ * @returns {{ ok: boolean, reasons: string[] }}
++ */
++function validate(catalog, layout) {
++ const reasons = [];
++ const fail = (reason) => { if (!reasons.includes(reason)) reasons.push(reason); };
++ if (!isObject(catalog) || !Array.isArray(catalog.parts)) {
++ return { ok: false, reasons: ['The surface catalog is missing.'] };
++ }
++ if (!isObject(layout)) return { ok: false, reasons: ['A layout has to be an object.'] };
++
++ // A layout is data: never a URL, markup or code, anywhere in a name, a value or a key.
++ if (stringsIn(layout).some((s) => URL_RE.test(s) || SCHEME_RE.test(s) || CODE_RE.test(s))) {
++ fail("A layout is data only: it can't carry a URL or code.");
++ }
++
++ if (layout.surface !== catalog.id) fail(`This layout is not for ${catalog.id || 'this surface'}.`);
++ if (layout.surface_version !== catalog.version) {
++ fail(`${catalog.name || 'This surface'} has changed since this layout was made; reset it to the default.`);
++ }
++ const device = layout.device;
++ const devices = Array.isArray(catalog.devices) ? catalog.devices : ['phone', 'tablet', 'computer'];
++ if (!devices.includes(device)) fail(`This surface doesn't come in ${device || 'that device'}.`);
++
++ const columns = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || DEVICE_COLUMNS[device] || 4;
++ const cellPx = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[device]) || DEVICE_PX[device] || 44;
++ const tokens = Array.isArray(catalog.theme_tokens) ? catalog.theme_tokens : [];
++ const byId = new Map(catalog.parts.filter((p) => p && p.id).map((p) => [p.id, p]));
++ const parts = Array.isArray(layout.parts) ? layout.parts : [];
++ const placed = new Map();
++
++ if (!Array.isArray(layout.parts) || layout.parts.length === 0) fail('A layout needs at least one part.');
++
++ for (const placedPart of parts) {
++ if (!isObject(placedPart) || typeof placedPart.id !== 'string') { fail('Every part in a layout needs an id.'); continue; }
++ const source = byId.get(placedPart.id);
++ if (!source) { fail(`${placedPart.id} is not a part of ${catalog.name || 'this surface'}.`); continue; }
++ if (placed.has(placedPart.id)) { fail(`${nameOf(source)} appears more than once.`); continue; }
++ placed.set(placedPart.id, placedPart);
++
++ const name = nameOf(source);
++ if (source.required && placedPart.visible !== true) fail(`${name} has to stay visible.`);
++
++ const place = placedPart.place;
++ if (!isObject(place)) { fail(`${name} needs a place on the surface.`); continue; }
++ const size = sizeOf(place);
++ if (!size) { fail(`${name} needs a size, in cells.`); continue; }
++ if (place.dock !== undefined) {
++ if (!['left', 'right', 'top', 'bottom'].includes(place.dock)) fail(`${name} can only dock to an edge.`);
++ if (!Number.isInteger(place.order) || place.order < 0) fail(`${name} needs a place in the dock order.`);
++ } else {
++ if (!Number.isInteger(place.x) || place.x < 0 || !Number.isInteger(place.y) || place.y < 0) fail(`${name} needs a place on the grid.`);
++ else if (place.x + place.w > columns) fail(`${name} runs past the edge of the ${device} grid.`);
++ }
++ const min = source.min || {};
++ if (size.w < (min.w || 1) || size.h < (min.h || 1)) {
++ fail(`${name} has to be at least ${Math.max(min.w || 1, 1)} × ${Math.max(min.h || 1, 1)} cells.`);
++ }
++ const needPx = Math.max(source.pressed ? PRESSED_PX : 0, Number(min.px) || 0);
++ if (needPx && (size.w * cellPx < needPx || size.h * cellPx < needPx)) fail(`${name} has to be at least ${needPx} px.`);
++
++ const style = isObject(placedPart.style) ? placedPart.style : {};
++ const allowedStyles = isObject(source.styles) ? source.styles : {};
++ if (typeof style.tint !== 'string' || !tokens.includes(style.tint)) {
++ fail(`${name}'s tint has to be one of the theme's tokens${tokens.length ? ` (${tokens.join(', ')})` : ''}.`);
++ } else if (Array.isArray(allowedStyles.tints) && !allowedStyles.tints.includes(style.tint)) {
++ fail(`${name} can only be tinted ${allowedStyles.tints.join(', ')}.`);
++ }
++ if (!['round', 'pill', 'square'].includes(style.shape)) fail(`${name} needs a shape: round, pill or square.`);
++ else if (Array.isArray(allowedStyles.shapes) && !allowedStyles.shapes.includes(style.shape)) {
++ fail(`${name} can only be ${allowedStyles.shapes.join(', ')}.`);
++ }
++ const low = Math.max(MIN_OPACITY, Number(allowedStyles.opacity && allowedStyles.opacity.min) || MIN_OPACITY);
++ const high = Math.min(MAX_OPACITY, Number(allowedStyles.opacity && allowedStyles.opacity.max) || MAX_OPACITY);
++ if (typeof style.opacity !== 'number' || style.opacity < low || style.opacity > high) {
++ fail(`${name}'s opacity has to be between ${low} and ${high}.`);
++ }
++ if (style.label !== true && style.label !== false) fail(`${name} says whether its label is on.`);
++ else if (style.label && allowedStyles.label === false) fail(`${name} can't show a label.`);
++
++ const bindings = isObject(placedPart.bindings) ? placedPart.bindings : {};
++ const allowed = isObject(source.bindings) ? source.bindings : {};
++ for (const kind of ['keys', 'gamepad']) {
++ const list = Array.isArray(bindings[kind]) ? bindings[kind] : [];
++ const ok = Array.isArray(allowed[kind]) ? allowed[kind] : [];
++ const bad = list.filter((b) => !ok.includes(b));
++ if (bad.length) fail(`${name} can't be bound to ${bad.join(', ')}.`);
++ if (new Set(list).size !== list.length) fail(`${name} binds ${kind} more than once.`);
++ }
++ }
++
++ for (const source of catalog.parts) {
++ if (source && source.required && !placed.has(source.id)) fail(`${nameOf(source)} has to stay on the surface.`);
++ }
++
++ // Nothing covers a required part. Only a visible part can cover anything, and a required part
++ // that is itself hidden is already refused above; its neighbours still do not cover it.
++ const visible = parts.filter((p) => p && p.visible === true && placed.has(p.id));
++ for (const required of visible) {
++ const source = byId.get(required.id);
++ if (!source || !source.required) continue;
++ for (const other of visible) {
++ if (other === required || other.id === required.id) continue;
++ const covers = overlaps(boxOf(required) || { x: 0, y: 0, w: 0, h: 0 }, boxOf(other) || { x: 0, y: 0, w: 0, h: 0 })
++ || dockOverlaps(required, other);
++ if (covers) fail(`${nameOf(byId.get(other.id))} covers ${nameOf(source)}.`);
++ }
++ }
++ // A catalog's own never_covers rules, in either direction of the relation it states.
++ for (const [id, source] of byId) {
++ const own = placed.get(id);
++ if (!own || own.visible !== true) continue;
++ for (const targetId of Array.isArray(source.never_covers) ? source.never_covers : []) {
++ const target = placed.get(targetId);
++ if (!target || target.visible !== true) continue;
++ const targetSource = byId.get(targetId);
++ if (overlaps(boxOf(own) || { x: 0, y: 0, w: 0, h: 0 }, boxOf(target) || { x: 0, y: 0, w: 0, h: 0 }) || dockOverlaps(own, target)) {
++ fail(`${nameOf(source)} covers ${nameOf(targetSource || { id: targetId })}.`);
++ }
++ }
++ }
++
++ return { ok: reasons.length === 0, reasons };
++}
++
++/** The cells a part needs at the default size: its own minimum, and at least 44 px when pressed. */
++function cellsFor(part, cellPx) {
++ const min = part.min || {};
++ const px = Math.max(part.pressed ? PRESSED_PX : 0, Number(min.px) || 0);
++ return {
++ w: Math.max(1, Number(min.w) || 1, px ? Math.ceil(px / cellPx) : 0),
++ h: Math.max(1, Number(min.h) || 1, px ? Math.ceil(px / cellPx) : 0),
++ };
++}
++
++/**
++ * The default arrangement of a surface: every catalog part, visible, at its minimum (or a size that
++ * meets the 44 px rule), required parts first, laid out left to right and then down the grid.
++ * @param {object} catalog ui.surface@1
++ * @param {'phone'|'tablet'|'computer'} [device] defaults to the catalog's first device
++ */
++function applyDefaults(catalog, device) {
++ if (!isObject(catalog) || !Array.isArray(catalog.parts)) throw new TypeError('applyDefaults: a ui.surface@1 catalog is required');
++ const chosen = (Array.isArray(catalog.devices) && catalog.devices.includes(device)) ? device : (catalog.devices || ['phone'])[0];
++ const columns = (catalog.grid && catalog.grid.columns && catalog.grid.columns[chosen]) || DEVICE_COLUMNS[chosen] || 4;
++ const cellPx = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[chosen]) || DEVICE_PX[chosen] || 44;
++ const ordered = [...catalog.parts].sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0));
++ let x = 0; let y = 0; let rowHeight = 0;
++ const parts = ordered.map((part) => {
++ const size = cellsFor(part, cellPx);
++ const w = Math.min(columns, size.w);
++ const h = size.h;
++ if (x + w > columns) { x = 0; y += rowHeight; rowHeight = 0; }
++ const place = { x, y, w, h };
++ x += w;
++ rowHeight = Math.max(rowHeight, h);
++ const styles = isObject(part.styles) ? part.styles : {};
++ const bindings = isObject(part.bindings) ? part.bindings : {};
++ const tokens = Array.isArray(catalog.theme_tokens) ? catalog.theme_tokens : [];
++ const tints = Array.isArray(styles.tints) ? styles.tints : [];
++ return {
++ id: part.id,
++ visible: true,
++ place,
++ style: {
++ tint: tints.find((t) => tokens.includes(t)) || tints[0] || tokens[0] || 'surface',
++ shape: (Array.isArray(styles.shapes) && styles.shapes[0]) || 'square',
++ opacity: 1,
++ label: styles.label !== false,
++ },
++ bindings: {
++ keys: Array.isArray(bindings.keys) ? [...bindings.keys] : [],
++ gamepad: Array.isArray(bindings.gamepad) ? [...bindings.gamepad] : [],
++ },
++ };
++ });
++ return {
++ surface: catalog.id,
++ surface_version: catalog.version,
++ device: chosen,
++ name: 'Default',
++ author: { name: 'OpenVibe' },
++ sharing: 'private',
++ parts,
++ };
++}
++
++function mergeInto(base, patch) {
++ if (!isObject(base)) return clone(patch);
++ if (!isObject(patch)) return clone(patch);
++ const out = clone(base);
++ for (const [key, value] of Object.entries(patch)) {
++ if (isObject(value) && isObject(out[key])) out[key] = mergeInto(out[key], value);
++ else out[key] = clone(value);
++ }
++ return out;
++}
++
++/**
++ * A new layout with `patch` applied. `parts` are merged by id: a patch part changes only the fields it
++ * names, and a part the layout does not have is appended. Nothing is mutated, and the result is not
++ * validated (the caller validates before storing or showing it).
++ */
++function merge(layout, patch) {
++ if (!isObject(layout)) throw new TypeError('merge: a layout is required');
++ if (!isObject(patch)) return clone(layout);
++ const out = clone(layout);
++ for (const [key, value] of Object.entries(patch)) {
++ if (key === 'parts' && Array.isArray(value)) {
++ const parts = Array.isArray(out.parts) ? out.parts.map(clone) : [];
++ const index = new Map(parts.map((p, i) => [p && p.id, i]));
++ for (const incoming of value) {
++ if (!isObject(incoming) || typeof incoming.id !== 'string') { parts.push(clone(incoming)); continue; }
++ if (index.has(incoming.id)) parts[index.get(incoming.id)] = mergeInto(parts[index.get(incoming.id)], incoming);
++ else { index.set(incoming.id, parts.length); parts.push(clone(incoming)); }
++ }
++ out.parts = parts;
++ } else if (isObject(value) && isObject(out[key])) out[key] = mergeInto(out[key], value);
++ else out[key] = clone(value);
++ }
++ return out;
++}
++
++module.exports = { validate, applyDefaults, merge, PRESSED_PX };
ddiff --git a/test/elements.test.js b/test/elements.test.js
nnew file mode 100644
iindex 0000000..b032d73
---- /dev/null
++++ b/test/elements.test.js
@@@ -0,0 +1,279 @@
++'use strict';
++/**
++ * openvibe-sdk/elements: validate, the host's run() with every refusal, and the state reducer.
++ * No network: the owner and the products are small stubs, which is the point — the host asks them
++ * and refuses before it asks when it already knows the answer.
++ */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const elements = require('../src/elements');
++
++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const EL = 'el_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const MOD = 'mod_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++
++const base = (over = {}) => ({
++ id: EL,
++ name: 'Make it dance',
++ author: { name: 'Ada', subject: { type: 'user', id: USR } },
++ sharing: 'private',
++ look: { component: 'button', label: 'Make it dance', icon: 'robot', props: { size: 'large' }, style: { tint: 'accent', shape: 'pill', opacity: 1, show_label: true } },
++ recipe: {
++ triggers: [{ kind: 'press' }],
++ conditions: { role: 'everyone' },
++ actions: [{ kind: 'screen.counter', product: 'live', key: 'dances', by: 1 }],
++ },
++ capabilities: ['screen.counter'],
++ initial_state: { dances: 0 },
++ ...over,
++});
++const recipe = (over = {}) => ({ ...base().recipe, ...over });
++const element = (over = {}) => ({ id: EL, name: 'Make it dance', capabilities: ['screen.counter'], ...over });
++
++/** A ctx that records the calls to the owner and the products; each test overrides one part. */
++function ctx(over = {}) {
++ const calls = { spend: [], refund: [], send: [], hit: [], cooldown: [], daily: [] };
++ const c = {
++ now: Date.parse('2026-11-11T20:31:00.000Z'),
++ actor: { subject: USR, name: 'Maticus', role: 'everyone' },
++ element: element(),
++ isLive: true,
++ robotInTurn: true,
++ limits: {
++ cooldown: async (q) => { calls.cooldown.push(q); return 0; },
++ dailyCount: async (q) => { calls.daily.push(q); return 0; },
++ hit: async (q) => { calls.hit.push(q); },
++ },
++ spend: async (q) => { calls.spend.push(q); return { ok: true }; },
++ refund: async (q) => { calls.refund.push(q); },
++ send: async (a, info) => { calls.send.push({ action: a, info }); return { ok: true, result: { id: a.kind } }; },
++ ...over,
++ };
++ c.calls = calls;
++ return c;
++}
++
++run([
++ ['validate accepts a full element and a mod reference', async () => {
++ assert.deepEqual(elements.validate(base()), { valid: true, errors: [] });
++ const modded = base({ mod: { id: MOD, version: '1.0.0', sha256: 'a'.repeat(64), capabilities: ['screen.counter'] } });
++ assert.deepEqual(elements.validate(modded), { valid: true, errors: [] });
++ }],
++
++ ['validate refuses the recipe using a capability the element did not declare', async () => {
++ const r = elements.validate(base({ capabilities: ['screen.alert'] }));
++ assert.equal(r.valid, false);
++ assert.ok(r.errors.some((e) => /did not declare/.test(e.message)), JSON.stringify(r.errors));
++ }],
++
++ ['validate refuses an unknown action, a wrong product and a malformed trigger', async () => {
++ const unknown = base({ recipe: recipe({ actions: [{ kind: 'robot.dance', product: 'bot' }] }), capabilities: ['robot.say'] });
++ assert.ok(elements.validate(unknown).errors.some((e) => /is not an action/.test(e.message)));
++ const product = base({ recipe: recipe({ actions: [{ kind: 'chat.message', product: 'bot', text: 'hi' }] }), capabilities: ['chat.message'] });
++ assert.ok(elements.validate(product).errors.some((e) => /authorised by chat only/.test(e.message)));
++ const command = base({ recipe: recipe({ triggers: [{ kind: 'chat_command', command: 'dance' }] }) });
++ assert.ok(elements.validate(command).errors.some((e) => /lowercase !command/.test(e.message)));
++ }],
++
++ ['validate refuses a mod that asks beyond the element and a bad state value', async () => {
++ const mod = base({ mod: { id: MOD, version: '1.0.0', sha256: 'a'.repeat(64), capabilities: ['robot.say'] } });
++ assert.ok(elements.validate(mod).errors.some((e) => /did not declare/.test(e.message)));
++ const state = base({ initial_state: { wheel: { entries: ['a'] } } });
++ assert.ok(elements.validate(state).errors.some((e) => /state value/.test(e.message)));
++ const tint = base({ look: { component: 'button', label: 'x', style: { tint: '#ff0000' } } });
++ assert.ok(elements.validate(tint).errors.some((e) => /theme token/.test(e.message)));
++ }],
++
++ ['run refuses a role below the condition', async () => {
++ const c = ctx({ actor: { subject: USR, name: 'Maticus', role: 'everyone' } });
++ const r = await elements.run(recipe({ conditions: { role: 'follower' } }), { kind: 'press' }, c);
++ assert.equal(r.ok, false);
++ assert.equal(r.code, 'role');
++ assert.equal(c.calls.send.length, 0);
++ }],
++
++ ['run admits a higher role than the condition', async () => {
++ const c = ctx({ actor: { subject: USR, name: 'Maticus', role: 'subscriber' } });
++ const r = await elements.run(recipe({ conditions: { role: 'follower' } }), { kind: 'press' }, c);
++ assert.equal(r.ok, true);
++ }],
++
++ ['run refuses a live-only element while the channel is not live', async () => {
++ const r = await elements.run(recipe({ conditions: { live_only: true } }), { kind: 'press' }, ctx({ isLive: false }));
++ assert.equal(r.code, 'not_live');
++ const unknown = await elements.run(recipe({ conditions: { live_only: true } }), { kind: 'press' }, ctx({ isLive: undefined }));
++ assert.equal(unknown.code, 'not_live', 'an unstated live state fails closed');
++ }],
++
++ ['run refuses a robot action when it is not the presser\'s turn', async () => {
++ const robot = base({ recipe: recipe({ actions: [{ kind: 'robot.say', product: 'bot', text: 'hello' }] }), capabilities: ['robot.say'] });
++ const c = ctx({ robotInTurn: false, element: element({ capabilities: ['robot.say'] }) });
++ const r = await elements.run(robot.recipe, { kind: 'press' }, c);
++ assert.equal(r.code, 'robot_turn');
++ assert.equal(c.calls.send.length, 0);
++
++ const only = ctx({ robotInTurn: undefined, element: element({ capabilities: ['robot.say'] }) });
++ const r2 = await elements.run(robot.recipe, { kind: 'press' }, { ...only, ...{ robotInTurn: undefined } });
++ assert.equal(r2.ok, true, 'robot_turn_only is off, so Bot decides');
++ const r3 = await elements.run(recipe({ conditions: { robot_turn_only: true }, actions: robot.recipe.actions }), { kind: 'press' }, only);
++ assert.equal(r3.code, 'robot_turn');
++ }],
++
++ ['run refuses a per-person cooldown and says how long', async () => {
++ const c = ctx({ limits: { cooldown: async () => 30000, dailyCount: async () => 0, hit: async () => {} } });
++ const r = await elements.run(recipe({ conditions: { cooldown: { per_person_seconds: 60 } } }), { kind: 'press' }, c);
++ assert.equal(r.code, 'cooldown');
++ assert.equal(r.retryAfterMs, 30000);
++ assert.match(r.message, /try again in 30 seconds/);
++ assert.equal(c.calls.send.length, 0);
++ }],
++
++ ['run refuses an overall cooldown', async () => {
++ const c = ctx({ limits: { cooldown: async (q) => (q.scope === 'overall' ? 5000 : 0), dailyCount: async () => 0, hit: async () => {} } });
++ const r = await elements.run(recipe({ conditions: { cooldown: { per_person_seconds: 10, overall_seconds: 30 } } }), { kind: 'press' }, c);
++ assert.equal(r.code, 'cooldown');
++ assert.equal(r.scope, 'overall');
++ }],
++
++ ['run refuses a per-person daily cap and an overall daily cap', async () => {
++ const person = ctx({ limits: { cooldown: async () => 0, dailyCount: async (q) => (q.scope === 'person' ? 5 : 0), hit: async () => {} } });
++ const r = await elements.run(recipe({ conditions: { daily_cap: { per_person: 5 } } }), { kind: 'press' }, person);
++ assert.equal(r.code, 'daily_cap');
++ assert.equal(r.scope, 'person');
++ const overall = ctx({ limits: { cooldown: async () => 0, dailyCount: async (q) => (q.scope === 'overall' ? 100 : 0), hit: async () => {} } });
++ const r2 = await elements.run(recipe({ conditions: { daily_cap: { overall: 100 } } }), { kind: 'press' }, overall);
++ assert.equal(r2.code, 'daily_cap');
++ assert.equal(r2.scope, 'overall');
++ }],
++
++ ['run checks the cost with the owner and refuses when it says no', async () => {
++ const c = ctx({ spend: async (q) => { c_spent.push(q); return { ok: false, message: 'Not enough channel points (40 of 50).' }; } });
++ const c_spent = [];
++ const r = await elements.run(recipe({ conditions: { cost: { points: 50 } } }), { kind: 'press' }, c);
++ assert.equal(r.code, 'points');
++ assert.match(r.message, /40 of 50/);
++ assert.equal(c_spent.length, 1);
++ assert.equal(c_spent[0].points, 50);
++ assert.equal(c.calls.send.length, 0);
++ assert.equal(c.calls.spend.length, 0, 'the overridden spend is the owner');
++ }],
++
++ ['run refuses an undeclared capability before it spends or sends', async () => {
++ const c = ctx({ element: element({ capabilities: ['screen.alert'] }) });
++ const r = await elements.run(recipe(), { kind: 'press' }, c);
++ assert.equal(r.code, 'capability');
++ assert.match(r.message, /did not declare screen.counter/);
++ assert.equal(c.calls.spend.length, 0);
++ assert.equal(c.calls.send.length, 0);
++ }],
++
++ ['run draws the chance before it charges and a refused draw costs nothing', async () => {
++ const c = ctx({ random: () => 0.9 });
++ const r = await elements.run(recipe({ conditions: { chance: { one_in: 10 }, cost: { points: 50 } } }), { kind: 'press' }, c);
++ assert.equal(r.code, 'chance');
++ assert.equal(c.calls.spend.length, 0);
++ const win = ctx({ random: () => 0.05 });
++ const r2 = await elements.run(recipe({ conditions: { chance: { one_in: 10 }, cost: { points: 50 } } }), { kind: 'press' }, win);
++ assert.equal(r2.ok, true);
++ assert.equal(win.calls.spend.length, 1);
++ }],
++
++ ['run sends every action in order to the product that authorises it', async () => {
++ const two = base({
++ recipe: recipe({ actions: [
++ { kind: 'screen.counter', product: 'live', key: 'dances', by: 1 },
++ { kind: 'chat.message', product: 'chat', text: 'Ada made it dance!' },
++ ] }),
++ capabilities: ['screen.counter', 'chat.message'],
++ });
++ const c = ctx({ element: element({ capabilities: ['screen.counter', 'chat.message'] }) });
++ const r = await elements.run(two.recipe, { kind: 'press' }, c);
++ assert.equal(r.ok, true);
++ assert.deepEqual(r.actions.map((x) => x.action.kind), ['screen.counter', 'chat.message']);
++ assert.deepEqual(c.calls.send.map((x) => x.info.product), ['live', 'chat']);
++ assert.equal(c.calls.hit.length, 1, 'a successful press records its limits once');
++ }],
++
++ ['run refunds the points when a product refuses and reports the refusal', async () => {
++ const c = ctx({
++ element: element({ capabilities: ['screen.counter', 'screen.alert'] }),
++ spend: async () => ({ ok: true }),
++ send: async (a) => (a.kind === 'screen.counter' ? { ok: true, result: { revision: 1 } } : { ok: false, message: 'the overlay is off' }),
++ });
++ const two = base({
++ recipe: recipe({ actions: [
++ { kind: 'screen.counter', product: 'live', key: 'dances', by: 1 },
++ { kind: 'screen.alert', product: 'live', title: 'Dance!' },
++ ], conditions: { cost: { points: 50 } } }),
++ capabilities: ['screen.counter', 'screen.alert'],
++ });
++ const r = await elements.run(two.recipe, { kind: 'press' }, c);
++ assert.equal(r.code, 'action');
++ assert.equal(r.refusedAction, 'screen.alert');
++ assert.equal(r.refunded, true);
++ assert.equal(c.calls.refund.length, 1);
++ assert.equal(c.calls.refund[0].points, 50);
++ assert.equal(r.pointsSpent, 0);
++ }],
++
++ ['run refuses a trigger the recipe does not listen for', async () => {
++ const r = await elements.run(recipe({ triggers: [{ kind: 'chat_command', command: '!dance' }] }), { kind: 'chat_command', command: '!wave' }, ctx());
++ assert.equal(r.code, 'trigger');
++ assert.equal(r.ok, false);
++ }],
++
++ ['run accepts the declared chat command', async () => {
++ const r = await elements.run(recipe({ triggers: [{ kind: 'chat_command', command: '!dance' }] }), { kind: 'chat_command', command: '!dance' }, ctx());
++ assert.equal(r.ok, true);
++ }],
++
++ ['run refuses a recipe with nothing to do', async () => {
++ const r = await elements.run({ triggers: [{ kind: 'press' }], actions: [] }, { kind: 'press' }, ctx());
++ assert.equal(r.code, 'invalid');
++ }],
++
++ ['reduce counts up from zero, sets values and increments the revision once', async () => {
++ const start = { values: { dances: 0 }, revision: 4 };
++ const one = elements.reduce(start, [{ kind: 'screen.counter', key: 'dances', by: 1 }]);
++ assert.deepEqual(one.values, { dances: 1 });
++ assert.equal(one.revision, 5);
++ assert.equal(one.changed, true);
++ const set = elements.reduce(one, [
++ { kind: 'screen.state', key: 'lit', value: true },
++ { kind: 'screen.state', key: 'wheel', value: ['a', 'b'] },
++ { kind: 'chat.message', text: 'not state' },
++ ]);
++ assert.deepEqual(set.values, { dances: 1, lit: true, wheel: ['a', 'b'] });
++ assert.equal(set.revision, 6);
++ const same = elements.reduce(set, [{ kind: 'screen.counter', key: 'dances', by: 0 }]);
++ assert.equal(same.changed, false);
++ assert.equal(same.revision, 6);
++ }],
++
++ ['reduce reports an invalid change without touching the store', async () => {
++ const state = { values: { name: 'ada' }, revision: 1 };
++ const r = elements.reduce(state, [{ kind: 'screen.counter', key: 'name', by: 1 }]);
++ assert.equal(r.changed, false);
++ assert.equal(r.revision, 1);
++ assert.equal(r.errors.length, 1);
++ const object = elements.reduce(state, [{ kind: 'screen.state', key: 'wheel', value: { a: 1 } }]);
++ assert.equal(object.errors.length, 1, 'screen.state never stores an object');
++ const long = elements.reduce(state, [{ kind: 'screen.state', key: 'name', value: 'x'.repeat(201) }]);
++ assert.equal(long.errors.length, 1);
++ }],
++
++ ['reduce holds at most 32 keys', async () => {
++ const values = {};
++ for (let i = 0; i < 32; i++) values[`k${i}`] = i;
++ const r = elements.reduce({ values, revision: 1 }, [{ kind: 'screen.state', key: 'extra', value: 1 }]);
++ assert.equal(r.errors.length, 1);
++ assert.equal(r.values.extra, undefined);
++ }],
++
++ ['the exported catalogs agree with the action and trigger ids', async () => {
++ assert.equal(elements.ACTION_KINDS.length, Object.keys(elements.PRODUCTS).length);
++ assert.ok(elements.ACTION_SET.has('robot.drive_pulse') && elements.PRODUCTS['robot.drive_pulse'] === 'bot');
++ assert.ok(elements.TRIGGER_SET.has('chat_command') && elements.TRIGGER_SET.has('press'));
++ assert.deepEqual(elements.ROLE_RANK, { everyone: 0, follower: 1, subscriber: 2, vip: 3, operator: 4, owner: 5 });
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex bbed7a0..0c34313 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 109); // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109.
++ assert.equal(subpaths.length, 111); // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/ui-layout.test.js b/test/ui-layout.test.js
nnew file mode 100644
iindex 0000000..4096cb8
---- /dev/null
++++ b/test/ui-layout.test.js
@@@ -0,0 +1,219 @@
++'use strict';
++// openvibe-sdk/ui-layout: the one validator the server, the editor and the AI call share (make your
++// own controls design, pieces 2-3). Every rule gets a case, in the plain words the person sees.
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const { validate, applyDefaults, merge, PRESSED_PX } = require('../src/ui-layout');
++
++const catalog = () => ({
++ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone', 'tablet', 'computer'],
++ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },
++ theme_tokens: ['danger', 'surface', 'surface-raised', 'accent', 'text'],
++ parts: [
++ {
++ id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true,
++ min: { w: 2, h: 2, px: 64 },
++ styles: { tints: ['danger'], shapes: ['round', 'square'], opacity: { min: 0.7, max: 1 }, label: true },
++ bindings: { keys: ['Space'], gamepad: ['south'] }, never_covers: [],
++ },
++ {
++ id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true,
++ min: { w: 2, h: 2 },
++ styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },
++ bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'],
++ },
++ {
++ id: 'hat', name: 'Hat', kind: 'slider', required: false, pressed: true,
++ min: { w: 1, h: 1 },
++ styles: { tints: ['accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },
++ bindings: { keys: ['ArrowUp'], gamepad: ['right-stick-y'] }, never_covers: ['battery'],
++ },
++ {
++ id: 'battery', name: 'Battery', kind: 'readout', required: false, pressed: false,
++ min: { w: 1, h: 1 },
++ styles: { tints: ['text', 'surface'], shapes: ['pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
++ bindings: { keys: [], gamepad: [] }, never_covers: [],
++ },
++ ],
++});
++
++const part = (id, place, extra = {}) => ({
++ id, visible: true, place,
++ style: { tint: id === 'stop' ? 'danger' : id === 'stick' ? 'surface' : id === 'hat' ? 'accent' : 'text', shape: id === 'stop' ? 'round' : id === 'battery' ? 'pill' : id === 'hat' ? 'pill' : 'round', opacity: 1, label: id !== 'stick' },
++ bindings: id === 'stop' ? { keys: ['Space'], gamepad: ['south'] } : id === 'stick' ? { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] } : id === 'hat' ? { keys: ['ArrowUp'], gamepad: ['right-stick-y'] } : { keys: [], gamepad: [] },
++ ...extra,
++});
++
++const layout = () => ({
++ surface: 'bot.controls', surface_version: 1, device: 'phone', name: 'Mine',
++ author: { name: 'Ana' }, sharing: 'private',
++ parts: [part('stop', { x: 0, y: 0, w: 2, h: 2 }), part('stick', { x: 0, y: 2, w: 2, h: 2 }), part('battery', { x: 2, y: 2, w: 1, h: 1 })],
++});
++
++const reasonsOf = (l, c = catalog()) => validate(c, l).reasons;
++const has = (reasons, re) => assert.ok(reasons.some((r) => re.test(r)), `expected ${re} in ${JSON.stringify(reasons)}`);
++
++run([
++ ['a complete layout passes, and every reason list is unique', async () => {
++ assert.deepEqual(validate(catalog(), layout()), { ok: true, reasons: [] });
++ assert.equal(PRESSED_PX, 44);
++ }],
++
++ ['required parts: present and visible', async () => {
++ const missing = layout(); missing.parts = missing.parts.filter((p) => p.id !== 'stop');
++ has(reasonsOf(missing), /Stop has to stay on the surface\./);
++ const hidden = layout(); hidden.parts[0].visible = false;
++ has(reasonsOf(hidden), /Stop has to stay visible\./);
++ }],
++
++ ['required parts: at their minimum cells and pixels', async () => {
++ const small = layout(); small.parts[0].place = { x: 0, y: 0, w: 1, h: 1 };
++ const reasons = reasonsOf(small);
++ has(reasons, /Stop has to be at least 2 × 2 cells\./);
++ has(reasons, /Stop has to be at least 64 px\./);
++ }],
++
++ ['pressed parts: at least 44 px even when the catalog asks for one cell', async () => {
++ const c = catalog();
++ c.grid.cell_px.phone = 40;
++ c.parts = [c.parts.find((p) => p.id === 'stick')];
++ c.parts[0].min = { w: 1, h: 1 };
++ const l = { ...layout(), parts: [part('stick', { x: 0, y: 0, w: 1, h: 1 })] };
++ has(reasonsOf(l, c), /Drive stick has to be at least 44 px\./);
++ }],
++
++ ['nothing covers a required part', async () => {
++ const l = layout(); l.parts[1].place = { x: 0, y: 0, w: 2, h: 2 };
++ has(reasonsOf(l), /Drive stick covers Stop\./);
++ // A hidden part covers nothing.
++ l.parts[1].visible = false;
++ assert.ok(!reasonsOf(l).some((r) => /covers Stop/.test(r)));
++ }],
++
++ ['a catalog never_covers rule holds', async () => {
++ const l = layout(); l.parts.push(part('hat', { x: 2, y: 3, w: 1, h: 1 }));
++ l.parts[3].place = { x: 2, y: 2, w: 1, h: 1 };
++ has(reasonsOf(l), /Hat covers Battery\./);
++ }],
++
++ ['only parts the catalog has, and only once', async () => {
++ const extra = layout(); extra.parts.push(part('battery', { x: 3, y: 3, w: 1, h: 1 }));
++ has(reasonsOf(extra), /Battery appears more than once\./);
++ const unknown = layout(); unknown.parts.push({ ...part('battery', { x: 3, y: 3, w: 1, h: 1 }), id: 'dial' });
++ has(reasonsOf(unknown), /dial is not a part of Robot controls\./);
++ }],
++
++ ['tints from theme tokens only, and only the ones the part allows', async () => {
++ const hex = layout(); hex.parts[0].style.tint = '#ff0000';
++ has(reasonsOf(hex), /Stop's tint has to be one of the theme's tokens/);
++ const wrong = layout(); wrong.parts[0].style.tint = 'accent';
++ has(reasonsOf(wrong), /Stop can only be tinted danger\./);
++ }],
++
++ ['shape, opacity and label follow the catalog', async () => {
++ const l = layout(); l.parts[0].style.shape = 'pill';
++ has(reasonsOf(l), /Stop can only be round, square\./);
++ const faint = layout(); faint.parts[0].style.opacity = 0.5;
++ has(reasonsOf(faint), /Stop's opacity has to be between 0.7 and 1\./);
++ const transparent = layout(); transparent.parts[0].style.opacity = 0.2;
++ has(reasonsOf(transparent), /Stop's opacity has to be between 0.7 and 1\./);
++ const labelled = layout(); labelled.parts[1].style.label = true;
++ has(reasonsOf(labelled), /Drive stick can't show a label\./);
++ }],
++
++ ['bindings are a subset of what the catalog allows', async () => {
++ const l = layout(); l.parts[0].bindings.keys = ['Space', 'Enter'];
++ has(reasonsOf(l), /Stop can't be bound to Enter\./);
++ const pad = layout(); pad.parts[0].bindings.gamepad = ['south', 'north'];
++ has(reasonsOf(pad), /Stop can't be bound to north\./);
++ const twice = layout(); twice.parts[1].bindings.keys = ['W', 'W'];
++ has(reasonsOf(twice), /Drive stick binds keys more than once\./);
++ }],
++
++ ['no URLs, markup or code anywhere', async () => {
++ const url = layout(); url.name = 'my https://example.com panel';
++ has(reasonsOf(url), /A layout is data only: it can't carry a URL or code\./);
++ const code = layout(); code.parts[0].style.tint = 'javascript:alert(1)';
++ has(reasonsOf(code), /A layout is data only: it can't carry a URL or code\./);
++ const markup = layout(); markup.name = '<script>alert(1)</script>';
++ has(reasonsOf(markup), /A layout is data only: it can't carry a URL or code\./);
++ const handler = layout(); handler.name = 'onerror=alert(1)';
++ has(reasonsOf(handler), /A layout is data only: it can't carry a URL or code\./);
++ }],
++
++ ['the layout is for the catalog it names, its version and a supported device', async () => {
++ const surface = layout(); surface.surface = 'bot.overlay';
++ has(reasonsOf(surface), /This layout is not for bot.controls\./);
++ const version = layout(); version.surface_version = 2;
++ has(reasonsOf(version), /has changed since this layout was made/);
++ const device = layout(); device.device = 'watch';
++ has(reasonsOf(device), /This surface doesn't come in watch\./);
++ }],
++
++ ['grid bounds and the dock form', async () => {
++ const past = layout(); past.parts[0].place = { x: 3, y: 0, w: 2, h: 2 };
++ has(reasonsOf(past), /Stop runs past the edge of the phone grid\./);
++ const dock = layout();
++ dock.parts = [part('stop', { dock: 'right', order: 0, w: 2, h: 2 }), part('battery', { dock: 'right', order: 1, w: 1, h: 1 })];
++ assert.deepEqual(validate(catalog(), dock), { ok: true, reasons: [] });
++ const same = layout();
++ same.parts = [part('stop', { dock: 'right', order: 0, w: 2, h: 2 }), part('battery', { dock: 'right', order: 0, w: 1, h: 1 })];
++ has(reasonsOf(same), /Battery covers Stop\./);
++ const noSize = layout(); noSize.parts[0].place = { dock: 'right', order: 0 };
++ has(reasonsOf(noSize), /Stop needs a size, in cells\./);
++ }],
++
++ ['applyDefaults gives a valid layout on every device, required parts first', async () => {
++ for (const device of catalog().devices) {
++ const d = applyDefaults(catalog(), device);
++ assert.deepEqual(validate(catalog(), d), { ok: true, reasons: [] }, device);
++ assert.equal(d.device, device);
++ assert.equal(d.parts[0].id, 'stop', 'required parts come first');
++ assert.ok(d.parts.every((p) => p.visible === true), 'the default shows every part');
++ for (const p of d.parts) {
++ const source = catalog().parts.find((x) => x.id === p.id);
++ const px = Math.max(source.pressed ? 44 : 0, source.min.px || 0);
++ assert.ok(p.place.w * 44 >= px && p.place.h * 44 >= px, `${p.id} meets its pixel minimum`);
++ }
++ }
++ assert.equal(applyDefaults(catalog()).device, 'phone');
++ }],
++
++ ['applyDefaults sizes a part by its pixel minimum when the cell is small', async () => {
++ const c = catalog();
++ c.grid.cell_px.phone = 40;
++ c.parts = c.parts.filter((p) => p.id === 'stop');
++ c.parts[0].min = { w: 1, h: 1, px: 64 };
++ const d = applyDefaults(c, 'phone');
++ assert.equal(d.parts[0].place.w, 2, 'two 40 px cells reach 64 px');
++ assert.deepEqual(validate(c, d), { ok: true, reasons: [] });
++ }],
++
++ ['merge is a copy: parts by id, deep style, new parts appended, nothing mutated', async () => {
++ const before = layout();
++ const snapshot = JSON.stringify(before);
++ const merged = merge(before, {
++ name: 'One thumb',
++ parts: [
++ { id: 'stop', style: { tint: 'danger', shape: 'square' } },
++ { id: 'battery', visible: false },
++ { id: 'hat', place: { x: 2, y: 3, w: 1, h: 1 }, style: { tint: 'accent', shape: 'pill', opacity: 1, label: true }, bindings: { keys: ['ArrowUp'], gamepad: ['right-stick-y'] } },
++ ],
++ });
++ assert.equal(JSON.stringify(before), snapshot, 'the original layout is not touched');
++ assert.equal(merged.name, 'One thumb');
++ assert.equal(merged.parts.length, 4);
++ assert.equal(merged.parts.find((p) => p.id === 'stop').style.shape, 'square');
++ assert.equal(merged.parts.find((p) => p.id === 'stop').place.w, 2, 'a place not patched stays');
++ assert.equal(merged.parts.find((p) => p.id === 'battery').visible, false);
++ assert.equal(merged.parts[3].id, 'hat', 'a new part is appended');
++ assert.deepEqual(validate(catalog(), merged), { ok: true, reasons: [] });
++ }],
++
++ ['merge can add a binding and hide a part; validate still decides', async () => {
++ const changed = merge(layout(), { parts: [{ id: 'stop', bindings: { keys: ['Space'], gamepad: ['south'] } }] });
++ assert.deepEqual(validate(catalog(), changed), { ok: true, reasons: [] });
++ const hidden = merge(layout(), { parts: [{ id: 'stop', visible: false }] });
++ assert.deepEqual(validate(catalog(), hidden).ok, false);
++ }],
++]);
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex a89bbf4..2215c41 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -11,6 +11,7 @@ import * as composer from './composer';
import * as cam from './cam';
import * as mediaUpload from './media-upload';
import * as ui from './ui';
++import * as uiLayout from './ui-layout';
import * as community from './community';
import * as homeFeed from './home-feed';
import * as feed from './feed';
@@@ -20,6 +21,7 @@ import * as bench from './bench';
import * as meme from './meme';
import * as vision from './vision';
import * as themes from './themes';
++import * as elements from './elements';
import * as design from './design';
import * as redact from './redact';
import * as chat from './chat';
@@@ -48,6 +50,7 @@ export * as composer from './composer';
export * as cam from './cam';
export * as mediaUpload from './media-upload';
export * as ui from './ui';
++export * as uiLayout from './ui-layout';
export * as community from './community';
export * as homeFeed from './home-feed';
export * as feed from './feed';
@@@ -57,6 +60,7 @@ export * as bench from './bench';
export * as meme from './meme';
export * as vision from './vision';
export * as themes from './themes';
++export * as elements from './elements';
export * as design from './design';
export * as redact from './redact';
export * as chat from './chat';
@@@ -80,6 +84,6 @@ declare const sdk: typeof core & {
userCard: typeof userCard;
player: typeof player;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;
++ media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; uiLayout: typeof uiLayout; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;
};
export default sdk;
ddiff --git a/types/elements.d.ts b/types/elements.d.ts
nnew file mode 100644
iindex 0000000..c017bdf
---- /dev/null
++++ b/types/elements.d.ts
@@@ -0,0 +1,203 @@
++import type { SubjectRef } from './core';
++
++export type ElementSharing = 'private' | 'link' | 'gallery';
++export type ElementRole = 'everyone' | 'follower' | 'subscriber' | 'vip' | 'operator' | 'owner';
++export type ElementShape = 'round' | 'pill' | 'square';
++
++export type ActionKind =
++ | 'robot.drive_pulse' | 'robot.head_preset' | 'robot.headlight' | 'robot.say' | 'robot.macro'
++ | 'chat.message' | 'chat.pin' | 'chat.poll'
++ | 'screen.alert' | 'screen.animation' | 'screen.confetti' | 'screen.sound' | 'screen.tts'
++ | 'screen.counter' | 'screen.state' | 'screen.visibility' | 'screen.scene'
++ | 'stream.clip' | 'stream.moment' | 'stream.title' | 'stream.category'
++ | 'ai.ask' | 'actor.task' | 'webhook.call' | 'money.tip_goal';
++
++export type ProductId = 'bot' | 'chat' | 'live' | 'ai' | 'actor' | 'billing';
++
++export type TriggerKind =
++ | 'press' | 'hold' | 'double_press' | 'key' | 'gamepad' | 'chat_command' | 'chat_text'
++ | 'follow' | 'tip' | 'ai_moment' | 'rundown_topic' | 'timer' | 'schedule'
++ | 'robot_telemetry' | 'robot_event' | 'stream_start' | 'stream_end' | 'viewer_count' | 'element_event';
++
++export type ElementValue = boolean | number | string | Array<number | string>;
++export type ElementValues = Record<string, ElementValue>;
++
++export interface ElementsError {
++ /** Where the problem is, e.g. "/recipe/actions/0/product". */
++ path: string;
++ /** A plain sentence an editor can show after "Can't do that: ". */
++ message: string;
++}
++
++export interface ElementValidation {
++ valid: boolean;
++ errors: ElementsError[];
++}
++
++/** One trigger declaration, or the fired occurrence shaped like it. */
++export interface UiTrigger {
++ kind: TriggerKind;
++ [key: string]: unknown;
++}
++
++/** One action from the catalog, with the product that authorises it. */
++export interface UiAction {
++ kind: ActionKind;
++ product: ProductId;
++ [key: string]: unknown;
++}
++
++export interface ElementConditions {
++ role?: ElementRole;
++ /** Channel points only; stage 1 never charges money or Vibes. */
++ cost?: { points: number };
++ cooldown?: { per_person_seconds?: number; overall_seconds?: number };
++ daily_cap?: { per_person?: number; overall?: number };
++ live_only?: boolean;
++ robot_turn_only?: boolean;
++ chance?: { one_in: number };
++}
++
++export interface ElementLook {
++ component: string;
++ label: string;
++ icon?: string;
++ props?: Record<string, boolean | number | string>;
++ style?: {
++ tint?: string;
++ shape?: ElementShape;
++ opacity?: number;
++ show_label?: boolean;
++ };
++}
++
++export interface ElementRecipe {
++ triggers: UiTrigger[];
++ conditions?: ElementConditions;
++ actions: UiAction[];
++}
++
++export interface ElementModReference {
++ id: string;
++ version: string;
++ sha256: string;
++ capabilities?: ActionKind[];
++}
++
++export interface UiElement {
++ id?: string;
++ revision?: number;
++ name: string;
++ description?: string;
++ author: { name: string; subject?: SubjectRef };
++ sharing: ElementSharing;
++ look: ElementLook;
++ recipe: ElementRecipe;
++ capabilities: ActionKind[];
++ initial_state?: ElementValues;
++ mod?: ElementModReference;
++ created_at?: string;
++ updated_at?: string;
++}
++
++export interface ElementState {
++ values?: ElementValues;
++ revision?: number;
++}
++
++export interface ElementLimits {
++ /** Milliseconds until this scope is ready; 0 when it is ready now. */
++ cooldown(query: { scope: 'person' | 'overall'; elementId: string; actorId: string; at: number }): number | Promise<number>;
++ /** How many times this scope already fired in the UTC day. */
++ dailyCount(query: { scope: 'person' | 'overall'; elementId: string; actorId: string; day: string }): number | Promise<number>;
++ /** Record an accepted fire so the cooldown and daily counters move. */
++ hit(query: { elementId: string; actorId: string; at: number; day: string }): void | Promise<void>;
++}
++
++export interface ElementSpend {
++ points: number;
++ elementId: string;
++ actorId: string;
++ subject: SubjectRef | null;
++ at: number;
++}
++
++export interface ElementSpendResult {
++ ok: boolean;
++ reason?: string;
++ message?: string;
++}
++
++export interface ElementSendInfo {
++ element: { id?: string; name?: string; capabilities: ActionKind[] };
++ actor: { subject?: SubjectRef; role?: ElementRole; name?: string };
++ trigger: UiTrigger;
++ /** The product that authorises this action; the host dispatches on it. */
++ product: ProductId;
++}
++
++export interface ElementSendResult {
++ ok: boolean;
++ result?: unknown;
++ reason?: string;
++ message?: string;
++}
++
++/** The host's side of run(). It is supplied by the product that owns the press. */
++export interface ElementsHostContext {
++ now?: number;
++ random?: () => number;
++ isLive?: boolean;
++ robotInTurn?: boolean;
++ element: { id?: string; name?: string; capabilities: ActionKind[] };
++ actor?: { subject?: SubjectRef; role?: ElementRole; name?: string };
++ limits?: ElementLimits;
++ spend?: (query: ElementSpend) => ElementSpendResult | Promise<ElementSpendResult>;
++ refund?: (query: ElementSpend) => void | Promise<void>;
++ send: (action: UiAction, info: ElementSendInfo) => ElementSendResult | Promise<ElementSendResult>;
++}
++
++export type ElementsRefusalCode =
++ | 'invalid' | 'trigger' | 'capability' | 'role' | 'not_live' | 'robot_turn' | 'chance'
++ | 'cooldown' | 'daily_cap' | 'points' | 'limits' | 'action';
++
++export interface ElementsRunSuccess {
++ ok: true;
++ pointsSpent: number;
++ actions: Array<{ action: UiAction; result: unknown }>;
++}
++
++export interface ElementsRunRefusal {
++ ok: false;
++ code: ElementsRefusalCode;
++ message: string;
++ retryAfterMs?: number;
++ scope?: 'person' | 'overall';
++ refusedAction?: ActionKind;
++ refunded?: boolean;
++ pointsSpent?: number;
++ actions?: Array<{ action: UiAction; result: unknown }>;
++}
++
++export type ElementsRunResult = ElementsRunSuccess | ElementsRunRefusal;
++
++export interface ElementsReduceResult {
++ values: ElementValues;
++ revision: number;
++ changed: boolean;
++ errors: ElementsError[];
++}
++
++export declare function validate(element: unknown): ElementValidation;
++export declare function run(recipe: ElementRecipe, trigger: UiTrigger, ctx: ElementsHostContext): Promise<ElementsRunResult>;
++export declare function reduce(state: ElementState | null, actions: UiAction[], options?: { maxKeys?: number }): ElementsReduceResult;
++export declare function isValidValue(value: unknown): boolean;
++
++export declare const PRODUCTS: Record<ActionKind, ProductId>;
++export declare const ACTION_KINDS: ActionKind[];
++export declare const ACTION_SET: Set<ActionKind>;
++export declare const TRIGGER_KINDS: TriggerKind[];
++export declare const TRIGGER_SET: Set<TriggerKind>;
++export declare const ROBOT_ACTIONS: ActionKind[];
++export declare const ROLES: ElementRole[];
++export declare const ROLE_RANK: Record<ElementRole, number>;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex bc2e672..dafb13f 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -13,6 +13,7 @@ export * as media from './media';
export * as cam from './cam';
export * as mediaUpload from './media-upload';
export * as ui from './ui';
++export * as uiLayout from './ui-layout';
export * as community from './community';
export * as homeFeed from './home-feed';
export * as feed from './feed';
@@@ -76,3 +77,4 @@ export * as composer from './composer';
export * as userCard from './user-card';
export * as player from './player';
export * as agentSheet from './agent-sheet';
++export * as elements from './elements';
ddiff --git a/types/ui-layout.d.ts b/types/ui-layout.d.ts
nnew file mode 100644
iindex 0000000..58d46e1
---- /dev/null
++++ b/types/ui-layout.d.ts
@@@ -0,0 +1,70 @@
++/** A ui.surface@1 part (the subset this module reads). */
++export interface UiSurfacePart {
++ id: string;
++ name: string;
++ kind?: 'stick' | 'button' | 'slider' | 'readout' | 'video' | 'list';
++ required?: boolean;
++ pressed?: boolean;
++ min?: { w?: number; h?: number; px?: number };
++ styles?: {
++ tints?: string[];
++ shapes?: Array<'round' | 'pill' | 'square'>;
++ opacity?: { min?: number; max?: number };
++ label?: boolean;
++ };
++ bindings?: { keys?: string[]; gamepad?: string[] };
++ never_covers?: string[];
++}
++
++/** The parts of ui.surface@1 this module reads. */
++export interface UiSurfaceCatalog {
++ id: string;
++ version: number;
++ name?: string;
++ devices?: Array<'phone' | 'tablet' | 'computer'>;
++ grid?: {
++ columns?: Partial<Record<'phone' | 'tablet' | 'computer', number>>;
++ cell_px?: Partial<Record<'phone' | 'tablet' | 'computer', number>>;
++ };
++ theme_tokens?: string[];
++ parts: UiSurfacePart[];
++}
++
++export interface UiLayoutPart {
++ id: string;
++ visible: boolean;
++ place: { x: number; y: number; w: number; h: number } | { dock: 'left' | 'right' | 'top' | 'bottom'; order: number; w: number; h: number };
++ style: { tint: string; shape: 'round' | 'pill' | 'square'; opacity: number; label: boolean };
++ bindings: { keys: string[]; gamepad: string[] };
++}
++
++/** A ui.layout@1 document as this module reads and writes it. */
++export interface UiLayout {
++ id?: string;
++ revision?: number;
++ active?: boolean;
++ surface: string;
++ surface_version: number;
++ device: 'phone' | 'tablet' | 'computer';
++ name: string;
++ author: { name: string; subject?: { type: string; id: string } };
++ sharing: 'private' | 'link' | 'gallery';
++ parts: UiLayoutPart[];
++ updated_at?: string;
++}
++
++export interface UiLayoutValidation {
++ ok: boolean;
++ reasons: string[];
++}
++
++/** Every rule the design note gives, in plain words. */
++export declare function validate(catalog: UiSurfaceCatalog, layout: UiLayout): UiLayoutValidation;
++
++/** The default arrangement: every part visible, required parts first, none under 44 px. */
++export declare function applyDefaults(catalog: UiSurfaceCatalog, device?: 'phone' | 'tablet' | 'computer'): UiLayout;
++
++/** A copy with the patch applied; parts merge by id, new parts append, nothing is mutated. */
++export declare function merge(layout: UiLayout, patch: Partial<UiLayout> & { parts?: Array<Partial<UiLayoutPart> & { id: string }> }): UiLayout;
++
++export declare const PRESSED_PX: number;